@event4u/agent-config 14.14.0 → 14.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +63 -0
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/create.md +16 -1
  7. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  8. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  9. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  13. package/dist/cli-delegate/cmd_conformance.js +513 -17
  14. package/dist/cli-delegate/cmd_doctor.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +5 -5
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +1043 -390
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/mcp/server.mjs +703 -115
  28. package/package.json +1 -1
  29. package/src/config/assurance-capability-registry.json +1 -1
  30. package/src/config/evaluator-budgets.json +4 -4
  31. package/src/config/gate-coverage.yml +62 -2
  32. package/src/config/gate-reachability-exemptions.json +1 -0
  33. package/src/config/gate-violation-baselines.json +4 -3
  34. package/src/config/rule-enforcement-baseline.json +0 -1
  35. package/src/config/web-launch-readiness.json +4 -2
  36. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  37. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  38. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  39. package/src/scripts/_dispatch.bash +11 -0
  40. package/src/scripts/_lib/branch_convergence.ts +186 -0
  41. package/src/scripts/_lib/context_observation.ts +132 -0
  42. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  43. package/src/scripts/_lib/experiment_binding.ts +95 -0
  44. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  45. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  46. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  47. package/src/scripts/_lib/release_highlights.ts +75 -10
  48. package/src/scripts/_lib/repeated_failure.ts +19 -2
  49. package/src/scripts/_lib/runtime_journal.ts +11 -4
  50. package/src/scripts/check_agent_artifact_location.ts +337 -0
  51. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  52. package/src/scripts/check_enforcement_coverage.ts +40 -1
  53. package/src/scripts/check_release_highlights.ts +17 -1
  54. package/src/scripts/check_web_launch_readiness.ts +310 -20
  55. package/src/scripts/evolution_lab.ts +53 -1
  56. package/src/scripts/generate_capability_matrix.ts +16 -9
  57. package/src/scripts/git_authorization_hook.ts +476 -6
  58. package/src/scripts/grade_target_readiness.ts +221 -2
  59. package/src/scripts/hook_effect_doctor.ts +47 -4
  60. package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
  61. package/src/scripts/hooks/merge_impact.ts +207 -0
  62. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  63. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  64. package/src/scripts/install-hooks.sh +18 -0
  65. package/src/scripts/lint_code_comments.ts +106 -1
  66. package/src/scripts/release.ts +5 -17
  67. package/src/scripts/release_drill.ts +76 -1
  68. package/src/scripts/release_env.ts +31 -0
  69. package/src/scripts/release_publication.ts +125 -1
  70. package/src/scripts/roadmap_context.ts +15 -0
  71. package/src/scripts/skill_scout.ts +633 -0
  72. package/src/scripts/sync_pr_branch.ts +521 -42
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@event4u/agent-config",
3
3
  "mcpName": "io.github.event4u-app/agent-config",
4
- "version": "14.14.0",
4
+ "version": "14.15.0",
5
5
  "description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including the counts in these badges.",
6
6
  "license": "MIT",
7
7
  "private": false,
@@ -6,7 +6,7 @@
6
6
  "registered_at": "2026-08-23",
7
7
  "grader": {
8
8
  "path": "src/scripts/grade_target_readiness.ts",
9
- "dimensions": 10,
9
+ "dimensions": 13,
10
10
  "knockouts": [
11
11
  "test-presence",
12
12
  "static-analysis",
@@ -58,11 +58,11 @@
58
58
  "deterministic": false
59
59
  },
60
60
  "cli_help_command_count": {
61
- "max": 109,
62
- "last_measured": 109,
63
- "last_measured_at": "2026-08-26",
61
+ "max": 110,
62
+ "last_measured": 110,
63
+ "last_measured_at": "2026-09-03",
64
64
  "method": "registry enumeration — count of `{ name: '...', disposition` entries in src/cli/registry.ts (NOT --help prose parsing; methods historically disagreed 74 vs 76 vs 79)",
65
- "baseline_note": "80 → 81 on 2026-08-03 (release 9.15.0 gate). This RECORDS growth that already shipped through its own reviewed PR; it does not authorize new surface. The +1 is f02db54c2 (fix(cli): register routing:doctor in the CLI registry) — the command existed, only its registry entry was missing, and the registering PR did not move this budget. The gate did its job again: breach WARNS on main and BLOCKS on release, which is the conscious-decision point rather than a silently raised cap. Re-measured 2026-08-03 via the pinned method: 81. Headroom stays zero by design — the next registry addition must move this number in its own PR. 81 → 82 on 2026-08-04: the `handoff` subcommand (road-to-agent-handoff-resume Phase 4) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 82. 82 → 84 on 2026-08-04: the `route:explain` + `route:audit` verbs (routing-correctness validation surface) register in the same PR that adds the surface. Re-measured via the pinned method: 84. 84 → 85 on 2026-08-05: the `settings:set` verb (road-to-zero-ceremony-settings Phase 2 — the one agent-reachable settings writer) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 85. 85 → 86 on 2026-08-06: the `conformance:behavior` verb (road-to-agent-behavior-conformance Phase 5 — replays the local transcript store through the four mechanised conformance checks) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 86. 86 → 88 on 2026-08-07: the `sessions:list` + `sessions:claim` verbs (road-to-parallel-session-coordination — the shell surface of the shared session register, needed because /roadmap:next is command markdown and can only reach the register through a shell call) register in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 88. 88 → 89 on 2026-08-08: the `council:status` verb registers in the same PR that adds the surface, per this record's own contract. It exists because an agent in a consumer project announced \"kein Council konfiguriert (keine .agent-settings.yml)\" and substituted a weaker path while the council was configured user-globally the whole time — there was no verb that answered availability, so the only route was an inference from a filename, and the intuitive filename is the one location ADR-104 guarantees is never consulted. Re-measured via the pinned method: 89. 89 → 91 on 2026-08-08: the `self-repair:status` + `self-repair:release` verbs (the queued-defect reader and the single Hard-Floor-gated outward step of the self-repair loop — the loop is deliberately unable to publish unless a human runs the release command) register in the same PR that adds the surface, per this record's own contract. Landed alongside the +1 council:status move in the same branch. Re-measured via the pinned method: 91. 91 → 94 on 2026-08-08: the `packs:active`, `settings:get` and `mcp:available` verbs (road-to-capability-answerability Phase 2 — the three capability probes for the gaps that had none) register in the same PR that adds the surface, per this record's own contract. Each answers a question that was previously only inferable from a filename: which packs are active and from which file, what a setting resolves to and which file set it, and what is declared in mcp.json versus what can actually launch. 94 -> 95 in the same PR: `brand:status` (Phase 2.4 — brand-layer presence, which the rule depended on and no surface could answer). Re-measured twice via the pinned method on the unchanged tree: 95/95. 95 → 96 on 2026-08-10: the `session:recycle` verb (road-to-token-economy-recycling Phase 2.2 — the deterministic producer of the main-session recycle envelope; the recycle advisory names this exact verb, so it must exist as a shell surface) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 96. 96 -> 97 on 2026-08-10: the `gates` verb registers in the same PR that adds the surface, per this record's own contract. It exists because the blocker fields the dashboard already parses (Owner, What to do, Resolved when — CI-enforced by lint_roadmap_blockers) had no owner-filtered reader: the dashboard counts the open blockers across a 600-line file and links each to a per-roadmap anchor, so \"which of these are mine\" was a manual read of every anchor. The same inference-from-a-filename shape as the council:status entry above, one layer up. This branch and the session:recycle branch each measured 96 independently and landed on the same number for different verbs; the merge makes it 97, re-measured twice via the pinned method on the merged tree: 97/97. 97 -> 98 on 2026-08-15: the `workspace:doctor` verb (road-to-inbox-harvest-2026-08-c-workspace-identity Phase 3 — the read-only report over the new `workspaceIdentity()` resolver) registers in the same PR that adds the surface, per this record's own contract. It exists for the same inference-from-a-filename shape as the council:status and gates entries above, one layer down: the tree answered \"where am I\" in eight ad-hoc places across six files, and the 12.0.0 span shipped the same worktree misclassification twice, so no verb could be asked which checkout is the main one. Read-only by construction — the roadmap's Non-goals put worktree disposal out of scope and its Risk 3 names \"the pressure read becomes a disposal path by accident\" as the failure to design against, so the command carries no --fix and no --prune. Re-measured twice via the pinned method on the unchanged tree: 98/98. 98 -> 99 on 2026-08-17: the `council:quota` verb (road-to-council-quota-accounting-truth Phase 2) registers in the same PR that adds the surface, per this record's own contract. Exactly the inference-from-a-filename shape as the council:status entry above, one layer down: `cmd_quota` had existed in council_cli.ts and been reachable only via `scripts-run`, so the documented recovery path for an exhausted shared counter -- read the usage, reset one provider -- did not resolve through `agent-config`, and an operator debugging a quota failure met `unknown command`. The verb is read-only apart from the pre-existing `--reset <provider>`, which already required `--confirm`. Re-measured twice via the pinned method on the unchanged tree: 99/99. 99 -> 100 on 2026-08-19: the `decision:memo` verb (road-to-long-horizon-execution Phase 2.2, sequencing UOTL Phase 4.3 -- the decision-memo channel) registers in the same PR that adds the surface, per this record's own contract. It exists because the question ladder gained a rung between \"halt and ask\" and \"decide silently\": a resolution the run makes without contacting the operator is only legitimate if it is reviewable afterwards, and there was no surface that wrote such a record with a fixed shape. Write-and-list only, local-only (agents/runtime/ is gitignored), and it gates nothing -- the locked decision classes are refused at the config schema, never in the ledger. 100 -> 101 in the same PR: `run:supervise` (Phase 3.1 -- the out-of-process watcher for runs whose session died with open steps left). It is the operator-facing half of session continuity: nothing anywhere had the job of noticing that a claimed run stopped moving, so a roadmap 40% done and a roadmap finished looked identical from outside the dead session. Report-only by default and it NEVER merges, pushes or closes -- the auto-merge the reference design carries is a named rejection in the roadmap's harvest section, not an unbuilt feature. Re-measured twice via the pinned method on the unchanged tree: 101/101. 101 → 102 on 2026-08-22: the `adr:effective` verb (road-to-evidence-based-adr-governance step 2.2 — the effective-state projection over one decision record) registers in the same PR that adds the surface, per this record's own contract. It exists because current truth about an ADR was only reconstructable from linear prose: an agent had to read the body, then every amendment, and decide for itself which clauses still stand. The verb reports status, the Decision verbatim, clauses the record's own amendments superseded, active amendments, the three descriptive axes and the review-trigger state. It AUTHORIZES NOTHING — Phase 2 of that roadmap is titled \"surfacing only; no authority\" and its AC-7 forbids any authority consequence, so the verb reports an absent axis as absent (`— (no provenance axis)`, `— (ungraded)`, `absent → evidence`) and an unresolvable trigger as `indeterminate` rather than inferring one. Re-measured twice via the pinned method on the unchanged tree: 102/102. 102 → 104 on 2026-08-22: the `council:grant-billing` + `council:revoke-billing` verbs (road-to-billing-cliff-gate Phase 2 — the run-scoped billing grant that lets `ai_council.fallback.api_on_quota: 'ask'` be answered once per run instead of once per seat) register in the same PR that adds the surface, per this record's own contract. The two are a pair by construction rather than by convenience: a grant that can be issued and not revoked would outlive its run, which is the failure the run-scoped TTL exists to prevent. Re-measured via the pinned method: 104. 104 -> 106 on 2026-08-23: the `ui:audit` + `ui:render` verbs (road-to-frontend-power E2.1 and E3.1 -- the two Class-A commands that give the frontend obligations an artefact a gate can read and a render a verdict can cite) register in the same PR that adds the surface, per this record's own contract. The two are a pair by construction rather than by convenience: `ui-audit-gate`'s enforced_by moved from `none` to a carrier in the same change, and that carrier reports `verification: degraded` whenever the render artefact is absent -- so an audit verb without a render verb would ship a gate that can never reach `verified`. Both are Class A under ADR-124: they terminate, their whole state is a rebuildable directory under agents/runtime/state/, and ui:render closes chromium in a `finally` on every path with no --serve, watch or cache for a resident browser to hide in. Re-measured via the pinned method on the unchanged tree: 106. 106 → 107 on 2026-08-23: the `roadmap:context` verb (road-to-roadmap-situational-awareness — the single live probe that answers what else is in flight before a roadmap is picked) registers in the same PR that adds the surface, per this record's own contract. Its branch measured 105 against a base of 104 and this branch measured 106 against the same base for `ui:audit` + `ui:render`; the merge makes it 107, re-measured on the merged tree via the pinned method (registry enumeration): 107. 107 → 108 on 2026-08-26: the `roadmap:set-step` verb (road-to-skill-ecosystem-runtime-enforcement Phase 6 — the single sanctioned writer of a checkbox glyph) registers in the same PR that adds the surface, per this record's own contract. It exists because the glyph is written today by whatever edits the file, and a concurrent writer destroys work SILENTLY: the loser of an overwrite receives no error. The verb carries an advisory lock, a line-anchored edit that cannot span entries even in principle, a step-count invariant asserted against the LIVE pre-write file rather than an in-memory snapshot, and a post-write survival check. Re-measured via the pinned method (registry enumeration) on the unchanged tree: 108. 108 → 109 on 2026-08-26: the `stubs:due` verb (road-to-inbox-harvest-2026-08-f-owner-decision-queue step 2.1 — the read-only reader over `agents/roadmaps/stubs/` frontmatter) registers in the same PR that adds the surface, per this record's own contract. It exists because step 1.2 backfills a `review_by:` date into 77 parked files, and an AI council (2026-08-26, 2/2) made the ordering a RULE rather than a preference: dates without a reader are strictly worse than no dates, because a stale date certifies attention nobody paid. The verb is read-only by construction — it authors nothing under `agents/roadmaps/stubs/`, so `check_no_stub_inventory_table` stays green and unmodified, and `roadmap:progress` consumes only its two COUNTS for the dashboard header (two integers, no rows, nothing for the deleted index tables to grow back from). Re-measured via the pinned method (registry enumeration) on the unchanged tree: 109."
65
+ "baseline_note": "80 → 81 on 2026-08-03 (release 9.15.0 gate). This RECORDS growth that already shipped through its own reviewed PR; it does not authorize new surface. The +1 is f02db54c2 (fix(cli): register routing:doctor in the CLI registry) — the command existed, only its registry entry was missing, and the registering PR did not move this budget. The gate did its job again: breach WARNS on main and BLOCKS on release, which is the conscious-decision point rather than a silently raised cap. Re-measured 2026-08-03 via the pinned method: 81. Headroom stays zero by design — the next registry addition must move this number in its own PR. 81 → 82 on 2026-08-04: the `handoff` subcommand (road-to-agent-handoff-resume Phase 4) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 82. 82 → 84 on 2026-08-04: the `route:explain` + `route:audit` verbs (routing-correctness validation surface) register in the same PR that adds the surface. Re-measured via the pinned method: 84. 84 → 85 on 2026-08-05: the `settings:set` verb (road-to-zero-ceremony-settings Phase 2 — the one agent-reachable settings writer) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 85. 85 → 86 on 2026-08-06: the `conformance:behavior` verb (road-to-agent-behavior-conformance Phase 5 — replays the local transcript store through the four mechanised conformance checks) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 86. 86 → 88 on 2026-08-07: the `sessions:list` + `sessions:claim` verbs (road-to-parallel-session-coordination — the shell surface of the shared session register, needed because /roadmap:next is command markdown and can only reach the register through a shell call) register in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 88. 88 → 89 on 2026-08-08: the `council:status` verb registers in the same PR that adds the surface, per this record's own contract. It exists because an agent in a consumer project announced \"kein Council konfiguriert (keine .agent-settings.yml)\" and substituted a weaker path while the council was configured user-globally the whole time — there was no verb that answered availability, so the only route was an inference from a filename, and the intuitive filename is the one location ADR-104 guarantees is never consulted. Re-measured via the pinned method: 89. 89 → 91 on 2026-08-08: the `self-repair:status` + `self-repair:release` verbs (the queued-defect reader and the single Hard-Floor-gated outward step of the self-repair loop — the loop is deliberately unable to publish unless a human runs the release command) register in the same PR that adds the surface, per this record's own contract. Landed alongside the +1 council:status move in the same branch. Re-measured via the pinned method: 91. 91 → 94 on 2026-08-08: the `packs:active`, `settings:get` and `mcp:available` verbs (road-to-capability-answerability Phase 2 — the three capability probes for the gaps that had none) register in the same PR that adds the surface, per this record's own contract. Each answers a question that was previously only inferable from a filename: which packs are active and from which file, what a setting resolves to and which file set it, and what is declared in mcp.json versus what can actually launch. 94 -> 95 in the same PR: `brand:status` (Phase 2.4 — brand-layer presence, which the rule depended on and no surface could answer). Re-measured twice via the pinned method on the unchanged tree: 95/95. 95 → 96 on 2026-08-10: the `session:recycle` verb (road-to-token-economy-recycling Phase 2.2 — the deterministic producer of the main-session recycle envelope; the recycle advisory names this exact verb, so it must exist as a shell surface) registers in the same PR that adds the surface, per this record's own contract. Re-measured via the pinned method: 96. 96 -> 97 on 2026-08-10: the `gates` verb registers in the same PR that adds the surface, per this record's own contract. It exists because the blocker fields the dashboard already parses (Owner, What to do, Resolved when — CI-enforced by lint_roadmap_blockers) had no owner-filtered reader: the dashboard counts the open blockers across a 600-line file and links each to a per-roadmap anchor, so \"which of these are mine\" was a manual read of every anchor. The same inference-from-a-filename shape as the council:status entry above, one layer up. This branch and the session:recycle branch each measured 96 independently and landed on the same number for different verbs; the merge makes it 97, re-measured twice via the pinned method on the merged tree: 97/97. 97 -> 98 on 2026-08-15: the `workspace:doctor` verb (road-to-inbox-harvest-2026-08-c-workspace-identity Phase 3 — the read-only report over the new `workspaceIdentity()` resolver) registers in the same PR that adds the surface, per this record's own contract. It exists for the same inference-from-a-filename shape as the council:status and gates entries above, one layer down: the tree answered \"where am I\" in eight ad-hoc places across six files, and the 12.0.0 span shipped the same worktree misclassification twice, so no verb could be asked which checkout is the main one. Read-only by construction — the roadmap's Non-goals put worktree disposal out of scope and its Risk 3 names \"the pressure read becomes a disposal path by accident\" as the failure to design against, so the command carries no --fix and no --prune. Re-measured twice via the pinned method on the unchanged tree: 98/98. 98 -> 99 on 2026-08-17: the `council:quota` verb (road-to-council-quota-accounting-truth Phase 2) registers in the same PR that adds the surface, per this record's own contract. Exactly the inference-from-a-filename shape as the council:status entry above, one layer down: `cmd_quota` had existed in council_cli.ts and been reachable only via `scripts-run`, so the documented recovery path for an exhausted shared counter -- read the usage, reset one provider -- did not resolve through `agent-config`, and an operator debugging a quota failure met `unknown command`. The verb is read-only apart from the pre-existing `--reset <provider>`, which already required `--confirm`. Re-measured twice via the pinned method on the unchanged tree: 99/99. 99 -> 100 on 2026-08-19: the `decision:memo` verb (road-to-long-horizon-execution Phase 2.2, sequencing UOTL Phase 4.3 -- the decision-memo channel) registers in the same PR that adds the surface, per this record's own contract. It exists because the question ladder gained a rung between \"halt and ask\" and \"decide silently\": a resolution the run makes without contacting the operator is only legitimate if it is reviewable afterwards, and there was no surface that wrote such a record with a fixed shape. Write-and-list only, local-only (agents/runtime/ is gitignored), and it gates nothing -- the locked decision classes are refused at the config schema, never in the ledger. 100 -> 101 in the same PR: `run:supervise` (Phase 3.1 -- the out-of-process watcher for runs whose session died with open steps left). It is the operator-facing half of session continuity: nothing anywhere had the job of noticing that a claimed run stopped moving, so a roadmap 40% done and a roadmap finished looked identical from outside the dead session. Report-only by default and it NEVER merges, pushes or closes -- the auto-merge the reference design carries is a named rejection in the roadmap's harvest section, not an unbuilt feature. Re-measured twice via the pinned method on the unchanged tree: 101/101. 101 → 102 on 2026-08-22: the `adr:effective` verb (road-to-evidence-based-adr-governance step 2.2 — the effective-state projection over one decision record) registers in the same PR that adds the surface, per this record's own contract. It exists because current truth about an ADR was only reconstructable from linear prose: an agent had to read the body, then every amendment, and decide for itself which clauses still stand. The verb reports status, the Decision verbatim, clauses the record's own amendments superseded, active amendments, the three descriptive axes and the review-trigger state. It AUTHORIZES NOTHING — Phase 2 of that roadmap is titled \"surfacing only; no authority\" and its AC-7 forbids any authority consequence, so the verb reports an absent axis as absent (`— (no provenance axis)`, `— (ungraded)`, `absent → evidence`) and an unresolvable trigger as `indeterminate` rather than inferring one. Re-measured twice via the pinned method on the unchanged tree: 102/102. 102 → 104 on 2026-08-22: the `council:grant-billing` + `council:revoke-billing` verbs (road-to-billing-cliff-gate Phase 2 — the run-scoped billing grant that lets `ai_council.fallback.api_on_quota: 'ask'` be answered once per run instead of once per seat) register in the same PR that adds the surface, per this record's own contract. The two are a pair by construction rather than by convenience: a grant that can be issued and not revoked would outlive its run, which is the failure the run-scoped TTL exists to prevent. Re-measured via the pinned method: 104. 104 -> 106 on 2026-08-23: the `ui:audit` + `ui:render` verbs (road-to-frontend-power E2.1 and E3.1 -- the two Class-A commands that give the frontend obligations an artefact a gate can read and a render a verdict can cite) register in the same PR that adds the surface, per this record's own contract. The two are a pair by construction rather than by convenience: `ui-audit-gate`'s enforced_by moved from `none` to a carrier in the same change, and that carrier reports `verification: degraded` whenever the render artefact is absent -- so an audit verb without a render verb would ship a gate that can never reach `verified`. Both are Class A under ADR-124: they terminate, their whole state is a rebuildable directory under agents/runtime/state/, and ui:render closes chromium in a `finally` on every path with no --serve, watch or cache for a resident browser to hide in. Re-measured via the pinned method on the unchanged tree: 106. 106 → 107 on 2026-08-23: the `roadmap:context` verb (road-to-roadmap-situational-awareness — the single live probe that answers what else is in flight before a roadmap is picked) registers in the same PR that adds the surface, per this record's own contract. Its branch measured 105 against a base of 104 and this branch measured 106 against the same base for `ui:audit` + `ui:render`; the merge makes it 107, re-measured on the merged tree via the pinned method (registry enumeration): 107. 107 → 108 on 2026-08-26: the `roadmap:set-step` verb (road-to-skill-ecosystem-runtime-enforcement Phase 6 — the single sanctioned writer of a checkbox glyph) registers in the same PR that adds the surface, per this record's own contract. It exists because the glyph is written today by whatever edits the file, and a concurrent writer destroys work SILENTLY: the loser of an overwrite receives no error. The verb carries an advisory lock, a line-anchored edit that cannot span entries even in principle, a step-count invariant asserted against the LIVE pre-write file rather than an in-memory snapshot, and a post-write survival check. Re-measured via the pinned method (registry enumeration) on the unchanged tree: 108. 108 → 109 on 2026-08-26: the `stubs:due` verb (road-to-inbox-harvest-2026-08-f-owner-decision-queue step 2.1 — the read-only reader over `agents/roadmaps/stubs/` frontmatter) registers in the same PR that adds the surface, per this record's own contract. It exists because step 1.2 backfills a `review_by:` date into 77 parked files, and an AI council (2026-08-26, 2/2) made the ordering a RULE rather than a preference: dates without a reader are strictly worse than no dates, because a stale date certifies attention nobody paid. The verb is read-only by construction — it authors nothing under `agents/roadmaps/stubs/`, so `check_no_stub_inventory_table` stays green and unmodified, and `roadmap:progress` consumes only its two COUNTS for the dashboard header (two integers, no rows, nothing for the deleted index tables to grow back from). Re-measured via the pinned method (registry enumeration) on the unchanged tree: 109. 109 → 110 on 2026-09-03: the `hooks:effect` verb (road-to-wired-instruments Phase 1.1 — the CLI entry for `hook_effect_doctor`) registers in the same PR that adds the surface, per this record's own contract. It exists because the probe and its five-state vocabulary were built, tested and reachable from nothing: `grep -rn 'hook_effect_doctor|hook_effect_probe' src Taskfile.yml .github` returned only the two modules themselves, and a closed roadmap recorded the wiring as landed at `src/scripts/hooks_doctor.ts:88`, a line that is the closing brace of a filesystem helper in a file with zero references to the probe. Deliberately NOT merged into `hooks:doctor`: that verb reads the MANIFEST (what is declared) and this one reads what happened (what took effect), and the probe's own header names merging them as the thing to avoid — a declaration report and an execution report are different instruments, and merging them makes the cheap one expensive. Read-only apart from a scratch root it removes in a `finally`. Re-measured via the pinned method (registry enumeration) on the unchanged tree: 110."
66
66
  },
67
67
  "mcp_public_tool_count": {
68
68
  "max": 20,
@@ -52,6 +52,14 @@
52
52
  # CI-identical argv, and 75 of those 78 are listed below.** The manifest is
53
53
  # complete for every gate that can carry a floor, covering ~25 % of the population.
54
54
  #
55
+ # RECOUNTED 2026-09-03, on the change that adds `check_agent_artifact_location`.
56
+ # Recomputed on this tree rather than incremented: `count_gate_scripts()` returns
57
+ # **313** and `grep -c '^ - id:'` returns **84** (83 before this row), so
58
+ # 84/313 = 26.8 %. The script count rose by three since the 2026-08-31 block
59
+ # below without any recount in between — the same drift every block here keeps
60
+ # describing, and the reason this one is a new block rather than an edit to that
61
+ # one: that block measured a tree that no longer exists.
62
+
55
63
  # RECOUNTED 2026-08-31, on the change that adds `lint_promotion_paths` — and the
56
64
  # 2026-08-30 block above was already stale when this row landed, in BOTH numbers
57
65
  # rather than only in the one this change moves. Recomputed on this tree:
@@ -765,7 +773,7 @@ gates:
765
773
 
766
774
  - id: check_requirements_trace
767
775
  argv: []
768
- min_scanned: 2
776
+ min_scanned: 1
769
777
  corpus: "roadmap files at the top level of agents/roadmaps/ (25 at registration, 2026-08-22; 9 live on 2026-08-23, mid-drain). The floor tracks a corpus that shrinks by design, so completing and archiving a roadmap lowers it — it is never satisfied by un-archiving finished work. LOWERED 10 -> 2 on 2026-08-23. The 15 -> 10 move earlier the same day already recorded this shape and under-shot it: eight further PRs were open at that moment, each one titled `roadmap: complete <slug>` and each archiving exactly one top-level file, so the estate this floor tracks was already committed to landing at one or two. A floor of 10 against a corpus heading for 2 does not detect anything — it reds on every remaining merge in the drain, which is the gate firing on the drain WORKING, and the note directly above says so in its own words. 2 is chosen as one above the only reading this floor exists to catch: a scan whose root moved reports 0, never 2, so the collapse signal survives the move intact. What is given up is the middle band, and it was never load-bearing here — this gate is a LISTING (see no_canary_reason: `main()` returns 0 on every path), so the floor is its only failure mode and a partial-corpus read is not something it could act on anyway. Revisit-if: the drain ends and the estate stabilises above 2 — re-derive the floor from what the estate is then expected to hold, and never from the live count, which reds on the next archive by construction."
770
778
  status: enforced
771
779
  no_canary_reason: >-
@@ -2402,12 +2410,19 @@ gates:
2402
2410
  argv: ["--base", "origin/main"]
2403
2411
  min_scanned: 0
2404
2412
  corpus: >
2405
- the source files a change touches, measured against the base ref — a DIFF,
2413
+ the source LINES a change adds, measured against the base ref — a DIFF,
2406
2414
  not a tree walk. The floor is 0 deliberately: a docs-only or config-only
2407
2415
  change legitimately carries no source file, and a floor above 0 would red
2408
2416
  exactly those commits. The scan-scope failure this class exists to catch
2409
2417
  cannot occur here, because there is no committed path list that can go
2410
2418
  stale — the root is `git diff`, and git answers for its own range.
2419
+
2420
+ LINES, not files, since 2026-09-02. Selecting the file set from the diff
2421
+ while scanning each file whole made every edit to an old file carry that
2422
+ file's whole comment history: PR #1813 added no flagged comment and was
2423
+ refused on eleven legacy lines in four files it had touched for unrelated
2424
+ reasons. An untracked file still counts whole — every line in it is new,
2425
+ which is also what keeps the canary below a genuine discriminator.
2411
2426
  status: enforced
2412
2427
  canary:
2413
2428
  class: contract-violation
@@ -2471,3 +2486,48 @@ gates:
2471
2486
  reason the corpus field gives, so it was never in that population and
2472
2487
  the count stays at 24 either side of this change. The self-test is
2473
2488
  therefore adopted on its merits rather than to clear a ratchet.
2489
+
2490
+ - id: check_agent_artifact_location
2491
+ argv: []
2492
+ min_scanned: 3000
2493
+ corpus: >
2494
+ every `.md` file the whole-tree walk VISITS, not the roadmap-shaped ones it
2495
+ matches. Zero matches is the normal clean state — the repository is
2496
+ expected to have no misplaced roadmap — so only the unfiltered walk can
2497
+ tell "nothing misplaced" from "nothing read", which is the exact
2498
+ distinction this manifest exists to enforce.
2499
+
2500
+ The floor is 3,000 against a real 3,866 on the tree carrying this row.
2501
+ Set well below the true corpus because `docs/archive/` and the roadmap
2502
+ archive legitimately grow and shrink, and far above a collapse: the failure
2503
+ mode this guards is the walk root moving, which would take the count to
2504
+ single digits rather than to 3,000.
2505
+
2506
+ The walk skips `.git`, `node_modules`, `dist/` and the per-tool projection
2507
+ trees (`.claude/`, `.cursor/`, `.augment/`, `.clinerules/`, `.agents/`).
2508
+ Those are generated copies, so a finding there is a finding about `src/`
2509
+ reported twice — excluded from the corpus deliberately, which is why the
2510
+ count is 3,866 rather than the full tree's markdown population.
2511
+
2512
+ SELF-TEST ADOPTED. `--self-test` runs 5 cases, 2 rejecting, against the
2513
+ real CLI over throwaway trees: a roadmap-shaped file under
2514
+ `docs/roadmaps/` and one at the tree root must RED; the byte-identical
2515
+ file under `agents/roadmaps/` and under `agents/roadmaps/later/` must stay
2516
+ GREEN, and so must prose that carries frontmatter and a checkbox but no
2517
+ `## Phase` heading. Both polarities, which is what the originating
2518
+ roadmap's step 1.1 required in as many words: "Both directions, or the
2519
+ detector's polarity is untested."
2520
+ canary:
2521
+ class: contract-violation
2522
+ path: docs/roadmaps/zz-canary-artifact-location.md
2523
+ content: |
2524
+ ---
2525
+ complexity: lightweight
2526
+ status: ready
2527
+ ---
2528
+ # Canary planted by check_gate_coverage --canary; never ships.
2529
+
2530
+ ## Phase 1 - the planted phase
2531
+
2532
+ - [ ] **1.1 A planted step.** Carries all three shape signals so the
2533
+ gate must report it from a directory that is not the roadmap root.
@@ -16,6 +16,7 @@
16
16
  "mcp:glama-drift-check": "Queries an external registry — network-dependent and flaky by construction. Would run against a recorded snapshot instead of a live query.",
17
17
  "migration-verify": "Verifies a physical move during a migration; there is no migration in flight. Would run during one.",
18
18
  "value": "Renders a dashboard; the render is the product, not a check. Nothing would make it a gate.",
19
+ "push-ready": "NOT A GATE — a developer-invoked helper that MUTATES the tree: it merges the base set in, regenerates derived files, and hands the push back to the contributor. It is gate-shaped only because step 5 calls `check_branch_freshness`, which IS wired to CI on its own. Wiring this target would make CI perform merges, which is the exact thing `road-to-cascading-base-integration` 2.2 forbids the pre-push hook from doing one layer down. Nothing would make it a gate; the checks inside it are wired separately.",
19
20
  "value:lint": "Lints that dashboard, which is generated and regenerated on demand. Would run if the dashboard became a committed artefact a drift could break."
20
21
  }
21
22
  }
