Repository navigation
ci(coverage): enforce 95% unit-test coverage on changed Python lines - #1933
Conversation
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.
e388a50 to
bba438d
Compare
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 |
|
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 CoverageDiff: origin/main...HEAD
Summary
src/scratch_uncovered.pyLines 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 CoverageDiff: origin/main...HEAD
Summary
The |
qingyun-wu
left a comment
There was a problem hiding this comment.
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: writeCross-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 indocs/testing-coverage.mdso 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-underon 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
left a comment
There was a problem hiding this comment.
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 onworkflow_runwithpull-requests: writeonly and never checks out PR code — avoiding thepull_request_targetfoot-gun. I considered the residual artifact-trust surface (a fork could stamp an arbitrarypr-number.txt, steering the sticky comment to another PR) and agree it's the accepted trade-off, sinceworkflow_run.pull_requestsis 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 (andfetch-depth: 0is present fordiff-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+combinematches 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.
|
@cla-assistant check |
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>
…(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>
…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>
…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>
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 withdiff-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.mddocuments 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, thendiff-cover --fail-under=95vs base. Fast path: PRs touching no.pyskip the instrumented run entirely. Runnable locally with the same command CI uses..coveragerc— scopesrc/ 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)
Follow-up (not in this PR)
TypeScript gating:
c8around the existingtsx --testrun + 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