Skip to content

ci(coverage): enforce 95% unit-test coverage on changed Python lines - #1933

Merged
qingyun-wu merged 4 commits into
sonichi:mainfrom
john-the-dev:feat/coverage-gate-95
Jul 6, 2026
Merged

qingyun-wu merged 4 commits into
sonichi:mainfrom
john-the-dev:feat/coverage-gate-95

Conversation

@john-the-dev

Copy link
Copy Markdown
Collaborator

What

Owner-requested: enforce a 95% minimum unit-test coverage bar. New CI job Coverage Gate: every PR must cover ≥ 95% of the Python lines it adds or changes, measured by running the full standalone suite (tests/**/*.test.py, same discovery as ci.yml) under coverage.py and judging the PR's diff with diff-cover. The whole-tree number is printed informationally in every run.

Why diff coverage instead of a global 95% floor

Measured global coverage today is 31% (10,570 statements, 7,323 missed) — the legacy surface (bridges with live APIs, GUI automation, launchd installers) predates the test push. A global floor at 95% would hard-red every open PR on day one and block all merges. The diff gate applies the full 95% bar to all new code: the protected number can't get worse, every merge converges the tree upward, and nobody gets punished for debt they didn't create. docs/testing-coverage.md documents the flip-to-global step for when the tree gets there. (If you want the hard global floor anyway, it's a one-line change — but I'd recommend against shipping that today.)