@@ -34,9 +34,10 @@
34
34
  "note": "The nine active non-draft roadmaps that predate the `relates:` requirement (road-to-agentic-engineering-assurance, road-to-component-library-lifecycle, road-to-council-evidence-integrity, road-to-frontend-fidelity-calibration, road-to-monorepo-scope-and-detection, road-to-observability-plate, road-to-role-scoped-spawn-profiles, road-to-skill-delivery-over-mcp, road-to-standing-payload-diet). This is a NEW obligation rather than a repaired scan root, and the ratchet is the point: `depends:` shipped in the template with nothing requiring it and measured 0 adoption, so requiring `relates:` retroactively across the estate would be an owner-reserved write with no analysis behind the edges it would have to invent (held as step 4.3, deferred). Clearing an entry here is one roadmap at a time: run `agent-config roadmap:context` on it, write the rows the probe surfaces, and lower the count."
35
35
  },
36
36
  "check_source_size_budget": {
37
- "count": 18230,
38
- "landed": "2026-09-01",
39
- "note": "Lowered 18,245 -> 18,230 on 2026-09-02 by drain/publication-integrity-option-a. Phase 2 Option A moved the authoring instruction out of the generator: release.ts, which sits ~530 lines PAST the 1500 cap so every line there costs one excess, loses the comment emission, a 16-line HEAD_DEFAULT doc block and its alias const, and gains a shorter writer docstring. The doc block moved to _lib/release_material.ts, which is 289 lines and therefore UNDER the cap, so the same prose now costs nothing. Net -15. The branch first came in at +8 and paid its own way down rather than raising the baseline."
37
+ "count": 18193,
38
+ "landed": "2026-09-03",
39
+ "note": "Lowered 18,245 -> 18,230 on 2026-09-02 by drain/publication-integrity-option-a. Phase 2 Option A moved the authoring instruction out of the generator: release.ts, which sits ~530 lines PAST the 1500 cap so every line there costs one excess, loses the comment emission, a 16-line HEAD_DEFAULT doc block and its alias const, and gains a shorter writer docstring. The doc block moved to _lib/release_material.ts, which is 289 lines and therefore UNDER the cap, so the same prose now costs nothing. Net -15. The branch first came in at +8 and paid its own way down rather than raising the baseline.",
40
+ "reason": "Lowered 18,230 -> 18,209 on 2026-09-03 by drain/artifact-location. `doctor --strict` needed 16 lines of integration wiring in cmd_doctor.ts, which sits ~2,100 lines PAST the 1500 cap so every line there costs one excess. The branch first came in at +137 and paid its own way down rather than raising the baseline: the strict semantics, threshold, kill switch, payload helper and argv fragments moved to _cli/_doctor_strict.ts (~160 lines, under the cap, costs nothing), taking it to +15; then the 37-line drift renderer moved to _cli/_doctor_emit.ts. Net -21. The renderer was chosen deliberately: pure rendering, two dependencies, no argparse involvement and therefore none of the byte-parity risk the parser surface carries against the retired Python implementation; nothing outside cmd_doctor.ts imported it. An AI council on the residual split (a: raise by 15 / d: descope the feature); neither was taken, because a parallel branch demonstrated the same day that paying down is achievable in this codebase, which is the evidence the split lacked. This gate's own test comment prescribes it: a commit that splits a god-file lowers the excess and must carry the lowered baseline. MERGE RECONCILIATION 2026-09-03, 18,209 -> 18,205: drain/wired-instruments and drain/artifact-location each lowered from the SAME 18,230 base in parallel -- the branch to 18,226 (net -4, run_continuation_hook.ts's termination ladder moved to _lib/continuation_ladder.ts and runtime_journal.ts's prose to _lib/outcome_vocabularies.ts, both under the cap) and main to 18,209 (net -21, the doctor --strict split above). Taking either side alone is wrong in a different direction: main's value silently discards the branch's earned -4, and the branch's value RAISES the baseline against main and weakens the gate. The committed number is neither -- it is the gate's own output on the merged tree, where both reductions apply. THIS LOCAL READING IS ADMISSIBLE HERE, and the discriminator matters because this file's ci-parity:local-only entry records a 165 -> 164 lowering that was reverted for exactly the opposite case: that count depends on how the environment resolves the projection surface, whereas this gate walks only SOURCE_ROOTS = ['src'] (check_source_size_budget.ts:88) counting excess lines over the 1500 ceiling, and `git status --porcelain -uall -- src/` reported ZERO untracked files, so a clean CI checkout of this commit measures the same 18,205. If CI reads anything else, CI's number is the correct one and this note is the record of why. LOWERED 18,205 -> 18,193 on 2026-09-03 by fix/release-curation-ask. The change adds a fifth publication guard, and its own additions landed at +10 excess: release.ts 2,008 -> 2,018, every line of which is past the 1500 cap. Paid down rather than baselined, in the two ways this gate's doctrine prescribes. The guard's rationale (~13 lines of docstring, including why prMerged is a parameter rather than a call-site if) lives in release_publication.ts, 915 lines and therefore UNDER the cap, so the prose costs nothing; and the two pure text helpers _splitlines and _rstrip moved out of release.ts into release_env.ts (333 lines, also under the cap), taking release.ts to 1,996 -- 12 BELOW where it started. The branch came in at +10 and leaves at -12. Recorded while moving them: _splitlines is duplicated a third and fourth time at adoption_report.ts:106 and readme_linter.ts:418; collapsing all three onto one definition touches three modules and was left out of this change rather than done silently."
40
41
  },
41
42
  "ci-parity:local-only": {
42
43
  "count": 165,
@@ -11,7 +11,6 @@
11
11
  "brand-source-of-truth.md",
12
12
  "broken-access-control.md",
13
13
  "cli-output-handling.md",
14
- "code-comment-discipline.md",
15
14
  "command-suggestion-policy.md",
16
15
  "commit-conventions.md",
17
16
  "commit-policy.md",
@@ -168,7 +168,7 @@
168
168
  "ceiling_enforced_by": "tests/scripts/web_launch_readiness_config.test.ts asserts checks.length < 50. Currently 7."
169
169
  },
170
170
  "regions": {
171
- "_comment": "Region axis (road-to-web-launch-readiness 2.3). Separate from site_types because it answers a different question: site_types decides WHETHER a check applies, regions decides HOW SEVERELY. A DE-targeted site owes Impressum and Datenschutz by law (TMG 5 / DSGVO Art. 13), so `required-legal-pages` is critical there and situational elsewhere -- the same check, a different tier. Modelled as a tier ESCALATION rather than as a second applies_to list, because a legal page is not more or less applicable in Germany, it is more or less optional.",
171
+ "_comment": "Region axis (road-to-web-launch-readiness 2.3). LEGAL-ROW CONTRACT: any row here or in `checks` whose `why` names a statute MUST carry `authority` (the citation and where it can be read) and `review_by` (the ISO date it must be re-read by); `authority` and `review_by` travel together, so a citation moved out of prose into `authority` stays dated; and a lapsed `review_by` FAILS the loader rather than warning, because a date nothing reads is a comment. Enforced by legalRowViolations() in src/scripts/check_web_launch_readiness.ts, which loadConfig() calls before returning. Separate from site_types because it answers a different question: site_types decides WHETHER a check applies, regions decides HOW SEVERELY. A DE-targeted site owes an Impressum and a Datenschutzerklaerung by law -- the citations sit in the escalation's `authority` field, dated, rather than in prose, which is what the `ddg-citation-authority` decision of 2026-09-03 settled -- so `required-legal-pages` is critical there and situational elsewhere -- the same check, a different tier. Modelled as a tier ESCALATION rather than as a second applies_to list, because a legal page is not more or less applicable in Germany, it is more or less optional.",
172
172
  "values": [
173
173
  "de",
174
174
  "eu",
@@ -181,7 +181,9 @@
181
181
  "check": "required-legal-pages",
182
182
  "region": "de",
183
183
  "to": "critical",
184
- "why": "TMG 5 obliges an Impressum and DSGVO Art. 13 a Datenschutzerklaerung for a DE-targeted commercial site. Abmahnung risk is real and immediate, which is what separates critical from situational here."
184
+ "why": "A DE-targeted commercial site owes a published imprint and a privacy notice by law; the two citations and the date they must be re-read by live in this row's `authority` field, not in this sentence. Abmahnung risk is real and immediate, which is what separates critical from situational here.",
185
+ "authority": "Impressum: DDG § 5 (Digitale-Dienste-Gesetz), gesetze-im-internet.de/ddg -- in force since 2024-05-14, superseding the identically-numbered provision of the TMG, which is why the pre-2026-09-03 form of this row cited dead law: it was written on 2026-08-25, twenty-seven months after that supersession. Datenschutzerklaerung: DSGVO Art. 13, Regulation (EU) 2016/679, consolidated text at eur-lex.europa.eu. Both are cited here as provenance a reader can re-check, never as counsel: src/rules/legal-safety-floor.md governs how this package may speak about law, and `review_by` below is the date these two must be read again.",
186
+ "review_by": "2027-09-03"
185
187
  }
186
188
  ]
187
189
  }
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The drift renderer, moved out of `cmd_doctor.ts`.
3
+ *
4
+ * WHY IT MOVED, because a reader will otherwise take this for a drive-by
5
+ * refactor. `cmd_doctor.ts` sits thousands of lines past the 1,500-line
6
+ * threshold `check_source_size_budget` measures, so every line added to it
7
+ * counts as new excess — and that ratchet turns one way, with its own test
8
+ * asserting `baseline == live` in BOTH directions.
9
+ *
10
+ * `doctor --strict` needed fifteen lines of integration wiring in that file
11
+ * that cannot live anywhere else: an import, two option fields, four argv-table
12
+ * splices, a validator call, both exit paths, and the payload attachment. So
13
+ * the choice was to raise the ratchet or to pay it down. The ratchet's own test
14
+ * comment answers that: a commit which splits a god-file lowers the excess and
15
+ * must carry the lowered baseline. Paying down is the response it was designed
16
+ * for; raising it is what the gate calls a defect.
17
+ *
18
+ * THIS function was chosen, and the choice is not arbitrary. It is pure
19
+ * rendering with two dependencies — a print sink and the tag id — no argparse
20
+ * involvement, and therefore none of the byte-parity risk the parser surface
21
+ * carries against the retired Python implementation. Nothing outside
22
+ * `cmd_doctor.ts` imported it; `cmd_prune.ts` has a same-named local of its own
23
+ * and is untouched. Behaviour is identical and the export surface is preserved
24
+ * by re-export.
25
+ */
26
+
27
+ type Dict = Record<string, unknown>;
28
+
29
+ const PACKAGE_TAG_ID = 'event4u/agent-config';
30
+
31
+ function print(line = ''): void {
32
+ process.stdout.write(`${line}\n`);
33
+ }
34
+
35
+ export function _emit_text(
36
+ project_root: string,
37
+ missing: Dict[],
38
+ modified: Dict[],
39
+ foreign: Dict[],
40
+ tag_drift: Dict[],
41
+ ): void {
42
+ const total = missing.length + modified.length + foreign.length + tag_drift.length;
43
+ if (total === 0) {
44
+ print(`✅ doctor: manifest matches filesystem under ${project_root}`);
45
+ return;
46
+ }
47
+ print(`⚠️ doctor: ${total} drift item(s) under ${project_root}`);
48
+ const groups: ReadonlyArray<[string, Dict[]]> = [
49
+ ['missing', missing],
50
+ ['modified', modified],
51
+ ['foreign', foreign],
52
+ ['tag-drift', tag_drift],
53
+ ];
54
+ for (const [label, items] of groups) {
55
+ if (items.length === 0) {
56
+ continue;
57
+ }
58
+ print(`\n ${label} (${items.length}):`);
59
+ for (const it of items) {
60
+ const tool = (it['tool'] as string) || '?';
61
+ print(` · [${tool}] ${it['path']}`);
62
+ if (label === 'tag-drift') {
63
+ const found = (it['found'] as string) || '(missing)';
64
+ const expected = 'expected' in it ? it['expected'] : PACKAGE_TAG_ID;
65
+ print(` expected: ${expected}`);
66
+ print(` found: ${found}`);
67
+ }
68
+ print(` fix: ${it['fix']}`);
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,163 @@
1
+ /**
2
+ * `doctor --strict` — the failing exit mode, its threshold, and its kill switch.
3
+ *
4
+ * A separate module rather than more lines in `cmd_doctor.ts`, which is already
5
+ * far past the source-size budget: every line added there is counted excess by
6
+ * `check_source_size_budget`, and a ratchet that only turns one way is a
7
+ * standing instruction to put new code somewhere else.
8
+ */
9
+ /** Structural, so this module needs no import from its caller. */
10
+ type Dict = Record<string, unknown>;
11
+ interface StrictOptions {
12
+ strict: boolean;
13
+ strict_level: string;
14
+ }
15
+
16
+ function eprint(msg: string): void {
17
+ process.stderr.write(`${msg}\n`);
18
+ }
19
+
20
+ /** Raised on an unknown `--strict-level`; the caller maps it to its argparse exit. */
21
+ export class StrictLevelError extends Error {}
22
+
23
+ /**
24
+ * `--strict` — the failing exit mode, and the ONE decision behind it.
25
+ *
26
+ * `doctor-exit-contract` was resolved by AI council on 2026-09-03 (members
27
+ * anthropic, openai), unanimously **(b)**: `doctor` keeps its exit contract and
28
+ * a separate strict mode carries the failing exit, leaving current callers
29
+ * untouched at the cost of one more surface. ADR-041 governs verb growth, so a
30
+ * flag rather than a verb; and the existing contract is load-bearing for callers
31
+ * that run `doctor` for information rather than for permission.
32
+ *
33
+ * WHAT WAS ALREADY TRUE, measured rather than assumed. The originating roadmap
34
+ * says `doctor` "writes nothing and always exits zero". Half of that is wrong:
35
+ * the default run already returns 1 on DRIFT (`return drift_present ? 1 : 0`),
36
+ * and `--ci` already folds check failures in on top. What is genuinely missing
37
+ * is a failing exit for check failures OUTSIDE the `--ci` JSON contract — `--ci`
38
+ * forces `opts.json = true`, so there is no human-readable strict run — and that
39
+ * gap is what `--strict` closes. Recorded here because a comment claiming to
40
+ * implement a premise the code contradicts is worse than no comment.
41
+ *
42
+ * SEVERITY IS CONFIGURABLE, per a condition both council seats attached:
43
+ * `--strict-level fail` (default) fails on `fail` rows; `--strict-level warn`
44
+ * also fails on `warn`. A caller adopting strict picks its own bar rather than
45
+ * inheriting one.
46
+ *
47
+ * THE KILL SWITCH is `AGENT_CONFIG_DOCTOR_NO_FAIL=1`, the other condition both
48
+ * seats attached: an immediate way to disable failing exits that is not a code
49
+ * rollback. When set, `--strict` still REPORTS every finding and still emits
50
+ * `can_proceed: false`; only the exit code is suppressed, and the suppression is
51
+ * announced on stderr so a green run cannot be silently bought. A switch that
52
+ * hid the finding as well would be the "warning ignored eighteen times" shape
53
+ * this repository already documents.
54
+ */
55
+ export const STRICT_DEFAULT_LEVEL = 'fail';
56
+ export const STRICT_LEVELS: ReadonlySet<string> = new Set(['fail', 'warn']);
57
+ /** Env var that suppresses the failing exit without suppressing the finding. */
58
+ export const STRICT_KILL_SWITCH = 'AGENT_CONFIG_DOCTOR_NO_FAIL';
59
+
60
+ /** Statuses that trip `--strict` at the given level. */
61
+ export function strictTripping(level: string): ReadonlySet<string> {
62
+ return level === 'warn' ? new Set(['fail', 'warn']) : new Set(['fail']);
63
+ }
64
+
65
+ /**
66
+ * The machine-readable "may I proceed" answer — nothing in this tree carried
67
+ * one before (`grep -rc can_proceed src` returned zero hits tree-wide).
68
+ *
69
+ * Deliberately independent of `--strict`: a caller asking the JSON whether it
70
+ * may proceed gets the same answer whether or not the exit code was configured
71
+ * to fail, because the two questions are different. The exit code is a policy;
72
+ * `can_proceed` is a fact.
73
+ */
74
+ export function canProceed(driftPresent: boolean, checks: readonly Dict[]): boolean {
75
+ return !driftPresent && !checks.some((c) => c['status'] === 'fail');
76
+ }
77
+
78
+ export function validateStrictLevel(level: string): void {
79
+ if (!STRICT_LEVELS.has(level)) {
80
+ throw new StrictLevelError(
81
+ `argument --strict-level: invalid choice: '${level}' (choose from 'fail', 'warn')`,
82
+ );
83
+ }
84
+ }
85
+
86
+ /**
87
+ * The `--strict` verdict, or `null` when strict is off.
88
+ *
89
+ * Factored out because `main` has TWO exit paths and only one of them was
90
+ * obvious: a project with no install manifest returns through
91
+ * `_run_no_manifest` long before the drift branch, so a strict check written
92
+ * only in the drift branch silently never fires there. Measured, not guessed —
93
+ * the first implementation exited 0 on a tree carrying a `fail` check row and a
94
+ * `can_proceed: false` payload, because this repository has no manifest of its
95
+ * own. Both paths call this now, so the two cannot diverge again.
96
+ */
97
+ export function strictExit(
98
+ opts: StrictOptions,
99
+ checks: readonly Dict[],
100
+ driftPresent: boolean,
101
+ ): number | null {
102
+ if (!opts.strict) {
103
+ return null;
104
+ }
105
+ const tripping = strictTripping(opts.strict_level);
106
+ const tripped = checks.filter((c) => tripping.has(c['status'] as string));
107
+ if (!driftPresent && tripped.length === 0) {
108
+ return 0;
109
+ }
110
+ if (process.env[STRICT_KILL_SWITCH] === '1') {
111
+ // The finding is NEVER suppressed — only the exit code is, and the
112
+ // suppression announces itself. A switch that hid the finding as well
113
+ // would be the ignored-warning shape this repository already documents.
114
+ eprint(
115
+ `⚠️ doctor --strict: ${String(tripped.length)} finding(s) at or above ` +
116
+ `'${opts.strict_level}'` +
117
+ (driftPresent ? ' plus manifest drift' : '') +
118
+ `, but ${STRICT_KILL_SWITCH}=1 suppressed the failing exit. ` +
119
+ 'The findings above still stand; unset it to restore the gate.',
120
+ );
121
+ return 0;
122
+ }
123
+ return 1;
124
+ }
125
+
126
+ /**
127
+ * The `can_proceed` / `status` pair, written onto a `--json` payload.
128
+ *
129
+ * Lives here rather than inline in `_emit_json` for the source-budget reason
130
+ * this whole module exists for. `status` is the roll-up a human reads;
131
+ * `can_proceed` is the boolean a caller branches on without parsing prose. Both
132
+ * are facts about the tree and never about the exit policy — a caller running
133
+ * with the kill switch set still gets `can_proceed: false`.
134
+ */
135
+ export function attachProceedFields(
136
+ payload: Dict,
137
+ driftPresent: boolean,
138
+ checks: readonly Dict[],
139
+ ): void {
140
+ const proceed = canProceed(driftPresent, checks);
141
+ payload['can_proceed'] = proceed;
142
+ payload['status'] = proceed
143
+ ? checks.some((c) => c['status'] === 'warn')
144
+ ? 'warn'
145
+ : 'ok'
146
+ : 'fail';
147
+ }
148
+
149
+ /** Argv wiring, exported as fragments so the caller spends one line each. */
150
+ export const STRICT_STORE_TRUE = { '--strict': 'strict' } as const;
151
+ export const STRICT_VALUE_FLAGS = { '--strict-level': 'strict_level' } as const;
152
+ export const STRICT_DEFAULTS = { strict: false, strict_level: STRICT_DEFAULT_LEVEL } as const;
153
+ export const STRICT_USAGE = ' [--strict] [--strict-level {fail,warn}]\n';
154
+
155
+ /** Validate, mapping the module's error onto the caller's argparse exit. */
156
+ export function validateStrictLevelOr(level: string, onError: (msg: string) => never): void {
157
+ try {
158
+ validateStrictLevel(level);
159
+ } catch (e) {
160
+ if (e instanceof StrictLevelError) onError(e.message);
161
+ throw e;
162
+ }
163
+ }
@@ -3049,6 +3049,8 @@ function _run_no_manifest(
3049
3049
  }
3050
3050
  return fail_check ? 1 : 0;
3051
3051
  }
3052
+ const sx = strictExit(opts, checks, false); // no manifest: keep the 2, never soften to 1
3053
+ if (sx !== null) return bridge_present ? sx : 2;
3052
3054
  if (opts.ci && bridge_present) {
3053
3055
  // Same fold as the manifest path: check failures are red under --ci.
3054
3056
  return fail_check ? 1 : 0;
@@ -3077,6 +3079,8 @@ function _emit_json(
3077
3079
  }
3078
3080
  if (checks !== null) {
3079
3081
  payload['checks'] = checks;
3082
+ const drift = missing.length + modified.length + foreign.length + tag_drift.length;
3083
+ attachProceedFields(payload, drift > 0, checks);
3080
3084
  // The detection section rides along whenever checks ran, so the agent /
3081
3085
  // GUI contract carries the full provider → transport → billing table
3082
3086
  // and not just the one-line check row.
@@ -3117,48 +3121,16 @@ function _emit_detection_text(project_root: string, checks: Dict[]): void {
3117
3121
  print('');
3118
3122
  }
3119
3123
 
3120
- function _emit_text(
3121
- project_root: string,
3122
- missing: Dict[],
3123
- modified: Dict[],
3124
- foreign: Dict[],
3125
- tag_drift: Dict[],
3126
- ): void {
3127
- const total = missing.length + modified.length + foreign.length + tag_drift.length;
3128
- if (total === 0) {
3129
- print(`✅ doctor: manifest matches filesystem under ${project_root}`);
3130
- return;
3131
- }
3132
- print(`⚠️ doctor: ${total} drift item(s) under ${project_root}`);
3133
- const groups: ReadonlyArray<[string, Dict[]]> = [
3134
- ['missing', missing],
3135
- ['modified', modified],
3136
- ['foreign', foreign],
3137
- ['tag-drift', tag_drift],
3138
- ];
3139
- for (const [label, items] of groups) {
3140
- if (items.length === 0) {
3141
- continue;
3142
- }
3143
- print(`\n ${label} (${items.length}):`);
3144
- for (const it of items) {
3145
- const tool = (it['tool'] as string) || '?';
3146
- print(` · [${tool}] ${it['path']}`);
3147
- if (label === 'tag-drift') {
3148
- const found = (it['found'] as string) || '(missing)';
3149
- const expected = 'expected' in it ? it['expected'] : PACKAGE_TAG_ID;
3150
- print(` expected: ${expected}`);
3151
- print(` found: ${found}`);
3152
- }
3153
- print(` fix: ${it['fix']}`);
3154
- }
3155
- }
3156
- }
3124
+
3125
+ // `--strict` lives in `_doctor_strict.ts`; the drift renderer in `_doctor_emit.ts`.
3126
+ import { STRICT_DEFAULTS, STRICT_STORE_TRUE, STRICT_USAGE, STRICT_VALUE_FLAGS, attachProceedFields, strictExit, validateStrictLevelOr } from './_doctor_strict.js';
3127
+ import { _emit_text } from './_doctor_emit.js';
3157
3128
 
3158
3129
  interface Options {
3159
3130
  project: string | null;
3160
3131
  json: boolean;
3161
3132
  ci: boolean;
3133
+ strict: boolean; strict_level: string; // see `_doctor_strict.ts`
3162
3134
  check: string | null;
3163
3135
  trace_root: boolean;
3164
3136
  context: boolean;
@@ -3173,18 +3145,21 @@ const PROG = 'agent-config doctor';
3173
3145
  // terminal width; golden tests assert the `usage:` token + exit code only.
3174
3146
  const USAGE =
3175
3147
  `usage: ${PROG} [-h] [--project PROJECT] [--json] [--ci] [--check ID]\n` +
3148
+ STRICT_USAGE +
3176
3149
  ' [--trace-root] [--context] [--anatomy]\n' +
3177
3150
  ' [--repair ID]\n';
3178
3151
 
3179
3152
  const _STORE_TRUE_FLAGS: Record<string, keyof Options> = {
3180
3153
  '--json': 'json',
3181
3154
  '--ci': 'ci',
3155
+ ...STRICT_STORE_TRUE,
3182
3156
  '--trace-root': 'trace_root',
3183
3157
  '--context': 'context',
3184
3158
  '--anatomy': 'anatomy',
3185
3159
  };
3186
3160
 
3187
3161
  const _VALUE_FLAGS: Record<string, keyof Options> = {
3162
+ ...STRICT_VALUE_FLAGS,
3188
3163
  '--project': 'project',
3189
3164
  '--check': 'check',
3190
3165
  '--repair': 'repair',
@@ -3201,6 +3176,7 @@ function _parse(argv: string[]): Options {
3201
3176
  project: null,
3202
3177
  json: false,
3203
3178
  ci: false,
3179
+ ...STRICT_DEFAULTS,
3204
3180
  check: null,
3205
3181
  trace_root: false,
3206
3182
  context: false,
@@ -3430,6 +3406,7 @@ function _run_repair(opts: Options): number {
3430
3406
 
3431
3407
  function main(argv: string[] | null = null): number {
3432
3408
  const opts = _parse(argv !== null ? Array.from(argv) : process.argv.slice(2));
3409
+ validateStrictLevelOr(opts.strict_level, _argError);
3433
3410
  if (opts.ci) {
3434
3411
  // `--ci` = machine-readable consumer-CI contract: JSON payload on
3435
3412
  // stdout, zero interactive output, and check failures fold into the
@@ -3502,6 +3479,8 @@ function main(argv: string[] | null = null): number {
3502
3479
  // · 2 unresolvable environment (handled above via ProjectRootError).
3503
3480
  return drift_present || fail_check ? 1 : 0;
3504
3481
  }
3482
+ const strict = strictExit(opts, checks, drift_present);
3483
+ if (strict !== null) return strict;
3505
3484
  return drift_present ? 1 : 0;
3506
3485
  }
3507
3486
 
@@ -290,6 +290,12 @@ Tier 2 — maintenance / internal (hooks, MCP, memory, telemetry):
290
290
  last dispatcher feedback per concern, missing trampolines.
291
291
  Wraps hooks:status. Read-only.
292
292
  Flags: --format json|table, --strict (CI), --project-root <path>
293
+ hooks:effect Does a bound concern actually FIRE on this host? Dispatches
294
+ each eligible concern against a scratch root and reports the
295
+ observed state. Not hooks:doctor: that reads the manifest,
296
+ this reads what happened. Read-only.
297
+ Flags: --host <name>, --format text|json, --root <path>,
298
+ --limit <n>, --help (prints both verdict vocabularies)
293
299
  routing:doctor Live routing diagnosis: per-gate ACTIVE/INACTIVE with the
294
300
  concern's own reason (read-only probes), session_start chain,
295
301
  router + projection freshness, host bridge status.
@@ -976,6 +982,10 @@ cmd_hooks_doctor() {
976
982
  exec_hook "src/scripts/hooks_doctor" "$@"
977
983
  }
978
984
 
985
+ cmd_hooks_effect() {
986
+ exec_hook "src/scripts/hook_effect_doctor" "$@"
987
+ }
988
+
979
989
  cmd_routing_doctor() {
980
990
  exec_hook "src/scripts/routing_doctor" "$@"
981
991
  }
@@ -1533,6 +1543,7 @@ main() {
1533
1543
  dispatch:hook) cmd_dispatch_hook "$@" ;;
1534
1544
  hooks:status) cmd_hooks_status "$@" ;;
1535
1545
  hooks:doctor) cmd_hooks_doctor "$@" ;;
1546
+ hooks:effect) cmd_hooks_effect "$@" ;;
1536
1547
  routing:doctor) cmd_routing_doctor "$@" ;;
1537
1548
  workspace:doctor) cmd_workspace_doctor "$@" ;;
1538
1549
  route:explain) cmd_route_explain "$@" ;;