diff --git a/.bestpractices.json b/.bestpractices.json
new file mode 100644
index 0000000..abad486
--- /dev/null
+++ b/.bestpractices.json
@@ -0,0 +1,129 @@
+{
+ "_comment": "Evidence dossier for OpenSSF Best Practices. Statuses describe main only after this file lands. Service-only facts must be reverified before submission; no badge level is claimed by this file.",
+
+ "description_good_status": "Met",
+ "description_good_justification": "README.md identifies the portable Codex, Gemini CLI, Claude, and Agent Skills surface before stating the exact Claude-only hook and statusline boundaries: https://github.com/cdeust/session-optimizer#readme",
+ "interact_status": "Met",
+ "interact_justification": "CONTRIBUTING.md documents development setup, the change process, testing, compatibility, and security reporting: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md",
+ "contribution_status": "Met",
+ "contribution_justification": "Changes are proposed and accepted through public issues and pull requests under the process in CONTRIBUTING.md: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#change-process",
+ "contribution_requirements_status": "Met",
+ "contribution_requirements_justification": "CONTRIBUTING.md gives exact local gates and requires documentation, tests, security impact, and user-visible evidence in each applicable pull request: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md",
+ "floss_license_status": "Met",
+ "floss_license_justification": "The project is released under the MIT License: https://github.com/cdeust/session-optimizer/blob/main/LICENSE",
+ "floss_license_osi_status": "Met",
+ "floss_license_osi_justification": "MIT is an OSI-approved license: https://opensource.org/license/mit",
+ "license_location_status": "Met",
+ "license_location_justification": "The license is in the repository-root LICENSE file: https://github.com/cdeust/session-optimizer/blob/main/LICENSE",
+ "documentation_basics_status": "Met",
+ "documentation_basics_justification": "README.md covers purpose, installation, components, interaction, migration, tests, and policy; docs/ covers architecture, assurance, roadmap, and Scorecard: https://github.com/cdeust/session-optimizer#readme",
+ "documentation_interface_status": "Met",
+ "documentation_interface_justification": "Each plugin README documents its hook, skill, configuration, inputs, and host boundary; docs/ARCHITECTURE.md maps portable and host-specific interfaces: https://github.com/cdeust/session-optimizer/blob/main/docs/ARCHITECTURE.md",
+ "sites_https_status": "Met",
+ "sites_https_justification": "The repository, issue tracker, documentation, and releases are served by GitHub over HTTPS: https://github.com/cdeust/session-optimizer",
+ "discussion_status": "Met",
+ "discussion_justification": "GitHub Issues is the public discussion channel: https://github.com/cdeust/session-optimizer/issues",
+ "english_status": "Met",
+ "english_justification": "Project-level README, policies, architecture, roadmap, issue tracker, and source comments are maintained in English; a clearly labeled French statusline translation is additionally provided.",
+ "maintained_status": "Met",
+ "maintained_justification": "The repository is actively maintained, with releases and changes documented in CHANGELOG.md: https://github.com/cdeust/session-optimizer/blob/main/CHANGELOG.md",
+ "repo_public_status": "Met",
+ "repo_public_justification": "The GitHub repository is publicly readable: https://github.com/cdeust/session-optimizer",
+ "repo_track_status": "Met",
+ "repo_track_justification": "The complete source and policy history is tracked in public git: https://github.com/cdeust/session-optimizer/commits/main",
+ "repo_interim_status": "Met",
+ "repo_interim_justification": "Reviewable pull requests and main-branch commits preserve interim states between releases: https://github.com/cdeust/session-optimizer/pulls",
+ "repo_distributed_status": "Met",
+ "repo_distributed_justification": "The project uses git, a distributed version-control system.",
+ "version_unique_status": "Met",
+ "version_unique_justification": "Every release is identified by a unique semantic version: https://github.com/cdeust/session-optimizer/releases",
+ "version_semver_status": "Met",
+ "version_semver_justification": "CHANGELOG.md states that the project follows Semantic Versioning and releases use vMAJOR.MINOR.PATCH tags: https://github.com/cdeust/session-optimizer/blob/main/CHANGELOG.md",
+ "version_tags_status": "Met",
+ "version_tags_justification": "Published releases correspond to git tags: https://github.com/cdeust/session-optimizer/tags",
+ "release_notes_status": "Met",
+ "release_notes_justification": "CHANGELOG.md records notable release changes in Keep a Changelog format: https://github.com/cdeust/session-optimizer/blob/main/CHANGELOG.md",
+ "report_process_status": "Met",
+ "report_process_justification": "CONTRIBUTING.md directs ordinary defects to GitHub Issues and vulnerabilities to the private reporting process: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md",
+ "report_tracker_status": "Met",
+ "report_tracker_justification": "GitHub Issues is the public bug tracker: https://github.com/cdeust/session-optimizer/issues",
+ "report_archive_status": "Met",
+ "report_archive_justification": "Open and closed issues remain publicly archived and searchable: https://github.com/cdeust/session-optimizer/issues?q=is%3Aissue",
+ "vulnerability_report_process_status": "Met",
+ "vulnerability_report_process_justification": "SECURITY.md documents supported versions, required report content, the private channel, response steps, coordinated disclosure, and reporter credit: https://github.com/cdeust/session-optimizer/blob/main/SECURITY.md",
+ "vulnerability_report_private_status": "Met",
+ "vulnerability_report_private_justification": "GitHub private vulnerability reporting was enabled and verified through the repository API on 2026-08-03; SECURITY.md links directly to it: https://github.com/cdeust/session-optimizer/security/advisories/new",
+ "vulnerability_report_response_status": "Met",
+ "vulnerability_report_response_justification": "SECURITY.md states the maintainer's reproduce, scope, private-fix, regression-test, and coordinated-disclosure response process without inventing an unmeasured deadline: https://github.com/cdeust/session-optimizer/blob/main/SECURITY.md",
+ "build_status": "Met",
+ "build_justification": "The source is directly executable; releases are assembled with one documented script and verified with its companion script: https://github.com/cdeust/session-optimizer/blob/main/tools/build-release-bundle.sh",
+ "build_common_tools_status": "Met",
+ "build_common_tools_justification": "The release uses standard Bash, tar, Python, and SHA-256 tooling.",
+ "build_floss_tools_status": "Met",
+ "build_floss_tools_justification": "Bash, GNU tar, Python, pytest, coverage.py, and ShellCheck are FLOSS and the workflow runs on a FLOSS operating-system image.",
+ "test_status": "Met",
+ "test_justification": "The tests/ tree contains automated Python and shell suites for the shipped hooks, tools, renderer, portable packaging, and release verifier: https://github.com/cdeust/session-optimizer/tree/main/tests",
+ "test_invocation_status": "Met",
+ "test_invocation_justification": "CONTRIBUTING.md gives exact commands for the complete Python, shell, ShellCheck, JSON, and release-integrity gates: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy",
+ "test_most_status": "Met",
+ "test_most_justification": "Tests exercise each shipped Python component, both shell suites, package manifests, host-event failures, transcript parsing, and the artifact path; the measured Python surface is 94 percent covered.",
+ "test_continuous_integration_status": "Met",
+ "test_continuous_integration_justification": "CI runs on every pull request and push to main, with pinned actions, hash-locked dependencies, coverage, ShellCheck, shell suites, and JSON validation: https://github.com/cdeust/session-optimizer/blob/main/.github/workflows/ci.yml",
+ "test_policy_status": "Met",
+ "test_policy_justification": "CONTRIBUTING.md requires tests for behavior changes and a pre-fix-failing regression test for every defect fix: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy",
+ "tests_are_added_status": "Met",
+ "tests_are_added_justification": "The OpenSSF foundation adds regression tests for newly measured hooks, transcript parsing, measurement tooling, and release integrity in the same change as the behavior and controls.",
+ "tests_documented_added_status": "Met",
+ "tests_documented_added_justification": "The same-pull-request test requirement is written in CONTRIBUTING.md and the CI gate enforces the resulting suite: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy",
+ "warnings_status": "Met",
+ "warnings_justification": "CI runs pinned ShellCheck 0.11.0 over shipped shell and CodeQL security-and-quality over Python: https://github.com/cdeust/session-optimizer/tree/main/.github/workflows",
+ "warnings_fixed_status": "Met",
+ "warnings_fixed_justification": "ShellCheck and CodeQL are blocking workflow jobs; ShellCheck returns no finding at the configured default severity on the current tree.",
+ "know_secure_design_status": "Met",
+ "know_secure_design_justification": "SECURITY.md and docs/ASSURANCE-CASE.md define the protected assets, threats, trust boundaries, secure-design principles, controls, and explicit limits: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md",
+ "know_common_errors_status": "Met",
+ "know_common_errors_justification": "The assurance case enumerates common implementation weaknesses while CodeQL, ShellCheck, malicious-input tests, checksum verification, and archive-path checks address them mechanically: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md#4-common-implementation-weaknesses",
+ "delivery_mitm_status": "Met",
+ "delivery_mitm_justification": "Source and release artifacts are delivered through GitHub over authenticated HTTPS/TLS: https://github.com/cdeust/session-optimizer/releases",
+ "delivery_unsigned_status": "Met",
+ "delivery_unsigned_justification": "No checksum or executable is fetched over plain HTTP; CI action identities are pinned to full commit SHAs.",
+ "static_analysis_status": "Met",
+ "static_analysis_justification": "CodeQL security-and-quality runs on pull requests, pushes to main, and a weekly schedule, while ShellCheck runs on every change: https://github.com/cdeust/session-optimizer/blob/main/.github/workflows/codeql.yml",
+ "static_analysis_common_vulnerabilities_status": "Met",
+ "static_analysis_common_vulnerabilities_justification": "CodeQL's security-and-quality query suite targets common Python vulnerability patterns; ShellCheck adds shell-specific diagnostics.",
+ "static_analysis_often_status": "Met",
+ "static_analysis_often_justification": "Static analysis runs per pull request and main push, plus CodeQL's weekly schedule.",
+
+ "achieve_passing_status": "Unmet",
+ "achieve_passing_justification": "The project has not yet been registered with OpenSSF Best Practices and therefore does not claim a Passing badge. This dossier is preparation, not a badge.",
+ "dco_status": "Unmet",
+ "dco_justification": "No DCO sign-off or CLA is required. GOVERNANCE.md records that adopting either requires a public governance change: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md#contribution-licensing",
+ "governance_status": "Met",
+ "governance_justification": "GOVERNANCE.md defines decision-making, evidence rules, roles, records, succession limits, maintainer appointment, and contribution licensing: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md",
+ "code_of_conduct_status": "Met",
+ "code_of_conduct_justification": "CODE_OF_CONDUCT.md states expected behavior, prohibited behavior, the private enforcement channel, available sanctions, and a non-conflicted GitHub escalation path: https://github.com/cdeust/session-optimizer/blob/main/CODE_OF_CONDUCT.md",
+ "roles_responsibilities_status": "Met",
+ "roles_responsibilities_justification": "GOVERNANCE.md identifies maintainer, contributor, and security-reporter responsibilities and states which roles do not yet exist: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md#roles-and-responsibilities",
+ "access_continuity_status": "Met",
+ "access_continuity_justification": "GOVERNANCE.md documents the credential-free succession path: the complete source, history, tests, marketplace manifests, release workflow, and documentation are public under MIT. A successor can fork, enable and manage Issues, accept pull requests, and publish an attested tagged release under the fork's own GitHub OIDC identity within a week, without any original key, token, domain, private dependency, or legal assignment. Users install from the continuation repository. @cdeust remains the only administrator of the original repository, so the bus factor is honestly 1 and identity transfer is weaker than with a second maintainer, but the project results and the three required operational capabilities can continue: https://github.com/cdeust/session-optimizer/blob/main/GOVERNANCE.md#continuity-of-access",
+ "documentation_roadmap_status": "Met",
+ "documentation_roadmap_justification": "docs/ROADMAP.md covers August 2026 through July 2027 in four dated phases and states explicit non-goals: https://github.com/cdeust/session-optimizer/blob/main/docs/ROADMAP.md",
+ "documentation_current_status": "Met",
+ "documentation_current_justification": "CONTRIBUTING.md requires documentation to change with behavior, and this foundation reconciles the repository introduction, architecture, host boundaries, privacy, security, and current measured test evidence.",
+ "test_statement_coverage80_status": "Met",
+ "test_statement_coverage80_justification": "Measured 2026-08-03 with coverage.py 7.15.2 and pytest 9.1.1 over the shipped Python surface declared in pyproject.toml: 712 statements, 42 missed, 94 percent, 50 tests passing. CI enforces fail_under = 80 and subprocess coverage captures the hook entry points: https://github.com/cdeust/session-optimizer/blob/main/pyproject.toml",
+ "test_policy_mandated_status": "Met",
+ "test_policy_mandated_justification": "CONTRIBUTING.md makes tests mandatory for behavior changes, regression tests mandatory for fixes, enumerates required boundary/failure coverage, and states that coverage does not replace assertions: https://github.com/cdeust/session-optimizer/blob/main/CONTRIBUTING.md#testing-policy",
+ "implement_secure_design_status": "Met",
+ "implement_secure_design_justification": "The assurance case maps least privilege, economy of mechanism, complete mediation, fail-safe artifact handling, transparent host boundaries, and defence in depth to implemented controls: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md#3-secure-design-principles",
+ "crypto_used_network_status": "N/A",
+ "crypto_used_network_justification": "The shipped runtime intentionally makes no network request; it processes host events and local files. CI and release publication use GitHub HTTPS but are not runtime network protocols implemented by the software: https://github.com/cdeust/session-optimizer/blob/main/PRIVACY.md",
+ "signed_releases_status": "Unmet",
+ "signed_releases_justification": "The release workflow can publish Sigstore provenance attestations for the source bundle, executable manifest, and SBOM, but it has not yet produced a public tagged release. SECURITY.md explicitly keeps this criterion unmet until a new tag completes and is independently verified: https://github.com/cdeust/session-optimizer/blob/main/SECURITY.md#verifying-a-release",
+ "input_validation_status": "Met",
+ "input_validation_justification": "Host JSON, transcript shapes, paths, checksums, archive members, and executable manifest entries are parsed or allowlisted at their boundaries. Regression tests include malformed hook events, whitespace-bearing valid JSON, unsafe archive paths, and tampered artifacts; the assurance case records the limits: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md",
+ "hardening_status": "Unmet",
+ "hardening_justification": "The project has no HTTP response surface and compiles no native binary, so the web-header and compiler-hardening examples for this criterion do not apply directly. Existing bounded reads, least privilege, and fail-closed artifact checks are real controls but are not relabeled as this criterion.",
+ "assurance_case_status": "Met",
+ "assurance_case_justification": "docs/ASSURANCE-CASE.md contains the four required parts as separately identified sections: threat model, five concrete trust boundaries, implemented secure-design principles, and a common-weakness table that states each control's limit: https://github.com/cdeust/session-optimizer/blob/main/docs/ASSURANCE-CASE.md"
+}
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..f895c17
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,22 @@
+version: 2
+
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "tuesday"
+ commit-message:
+ prefix: "chore(ci)"
+ groups:
+ github-actions:
+ patterns:
+ - "*"
+
+ - package-ecosystem: "pip"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ day: "tuesday"
+ commit-message:
+ prefix: "chore(deps)"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d92f467..f4f300e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -5,20 +5,25 @@ on:
branches: [main]
pull_request:
+permissions:
+ contents: read
+
jobs:
test:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
- - uses: actions/setup-python@v5
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.11"
- name: Install dependencies
- run: |
- python -m pip install --upgrade pip
- pip install pytest
+ run: python -m pip install --require-hashes -r requirements-dev.lock
# Pinned rather than taken from apt: the distro build is several releases
# behind, and the two versions do not report the same findings — it raises
@@ -28,15 +33,19 @@ jobs:
- name: Install shellcheck 0.11.0
run: |
v=v0.11.0
- curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${v}/shellcheck-${v}.linux.x86_64.tar.xz" \
- | sudo tar -xJf - --strip-components=1 -C /usr/local/bin "shellcheck-${v}/shellcheck"
+ archive="shellcheck-${v}.linux.x86_64.tar.xz"
+ curl -fsSLO "https://github.com/koalaman/shellcheck/releases/download/${v}/${archive}"
+ echo "8c3be12b05d5c177a04c29e3c78ce89ac86f1595681cab149b65b97c4e227198 ${archive}" | sha256sum -c -
+ sudo tar -xJf "${archive}" --strip-components=1 -C /usr/local/bin "shellcheck-${v}/shellcheck"
shellcheck --version
- - name: Refine gate contract tests
- run: pytest tests/test_refine_gate.py tests/test_portable_packaging.py -v
-
- - name: Subagent tracker tests
- run: pytest tests/test_subagent_usage.py -v
+ - name: Python suite and 80 percent coverage gate
+ run: |
+ coverage erase
+ coverage run -m pytest -q
+ coverage combine
+ coverage report
+ coverage xml
- name: Statusline heat-track tests
run: bash tests/statusline/test_heat_rgb.sh
@@ -49,6 +58,7 @@ jobs:
shellcheck plugins/statusline/assets/statusline-command.sh
shellcheck plugins/statusline/assets/statusline-lib/*.sh
shellcheck plugins/statusline/assets/costs.sh
+ shellcheck tools/*.sh
# The suites are shell too, and a checker that skips them lets the
# code that guards the renderer rot unwatched.
shellcheck tests/statusline/*.sh
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
new file mode 100644
index 0000000..5c1bd6e
--- /dev/null
+++ b/.github/workflows/codeql.yml
@@ -0,0 +1,37 @@
+name: CodeQL
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ schedule:
+ - cron: "0 5 * * 1"
+
+permissions:
+ contents: read
+
+jobs:
+ analyze:
+ name: Analyze (python)
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ contents: read
+ actions: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Initialize CodeQL
+ uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ with:
+ languages: python
+ queries: security-and-quality
+
+ - name: Perform CodeQL analysis
+ uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ with:
+ category: "/language:python"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..1da8633
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,91 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*.*.*"
+
+permissions:
+ contents: read
+
+jobs:
+ release:
+ name: Test, attest, and publish
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ id-token: write
+ attestations: write
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.11"
+
+ - name: Install hash-locked test dependencies
+ run: python -m pip install --require-hashes -r requirements-dev.lock
+
+ - name: Run Python suite and coverage gate
+ run: |
+ coverage erase
+ coverage run -m pytest -q
+ coverage combine
+ coverage report
+
+ - name: Run shell suites
+ run: |
+ bash tests/statusline/test_heat_rgb.sh
+ bash tests/statusline/test_fit_and_pace.sh
+
+ - name: Build and self-verify release bundle
+ run: |
+ bash tools/build-release-bundle.sh dist
+ bash tools/verify-release-bundle.sh \
+ dist/session-optimizer.tar.gz \
+ dist/session-optimizer.tar.gz.sha256 \
+ dist/EXECUTABLE-MANIFEST.sha256
+
+ - name: Attest build provenance
+ uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1
+ with:
+ subject-path: |
+ dist/session-optimizer.tar.gz
+ dist/EXECUTABLE-MANIFEST.sha256
+ dist/session-optimizer.cdx.json
+
+ - name: Extract release notes
+ run: |
+ version="${GITHUB_REF_NAME#v}"
+ awk -v ver="$version" '
+ /^## \[/ {
+ if (in_section) exit
+ if (index($0, "[" ver "]")) { in_section = 1; next }
+ }
+ in_section { print }
+ ' CHANGELOG.md > release-notes.md
+ if [ ! -s release-notes.md ]; then
+ echo "No CHANGELOG entry found for $version." > release-notes.md
+ fi
+
+ - name: Create GitHub release
+ uses: softprops/action-gh-release@3d0d9888cb7fd7b750713d6e236d1fcb99157228 # v3.0.2
+ with:
+ body_path: release-notes.md
+ draft: false
+ prerelease: ${{ contains(github.ref_name, '-') }}
+ files: |
+ dist/session-optimizer.tar.gz
+ dist/session-optimizer.tar.gz.sha256
+ dist/EXECUTABLE-MANIFEST.sha256
+ dist/EXECUTABLE-MANIFEST.sha256.sha256
+ dist/session-optimizer.cdx.json
+ dist/session-optimizer.cdx.json.sha256
+ fail_on_unmatched_files: true
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
new file mode 100644
index 0000000..09f8ba4
--- /dev/null
+++ b/.github/workflows/scorecard.yml
@@ -0,0 +1,38 @@
+name: OpenSSF Scorecard
+
+on:
+ schedule:
+ - cron: "0 7 * * 2"
+ branch_protection_rule:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions: read-all
+
+jobs:
+ analysis:
+ name: Scorecard analysis
+ runs-on: ubuntu-latest
+ permissions:
+ security-events: write
+ id-token: write
+ contents: read
+ actions: read
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Run analysis
+ uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4
+ with:
+ results_file: results.sarif
+ results_format: sarif
+ publish_results: true
+
+ - name: Upload SARIF to code scanning
+ uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+ with:
+ sarif_file: results.sarif
diff --git a/.gitignore b/.gitignore
index e1333eb..ab742ba 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,5 +4,6 @@ __pycache__/
.claude/
.pytest_cache/
.coverage
+.coverage.*
coverage.xml
htmlcov/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 82f706c..801c24a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,12 +12,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- A skills-only Codex package for `refine-gate`, exposed through a repository
marketplace at `.agents/plugins/marketplace.json`.
- Gemini CLI installation through the existing portable Agent Skill.
+- OpenSSF Scorecard, CodeQL, Dependabot, and a hash-locked development
+ dependency set.
+- Security, contribution, conduct, governance, architecture, assurance-case,
+ Scorecard, and twelve-month roadmap documentation.
+- A release workflow that tests and self-verifies a source bundle, publishes
+ SHA-256 checksums, an executable manifest and CycloneDX SBOM, and creates
+ Sigstore build-provenance attestations.
+- Regression tests for context-guard hooks, prompt-refinement measurement,
+ statusline transcript handling, and release integrity.
### Changed
- The `refine` skill now uses the portable Agent Skills frontmatter and
host-neutral wording. Claude's `UserPromptSubmit` hook and plugin manifests
are unchanged.
+- CI actions are pinned to full commit SHAs, workflow permissions are read-only
+ by default, ShellCheck is checksum-verified, and Python dependencies are
+ installed from the hashed lock file.
+- CI measures the shipped Python surface with coverage.py's subprocess support
+ and enforces an 80% floor; the initial complete measurement is 94%.
+- The repository introduction now leads with its portable Codex, Gemini CLI,
+ Claude, and Agent Skills surface while preserving explicit labels on
+ Claude-only integrations.
+
+### Fixed
+
+- The statusline transcript scanner now recognizes valid compaction records
+ whose JSON contains insignificant whitespace before confirming the parsed
+ marker values.
## [2.1.1] - 2026-07-26
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..c412d42
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,26 @@
+# Code of Conduct
+
+## Our pledge
+
+We pledge to make participation in this project respectful and harassment-free
+for everyone, regardless of identity, background, experience, or ability.
+
+## Expected behavior
+
+Be constructive, assume good faith while checking evidence, respect differing
+viewpoints, accept responsibility for mistakes, and focus criticism on the
+work rather than the person. Harassment, threats, discriminatory language,
+sexualized attention, deliberate intimidation, and disclosure of another
+person's private information are unacceptable.
+
+## Enforcement
+
+Report conduct concerns privately to `admin@ai-architect.tools`. The maintainer
+will investigate proportionately, protect reporter privacy where possible, and
+may edit or remove content, warn a participant, restrict participation, or ban
+a participant. If the maintainer is the subject of a complaint, reporters may
+use GitHub Support so the subject does not adjudicate the report.
+
+This policy applies in repository spaces and whenever someone officially
+represents the project. It is adapted from the
+[Contributor Covenant 2.1](https://www.contributor-covenant.org/version/2/1/code_of_conduct/).
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..6ff1cf9
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,75 @@
+# Contributing
+
+Thank you for improving session-optimizer. Please follow the
+[Code of Conduct](CODE_OF_CONDUCT.md) and report vulnerabilities through the
+private process in [SECURITY.md](SECURITY.md), not a public issue.
+
+## Development setup
+
+Python 3.11+, Bash, and Git are required. ShellCheck 0.11.0 is required for the
+shell gate; CI verifies the downloaded archive before installing it.
+
+```bash
+python -m venv .venv
+. .venv/bin/activate
+python -m pip install --require-hashes -r requirements-dev.lock
+```
+
+## Change process
+
+1. Open or reference an issue that states the observable problem.
+2. Create a focused branch and keep unrelated changes out of it.
+3. Add or update documentation and tests in the same pull request.
+4. Run the complete local gate below.
+5. Explain the user-visible change, security impact, and test evidence in the
+ pull request.
+
+Changes are accepted by pull request. The maintainer records the decision in
+the review and merges only after required automated checks pass. Small typo or
+link fixes may omit an issue when the pull request is self-explanatory.
+
+## Testing policy
+
+Every change to observable behaviour must carry automated tests in the same
+pull request. A bug fix must include a regression test that fails on the
+unfixed code. Tests must cover the happy path, each newly introduced input
+boundary, and failure behaviour that callers rely on. The measured shipped
+Python surface must remain at or above 80% statement coverage; a passing
+percentage does not replace behaviour assertions.
+
+```bash
+coverage erase
+coverage run -m pytest -q
+coverage combine
+coverage report
+bash tests/statusline/test_heat_rgb.sh
+bash tests/statusline/test_fit_and_pace.sh
+shellcheck plugins/statusline/assets/statusline-command.sh \
+ plugins/statusline/assets/statusline-lib/*.sh \
+ plugins/statusline/assets/costs.sh \
+ tests/statusline/*.sh
+```
+
+Validate every changed JSON file with `python -m json.tool FILE`. Release
+changes must also pass:
+
+```bash
+bash tools/build-release-bundle.sh dist
+bash tools/verify-release-bundle.sh \
+ dist/session-optimizer.tar.gz \
+ dist/session-optimizer.tar.gz.sha256 \
+ dist/EXECUTABLE-MANIFEST.sha256
+```
+
+## Style and compatibility
+
+- Keep the three plugins independently installable.
+- Preserve the portable Agent Skill contract for Codex and Gemini while
+ identifying Claude-only hook, transcript, and statusline behavior plainly.
+- Prefer standard-library Python and dependency-light shell.
+- Treat hook JSON, transcript content, paths, and git output as untrusted input.
+- Never add telemetry or a runtime network request without an explicit design
+ review and an update to `PRIVACY.md`.
+
+Contributions are provided under the repository's MIT license. The project
+does not currently require a Contributor License Agreement or DCO sign-off.
diff --git a/GOVERNANCE.md b/GOVERNANCE.md
new file mode 100644
index 0000000..d1bd40f
--- /dev/null
+++ b/GOVERNANCE.md
@@ -0,0 +1,71 @@
+# Governance
+
+## Decision model
+
+session-optimizer is currently maintained by `@cdeust`. Changes are proposed
+through GitHub issues and pull requests. The maintainer considers user impact,
+compatibility, test evidence, security, maintenance cost, and the published
+roadmap, then records acceptance or rejection on the pull request. Larger
+changes begin with an issue so alternatives can be evaluated before code is
+written.
+
+Disagreement is resolved with reproducible evidence and documented trade-offs.
+When evidence is insufficient, the smallest reversible change wins or the
+proposal remains open until the missing evidence exists.
+
+## Roles and responsibilities
+
+- **Maintainer** — triages issues, reviews and merges changes, manages releases,
+ repository settings, security advisories, and the roadmap.
+- **Contributor** — proposes focused changes, follows the testing policy,
+ responds to review, and reports conflicts of interest.
+- **Security reporter** — uses the private channel, preserves confidentiality
+ during coordination, and supplies enough evidence to reproduce the issue.
+
+There is no separate committer, security team, or release-manager role today.
+New maintainers are appointed in a public governance pull request after a
+sustained record of technically sound and respectful contributions.
+
+## Records
+
+Implementation decisions live in pull requests; user-visible changes in
+`CHANGELOG.md`; security handling in private advisories and the eventual
+release notes; priorities in `docs/ROADMAP.md`; and governance changes in this
+document's history.
+
+## Continuity of access
+
+The project can continue without credentials held by the current maintainer.
+Its complete source, history, tests, marketplace manifests, release workflow,
+and documentation are public under MIT. A successor can fork the repository,
+enable Issues, accept pull requests into the fork, and publish a tagged release
+through the committed workflow under the fork's own GitHub OIDC identity. No
+original signing key, package-registry token, domain, private dependency, or
+legal assignment is required. Users can install from the successor's public
+repository and marketplace URL.
+
+This provides the three OpenSSF continuity capabilities within a week: create
+and close issues on the continuation repository, accept proposed changes, and
+release a version. Past releases remain independently verifiable through their
+published checksums and attestations. The procedure is:
+
+1. fork the complete public repository under the successor's account or
+ organization and enable its issue tracker;
+2. publish a continuity notice naming the former repository and the new
+ canonical URL;
+3. accept changes through the unchanged CI-gated pull-request process; and
+4. create a semantic-version tag, let the committed release workflow attest
+ the artifacts under the fork identity, and publish the new install URL.
+
+As verified on 2026-08-03, `@cdeust` remains the only administrator of the
+current GitHub repository. That makes the bus factor 1 and means its original
+URL and listings cannot be transferred without the account; it does not make
+the MIT-licensed project results or their issue/change/release process
+non-continuable. Adding a second trusted maintainer is still the preferred way
+to preserve the existing identity with even less interruption.
+
+## Contribution licensing
+
+Contributions are accepted under the MIT license. No CLA or DCO sign-off is
+required today; adopting either requires a governance pull request explaining
+the need and migration impact.
diff --git a/README.md b/README.md
index 7d7bb7d..c1b1c20 100644
--- a/README.md
+++ b/README.md
@@ -2,6 +2,11 @@

+[](https://github.com/cdeust/session-optimizer/actions/workflows/ci.yml)
+[](https://github.com/cdeust/session-optimizer/actions/workflows/codeql.yml)
+[](https://securityscorecards.dev/viewer/?uri=github.com/cdeust/session-optimizer)
+[](LICENSE)
+
**Cross-platform prompt refinement for Codex, Gemini CLI, and Claude Code,
plus Claude-native context-budget and telemetry controls.** The portable
`refine-gate` skill binds vague requests to concrete evidence on all three
@@ -22,7 +27,7 @@ need; none requires the others.
| [**refine-gate**](plugins/refine-gate) | Claude: `/plugin install refine-gate@session-optimizer-marketplace`; Codex/Gemini: [portable install](plugins/refine-gate/README.md) | A portable skill that binds vague prompt references ("the SSE solution", "like before", "still broken") to concrete artifacts with evidence, then selects an execution strategy from a research-backed table before any code is touched. Claude Code additionally receives an automatic `UserPromptSubmit` hook. |
| [**statusline**](plugins/statusline) | `/plugin install statusline@session-optimizer-marketplace` | A multi-line status bar: discrete heat-track context bar tied to per-model checkpoint thresholds, one deduplicated cost ledger covering subagent spend, telemetry (tok/s, compactions, cache countdown), rate-limit gauges with burn-rate pacing, and terminal-width fitting. Ships an install skill — after installing, ask Claude to "install the statusline" and it wires everything. |
-For Codex and Gemini installation commands, see the
+For Codex, Gemini CLI, and Claude installation commands, see the
[refine-gate README](plugins/refine-gate/README.md).
## Why
@@ -91,12 +96,30 @@ hooks and just announces the migration at session start.
## Tests
```bash
-pytest tests/test_refine_gate.py tests/test_subagent_usage.py
+python -m pip install --require-hashes -r requirements-dev.lock
+coverage erase
+coverage run -m pytest -q
+coverage combine
+coverage report
bash tests/statusline/test_heat_rgb.sh
+bash tests/statusline/test_fit_and_pace.sh
```
-CI (`.github/workflows/ci.yml`) runs all three suites, shellchecks the
-statusline renderer, and validates every plugin/hook/marketplace JSON.
+CI (`.github/workflows/ci.yml`) runs the Python and shell suites, enforces at
+least 80% statement coverage over shipped Python, shellchecks the statusline,
+and validates every plugin, hook, and marketplace JSON. The current measured
+result is 94% (712 statements, 42 missed, 50 tests; measured 2026-08-03).
+
+## Project policy and security
+
+- [Architecture and host boundaries](docs/ARCHITECTURE.md)
+- [Security policy and release verification](SECURITY.md)
+- [Security assurance case](docs/ASSURANCE-CASE.md)
+- [Governance and access continuity](GOVERNANCE.md)
+- [Contributing and mandatory test policy](CONTRIBUTING.md)
+- [August 2026–July 2027 roadmap](docs/ROADMAP.md)
+- [OpenSSF Scorecard policy](docs/SCORECARD.md)
+- [Privacy policy](PRIVACY.md)
## License
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..6055c06
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,65 @@
+# Security policy
+
+## Supported versions
+
+Only the latest release is supported with security fixes. Upgrade before
+reporting a problem that is already fixed on `main` or in a newer release.
+
+## Reporting a vulnerability
+
+Do not open a public issue. Use GitHub's
+[private vulnerability report](https://github.com/cdeust/session-optimizer/security/advisories/new)
+and include:
+
+- the affected plugin, version, and host;
+- the smallest reproducible input or transcript shape;
+- the security impact and the local files or privileges involved; and
+- any proposed mitigation, if known.
+
+The maintainer will reproduce and scope the report in the private advisory,
+prepare a fix and regression test on a private fork or advisory branch, and
+coordinate disclosure with the reporter. A fixed release will identify the
+affected versions and credit the reporter unless anonymity is requested. No
+response or repair deadline is promised before it has been measured reliably;
+progress and any revised disclosure date will be recorded in the advisory.
+
+## Security boundaries
+
+The shipped plugins run locally with the user's permissions. They do not
+provide a sandbox and must be reviewed like any other executable hook.
+
+- `context-guard` consumes host hook JSON and reads local transcripts.
+- `refine-gate` consumes the submitted prompt. Its portable Agent Skill is
+ prose; Claude's optional hook is executable Python.
+- `statusline` consumes host status JSON, transcripts, configuration, and local
+ git metadata.
+- None of the runtime components intentionally makes a network request. See
+ [PRIVACY.md](PRIVACY.md).
+
+Malformed host events fail without granting new privileges. Release and
+installation integrity checks fail closed: the bundle checksum is verified
+before extraction, unsafe archive paths are rejected, and every shipped Python
+or shell executable is checked against the release manifest.
+
+The complete threat model, trust boundaries, controls, and their limits are in
+[docs/ASSURANCE-CASE.md](docs/ASSURANCE-CASE.md).
+
+## Verifying a release
+
+Tagged releases are produced by `.github/workflows/release.yml`. The workflow
+tests the repository, builds the source bundle, publishes SHA-256 checksums, an
+executable manifest, a CycloneDX SBOM, and Sigstore build-provenance
+attestations.
+
+```bash
+sha256sum -c session-optimizer.tar.gz.sha256
+gh attestation verify session-optimizer.tar.gz --repo cdeust/session-optimizer
+bash tools/verify-release-bundle.sh \
+ session-optimizer.tar.gz \
+ session-optimizer.tar.gz.sha256 \
+ EXECUTABLE-MANIFEST.sha256
+```
+
+The workflow is prepared but does not make historical releases signed. The
+OpenSSF signed-release criterion remains unmet until a new public tag completes
+this workflow and its attestation is independently verified.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..4f7e38a
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,41 @@
+# Architecture
+
+session-optimizer is a distribution repository for three independent tools.
+There is no resident server and no shared runtime dependency.
+
+## Components
+
+| Component | Portable surface | Host-specific surface | Local data |
+|---|---|---|---|
+| `refine-gate` | Agent Skill instructions for Codex, Gemini CLI, and compatible hosts | Claude `UserPromptSubmit` hook | submitted prompt and a small local rate-limit state file |
+| `context-guard` | checkpoint protocol concepts | Claude `Stop` and `SubagentStop` hooks and transcript layout | host events, transcripts, checkpoints, and `/tmp` session counters |
+| `statusline` | none today | Claude statusline payload, transcript layout, and install hook | host payload, transcripts, git metadata, config, and caches |
+
+The portable and Claude packages reference the same `refine-gate` skill source.
+The marketplace manifests select which surfaces each host installs; they do not
+make a host-specific executable portable.
+
+## Runtime flow
+
+1. The host invokes a hook or statusline executable with JSON on standard
+ input, or an agent loads the portable skill text.
+2. The executable validates the event shape and reads only the local files its
+ component documents.
+3. It returns hook JSON or rendered text on standard output and may update its
+ documented local state.
+4. It makes no intentional network request and does not call another plugin.
+
+`context-guard` and `statusline` may read the same threshold file and session
+spend record. This is an optional file contract: either plugin continues when
+the other is absent.
+
+## Build and release flow
+
+The source files are the product. `tools/build-release-bundle.sh` creates a
+source archive, SHA-256 executable manifest, and CycloneDX file inventory.
+`tools/verify-release-bundle.sh` verifies the archive before extraction,
+rejects unsafe paths, and compares extracted executables with the manifest.
+The release workflow tests first, then attests and publishes those artifacts.
+
+See [ASSURANCE-CASE.md](ASSURANCE-CASE.md) for the security boundaries and
+[PRIVACY.md](../PRIVACY.md) for the local data contract.
diff --git a/docs/ASSURANCE-CASE.md b/docs/ASSURANCE-CASE.md
new file mode 100644
index 0000000..84c1bd8
--- /dev/null
+++ b/docs/ASSURANCE-CASE.md
@@ -0,0 +1,79 @@
+# Security assurance case
+
+This document states what the project is trying to protect, how its controls
+support that claim, and where those controls stop. It is an argument backed by
+tests and repository configuration, not an independent certification.
+
+## 1. Threat model
+
+The protected assets are the user's local session data, prompts, filesystem,
+host configuration, and confidence that installed executable files match the
+reviewed release.
+
+| Threat | Attacker capability | Intended protection |
+|---|---|---|
+| Malicious release or dependency | can modify an artifact or CI reference | pinned actions, least-privilege workflows, checksums, executable manifest, SBOM, and provenance attestation |
+| Crafted hook payload or transcript | can supply malformed, large, or adversarial local input | parsing, bounded reads already present in hook implementations, explicit fallback behavior, and regression tests |
+| Unsafe archive | can replace a downloaded bundle and companion files | checksum-before-extract, path-traversal rejection, and per-executable hashes |
+| Accidental maintainer error | can merge a defect or stale security claim | pull-request CI, 80% coverage gate, CodeQL, ShellCheck, Scorecard, and documented review evidence |
+
+Out of scope: containing a hostile host process that already runs with the
+user's permissions; protecting a machine or GitHub account that is already
+compromised; and guaranteeing the correctness of model-generated advice.
+
+## 2. Trust boundaries
+
+1. **Repository to CI runner.** Workflow source and action references cross
+ into GitHub-hosted execution. Actions are pinned to full commits and jobs
+ receive only stated permissions. This does not protect a compromised GitHub
+ account with authority to change the workflow.
+2. **Release service to installer.** The archive, checksums, manifest, SBOM,
+ and attestation cross the network. Verification detects modification after
+ publication; it cannot make unreviewed source safe.
+3. **Host to hook.** Host JSON crosses standard input into Python or shell.
+ Parsers validate required shapes and tests exercise malformed inputs. The
+ hook still runs with the user's filesystem permissions.
+4. **Transcript/configuration files to renderer.** Local content and git output
+ enter parsers and shell formatting. Values are treated as data, not sourced
+ as shell code. This does not make the surrounding host transcript private
+ from other local processes running as the same user.
+5. **Portable skill to model.** Host-neutral instructions enter an agent's
+ context. The skill can structure decisions but cannot enforce a sandbox or
+ prove the model followed every instruction.
+
+## 3. Secure design principles
+
+- **Least privilege:** CI begins read-only; only Scorecard SARIF upload,
+ CodeQL, release publication, and attestation receive the additional
+ permissions they require.
+- **Economy of mechanism:** the three plugins remain installable and testable
+ independently, and release verification is a small standalone script.
+- **Complete mediation at artifact boundaries:** the bundle is hashed before
+ extraction and every executable listed in the release manifest is rehashed
+ after extraction.
+- **Fail-safe release defaults:** malformed checksums, unsafe archive members,
+ missing executables, and mismatches terminate verification with failure.
+- **Transparent runtime limits:** host-specific behavior is labeled rather
+ than inferred from the portable skill package; privacy and local file access
+ are documented.
+- **Defence in depth:** tests, static analysis, dependency updates, Scorecard,
+ checksums, SBOM, and attestation address different failure modes. No one
+ layer is described as sufficient.
+
+## 4. Common implementation weaknesses
+
+| Weakness | Control | Limit |
+|---|---|---|
+| command or expression injection | ShellCheck, CodeQL, quoted paths, and tests with hostile values | static analysis does not prove all shell composition safe |
+| unsafe deserialization or malformed JSON | standard JSON parsers, shape checks, and malformed-input tests | the host remains responsible for transport framing |
+| path traversal during installation | archive member validation before extraction | verification must actually be run by the installer/user |
+| dependency substitution | hash-locked Python test dependencies and full-SHA actions | the shipped runtime intentionally uses the standard library but relies on the host OS and Python |
+| unreviewed executable drift | executable manifest and per-file hashes | non-executable documentation is covered by the archive checksum, not the executable manifest |
+| resource exhaustion | existing bounded transcript/event reads and regression tests | a same-user hostile process can still consume machine resources externally |
+| silent security regression | CI coverage gate, behavior tests, CodeQL, and Scorecard | a green gate proves only the encoded checks |
+| release tampering | SHA-256, Sigstore provenance, SBOM, and verification tool | historical releases gain no attestation retroactively |
+
+The author currently performs both implementation and review, and the controls
+have not been evaluated by an independent security assessor. That concentration
+of authority keeps the bus factor at 1 even though the public MIT repository
+and credential-free fork/release path provide operational continuity.
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
new file mode 100644
index 0000000..5fdb9be
--- /dev/null
+++ b/docs/ROADMAP.md
@@ -0,0 +1,46 @@
+# Roadmap: August 2026 to July 2027
+
+This roadmap expresses priorities, not promises. Evidence from security review,
+host API changes, or user reports may reorder it; any change is recorded here.
+
+## August–October 2026
+
+- Land the OpenSSF foundation: harden CI, enable CodeQL, Scorecard and
+ Dependabot, publish the governance and assurance documents, and enforce 80%
+ statement coverage over shipped Python.
+- Exercise the release workflow on the next semantic-version tag and
+ independently verify its checksum, executable manifest, SBOM, and Sigstore
+ attestation before marking signed releases as satisfied.
+- Register the project on OpenSSF Best Practices and publish only criteria that
+ have repository or service evidence.
+
+## November 2026–January 2027
+
+- Keep `refine-gate`'s portable Agent Skill validated on Codex and Gemini CLI.
+- Document host-neutral behavior separately from Claude lifecycle-hook,
+ transcript, and statusline integrations.
+- Ratchet tests when defects are found while keeping the enforced coverage
+ floor at or above 80%.
+
+## February–April 2027
+
+- Recruit and onboard a second trusted maintainer with tested issue, merge,
+ advisory, and release permissions to preserve the existing repository
+ identity as well as the documented fork-based continuity path.
+- Review whether cryptographically signed version tags add useful assurance on
+ top of artifact attestations.
+- Reassess parser fuzzing from measured defect history rather than claiming a
+ tool that is not being run.
+
+## May–July 2027
+
+- Audit documentation accessibility and internationalization boundaries.
+- Review all OpenSSF Best Practices Silver evidence and complete an honest Gold
+ gap assessment.
+- Re-evaluate host integrations as Codex and Gemini expose new lifecycle APIs.
+
+## Explicitly out of scope
+
+- Claiming that Claude-only hooks work on hosts without equivalent APIs.
+- Adding SaaS, telemetry, or runtime network dependencies to local plugins.
+- Trading transparent security limits for a higher badge percentage.
diff --git a/docs/SCORECARD.md b/docs/SCORECARD.md
new file mode 100644
index 0000000..5e32f97
--- /dev/null
+++ b/docs/SCORECARD.md
@@ -0,0 +1,26 @@
+# OpenSSF Scorecard policy
+
+`.github/workflows/scorecard.yml` runs OpenSSF Scorecard weekly, on changes to
+`main`, when branch protection changes, and on manual request. It publishes
+SARIF to GitHub code scanning and public aggregate results to the Scorecard API.
+
+Findings are handled as evidence, not as a target score:
+
+- actionable repository or workflow defects are fixed in a pull request;
+- findings that require a project property we do not have are documented here
+ and left unsatisfied; and
+- a criterion is never marked met solely because a workflow file exists.
+
+## Known dispositions
+
+| Area | Current disposition |
+|---|---|
+| Branch protection and code review | Desired, but a sole-maintainer project cannot provide independent approval today. Required status checks should be enabled after the new workflows have completed successfully on `main`. |
+| Contributors / bus factor | Unmet: `@cdeust` is the only administrator and maintainer. See `GOVERNANCE.md`. |
+| Signed releases | Workflow prepared; unmet until a new tagged release completes and the public attestation is verified. |
+| Fuzzing | Not currently run. Parser behavior is covered by deterministic tests; fuzzing will be adopted only with a maintained target and reproducible evidence. |
+| Packaging | Source bundle, checksums, executable manifest, CycloneDX SBOM, and provenance attestation are produced by the release workflow. |
+| Token permissions | Default workflow permissions are read-only; write scopes are job-local and purpose-specific. |
+
+The latest service result is authoritative once the workflow has landed and
+run on `main`: .
diff --git a/plugins/statusline/assets/statusline-transcript.py b/plugins/statusline/assets/statusline-transcript.py
index 716fac0..c204e02 100644
--- a/plugins/statusline/assets/statusline-transcript.py
+++ b/plugins/statusline/assets/statusline-transcript.py
@@ -49,7 +49,12 @@
# Substring prefilters — cheap screen before JSON parse. A compaction boundary
# carries at least one of these markers across Claude Code versions.
-COMPACT_MARKERS = ('"isCompactSummary"', '"subtype":"compact"', '"type":"summary"')
+# Prefilter values rather than serialized key/value pairs. JSON permits
+# arbitrary whitespace around `:`, and producers are not required to use the
+# compact separators Claude Code currently emits. `_is_compaction` parses and
+# confirms the exact key/value relationship after this cheap screen, so the
+# broader markers cannot inflate the count.
+COMPACT_MARKERS = ('"isCompactSummary"', '"compact"', '"summary"')
def _epoch(ts):
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..7aa2736
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,26 @@
+[tool.pytest.ini_options]
+testpaths = ["tests"]
+
+[tool.coverage.run]
+source = [
+ "plugins/context-guard/hooks",
+ "plugins/context-guard/tools",
+ "plugins/refine-gate/hooks",
+ "plugins/refine-gate/tools",
+ "plugins/statusline/assets",
+]
+# coverage.py 7.10+ starts measurement at Python subprocess startup. The
+# existing contract tests invoke the shipped hook entry points as subprocesses,
+# so this measures what actually executes instead of replacing those tests with
+# direct function calls solely to make the percentage move.
+# source: https://coverage.readthedocs.io/en/7.15.2/subprocess.html
+patch = ["subprocess"]
+
+[tool.coverage.report]
+show_missing = true
+skip_covered = false
+precision = 0
+fail_under = 80
+exclude_also = [
+ "if __name__ == .__main__.:",
+]
diff --git a/requirements-dev.lock b/requirements-dev.lock
new file mode 100644
index 0000000..ffbe5dc
--- /dev/null
+++ b/requirements-dev.lock
@@ -0,0 +1,119 @@
+# This file was autogenerated by uv via the following command:
+# uv pip compile requirements-dev.txt --generate-hashes --universal --python-version 3.11 -o requirements-dev.lock
+colorama==0.4.6 ; sys_platform == 'win32' \
+ --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
+ --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
+ # via pytest
+coverage==7.15.2 \
+ --hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \
+ --hash=sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e \
+ --hash=sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db \
+ --hash=sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf \
+ --hash=sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c \
+ --hash=sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8 \
+ --hash=sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443 \
+ --hash=sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a \
+ --hash=sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145 \
+ --hash=sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9 \
+ --hash=sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2 \
+ --hash=sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 \
+ --hash=sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138 \
+ --hash=sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578 \
+ --hash=sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c \
+ --hash=sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88 \
+ --hash=sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036 \
+ --hash=sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c \
+ --hash=sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071 \
+ --hash=sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a \
+ --hash=sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d \
+ --hash=sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b \
+ --hash=sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a \
+ --hash=sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b \
+ --hash=sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050 \
+ --hash=sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 \
+ --hash=sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d \
+ --hash=sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1 \
+ --hash=sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660 \
+ --hash=sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40 \
+ --hash=sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026 \
+ --hash=sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0 \
+ --hash=sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b \
+ --hash=sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0 \
+ --hash=sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa \
+ --hash=sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d \
+ --hash=sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658 \
+ --hash=sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89 \
+ --hash=sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072 \
+ --hash=sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199 \
+ --hash=sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446 \
+ --hash=sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743 \
+ --hash=sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7 \
+ --hash=sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1 \
+ --hash=sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287 \
+ --hash=sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5 \
+ --hash=sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be \
+ --hash=sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688 \
+ --hash=sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934 \
+ --hash=sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7 \
+ --hash=sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440 \
+ --hash=sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984 \
+ --hash=sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc \
+ --hash=sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d \
+ --hash=sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098 \
+ --hash=sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6 \
+ --hash=sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629 \
+ --hash=sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b \
+ --hash=sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee \
+ --hash=sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f \
+ --hash=sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1 \
+ --hash=sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad \
+ --hash=sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3 \
+ --hash=sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9 \
+ --hash=sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3 \
+ --hash=sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a \
+ --hash=sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296 \
+ --hash=sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1 \
+ --hash=sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd \
+ --hash=sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73 \
+ --hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f \
+ --hash=sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0 \
+ --hash=sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6 \
+ --hash=sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5 \
+ --hash=sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1 \
+ --hash=sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589 \
+ --hash=sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688 \
+ --hash=sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487 \
+ --hash=sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9 \
+ --hash=sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd \
+ --hash=sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee \
+ --hash=sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d \
+ --hash=sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d \
+ --hash=sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb \
+ --hash=sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a \
+ --hash=sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328 \
+ --hash=sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635 \
+ --hash=sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188 \
+ --hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c \
+ --hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \
+ --hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a
+ # via -r requirements-dev.txt
+iniconfig==2.3.0 \
+ --hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
+ --hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
+ # via pytest
+packaging==26.2 \
+ --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
+ --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
+ # via pytest
+pluggy==1.6.0 \
+ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
+ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
+ # via pytest
+pygments==2.20.0 \
+ --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
+ --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
+ # via pytest
+pytest==9.1.1 \
+ --hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
+ --hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
+ # via -r requirements-dev.txt
diff --git a/requirements-dev.txt b/requirements-dev.txt
new file mode 100644
index 0000000..da8bd6f
--- /dev/null
+++ b/requirements-dev.txt
@@ -0,0 +1,3 @@
+# Direct test inputs are pinned so a new upstream release cannot change CI.
+coverage==7.15.2
+pytest==9.1.1
diff --git a/tests/test_context_guard_hooks.py b/tests/test_context_guard_hooks.py
new file mode 100644
index 0000000..bed92fd
--- /dev/null
+++ b/tests/test_context_guard_hooks.py
@@ -0,0 +1,216 @@
+"""Behavioral coverage for the context-guard hook boundary."""
+
+from __future__ import annotations
+
+import importlib.util
+import io
+import json
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+
+import pytest
+
+
+ROOT = Path(__file__).resolve().parent.parent
+HOOKS = ROOT / "plugins" / "context-guard" / "hooks"
+TOOLS = ROOT / "plugins" / "context-guard" / "tools"
+
+
+def _load(name: str, path: Path):
+ spec = importlib.util.spec_from_file_location(name, path)
+ module = importlib.util.module_from_spec(spec)
+ assert spec.loader is not None
+ sys.modules[name] = module
+ spec.loader.exec_module(module)
+ return module
+
+
+if str(HOOKS) not in sys.path:
+ sys.path.insert(0, str(HOOKS))
+if str(TOOLS) not in sys.path:
+ sys.path.insert(0, str(TOOLS))
+
+protocol = _load("session_optimizer_checkpoint_protocol", HOOKS / "checkpoint_protocol.py")
+guard = _load("session_optimizer_stop_guard", HOOKS / "stop-context-guard.py")
+usage_core = _load("session_optimizer_usage_core", TOOLS / "subagent_usage.py")
+tracker = _load("session_optimizer_subagent_tracker", HOOKS / "subagent-tracker.py")
+
+
+def test_protocol_detects_project_tool_and_renders_both_contracts(tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ project_tool = tmp_path / "tools" / "memory-tool.sh"
+ project_tool.parent.mkdir()
+ project_tool.write_text("#!/bin/sh\n")
+ assert protocol.detect_memory_tool(str(tmp_path)) == str(project_tool)
+ assert protocol.detect_memory_tool(str(tmp_path / "missing")) is None
+
+ generic_warn = protocol.warn_reason(180_000, "/tmp/check.md", 180_000, 200_000)
+ scoped_warn = protocol.warn_reason_scoped(180_000, "", 180_000, 200_000)
+ generic_hard = protocol.block_reason(200_000, "", 200_000)
+ scoped_hard = protocol.block_reason_scoped(200_000, "/tmp/check.md", 200_000)
+ assert "memory-writer" in generic_warn
+ assert "remember endpoint" in scoped_warn
+ assert "latest.md" in generic_hard
+ assert "MEMORY_AGENT_ID" in scoped_hard
+
+
+def test_threshold_config_and_fallback(tmp_path, monkeypatch):
+ config = tmp_path / "thresholds.json"
+ config.write_text(json.dumps({
+ "models": [{"match": "mini", "warn": 10, "hard": 20}],
+ "default": {"warn": 30, "hard": 40},
+ }))
+ monkeypatch.setattr(guard, "CONFIG_PATH", str(config))
+ assert guard._thresholds("agent-mini") == (10, 20)
+ assert guard._thresholds("other") == (30, 40)
+
+ config.write_text('{"models":[{"match":"broken"}],"default":{"warn":9,"hard":2}}')
+ assert guard._thresholds("broken") == (180_000, 200_000)
+ config.write_text("not json")
+ assert guard._thresholds("haiku-4") == (120_000, 170_000)
+
+
+@pytest.mark.parametrize("line", ["", "not json", "{}", '{"message":{"usage":{}}}'])
+def test_usage_line_rejects_non_usage(line):
+ assert guard._usage_from_line(line) is None
+
+
+def test_usage_line_and_reverse_tail_reader(tmp_path, monkeypatch):
+ line = json.dumps({"message": {"model": "opus", "usage": {
+ "input_tokens": 2, "cache_creation_input_tokens": 3,
+ "cache_read_input_tokens": 5,
+ }}})
+ assert guard._usage_from_line(line) == (10, "opus")
+ transcript = tmp_path / "transcript.jsonl"
+ transcript.write_text("noise\n" * 30 + line + "\ntrailing junk\n")
+ monkeypatch.setattr(guard, "TAIL_CHUNK", 64)
+ monkeypatch.setattr(guard, "TAIL_MAX_BYTES", 512)
+ assert guard._read_last_usage(str(transcript)) == (10, "opus")
+ assert guard._read_last_usage(str(tmp_path / "missing")) == (None, None)
+ empty = tmp_path / "empty"
+ empty.write_text("")
+ assert guard._read_last_usage(str(empty)) == (None, None)
+
+
+def test_subagent_summary_line_and_git_fail_open(tmp_path, monkeypatch):
+ real_join = guard.os.path.join
+ monkeypatch.setattr(
+ guard.os.path,
+ "join",
+ lambda root, leaf: str(tmp_path / leaf) if root == "/tmp" else real_join(root, leaf),
+ )
+ assert guard._subagent_summary("none") == (0, 0, 0.0)
+ state = tmp_path / "zetetic-subagents-s1.json"
+ state.write_text(json.dumps({"totals": {
+ "count": 2, "input_tokens": 10, "output_tokens": 5,
+ "cache_tokens": 20, "cost_usd": 1.25,
+ }}))
+ assert guard._subagent_summary("s1") == (2, 35, 1.25)
+ assert "2 runs" in guard._subagent_line("s1")
+ assert guard._subagent_line("none") == ""
+
+ monkeypatch.setattr(guard.subprocess, "run", lambda *a, **k: SimpleNamespace(stdout=" main \n"))
+ assert guard._git(str(tmp_path), "status") == "main"
+ monkeypatch.setattr(guard.subprocess, "run", lambda *a, **k: (_ for _ in ()).throw(OSError()))
+ assert guard._git(str(tmp_path), "status") == ""
+
+
+def test_stub_and_level_state_round_trip(tmp_path, monkeypatch):
+ monkeypatch.setenv("HOME", str(tmp_path))
+ monkeypatch.setattr(guard, "_git", lambda _cwd, *args: {
+ "symbolic-ref": "feature", "log": "abc subject", "status": " M README.md",
+ }.get(args[0], ""))
+ monkeypatch.setattr(guard, "_subagent_summary", lambda _sid: (2, 3000, 0.5))
+ stub = guard._write_stub("session-123", str(tmp_path), 190_000, "opus", "warn")
+ text = Path(stub).read_text()
+ assert "feature" in text and "README.md" in text and "2 runs" in text
+ assert (Path(stub).parent / "latest.md").read_text() == text
+
+ monkeypatch.setattr(guard, "STATE_DIR", str(tmp_path))
+ assert guard._load_level("fresh") == "none"
+ guard._save_level("fresh", "warn")
+ assert guard._load_level("fresh") == "warn"
+ (tmp_path / "zetetic-ctxguard-bad.json").write_text("bad")
+ assert guard._load_level("bad") == "none"
+
+
+def _run_guard_main(monkeypatch, payload, *, ctx=(190_000, "opus"), prev="none", scoped=False):
+ stdin = io.StringIO(payload if isinstance(payload, str) else json.dumps(payload))
+ stdout = io.StringIO()
+ monkeypatch.setattr(guard.sys, "stdin", stdin)
+ monkeypatch.setattr(guard.sys, "stdout", stdout)
+ monkeypatch.setattr(guard, "_read_last_usage", lambda _path: ctx)
+ monkeypatch.setattr(guard, "_thresholds", lambda _model: (180_000, 200_000))
+ monkeypatch.setattr(guard, "_load_level", lambda _sid: prev)
+ monkeypatch.setattr(guard, "_save_level", lambda *_: None)
+ monkeypatch.setattr(guard, "_write_stub", lambda *_: "/tmp/check.md")
+ monkeypatch.setattr(guard, "_subagent_line", lambda _sid: "\nsubagents")
+ monkeypatch.setattr(guard.checkpoint_protocol, "detect_memory_tool", lambda _cwd: "/tool" if scoped else None)
+ with pytest.raises(SystemExit) as exc:
+ guard.main()
+ assert exc.value.code == 0
+ return stdout.getvalue()
+
+
+def test_guard_main_fail_open_and_threshold_paths(monkeypatch):
+ assert _run_guard_main(monkeypatch, "not json") == ""
+ assert _run_guard_main(monkeypatch, {"stop_hook_active": True}) == ""
+ assert _run_guard_main(monkeypatch, {}, ctx=(None, None)) == ""
+ assert _run_guard_main(monkeypatch, {}, ctx=(100, "opus")) == ""
+ assert _run_guard_main(monkeypatch, {}, prev="warn") == ""
+
+
+def test_guard_main_warn_and_hard_payloads(monkeypatch):
+ warn = json.loads(_run_guard_main(monkeypatch, {"session_id": "s", "cwd": "/x"}))
+ assert warn["decision"] == "block"
+ assert "memory-writer" in warn["reason"]
+ assert "subagents" in warn["systemMessage"]
+ hard = json.loads(_run_guard_main(
+ monkeypatch, {"session_id": "s", "cwd": "/x"},
+ ctx=(210_000, "opus"), prev="warn", scoped=True,
+ ))
+ assert hard["decision"] == "block"
+ assert "MEMORY_AGENT_ID" in hard["reason"]
+
+
+def test_tracker_helpers_and_main_sweep(tmp_path, monkeypatch):
+ monkeypatch.setattr(tracker, "_state_path", lambda sid: str(tmp_path / f"{sid}.json"))
+ assert tracker._load_state("s") == {"session_id": "s", "agents": {}}
+ (tmp_path / "s.json").write_text(json.dumps({"session_id": "s", "agents": {}}))
+ assert tracker._load_state("s")["session_id"] == "s"
+
+ usage = usage_core.Usage(input_tokens=3, output_tokens=4, cache_write_5m=5,
+ cache_write_1h=6, cache_read=7, tool_uses=2,
+ web_search_requests=1, web_fetch_requests=2,
+ model="opus")
+ rec = usage_core.SubagentRecord("a", "Explore", "look", "t1", usage, 1.23456, "/a")
+ entry = tracker._agent_entry(rec)
+ assert entry["cache_tokens"] == 18 and entry["cost_usd"] == 1.2346
+ state = {"agents": {"a": entry, "b": {"input_tokens": 2, "cost_usd": 0.1}}}
+ tracker._recompute_totals(state)
+ assert state["totals"]["count"] == 2
+ assert state["totals"]["input_tokens"] == 5
+
+ monkeypatch.setattr(tracker, "subagent_record", lambda path: rec if path else None)
+ tracker._update_from_transcript({"agents": {}}, "")
+ payload_path = tmp_path / "agent-a.jsonl"
+ payload_path.write_text("{}\n")
+ monkeypatch.setattr(tracker, "session_dir_for", lambda _path: str(tmp_path))
+ monkeypatch.setattr(tracker, "discover_subagents", lambda _dir: [str(payload_path), str(tmp_path / "agent-b.jsonl")])
+ monkeypatch.setattr(tracker.sys, "stdin", io.StringIO(json.dumps({
+ "session_id": "s", "transcript_path": str(payload_path),
+ })))
+ with pytest.raises(SystemExit) as exc:
+ tracker.main()
+ assert exc.value.code == 0
+ saved = json.loads((tmp_path / "s.json").read_text())
+ assert saved["totals"]["count"] == 1
+ assert saved["agents"]["a"]["agent_type"] == "Explore"
+
+
+def test_tracker_malformed_input_is_nonfatal(monkeypatch):
+ monkeypatch.setattr(tracker.sys, "stdin", io.StringIO("bad"))
+ with pytest.raises(SystemExit) as exc:
+ tracker.main()
+ assert exc.value.code == 0
diff --git a/tests/test_measure_refine_overhead.py b/tests/test_measure_refine_overhead.py
new file mode 100644
index 0000000..d2db1f9
--- /dev/null
+++ b/tests/test_measure_refine_overhead.py
@@ -0,0 +1,54 @@
+"""Tests for the opt-in local refine-gate overhead measurement."""
+
+from __future__ import annotations
+
+import importlib.util
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+
+ROOT = Path(__file__).resolve().parent.parent
+SCRIPT = ROOT / "plugins" / "refine-gate" / "tools" / "measure_refine_overhead.py"
+SPEC = importlib.util.spec_from_file_location("session_optimizer_measure_refine", SCRIPT)
+measure = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(measure)
+
+
+def test_collect_prompts_filters_non_user_and_harness_records(tmp_path, monkeypatch):
+ project = tmp_path / ".claude" / "projects" / "p"
+ project.mkdir(parents=True)
+ transcript = project / "s.jsonl"
+ records = [
+ {"type": "user", "message": {"content": " fix the cache "}},
+ {"type": "user", "message": {"content": ""}},
+ {"type": "user", "message": {"content": "Caveat: wrapper"}},
+ {"type": "user", "message": {"content": ["tool result"]}},
+ {"type": "assistant", "message": {"content": "ignored"}},
+ ]
+ transcript.write_text("bad json\n" + "".join(json.dumps(r) + "\n" for r in records))
+ monkeypatch.setattr(measure.Path, "home", staticmethod(lambda: tmp_path))
+ assert measure.collect_prompts() == ["fix the cache"]
+
+
+def test_main_reports_tier_mix(monkeypatch, capsys):
+ monkeypatch.setattr(measure, "collect_prompts", lambda: ["tier1", "tier2", "silent"])
+
+ def fake_run(args, *, input, **kwargs):
+ if "tier1" in input:
+ ctx = "matched: prior solution"
+ elif "tier2" in input:
+ ctx = "names no concrete artifact"
+ else:
+ return SimpleNamespace(stdout="")
+ return SimpleNamespace(stdout=json.dumps({
+ "hookSpecificOutput": {"additionalContext": ctx},
+ }))
+
+ monkeypatch.setattr(measure.subprocess, "run", fake_run)
+ measure.main()
+ out = capsys.readouterr().out
+ assert "corpus: 3" in out
+ assert "tier1 1, tier2 1, silent 1" in out
+ assert "mean per prompt" in out
diff --git a/tests/test_release_tools.py b/tests/test_release_tools.py
new file mode 100644
index 0000000..913c3a0
--- /dev/null
+++ b/tests/test_release_tools.py
@@ -0,0 +1,80 @@
+"""Release bundle inventory and verification contracts."""
+
+from __future__ import annotations
+
+import importlib.util
+import hashlib
+import io
+import json
+import subprocess
+import tarfile
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent.parent
+SBOM_SCRIPT = ROOT / "tools" / "gen-bundle-sbom.py"
+SPEC = importlib.util.spec_from_file_location("session_optimizer_sbom", SBOM_SCRIPT)
+sbom = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(sbom)
+
+
+def test_sbom_has_one_hashed_component_per_file(tmp_path):
+ (tmp_path / "a").write_text("a")
+ nested = tmp_path / "d"
+ nested.mkdir()
+ (nested / "b").write_text("b")
+ document = sbom.build_sbom("1.2.3", [str(tmp_path)])
+ assert document["bomFormat"] == "CycloneDX"
+ assert document["metadata"]["component"]["version"] == "1.2.3"
+ assert len(document["components"]) == 2
+ assert all(len(c["hashes"][0]["content"]) == 64 for c in document["components"])
+
+
+def test_release_bundle_builds_verifies_and_rejects_tampering(tmp_path):
+ out = tmp_path / "dist"
+ subprocess.run(["bash", "tools/build-release-bundle.sh", str(out)], cwd=ROOT, check=True)
+ bundle = out / "session-optimizer.tar.gz"
+ checksum = out / "session-optimizer.tar.gz.sha256"
+ manifest = out / "EXECUTABLE-MANIFEST.sha256"
+ document = json.loads((out / "session-optimizer.cdx.json").read_text())
+ assert bundle.is_file() and manifest.read_text().strip()
+ assert document["components"]
+
+ good = subprocess.run(
+ ["bash", "tools/verify-release-bundle.sh", str(bundle), str(checksum), str(manifest)],
+ cwd=ROOT, capture_output=True, text=True,
+ )
+ assert good.returncode == 0, good.stderr
+ with bundle.open("ab") as handle:
+ handle.write(b"tampered")
+ bad = subprocess.run(
+ ["bash", "tools/verify-release-bundle.sh", str(bundle), str(checksum), str(manifest)],
+ cwd=ROOT, capture_output=True, text=True,
+ )
+ assert bad.returncode == 1
+ assert "INTEGRITY FAILURE" in bad.stderr
+
+
+def test_release_verifier_rejects_path_traversal_before_extraction(tmp_path):
+ bundle = tmp_path / "unsafe.tar.gz"
+ payload = b"must not escape"
+ with tarfile.open(bundle, "w:gz") as archive:
+ member = tarfile.TarInfo("../escaped.txt")
+ member.size = len(payload)
+ archive.addfile(member, io.BytesIO(payload))
+
+ checksum = tmp_path / "unsafe.tar.gz.sha256"
+ checksum.write_text(f"{hashlib.sha256(bundle.read_bytes()).hexdigest()} {bundle.name}\n")
+ manifest = tmp_path / "manifest.sha256"
+ manifest.write_text("")
+
+ result = subprocess.run(
+ ["bash", "tools/verify-release-bundle.sh", str(bundle), str(checksum), str(manifest)],
+ cwd=ROOT,
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode == 1
+ assert "unsafe archive path" in result.stderr
+ assert not (tmp_path.parent / "escaped.txt").exists()
diff --git a/tests/test_statusline_transcript.py b/tests/test_statusline_transcript.py
new file mode 100644
index 0000000..7a6c26e
--- /dev/null
+++ b/tests/test_statusline_transcript.py
@@ -0,0 +1,118 @@
+"""Behavioral tests for the statusline transcript cache builder."""
+
+from __future__ import annotations
+
+import importlib.util
+import io
+import json
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parent.parent
+SCRIPT = ROOT / "plugins" / "statusline" / "assets" / "statusline-transcript.py"
+SPEC = importlib.util.spec_from_file_location("session_optimizer_statusline_transcript", SCRIPT)
+telemetry = importlib.util.module_from_spec(SPEC)
+assert SPEC.loader is not None
+SPEC.loader.exec_module(telemetry)
+
+
+def _record(kind, timestamp, *, output=0, model="opus", **extra):
+ record = {"type": kind, "timestamp": timestamp, **extra}
+ if kind == "assistant":
+ record["message"] = {
+ "role": "assistant", "model": model,
+ "usage": {"output_tokens": output},
+ }
+ return record
+
+
+def _jsonl(path: Path, records):
+ path.write_text("".join(json.dumps(r) + "\n" for r in records))
+
+
+def test_epoch_and_compaction_detection():
+ assert telemetry._epoch("2026-01-01T00:00:00Z") is not None
+ assert telemetry._epoch(None) is None
+ assert telemetry._epoch("bad") is None
+ assert telemetry._is_compaction(json.dumps({"isCompactSummary": True}))
+ assert telemetry._is_compaction(json.dumps({"type": "summary"}))
+ assert telemetry._is_compaction(json.dumps({"subtype": "compact"}))
+ assert telemetry._is_compaction(json.dumps({"message": {"subtype": "compact"}}))
+ assert not telemetry._is_compaction('{"text":"type summary"}')
+ assert not telemetry._is_compaction('"type":"summary" not-json')
+
+
+def test_count_tail_and_last_turn(tmp_path, monkeypatch):
+ path = tmp_path / "session.jsonl"
+ records = [
+ _record("user", "2026-01-01T00:00:00Z"),
+ _record("assistant", "2026-01-01T00:00:10Z", output=50, model="sonnet"),
+ {"type": "summary", "timestamp": "2026-01-01T00:00:11Z"},
+ ]
+ _jsonl(path, records)
+ assert telemetry._count_compactions(str(path), 0) == 1
+ assert telemetry._count_compactions(str(tmp_path / "missing"), 0) == 0
+ parsed = telemetry._tail_records(str(path), path.stat().st_size)
+ last_ts, rate, model = telemetry._last_turn(parsed)
+ assert last_ts is not None and rate == 5.0 and model == "sonnet"
+ assert telemetry._last_turn([]) == (None, None, None)
+ assert telemetry._tail_records(str(tmp_path / "missing"), 1) == []
+
+ # Exercise the partial-leading-line branch used for large transcripts.
+ monkeypatch.setattr(telemetry, "TAIL_WINDOW", 256)
+ path.write_text("x" * 200 + "\n" + json.dumps(records[1]) + "\n")
+ assert telemetry._tail_records(str(path), path.stat().st_size)[-1]["asst"] is True
+
+
+def test_last_turn_degenerate_and_model_fallback():
+ records = [
+ {"ts": 10.0, "asst": False, "out": 0, "model": None},
+ {"ts": 10.0, "asst": True, "out": 20, "model": None},
+ {"ts": 12.0, "asst": True, "out": 0, "model": "opus"},
+ ]
+ assert telemetry._last_turn(records) == (12.0, None, "opus")
+
+
+def test_cache_load_and_build_incremental_paths(tmp_path, monkeypatch):
+ cache = tmp_path / "cache.json"
+ monkeypatch.setattr(telemetry, "CACHE_PATH", str(cache))
+ assert telemetry._load_cache() == {}
+ cache.write_text("bad")
+ assert telemetry._load_cache() == {}
+
+ path = tmp_path / "session.jsonl"
+ assert telemetry.build(str(path)) == {}
+ path.write_text("")
+ assert telemetry.build(str(path)) == {}
+ _jsonl(path, [
+ _record("user", "2026-01-01T00:00:00Z"),
+ _record("assistant", "2026-01-01T00:00:04Z", output=20),
+ {"isCompactSummary": True},
+ ])
+ first = telemetry.build(str(path))
+ assert first["compactions"] == 1 and first["tok_per_s"] == 5.0
+
+ cache.write_text(json.dumps(first))
+ unchanged = telemetry.build(str(path))
+ assert unchanged["compactions"] == 1
+ with path.open("a") as fh:
+ fh.write(json.dumps({"subtype": "compact"}) + "\n")
+ grown = telemetry.build(str(path))
+ assert grown["compactions"] == 2
+ path.write_text(json.dumps(_record("assistant", "2026-01-01T00:00:05Z", output=1)) + "\n")
+ shrunk = telemetry.build(str(path))
+ assert shrunk["compactions"] == 0
+
+
+def test_main_writes_cache_and_prints_json(tmp_path, monkeypatch):
+ transcript = tmp_path / "session.jsonl"
+ _jsonl(transcript, [_record("assistant", "2026-01-01T00:00:01Z", output=1)])
+ cache = tmp_path / "cache.json"
+ stdout = io.StringIO()
+ monkeypatch.setattr(telemetry, "CACHE_PATH", str(cache))
+ monkeypatch.setattr(telemetry.sys, "argv", [str(SCRIPT), str(transcript)])
+ monkeypatch.setattr(telemetry.sys, "stdout", stdout)
+ telemetry.main()
+ emitted = json.loads(stdout.getvalue())
+ assert emitted["path"] == str(transcript)
+ assert json.loads(cache.read_text()) == emitted
diff --git a/tests/test_subagent_usage.py b/tests/test_subagent_usage.py
index 3d38215..9a75bea 100644
--- a/tests/test_subagent_usage.py
+++ b/tests/test_subagent_usage.py
@@ -140,3 +140,95 @@ def test_session_dir_for_resolves_owning_session(tmp_path):
assert su.session_dir_for(p) == "/x/proj/sessid"
p2 = "/x/proj/sessid/subagents/agent-a.jsonl"
assert su.session_dir_for(p2) == "/x/proj/sessid"
+
+
+def test_usage_add_context_and_dict():
+ left = su.Usage(input_tokens=1, output_tokens=2, cache_write_5m=3,
+ cache_write_1h=4, cache_read=5, model="")
+ right = su.Usage(input_tokens=10, output_tokens=20, cache_write_5m=30,
+ cache_write_1h=40, cache_read=50, tool_uses=2,
+ web_search_requests=3, web_fetch_requests=4,
+ model="sonnet", models={"sonnet"})
+ left.add(right)
+ assert left.context_tokens == 143
+ payload = left.to_dict()
+ assert payload["output_tokens"] == 22
+ assert payload["models"] == ["sonnet"]
+
+
+def test_parse_uses_largest_duplicate_and_uuid_fallback(tmp_path):
+ path = str(tmp_path / "agent-dups.jsonl")
+ records = [
+ {"uuid": "u1", "message": {"usage": {"input_tokens": 1}, "content": []}},
+ {"uuid": "u1", "message": {"usage": {"input_tokens": 9}, "content": []}},
+ {"message": {"model": "sonnet", "usage": {"output_tokens": 2},
+ "content": ["text", {"type": "tool_use"}]}},
+ ]
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write("bad\n\n")
+ for record in records:
+ fh.write(json.dumps(record) + "\n")
+ usage = su.parse_transcript_usage(path)
+ assert usage.input_tokens == 9
+ assert usage.output_tokens == 2
+ assert usage.tool_uses == 1
+ assert usage.model == "sonnet"
+
+
+def test_meta_agent_id_and_discovery_fallbacks(tmp_path):
+ bad = tmp_path / "bad.meta.json"
+ bad.write_text("bad")
+ assert su._read_meta(str(bad)) == ("", "", "")
+ assert su._agent_id_from_path("/x/not-an-agent.log") == "not-an-agent.log"
+ assert su.discover_subagents(str(tmp_path / "missing")) == []
+ assert su.session_dir_for("/x/no-subagents/agent-a.jsonl") == "/x/no-subagents"
+
+
+def test_token_format_and_session_iteration(tmp_path):
+ assert su._fmt_tokens(999) == "999"
+ assert su._fmt_tokens(1_000) == "1k"
+ assert su._fmt_tokens(1_500_000) == "1.5M"
+ assert list(su._iter_session_dirs(str(tmp_path / "missing"))) == []
+ session = tmp_path / "s1" / "subagents"
+ session.mkdir(parents=True)
+ (tmp_path / "file").write_text("x")
+ assert list(su._iter_session_dirs(str(tmp_path))) == [str(tmp_path / "s1")]
+
+
+def test_build_report_groups_records(monkeypatch):
+ usage = su.Usage(input_tokens=100, output_tokens=20, model="haiku", models={"haiku"})
+ rec = su.SubagentRecord("a", "Explore", "look", "tool", usage, 0.001, "/a")
+ monkeypatch.setattr(su, "_encoded_project_dir", lambda _cwd: "/project")
+ monkeypatch.setattr(su, "_iter_session_dirs", lambda _root: ["/project/s"])
+ monkeypatch.setattr(su, "discover_subagents", lambda _session: ["/a", "/b"])
+ monkeypatch.setattr(su, "subagent_record", lambda _path: rec)
+ report = su.build_report("/cwd")
+ assert report["subagent_count"] == 2
+ assert report["by_agent_type"]["Explore"]["count"] == 2
+ assert report["totals"]["usage"]["input_tokens"] == 200
+
+
+def test_print_table_and_cli_modes(monkeypatch, capsys, tmp_path):
+ empty = {
+ "project_dir": "/p", "subagent_count": 0,
+ "by_agent_type": {}, "totals": {"cost_usd": 0.0},
+ }
+ su._print_table(empty)
+ assert "no subagent transcripts" in capsys.readouterr().out
+
+ report = {
+ "project_dir": "/p", "subagent_count": 1,
+ "by_agent_type": {"Explore": {"count": 1, "usage": {
+ "input_tokens": 1000, "output_tokens": 2,
+ "cache_write_5m": 3, "cache_write_1h": 4, "cache_read": 5,
+ }, "cost_usd": 1.25}},
+ "totals": {"cost_usd": 1.25},
+ }
+ su._print_table(report)
+ assert "Explore" in capsys.readouterr().out
+
+ monkeypatch.setattr(su, "build_report", lambda _cwd: report)
+ assert su.main(["--json", str(tmp_path)]) == 0
+ assert '"subagent_count": 1' in capsys.readouterr().out
+ assert su.main([str(tmp_path)]) == 0
+ assert "TOTAL" in capsys.readouterr().out
diff --git a/tools/build-release-bundle.sh b/tools/build-release-bundle.sh
new file mode 100644
index 0000000..b451cd6
--- /dev/null
+++ b/tools/build-release-bundle.sh
@@ -0,0 +1,52 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+repo_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
+cd "$repo_root"
+out_dir="${1:-dist}"
+bundle_name="session-optimizer"
+bundle_paths=(
+ .claude-plugin
+ .agents
+ hooks
+ plugins
+ README.md
+ LICENSE
+ PRIVACY.md
+ SECURITY.md
+ CHANGELOG.md
+)
+
+mkdir -p "$out_dir"
+
+tar_args=(--create --gzip)
+if tar --version 2>/dev/null | grep -qi "gnu tar"; then
+ tar_args+=(--sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner)
+fi
+tar "${tar_args[@]}" -f "$out_dir/$bundle_name.tar.gz" "${bundle_paths[@]}"
+
+find hooks plugins -type f \( -name "*.sh" -o -name "*.py" \) -print \
+ | LC_ALL=C sort \
+ | while IFS= read -r file; do
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$file"
+ else
+ shasum -a 256 "$file"
+ fi
+ done > "$out_dir/EXECUTABLE-MANIFEST.sha256"
+
+version="$(python3 -c 'import json; print(json.load(open(".claude-plugin/marketplace.json"))["metadata"]["version"])')"
+python3 tools/gen-bundle-sbom.py \
+ --version "$version" \
+ --output "$out_dir/$bundle_name.cdx.json" \
+ "${bundle_paths[@]}"
+
+for file in "$bundle_name.tar.gz" "$bundle_name.cdx.json" EXECUTABLE-MANIFEST.sha256; do
+ if command -v sha256sum >/dev/null 2>&1; then
+ (cd "$out_dir" && sha256sum "$file" > "$file.sha256")
+ else
+ (cd "$out_dir" && shasum -a 256 "$file" > "$file.sha256")
+ fi
+done
+
+printf 'Release bundle assembled in %s\n' "$out_dir"
diff --git a/tools/gen-bundle-sbom.py b/tools/gen-bundle-sbom.py
new file mode 100644
index 0000000..7bf6ee6
--- /dev/null
+++ b/tools/gen-bundle-sbom.py
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+"""Generate a deterministic-file inventory as a CycloneDX 1.5 SBOM."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+from datetime import datetime, timezone
+from pathlib import Path
+
+
+def _sha256(path: Path) -> str:
+ digest = hashlib.sha256()
+ with path.open("rb") as handle:
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def _iter_files(roots: list[str]) -> list[Path]:
+ found: set[Path] = set()
+ for root in roots:
+ path = Path(root)
+ if path.is_dir():
+ found.update(item for item in path.rglob("*") if item.is_file())
+ elif path.is_file():
+ found.add(path)
+ return sorted(found)
+
+
+def build_sbom(version: str, roots: list[str]) -> dict:
+ components = [
+ {
+ "type": "file",
+ "name": str(path),
+ "version": version,
+ "hashes": [{"alg": "SHA-256", "content": _sha256(path)}],
+ }
+ for path in _iter_files(roots)
+ ]
+ timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
+ return {
+ "bomFormat": "CycloneDX",
+ "specVersion": "1.5",
+ "version": 1,
+ "metadata": {
+ "timestamp": timestamp,
+ "component": {
+ "type": "application",
+ "name": "session-optimizer",
+ "version": version,
+ },
+ },
+ "components": components,
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--version", required=True)
+ parser.add_argument("--output", required=True, type=Path)
+ parser.add_argument("roots", nargs="+")
+ args = parser.parse_args(argv)
+ document = build_sbom(args.version, args.roots)
+ args.output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/verify-release-bundle.sh b/tools/verify-release-bundle.sh
new file mode 100644
index 0000000..9075e13
--- /dev/null
+++ b/tools/verify-release-bundle.sh
@@ -0,0 +1,57 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+bundle="${1:-}"
+bundle_sum="${2:-}"
+exec_manifest="${3:-}"
+
+if [[ -z "$bundle" || -z "$bundle_sum" ]]; then
+ echo "usage: $0 [executable-manifest]" >&2
+ exit 2
+fi
+[[ -f "$bundle" ]] || { echo "error: bundle not found: $bundle" >&2; exit 2; }
+[[ -f "$bundle_sum" ]] || { echo "error: checksum not found: $bundle_sum" >&2; exit 2; }
+
+sha256_file() {
+ if command -v sha256sum >/dev/null 2>&1; then
+ sha256sum "$1" | awk '{print $1}'
+ else
+ shasum -a 256 "$1" | awk '{print $1}'
+ fi
+}
+
+expected="$(awk '{print $1}' "$bundle_sum" | head -1 | tr '[:upper:]' '[:lower:]')"
+[[ "$expected" =~ ^[0-9a-f]{64}$ ]] || { echo "error: invalid SHA-256 checksum" >&2; exit 2; }
+actual="$(sha256_file "$bundle" | tr '[:upper:]' '[:lower:]')"
+[[ "$actual" == "$expected" ]] || { echo "INTEGRITY FAILURE: bundle checksum mismatch" >&2; exit 1; }
+
+[[ -z "$exec_manifest" ]] && { echo "OK: bundle checksum matches"; exit 0; }
+[[ -f "$exec_manifest" ]] || { echo "error: manifest not found: $exec_manifest" >&2; exit 2; }
+
+workdir="$(mktemp -d)"
+trap 'rm -rf "$workdir"' EXIT
+
+# Reject path traversal before extraction even when the archive checksum is
+# internally consistent. Release verification is a trust boundary, not a
+# convenience untar command.
+while IFS= read -r member; do
+ case "$member" in
+ /*|../*|*/../*|*/..) echo "INTEGRITY FAILURE: unsafe archive path: $member" >&2; exit 1 ;;
+ esac
+done < <(tar -tzf "$bundle")
+tar -xzf "$bundle" -C "$workdir"
+
+while IFS= read -r line; do
+ [[ -z "$line" ]] && continue
+ want="$(awk '{print $1}' <<<"$line" | tr '[:upper:]' '[:lower:]')"
+ rel="$(awk '{print $2}' <<<"$line")"
+ [[ "$want" =~ ^[0-9a-f]{64}$ ]] || { echo "error: invalid executable digest" >&2; exit 2; }
+ [[ -n "$rel" && "$rel" != /* && "$rel" != ../* && "$rel" != */../* ]] \
+ || { echo "INTEGRITY FAILURE: unsafe manifest path" >&2; exit 1; }
+ target="$workdir/$rel"
+ [[ -f "$target" ]] || { echo "INTEGRITY FAILURE: missing executable: $rel" >&2; exit 1; }
+ got="$(sha256_file "$target" | tr '[:upper:]' '[:lower:]')"
+ [[ "$got" == "$want" ]] || { echo "INTEGRITY FAILURE: executable mismatch: $rel" >&2; exit 1; }
+done < "$exec_manifest"
+
+echo "OK: bundle and executable manifest verified"