Pieces

  • scripts/coverage-gate.sh — instrumented suite run (parallel-mode per test file, combined), informational tree report, then diff-cover --fail-under=95 vs base. Fast path: PRs touching no .py skip the instrumented run entirely. Runnable locally with the same command CI uses.
  • .coveragerc — scope src/ scripts/ skills/; excludes tests, __main__ blocks, pragma: no cover.
  • .github/workflows/coverage-gate.yml — PR-triggered, fetch-depth: 0 (diff-cover needs the base), dev-deps installed inline (same pattern as ci.yml's detect-secrets).
  • docs/testing-coverage.md — the rule, local usage, pragma policy, the subprocess-boundary caveat (prefer importlib over subprocess in tests so coverage sees the module), TS follow-up note.

Validation (local, full suite)

  • 112-file Python suite runs green under instrumentation.
  • Uncovered scratch module → gate fails (exit 1) naming the exact missing lines.
  • Same module + test → gate passes (100% diff coverage).
  • No-Python diff → fast-path skip.

Follow-up (not in this PR)

TypeScript gating: c8 around the existing tsx --test run + diff-cover on lcov output. Bash: no practical coverage tooling — excluded with rationale in docs.

🤖 Generated with Claude Code

https://claude.ai/code/session_01R4cEk3Nzp6kVVowwQaN1J1

Owner-requested (Discord 2026-07-06): enforce a 95% minimum unit-test
coverage bar. Implemented as a DIFF-coverage gate: every PR must cover
>= 95% of the Python lines it adds or changes; the whole-tree number is
printed informationally each run.

Why not a global 95% floor: measured global coverage today is 31%
(10,570 stmts, 7,323 missed - bridges/GUI/installer legacy surface).
A global floor would red every PR immediately and block all merges.
The diff gate holds all NEW code to the full bar and converges the
tree upward; docs describe the flip-to-global step for when the tree
gets there.

- scripts/coverage-gate.sh: runs the standalone suite (same discovery
  as ci.yml) under coverage.py parallel instrumentation, combines,
  prints the tree report, then diff-cover --fail-under=95 vs base.
  Fast path: PRs touching no Python skip the instrumented run.
- .coveragerc: scope src/ scripts/ skills/; excludes tests, __main__
  blocks, pragma: no cover.
- .github/workflows/coverage-gate.yml: PR-triggered job (fetch-depth 0
  for base diff; installs coverage + diff-cover + detect-secrets, the
  same dev-dep pattern ci.yml uses).
- docs/testing-coverage.md: the rule, local usage, pragma policy,
  subprocess-boundary caveat, TS follow-up note.

Validated locally: full 112-file suite green under instrumentation;
gate fails (exit 1) naming exact uncovered lines on an uncovered
scratch module; passes at 100% once covered; fast path exits early on
no-Python diffs.
Owner feedback: the coverage percentage should be visible on the PR,
not buried in job logs.

- coverage-gate.sh writes coverage-summary.md in every path (fast-path
  skip / suite failure / verdict + diff-cover --markdown-report with
  per-file uncovered lines) and mirrors it to $GITHUB_STEP_SUMMARY.
- coverage-gate.yml uploads the summary + PR number as an artifact.
- NEW coverage-comment.yml (workflow_run, base-repo context,
  pull-requests: write): downloads the artifact and posts/updates ONE
  sticky comment (marker-matched — no per-push comment spam). Two-stage
  because fork PRs run the gate with a read-only token; the comment
  workflow never checks out PR code, keeping the write token safe.
  workflow_run runs the default-branch copy, so the comment half
  activates on merge; until then the numbers are in the job summary.
- .gitignore: local gate artifacts.

Validated locally: fast-path summary; fail-path summary carries
verdict + 31% tree number + per-file missing-lines table.
@john-the-dev
john-the-dev force-pushed the feat/coverage-gate-95 branch from e388a50 to bba438d Compare July 6, 2026 15:39
@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Coverage Gate

✅ No Python changes — nothing to gate (bar: 95% on changed lines). Whole-tree coverage (informational, from today's full-suite run): 31%.


(Posted manually this once — the automated sticky comment activates after this PR merges: GitHub only runs workflow_run workflows from the main branch's copy, so the comment pipeline in this PR can't fire on itself. On PRs that touch Python, this comment shows the diff-coverage percentage and, on failure, the exact uncovered lines per file.)

@john-the-dev

Copy link
Copy Markdown
Collaborator Author

Example of the automated comment on a Python-touching PR (real output from this PR's local validation runs — a scratch module was added, gated, covered, re-gated):


When the PR's changed lines are under-covered (gate fails, CI red):

Coverage Gate

❌ Diff coverage BELOW the 95% bar — uncovered changed lines listed below. Whole-tree (informational): 31%.

Diff Coverage

Diff: origin/main...HEAD

  • src/scratch_uncovered.py (0.0%): Missing lines 1-4

Summary

  • Total: 4 lines
  • Missing: 4 lines
  • Coverage: 0%

src/scratch_uncovered.py

Lines 1-5

! 1 def classify(n):
! 2     if n < 0:
! 3         return "negative"
! 4     return "positive"

Same change after a test covering it was added (gate passes, CI green):

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 31%.

Diff Coverage

Diff: origin/main...HEAD

  • src/scratch_uncovered.py (100%)

Summary

  • Total: 4 lines
  • Missing: 0 lines
  • Coverage: 100%

The !-prefixed lines are the exact changed lines the suite never executed — the author sees precisely what to cover. One sticky comment per PR, updated on every push.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well-designed gate overall — the diff-coverage choice (95% on changed lines, whole-tree informational) is the right call for a tree at 31%, and the two-stage comment pattern (gate on pull_request with read-only token, comment via workflow_run that never checks out PR code) is the textbook-safe way to post fork-PR comments. The .coveragerc source mode also closes the classic loophole: a brand-new untested module still lands in coverage.xml at 0%, so diff-cover counts it.

One required change — coverage-comment.yml can't download the artifact:

permissions:
  pull-requests: write

Cross-run artifact download (download-artifact@v4 with run-id: + github-token:) requires actions: read on the token, and declaring a permissions block sets every unlisted scope to none — so as written, the download step 403s on every fire and no comment is ever posted. Fix:

permissions:
  pull-requests: write
  actions: read

(Refs: download-artifact cross-run discussion, GITHUB_TOKEN permissions docs.)

One verification ask: this PR touches no .py, so its own gate run took the no-Python fast path (the 13s check) — the instrumented-suite + diff-cover path has never executed in CI. Before merge, please paste a local bash scripts/coverage-gate.sh run from a branch with a real Python change (one passing, ideally one failing) so we know the full path works, since the next Python PR becomes the guinea pig otherwise.

Two non-blocking notes:

  • Test-only PRs behave correctly by accident of design: tests/* is omitted from coverage, and diff-cover ignores files absent from the report rather than treating them as 0% — worth one line in docs/testing-coverage.md so nobody "fixes" the omit later and breaks test-only PRs.
  • Policy (echoing the #pr-triage thread): 95% on changed lines in bridge/GUI code will force mock-heavy tests of low value. Consider launching warn-only (--fail-under on a pure-logic allowlist, report-only elsewhere) and letting the owner flip it to blocking once the sticky comment has built some track record. The escape-hatch paragraph in the doc already acknowledges this — starting soft just makes it structural.

Happy to re-review once the permission fix lands.

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM.

  • Diff-coverage over a global floor is the right call with the tree at 31%: the 95% bar lands on every new line without redding unrelated PRs for legacy debt, and the informational whole-tree number keeps the ratchet visible. The flip-to-global-floor path is documented for when the tree converges.
  • The two-stage comment pattern is the security-correct one: the gate runs on pull_request (fork PRs get a read-only token), the commenter runs on workflow_run with pull-requests: write only and never checks out PR code — avoiding the pull_request_target foot-gun. I considered the residual artifact-trust surface (a fork could stamp an arbitrary pr-number.txt, steering the sticky comment to another PR) and agree it's the accepted trade-off, since workflow_run.pull_requests is empty for fork PRs — which is exactly why the artifact exists. Token can only comment; content is markdown, never executed.
  • Gate script details check out: set -euo pipefail; the no-Python fast path saves the instrumented suite run (and fetch-depth: 0 is present for diff-cover's base comparison); test failures under instrumentation are collected across all files and fail the gate with a clear "suite must be green first" verdict; parallel = True + combine matches the one-process-per-standalone-test reality; sticky-comment marker + paginate avoids comment spam.
  • Self-demonstrating: the new "diff coverage >= 95% (python)" check ran green on this PR itself, and all existing checks pass.
  • Docs cover the local run path, the pragma policy, the subprocess-boundary caveat (import-the-module guidance), and the owner escape hatch — good reviewer ergonomics.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu
qingyun-wu merged commit d997b75 into sonichi:main Jul 6, 2026
7 checks passed
qingyun-wu added a commit that referenced this pull request Jul 6, 2026
The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Jul 6, 2026
…(interaction-planes step 1) (#1953)

* feat(tasks): additive interaction_type header at every task producer (interaction-planes step 1)

A task producer is a (source, interaction_type) pair, not a source alone.
This stamps the pair at every write site — purely additive, old readers
ignore the extra line, no consumer behavior changes:

- message: slack/telegram/discord bridges, twilio SMS + voicemail, chat template
- realtime_audio: voice work-tool (task-bridge.ts), phone work-tool (delegateTask)
- tool_initiated: writeChatTask (Gemini inline tool), agent-api /task (agent-to-agent)
- system_event: health-check --emit-task, context-drop (app + task-bridge),
  github-webhook, twilio incoming-call, phone call-end summary
- remote-gateway-bridge: passes through the gateway's interaction_type when
  present, defaults to message (all current gateway traffic is Matrix room
  messages)

tests/interaction-type-header.test.py sweeps every producer write site and
fails if a source: serialization lacks the paired interaction_type within
the write window — so new producers can't silently ship without it.

First step of the interaction-planes refactor (snapshot: v0.5.0-pre-interaction-planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tasks): harden gateway interaction_type + pair source: phone on delegateTask

Round-2 improvements on the same PR:

- remote-gateway-bridge: whitelist the pass-through against the six-value
  interaction-plane vocabulary — the gateway is outside the trust boundary,
  so an unknown remote value degrades to "message" instead of landing
  verbatim in the local task file.
- conversation-server delegateTask: add the missing source: phone so the
  producer carries the full (source, interaction_type) pair. This activates
  two consumers that always listed "phone" but never matched because the
  writer predates the source header: task_priority phone→urgent, and
  discord-bridge DM_FALLBACK_SOURCES (a result landing after the call ended
  now DMs the owner instead of rotting unclaimed).
- call-end summary writer deliberately does NOT get source: phone — its task
  instructions already have the core DM the owner, so the bridge fallback
  would deliver the same summary twice. Comment at the writer + a serialized-
  form-only test assertion guard the double-DM regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): unit-cover the interaction_type whitelist serialization

The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Jul 7, 2026
…ice (steps 3+4 → main) (#1962)

* feat(tasks): additive interaction_type header at every task producer (interaction-planes step 1)

A task producer is a (source, interaction_type) pair, not a source alone.
This stamps the pair at every write site — purely additive, old readers
ignore the extra line, no consumer behavior changes:

- message: slack/telegram/discord bridges, twilio SMS + voicemail, chat template
- realtime_audio: voice work-tool (task-bridge.ts), phone work-tool (delegateTask)
- tool_initiated: writeChatTask (Gemini inline tool), agent-api /task (agent-to-agent)
- system_event: health-check --emit-task, context-drop (app + task-bridge),
  github-webhook, twilio incoming-call, phone call-end summary
- remote-gateway-bridge: passes through the gateway's interaction_type when
  present, defaults to message (all current gateway traffic is Matrix room
  messages)

tests/interaction-type-header.test.py sweeps every producer write site and
fails if a source: serialization lacks the paired interaction_type within
the write window — so new producers can't silently ship without it.

First step of the interaction-planes refactor (snapshot: v0.5.0-pre-interaction-planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tasks): harden gateway interaction_type + pair source: phone on delegateTask

Round-2 improvements on the same PR:

- remote-gateway-bridge: whitelist the pass-through against the six-value
  interaction-plane vocabulary — the gateway is outside the trust boundary,
  so an unknown remote value degrades to "message" instead of landing
  verbatim in the local task file.
- conversation-server delegateTask: add the missing source: phone so the
  producer carries the full (source, interaction_type) pair. This activates
  two consumers that always listed "phone" but never matched because the
  writer predates the source header: task_priority phone→urgent, and
  discord-bridge DM_FALLBACK_SOURCES (a result landing after the call ended
  now DMs the owner instead of rotting unclaimed).
- call-end summary writer deliberately does NOT get source: phone — its task
  instructions already have the core DM the owner, so the bridge fallback
  would deliver the same summary twice. Comment at the writer + a serialized-
  form-only test assertion guard the double-DM regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): unit-cover the interaction_type whitelist serialization

The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3) (#1954)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* feat(delegation): TaskDelegationService — LocalTaskBackend byte-identical + RelayTaskBackend over agent-api (step 4, #1947) (#1956)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* wip(delegation): step-4 seam — backends, relay watcher, agent-api endpoints

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(delegation): TaskDelegationService — LocalTaskBackend byte-identical, RelayTaskBackend over agent-api (step 4, #1947)

The seam between delegation logic and where tasks/results live:

- src/task-delegation.ts: TaskDelegationService interface (submit + result
  I/O primitives), LocalTaskBackend (today's exact file I/O), RelayTaskBackend
  (same ops over agent-api HTTP with bearer), selectBackend (boot-time
  probe: writable tasks/ → local; else CORE_API_URL → relay; else loud throw
  — the 2026-05-18 silent-drop lesson applied at the seam).
- task-bridge.ts: workTool + writeChatTask submit via the backend (local
  mode = byte-identical writes). The LOCAL result watcher is untouched — in
  relay mode a separate loop covers the delegation-critical subset only:
  timeout sweep + results for tasks THIS process submitted (consuming other
  task-* results would steal them from core-host consumers; deliver-once).
  Core-host-only flows (voice-/question-/proactive-, context consolidation)
  stay core-host-only by design.
- agent-api.py: 4 delegation endpoints (POST /delegation/tasks, GET
  /delegation/results[/name], POST /delegation/archive). Stricter auth than
  the rest of the API: a core with no SUTANDO_API_TOKEN refuses delegation
  outright (403) instead of the local-dev allow. Ids via
  local_task_protocol.valid_task_id; names via the CodeQL-shaped _safe_path
  (which appends .txt itself — callers pass stems); archive matches
  task-bridge's month-partitioned layout via archive_month_dir.

Tests: tests/task-delegation.test.ts (5 — byte-identity, watcher-I/O mirror,
probe order, loud-throw) and tests/agent-api-delegation.test.py (14-check
E2E against a real HTTP server on an ephemeral port: auth both ways,
byte-identical submit, traversal rejection, list/read round-trip,
month-partitioned archive). Existing task-bridge suites green (8+10), tsc
clean, full python suite unchanged.

Cross-machine E2E (Air voice-agent → MBP core) needs the second machine —
flagged in the PR for owner scheduling; the relay path is otherwise covered
by the HTTP E2E above.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(state-paths): allowlist task-delegation.ts (type-union false positive)

The flagged token is the `kind: 'tasks' | 'results'` TYPE UNION on the
injected archive callback — the module never resolves workspace paths
itself; dirs are injected by task-bridge, which resolves via
workspace_default. Rationale recorded in the allowlist entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(delegation): extract route bodies as module functions + direct-call coverage

The coverage gate's tracer misses handler-thread execution, so the HTTP
E2E alone left the route bodies unattributed (77.5% on changed lines).
The four delegation route bodies are now module-level (status, payload)
functions — the handler is thin dispatch — and the test exercises them
BOTH ways: HTTP round-trip for dispatch+auth, direct main-thread calls
for the bodies (submit good/bad-id/empty, list, read good/404/traversal,
archive move/already-gone/bad-tid).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(delegation): serve the E2E on the main thread + cover the OSError branch

The coverage gate's tracer misses handler-THREAD execution, so even with
the route bodies extracted, the thin dispatch lines stayed unattributed.
Inverted the E2E threading: plain HTTPServer with a handle_request loop on
the MAIN thread, requests issued from a worker thread — every handler line
now runs traced. Also covers delegation_list_results' unreadable-dir
branch. 25 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(delegation): exercise every guard branch — the real coverage gap

Corrected diagnosis: the missed lines were never thread-attribution — they
were the UNTAKEN guard branches (wrong-bearer on the GET/archive routes,
malformed-JSON except paths, tokenless refusal on read/archive). Local
repro under the gate's exact invocation (coverage run --rcfile=.coveragerc)
now shows zero missing delegation lines.

Also hardened the test client: the server may close the socket right after
an error response, so reading the error body races ECONNRESET — status
codes are what the assertions need; the body read is best-effort now.
32 checks green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(delegation): positive relay config + task-identity coherence + archive ownership (Codex P1s)

All three P1s from the re-review:

1. CORE_API_URL is now POSITIVE configuration: set → relay, regardless of
   local writability. The probe-first order made relay unreachable on any
   normal voice-host checkout (default workspace is <repo>/workspace —
   always writable), silently stranding delegated tasks locally. Local dirs
   are now created only on the local selection path, so a relay host grows
   no unused tasks/ + results/ dirs. Tests assert relay-wins-on-writable
   and no-local-dirs-in-relay-mode.

2. /delegation/tasks enforces identity coherence: the body's embedded id:
   header must equal the request id — a mismatched write would split task
   identity across result polling, dedupe, archive, and history.

3. /delegation/archive enforces ownership + no-clobber: the source name
   must be exactly <task_id>.txt (a relay client archives only ITS OWN
   result), and an occupied archive slot gets the epoch-suffixed name the
   bridges already use instead of overwriting history.

Suites: 36 py E2E checks + 6 TS checks green; tsc clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
qingyun-wu added a commit that referenced this pull request Jul 7, 2026
…extraction (LiveAgentRuntime slice 1) (#1964)

* feat(tasks): additive interaction_type header at every task producer (interaction-planes step 1)

A task producer is a (source, interaction_type) pair, not a source alone.
This stamps the pair at every write site — purely additive, old readers
ignore the extra line, no consumer behavior changes:

- message: slack/telegram/discord bridges, twilio SMS + voicemail, chat template
- realtime_audio: voice work-tool (task-bridge.ts), phone work-tool (delegateTask)
- tool_initiated: writeChatTask (Gemini inline tool), agent-api /task (agent-to-agent)
- system_event: health-check --emit-task, context-drop (app + task-bridge),
  github-webhook, twilio incoming-call, phone call-end summary
- remote-gateway-bridge: passes through the gateway's interaction_type when
  present, defaults to message (all current gateway traffic is Matrix room
  messages)

tests/interaction-type-header.test.py sweeps every producer write site and
fails if a source: serialization lacks the paired interaction_type within
the write window — so new producers can't silently ship without it.

First step of the interaction-planes refactor (snapshot: v0.5.0-pre-interaction-planes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(tasks): harden gateway interaction_type + pair source: phone on delegateTask

Round-2 improvements on the same PR:

- remote-gateway-bridge: whitelist the pass-through against the six-value
  interaction-plane vocabulary — the gateway is outside the trust boundary,
  so an unknown remote value degrades to "message" instead of landing
  verbatim in the local task file.
- conversation-server delegateTask: add the missing source: phone so the
  producer carries the full (source, interaction_type) pair. This activates
  two consumers that always listed "phone" but never matched because the
  writer predates the source header: task_priority phone→urgent, and
  discord-bridge DM_FALLBACK_SOURCES (a result landing after the call ended
  now DMs the owner instead of rotting unclaimed).
- call-end summary writer deliberately does NOT get source: phone — its task
  instructions already have the core DM the owner, so the bridge fallback
  would deliver the same summary twice. Comment at the writer + a serialized-
  form-only test assertion guard the double-DM regression.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(gateway): unit-cover the interaction_type whitelist serialization

The #1933 coverage gate (first PR through it) flagged the new
remote-gateway-bridge lines as uncovered — the sweep test greps write
sites but never executes them. This exercises _write_task directly:
whitelisted pass-through, unknown-value degradation to message (trust
boundary), absent-field default, full vocabulary round-trip, and the
access_tier-last ordering guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol — read-side module, golden corpus tests, reader switch-over (step 3) (#1954)

* fix(task-body-guard): defang interaction_type in untrusted bodies

The new header must be in confine_user_content's _HEADER_KEYS like every
other header — otherwise a message body containing a literal
'interaction_type: ...' line survives into the task file undefanged and
could forge the field under a full-scan reader.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(protocol): local_task_protocol read-side module + golden corpus tests (step 3a)

The Local Task Protocol's task-file half as pure stdlib functions (the
result-marker half already lives in result_markers.py, #873):

- schema constants: INTERACTION_TYPES (single source, asserted equal to the
  gateway whitelist), PRIORITIES, ACCESS_TIERS, KNOWN_HEADER_KEYS (from a
  3.4k-file archive corpus survey)
- valid_task_id — the filename/traversal gate for ids arriving in content
- parse_task_headers — the safe stop-at-task: reader (PR #982 delimiter rule)
- parse_task_headers_trusted — full-scan last-wins, for writers that
  neutralize bodies (_one_line strip or confine_user_content defang);
  last-wins is load-bearing for the gateway's access_tier-written-last defense
- archive rules: month partitioning, find_archived_task candidate walk,
  iter_archived_tasks corpus sweep

Writers untouched (read-side-first). The module docstring records the real
shape taxonomy the corpus survey established: TS writers + agent-api /task
are task-last; every Python bridge is task-mid — which means stop-at-task:
readers under-read bridge files (e.g. parse_priority_from_text has never
seen a bridge-written priority: field). Reader consolidation and writer
convergence are follow-up steps with per-bridge byte-identical acceptance.

Tests: 33 checks — per-shape fixtures (discord/gateway/voice/chat/phone-
legacy/health/forged-body), id validation, archive rules, vocabulary parity
with the gateway, plus a live-corpus sweep (3,397 archived files parse
clean) that skips gracefully where no workspace exists (CI).

R1 honored: stdlib-only, no I/O beyond the explicit archive helpers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run CI on staging-interaction-planes PRs and pushes

The branch filter predates this staging branch — without it, refactor-step
PRs (like this one) get zero CI. Same pattern staging-workspace-revamp used.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: run the coverage gate on staging-interaction-planes PRs too

Same branch-filter gap as ci.yml — without it, refactor-step PRs with
Python changes skip the 95% diff-coverage bar until the staging→main merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): task_priority reads via local_task_protocol (step 3b, reader 1/3)

parse_priority_from_text delegates header extraction to the protocol
module's safe parser; the legacy blank/'---' stop is preserved locally as
a documented pre-#982 quirk. Deliberate tightening: header keys are
canonical lowercase at column 0 — asserted as the ONLY behavioral delta in
the new invariance test, which dual-runs a verbatim copy of the legacy
implementation against the new one over adversarial fixtures and the live
corpus (3,399 archived files, zero verdict changes).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(protocol): discord _task_source reads via parse_task_headers_lenient (step 3b, reader 2/3)

New protocol parser: parse_task_headers_lenient — full scan, FIRST
occurrence per key wins. This is the shape-union reader: the archive
proves producer field order changed across eras (May-2026 voice tasks are
task-MID; today's are task-last), and _task_source classifies files of any
age. The stricter stop-at-task: parser flips the DM-fallback verdict on 23
real archived voice tasks — caught by this commit's corpus dual-run, which
now asserts extraction-identical over all 3,401 files.

Known pre-existing first-wins spoofability (a body line can supply a key
the file legitimately lacks) is documented at the parser and pinned by a
fixture; hardening it changes historical verdicts and is an owner decision,
not a read-side refactor.

Gateway _valid_tid deliberately NOT switched: it is a remote-compat gate
(any dot/dash id from older gateways), a different contract from the local
task-id canon in valid_task_id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): self-sufficient local_task_protocol import in task_priority

tests/task-priority-stop-at-task-delimiter.test.py (and any other consumer)
loads task_priority standalone via importlib without src/ on sys.path — the
bare import broke it in CI, which also failed the coverage gate (suite dies
under instrumentation before coverage is measurable). Same self-pathing
pattern the bridges use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci: retire staging-workspace-revamp from workflow branch filters

That refactor's staging branch merged 2026-06-01 (M0); the filter entry is
dead config. Owner-requested cleanup alongside the staging-interaction-planes
addition.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(protocol): cover the archive-walk helpers with fixtures; pragma the unmeasurable discord glue

The coverage gate runs without a live workspace (corpus sweeps skip) and
without discord.py (the bridge can't import under instrumentation):
- find_archived_task / iter_archived_tasks now have fixture-based tests
  (candidate walk across live/processed/flat/month dirs, stray-dir and
  malformed-id gating) instead of relying on the live-corpus path.
- the two _task_source glue lines get pragma: no cover with the rationale
  at the site — their semantics are pinned by the invariance test's
  corpus dual-run, but the module itself is unimportable in the gate env.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): define per-parser body contract + lossless task_body() reader (Codex blocker)

The trusted/lenient parsers' body looked like the full work item but
silently dropped continuation lines (health-check failure bullets, phone
hint/transcript sections). A header scan cannot losslessly split task-mid
continuation content from trailing headers, so stop pretending:

- TaskHeaders.body is now DOCUMENTED per parser: full work item for the
  task-last safe parser; scalar first-task:-line value for trusted/lenient.
- New task_body(text): the shape-independent lossless work-item reader —
  everything from the first task: line onward, verbatim. For task-mid files
  that includes trailing header lines; the honest trade is stated at the
  function.
- Tests pin the contract: trusted body excludes the health bullet (scalar
  by contract, not by accident), task_body keeps health bullets and phone
  hint/transcript, equals safe-parser body on task-last shapes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(protocol): vocabulary-locked parsers + lossless task-mid bodies (Codex P2s)

Both P2s from the re-review, fixed as one coherent mechanism:

1. Parsers promote ONLY KNOWN_HEADER_KEYS to headers, and
   task_body_guard._HEADER_KEYS now IS that list (imported — single source
   of truth). Lockstep is the invariant: no key a parser would trust can
   survive undefanged in user-supplied content. Side benefit: junk
   key-shaped lines (transcript 'Caller: hi') can no longer masquerade as
   metadata.

2. With the vocabulary boundary in place, task-mid bodies become losslessly
   splittable: trusted/lenient body = the task: line's content plus every
   subsequent NON-vocabulary line — health-check bullets and phone
   transcript dialogue stay in body, trailing real headers are excluded.
   Every post-task: line lands in exactly one of headers/body.

Tests: lockstep assertion (guard set == parser vocabulary), forged
non-classic keys (instructions/hint/from/transcript/attempts) defanged,
health bullets + transcript preserved in trusted body, and a corpus-wide
body-fidelity sweep (3,415 archived files: no non-header line lost).
Full suite: injection-guard, dm-fallback-guard, both invariance tests,
interaction-type, gateway, priority — all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* test(voice): step-5 behavior anchors — tool-table snapshot + tuned-region tripwires

Pins the voice agent's tuned behavior surface before the LiveAgentRuntime
extraction moves anything: 126 importable tool definitions (name,
description, execution mode, parameter keys + per-key descriptions)
snapshotted to a committed fixture, plus SHA-256 tripwires on the
non-importable tuned regions of voice-agent.ts (instructions factory,
greeting/reconnect factory, tool-table composition — the module runs
main() at load, so tests cannot import it). Every step-5 slice must keep
these green or deliberately regenerate with the diff called out.

Verified environment-independent: identical anchors with an empty
CLAUDE_CONFIG_DIR (manifest tools resolve from the repo tree).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(voice): extract tuned prompt factories into voice-agent-config.ts (step 5a-1)

PURE MOVE of the greeting factory and system-instruction factory out of
voice-agent.ts (which runs main() at module load and is therefore
untestable) into an importable module. voice-agent.ts keeps its state
(mode resolver, meeting flag, session gates) and threads it in through
VoiceConfigContext; env-dependent reads (stand identity, voice context,
repo URL, agent context) run verbatim by default with test-only overrides.

Equivalence evidence:
- all 87 tuned string-array entries identical IN ORDER between the old
  region and the new module (programmatic comparison)
- template-literal set identical except the intentional override branch
- behavior anchors upgraded from source-hash tripwires to REAL factory
  output: fixed-env instruction hash, exact meeting-greeting string,
  conditional-variant assertions (meeting/googleSearch/mode-marker
  placement), reconnect replay-guard shape — 10/10 green across normal,
  repeat, and empty-CLAUDE_CONFIG_DIR runs

tsc clean; task-bridge suites green. Phone (conversation-server) untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(anchors): exclude env-dependent tools from the tool-table fixture

First CI run of the anchors showed the tool table is machine-specific
after all: manifest-loaded skill tools (gws-gmail read_email,
report_feedback, screenshot_explain, …) load on installed hosts but not
on CI — the empty-CLAUDE_CONFIG_DIR simulation missed them because the
manifest scan also covers other paths. inline-tools now exports
envDependentToolNames (manifest tools + presenter-sentinel conditionals)
and the anchor pins the 80-tool STATIC surface, which is identical
everywhere. Excluding per-env extras is sound: a tool absent from an
env's exclusion set is also absent from that env's tool list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(anchors): mask installed-tool lines from the instructions hash

The prompt deliberately embeds the installed tool list, so a raw hash of
buildInstructions output can never be env-stable (CI lacks the manifest
tools). The anchor now hashes the instructions with the two dynamic
segments masked (the joined tool-names line and per-tool '. Instant.'
lines) — pinning all tuned prose exactly — with a separate well-formedness
assertion that the dynamic lines exist. 11/11 in both local and
empty-config runs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants