@event4u/agent-config 14.8.0 → 14.10.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 (170) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +214 -237
  3. package/README.md +1 -1
  4. package/agents/templates/.ai-council.yml.example +34 -4
  5. package/dist/agent-src/commands/pr/merge.md +9 -5
  6. package/dist/agent-src/commands/review/changes.md +95 -14
  7. package/dist/agent-src/commands/roadmap/process-full.md +2 -2
  8. package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
  9. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
  10. package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
  11. package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
  12. package/dist/agent-src/skills/async-python-patterns/SKILL.md +1 -1
  13. package/dist/agent-src/skills/code-refactoring/SKILL.md +1 -1
  14. package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
  15. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  16. package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +1 -1
  17. package/dist/agent-src/skills/design-review/SKILL.md +1 -1
  18. package/dist/agent-src/skills/discovery-interview/SKILL.md +1 -1
  19. package/dist/agent-src/skills/doc-coauthoring/SKILL.md +1 -1
  20. package/dist/agent-src/skills/docx-authoring/SKILL.md +1 -1
  21. package/dist/agent-src/skills/editorial-calendar/SKILL.md +1 -1
  22. package/dist/agent-src/skills/github-ci/SKILL.md +1 -1
  23. package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
  24. package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
  25. package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
  26. package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +1 -1
  27. package/dist/agent-src/skills/judge-injection-defense/SKILL.md +1 -1
  28. package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
  29. package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
  30. package/dist/agent-src/skills/laravel-validation/SKILL.md +1 -1
  31. package/dist/agent-src/skills/legal-intake-triage/SKILL.md +1 -1
  32. package/dist/agent-src/skills/legal-practice-profile/SKILL.md +1 -1
  33. package/dist/agent-src/skills/messaging-architecture/SKILL.md +1 -1
  34. package/dist/agent-src/skills/migration-architect/SKILL.md +1 -1
  35. package/dist/agent-src/skills/module-detect-on-the-fly/SKILL.md +1 -1
  36. package/dist/agent-src/skills/module-management/SKILL.md +1 -1
  37. package/dist/agent-src/skills/motion-choreographer/SKILL.md +1 -1
  38. package/dist/agent-src/skills/onboarding-program/SKILL.md +1 -1
  39. package/dist/agent-src/skills/perf-feedback-craft/SKILL.md +1 -1
  40. package/dist/agent-src/skills/prompt-optimizer/SKILL.md +1 -1
  41. package/dist/agent-src/skills/receiving-code-review/SKILL.md +1 -1
  42. package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
  43. package/dist/agent-src/skills/scene-expander/SKILL.md +1 -1
  44. package/dist/agent-src/skills/secrets-management/SKILL.md +1 -1
  45. package/dist/agent-src/skills/using-git-worktrees/SKILL.md +1 -1
  46. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  47. package/dist/agent-src/templates/roadmaps.md +8 -0
  48. package/dist/cli/registry.js +2 -0
  49. package/dist/cli/registry.js.map +1 -1
  50. package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
  51. package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
  52. package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
  53. package/dist/cli-delegate/cmd_conformance.js +89 -13
  54. package/dist/cli-delegate/cmd_doctor.js +3 -3
  55. package/dist/cli-delegate/cmd_handoff.js +2 -2
  56. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  57. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  58. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  59. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  60. package/dist/discovery/deprecation-report.md +1 -1
  61. package/dist/discovery/discovery-manifest.json +64 -42
  62. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  63. package/dist/discovery/discovery-manifest.summary.md +3 -3
  64. package/dist/discovery/dormancy-report.md +1 -1
  65. package/dist/discovery/orphan-report.md +1 -1
  66. package/dist/discovery/packs.json +6 -5
  67. package/dist/discovery/trust-report.md +2 -2
  68. package/dist/discovery/workspaces.json +5 -4
  69. package/dist/hooks/dispatch.js +562 -204
  70. package/dist/mcp/registry-manifest.json +3 -3
  71. package/dist/mcp/server.json +2 -2
  72. package/dist/mcp/server.mjs +808 -331
  73. package/dist/scripts/_lib/claude_plugin.js +162 -0
  74. package/dist/scripts/_lib/claude_plugin.js.map +1 -0
  75. package/dist/scripts/_lib/environment_detector.js +397 -0
  76. package/dist/scripts/_lib/environment_detector.js.map +1 -0
  77. package/dist/scripts/_lib/spawn_env.js +129 -0
  78. package/dist/scripts/_lib/spawn_env.js.map +1 -0
  79. package/dist/scripts/ai_council/modes.js +199 -0
  80. package/dist/scripts/ai_council/modes.js.map +1 -0
  81. package/dist/scripts/ai_council/transport_resolver.js +418 -0
  82. package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
  83. package/dist/server/routes/wizard.js +3 -2
  84. package/dist/server/routes/wizard.js.map +1 -1
  85. package/dist/ui/assets/index-Cxgy63kz.js +26 -0
  86. package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
  87. package/dist/ui/index.html +1 -1
  88. package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
  89. package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
  90. package/package.json +7 -3
  91. package/src/config/estate-count-budget.json +13 -744
  92. package/src/config/evaluator-budgets.json +4 -4
  93. package/src/config/gate-coverage.yml +267 -9
  94. package/src/config/gate-violation-baselines.json +72 -72
  95. package/src/config/preamble-payload-budget.json +71 -67
  96. package/src/config/publish-surface.json +60 -0
  97. package/src/config/requirements-trace-budget.json +32 -0
  98. package/src/config/rule-stub-ceilings.json +8 -1
  99. package/src/scripts/_dispatch.bash +7 -0
  100. package/src/scripts/_lib/base_tree.ts +175 -0
  101. package/src/scripts/_lib/billing_grant.ts +165 -0
  102. package/src/scripts/_lib/billing_grant_cli.ts +123 -0
  103. package/src/scripts/_lib/council_fallback_posture.ts +53 -6
  104. package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
  105. package/src/scripts/_lib/host_capability.ts +59 -5
  106. package/src/scripts/_lib/quota_parked.ts +133 -0
  107. package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
  108. package/src/scripts/_lib/review_telemetry.ts +196 -0
  109. package/src/scripts/_lib/subagent_response.ts +92 -0
  110. package/src/scripts/adr/regenerate_index.ts +14 -7
  111. package/src/scripts/ai_council/clients.ts +12 -1
  112. package/src/scripts/ai_council/config.ts +72 -115
  113. package/src/scripts/ai_council/fallback_config.ts +19 -7
  114. package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
  115. package/src/scripts/ai_council/orchestrator.ts +2 -2
  116. package/src/scripts/ai_council/py_format.ts +124 -0
  117. package/src/scripts/ai_council/seating.ts +211 -0
  118. package/src/scripts/ai_council/transport_resolver.ts +65 -2
  119. package/src/scripts/audit_adr_coverage.ts +33 -6
  120. package/src/scripts/check_council_pin_staleness.ts +300 -0
  121. package/src/scripts/check_estate_count.ts +420 -293
  122. package/src/scripts/check_gate_coverage.ts +91 -0
  123. package/src/scripts/check_no_stub_inventory_table.ts +292 -0
  124. package/src/scripts/check_pack_passport_reconciliation.ts +274 -0
  125. package/src/scripts/check_pack_size.ts +234 -1
  126. package/src/scripts/check_publish_surface.ts +297 -0
  127. package/src/scripts/check_requirements_trace.ts +378 -0
  128. package/src/scripts/check_secret_leak.ts +166 -12
  129. package/src/scripts/code_graph/cli.ts +44 -5
  130. package/src/scripts/code_graph/extract.ts +87 -0
  131. package/src/scripts/council_cli.ts +12 -16
  132. package/src/scripts/detect_ai_tells.ts +87 -0
  133. package/src/scripts/dispatch_r2_reviewer.ts +42 -0
  134. package/src/scripts/generate_capabilities_index.ts +41 -0
  135. package/src/scripts/generate_pack_manifests.ts +78 -0
  136. package/src/scripts/git_authorization_hook.ts +54 -4
  137. package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
  138. package/src/scripts/hooks/run_continuation_hook.ts +3 -1
  139. package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
  140. package/src/scripts/injection_scan_hook.ts +120 -10
  141. package/src/scripts/lint_adr_sweep_routing.ts +365 -0
  142. package/src/scripts/lint_workflow_security.ts +53 -10
  143. package/src/scripts/lint_workflow_security_allowlist.json +2 -18
  144. package/src/scripts/report_envelope_rate.ts +166 -0
  145. package/src/scripts/review_axis_report.ts +218 -0
  146. package/src/scripts/run_supervise.ts +46 -1
  147. package/src/scripts/schemas/evals.schema.json +5 -0
  148. package/src/scripts/schemas/pack.schema.json +62 -4
  149. package/src/scripts/schemas/skill.schema.json +2 -2
  150. package/src/scripts/sync_pr_branch.ts +14 -7
  151. package/src/scripts/tighten_skill_descriptions.ts +264 -0
  152. package/dist/cli/commands/commands.candidates.test.js +0 -257
  153. package/dist/cli/commands/commands.candidates.test.js.map +0 -1
  154. package/dist/cli/commands/profiles.test.js +0 -88
  155. package/dist/cli/commands/profiles.test.js.map +0 -1
  156. package/dist/cli/commands/recordTriggerEval.test.js +0 -113
  157. package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
  158. package/dist/cli/configRoot.test.js +0 -97
  159. package/dist/cli/configRoot.test.js.map +0 -1
  160. package/dist/cli/mcp/content.test.js +0 -54
  161. package/dist/cli/mcp/content.test.js.map +0 -1
  162. package/dist/cli/mcp/dispatch.test.js +0 -108
  163. package/dist/cli/mcp/dispatch.test.js.map +0 -1
  164. package/dist/server/writeRoot.test.js +0 -91
  165. package/dist/server/writeRoot.test.js.map +0 -1
  166. package/dist/shared/capabilities.test.js +0 -39
  167. package/dist/shared/capabilities.test.js.map +0 -1
  168. package/dist/ui/assets/index-C1pBRm1f.js +0 -26
  169. package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
  170. /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
@@ -120,11 +120,65 @@ export function normalizeHostManifest(input: unknown): HostCapabilityManifest {
120
120
  * host identity a hook/dispatch envelope carries).
121
121
  *
122
122
  * Each row is partial: an omitted field stays at the `SAFE_DEFAULT` `false`
123
- * when the row is applied. Claude Code is the one host this roadmap's
124
- * transcript evidence measured spawning and running subagents concurrently
125
- * (`Agent` tool dispatch, parallel tool-use blocks); polling and a separate
126
- * quota pool were never observed, so they stay `false` — add a field only
127
- * once it is itself observed, never by inference.
123
+ * when the row is applied — and an ABSENT field is the record for "never
124
+ * looked", which is a different state from a field written as `false`
125
+ * deliberately. Add a field only once it is itself observed, never by
126
+ * inference.
127
+ *
128
+ * ## Rows are written under the observation protocol
129
+ *
130
+ * `contexts/execution/host-capability-manifest.md` § Observation protocol states
131
+ * the per-field criterion and the four-part citation (host · host version ·
132
+ * transcript or artefact reference · date). A row without all four is not
133
+ * admissible, and no row may be filled from a host's documentation.
134
+ *
135
+ * ## REACHABILITY, 2026-08-22 — one of eight, and the other seven are recorded
136
+ *
137
+ * `hook_manifest.yaml` declares eight platform keys. Reachable means a real
138
+ * session can be started on the host, not that it is installable in principle.
139
+ *
140
+ * - `claude` — REACHABLE. Row below, re-cited under the protocol.
141
+ * - `augment` — not reachable from this session: a different editor host, no
142
+ * session available to the running agent.
143
+ * - `cowork` — not reachable from this session, same reason.
144
+ * - `cursor` — not reachable from this session, same reason.
145
+ * - `cline` — not reachable from this session, same reason.
146
+ * - `windsurf` — not reachable from this session, same reason.
147
+ * - `gemini` — not reachable from this session, same reason.
148
+ * - `copilot` — not reachable from this session, same reason; additionally
149
+ * `fallback_only` in the manifest.
150
+ *
151
+ * The seven have **no row**, so every field resolves to the safe default and
152
+ * `describeHostCapabilities` reports its source as `default`. That is the
153
+ * "never looked" state, and it is the honest one — a recorded silence rather
154
+ * than a table with gaps that reads as a table of negatives.
155
+ *
156
+ * ## The `claude` row, RE-CITED rather than grandfathered
157
+ *
158
+ * The row predated the protocol. Both its fields are now carried on transcript
159
+ * evidence, so it is brought up to the new shape rather than marked
160
+ * grandfathered — which was the other permitted disposition and would have left
161
+ * it looking like it passed a protocol it never ran.
162
+ *
163
+ * `subagent_spawn: true` — OBSERVED. claude (Claude Code, Opus 5 1M session,
164
+ * 2026-08-22). `agents/runtime/state/subagent-ledger/2026-08.jsonl`: **445**
165
+ * `subagent_start` records, and **420** stops carrying a measured
166
+ * `duration_ms` — i.e. 420 child legs whose start and stop pair, which is the
167
+ * criterion (a measurable child duration, not an inferred one).
168
+ *
169
+ * `parallel_spawn: true` — OBSERVED. Same host, session and artefact.
170
+ * **6,168** of 6,388 stop records carry `concurrent_open >= 2`, with a
171
+ * maximum of **30** concurrently open children. Overlapping intervals at one
172
+ * instant, which is the criterion — not two children in sequence.
173
+ *
174
+ * `status_polling`, `separate_quota_pool`, `worker_respawn` — **absent from the
175
+ * row, and that is deliberate.** The ledger does not instrument any of the
176
+ * three, so this is "never looked", NOT "observed absent". They stay at the
177
+ * safe default and report `default` provenance. Writing any of them as an
178
+ * explicit `false` would claim a measurement nobody took.
179
+ *
180
+ * `agent_teams` — out of scope for a row by construction; see its own field
181
+ * doc comment and the protocol section.
128
182
  */
129
183
  const HOST_CAPABILITY_REGISTRY: Readonly<Record<string, Partial<HostCapabilityManifest>>> = {
130
184
  claude: { subagent_spawn: true, parallel_spawn: true },
@@ -0,0 +1,133 @@
1
+ /**
2
+ * quota_parked — the marker that says a run stopped because plan quota ran out,
3
+ * rather than because it crashed.
4
+ *
5
+ * `run_supervise` can already tell a dead session from a live one, and a
6
+ * finished run from an unfinished one. What it cannot tell is WHY a run
7
+ * stopped, and for one cause that distinction decides the whole response: a run
8
+ * held back by an exhausted plan quota is not broken, it is early. Reported as
9
+ * `relaunchable` with "the session is gone" it reads as a crash, and the one
10
+ * fact an operator needs — that waiting is a strategy here and is not a
11
+ * strategy for a crash — is the fact that gets lost.
12
+ *
13
+ * **The trigger is ours, and that is the point.** Detecting the billing cliff
14
+ * inside Claude Code needs a host signal nobody has shown exists
15
+ * (`agents/roadmaps/later/road-to-billing-cliff-detection.md`). But the council
16
+ * establishes the same fact under its own roof: when `api_on_quota: 'ask'`
17
+ * parks a seat, plan quota for that provider is exhausted, measured by us, with
18
+ * no host surface involved. This marker is written from there.
19
+ *
20
+ * PII-exclusion-by-construction, the same shape as `billing_grant`: a record
21
+ * holds a run id, provider names, and an ISO stamp. There is no field capable
22
+ * of carrying a prompt, a path, or an error body, so there is no scrubbing pass
23
+ * here that could fail.
24
+ *
25
+ * **It records, it does not act.** Nothing here sleeps, probes, or relaunches.
26
+ * Unattended relaunch is a published refusal
27
+ * (road-to-long-horizon-execution 4.0, AI council 2026-08-19) whose reopen
28
+ * condition has since fired — see
29
+ * `agents/evidence/billing-cliff/spawn-reopen-condition.md` — and a fired
30
+ * condition reopens the question in the venue that closed it, never the
31
+ * capability by itself.
32
+ */
33
+
34
+ import * as fs from 'node:fs';
35
+ import * as path from 'node:path';
36
+
37
+ /** Directory, relative to the repo root, holding one marker per parked run. */
38
+ export const QUOTA_PARKED_DIR_REL = path.join('agents', 'runtime', 'state', 'quota-parked');
39
+
40
+ export interface QuotaParkedMarker {
41
+ readonly run_id: string;
42
+ /** Providers whose plan quota was exhausted, sorted and deduplicated. */
43
+ readonly providers: string[];
44
+ readonly parked_at: string;
45
+ }
46
+
47
+ /** Filesystem-safe path for a run's marker. Mirrors `billing_grant.grantFile`. */
48
+ export function markerFile(repoRoot: string, runId: string): string {
49
+ return path.join(
50
+ repoRoot,
51
+ QUOTA_PARKED_DIR_REL,
52
+ `${runId.replace(/[^A-Za-z0-9_-]/g, '_')}.json`,
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Record that `runId` is held back by exhausted plan quota on `providers`.
58
+ *
59
+ * Idempotent on the timestamp: a second park inside one run keeps the original
60
+ * `parked_at` and merges the provider set, so "when did this run first hit the
61
+ * cliff" survives a round that parks twice.
62
+ */
63
+ export function writeQuotaParked(
64
+ repoRoot: string,
65
+ runId: string,
66
+ providers: readonly string[],
67
+ now: () => Date = () => new Date(),
68
+ ): QuotaParkedMarker {
69
+ const file = markerFile(repoRoot, runId);
70
+ const existing = readMarker(file);
71
+ const merged = [...new Set([...(existing?.providers ?? []), ...providers])].sort();
72
+ const marker: QuotaParkedMarker = {
73
+ run_id: runId,
74
+ providers: merged,
75
+ parked_at: existing?.parked_at ?? now().toISOString(),
76
+ };
77
+ fs.mkdirSync(path.dirname(file), { recursive: true });
78
+ fs.writeFileSync(file, `${JSON.stringify(marker, null, 2)}\n`, 'utf8');
79
+ return marker;
80
+ }
81
+
82
+ /** Remove a run's marker. Safe to call when none exists. */
83
+ export function clearQuotaParked(repoRoot: string, runId: string): void {
84
+ try {
85
+ fs.unlinkSync(markerFile(repoRoot, runId));
86
+ } catch {
87
+ // Already gone is the state the caller asked for.
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The marker for a session, or `null`.
93
+ *
94
+ * Looks up by run id first, then scans. The scan is not redundant: the run id
95
+ * the council writes comes from `currentRunId`, whose chain is
96
+ * `AC_BILLING_GRANT` → `AC_RUN_ID` → `CLAUDE_CODE_SESSION_ID`, while the
97
+ * register's `session_id` comes from `AGENT_CONFIG_SESSION_ID` →
98
+ * `CLAUDE_CODE_SESSION_ID`. The two coincide in an ordinary Claude Code session
99
+ * and diverge the moment an orchestrator sets `AC_RUN_ID` — so a direct hit is
100
+ * the common case and the scan is what stops the uncommon one from silently
101
+ * reporting a parked run as a crashed one. The directory holds one small file
102
+ * per parked run, so the scan is cheap by construction.
103
+ */
104
+ export function findQuotaParked(repoRoot: string, sessionId: string): QuotaParkedMarker | null {
105
+ const direct = readMarker(markerFile(repoRoot, sessionId));
106
+ if (direct !== null) return direct;
107
+ let names: string[];
108
+ try {
109
+ names = fs.readdirSync(path.join(repoRoot, QUOTA_PARKED_DIR_REL));
110
+ } catch {
111
+ return null;
112
+ }
113
+ for (const name of names) {
114
+ if (!name.endsWith('.json')) continue;
115
+ const m = readMarker(path.join(repoRoot, QUOTA_PARKED_DIR_REL, name));
116
+ if (m !== null && m.run_id === sessionId) return m;
117
+ }
118
+ return null;
119
+ }
120
+
121
+ function readMarker(file: string): QuotaParkedMarker | null {
122
+ try {
123
+ const raw: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
124
+ if (raw === null || typeof raw !== 'object') return null;
125
+ const o = raw as Record<string, unknown>;
126
+ if (typeof o['run_id'] !== 'string' || typeof o['parked_at'] !== 'string') return null;
127
+ if (!Array.isArray(o['providers'])) return null;
128
+ const providers = o['providers'].filter((p): p is string => typeof p === 'string');
129
+ return { run_id: o['run_id'], providers, parked_at: o['parked_at'] };
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
@@ -85,6 +85,68 @@ export function sanitize_text(s: string): string {
85
85
  return out.length > MAX_FIELD_CHARS ? out.slice(0, MAX_FIELD_CHARS) : out;
86
86
  }
87
87
 
88
+ /**
89
+ * Report the INVISIBLE layer without removing it.
90
+ *
91
+ * `sanitize_text` above strips four channels — hidden-instruction vectors
92
+ * (bidi, zero-width, tag, deprecated-format, private-use-area, per `_classify`),
93
+ * strippable control chars, and invisible fillers. Stripping is the right
94
+ * disposition for a retrieval path that is about to hand text to a model.
95
+ *
96
+ * It is the WRONG disposition for a reporting surface. A warn-only PostToolUse
97
+ * hook must not rewrite what the agent already read, so it cannot call
98
+ * `sanitize_text` at all — and `scan_encoding_findings` is the VISIBLE-layer
99
+ * twin and deliberately says nothing about these four. Measured over the frozen
100
+ * corpus before this function existed: `deprecated-format`, `private-use-area`,
101
+ * `control-char` and `invisible-filler` reported **0 of 20 each** through the
102
+ * reporting API while `sanitize_text` changed **20 of 20** for every one of
103
+ * them. So the layer had a reporting blind spot exactly where its stripping was
104
+ * perfect, and a consumer that may not strip was blind to a quarter of the
105
+ * channel set.
106
+ *
107
+ * The published 99.00 % recall figure is the STRIPPING pipeline's. The reporting
108
+ * half alone measured 72.33 %. Both numbers are correct about different things,
109
+ * and conflating them is what made "import the measured layer and get 15
110
+ * channels" read as true.
111
+ *
112
+ * Additive by construction: nothing that exists changes behaviour, and the
113
+ * predicates are the SAME ones `sanitize_text` uses — not copies — so a channel
114
+ * cannot become strippable without becoming reportable in the same edit.
115
+ */
116
+ export function scan_invisible_findings(s: string): EncodingFinding[] {
117
+ const out: EncodingFinding[] = [];
118
+ const seen = new Set<string>();
119
+ const add = (channel: string, cp: number): void => {
120
+ const key = `${channel}:${cp}`;
121
+ if (seen.has(key)) return;
122
+ seen.add(key);
123
+ out.push({
124
+ channel,
125
+ detail: `U+${cp.toString(16).toUpperCase().padStart(4, '0')} in the invisible layer`,
126
+ });
127
+ };
128
+ for (const ch of s) {
129
+ const cp = ch.codePointAt(0);
130
+ if (cp === undefined) continue;
131
+ const classified = _classify(cp);
132
+ if (classified !== null) {
133
+ // `_classify` already names the channel; reuse its label rather than
134
+ // re-deriving one, so the hook's output and the authoring-time
135
+ // linter's speak the same vocabulary.
136
+ add(typeof classified === 'string' ? classified : 'hidden-instruction', cp);
137
+ continue;
138
+ }
139
+ if (_isStrippableControl(cp)) {
140
+ add('control-char', cp);
141
+ continue;
142
+ }
143
+ if (_INVISIBLE_FILLERS.has(cp)) {
144
+ add('invisible-filler', cp);
145
+ }
146
+ }
147
+ return out;
148
+ }
149
+
88
150
  // ---------------------------------------------------------------------------
89
151
  // Visible-layer scanner — FLAG ONLY, never rewrites
90
152
  // ---------------------------------------------------------------------------
@@ -0,0 +1,196 @@
1
+ /**
2
+ * One structural line per review run — the review surface's only telemetry.
3
+ *
4
+ * Why it exists: before this file the review surface emitted nothing. Nothing
5
+ * recorded how often a review ran, which judges fired, or whether the spec
6
+ * axis ever changed a recommendation — so the value of adding a sixth judge
7
+ * was unmeasurable in either direction, and the cheapest response to a slower
8
+ * default path is to switch the new judge off. A number is the only defence
9
+ * against that, and the honest number includes "it changed nothing".
10
+ *
11
+ * PII-exclusion by CONSTRUCTION, not by scrubbing. `ReviewLine` has no field
12
+ * able to hold free-form content: every member is an enum, a boolean, a
13
+ * non-negative integer, or an ISO timestamp. There is no `payload`, `notes`,
14
+ * `extra`, `prompt`, `diff` or `path` member, and `buildReviewLine` REJECTS an
15
+ * input object carrying one rather than dropping it silently — a scrubber can
16
+ * fail, an absent field cannot. Same principle the artifact-engagement event
17
+ * and `orchestration_record`'s hex-only `payload_hash` apply.
18
+ *
19
+ * Deliberately NOT reused from `_lib/orchestration_record.ts`: that line
20
+ * describes a subagent DISPATCH (tier, route, token deltas, cache hits). A
21
+ * review is not a dispatch, and widening `RecordInput` with review-shaped
22
+ * optionals would make both types describe neither thing precisely.
23
+ */
24
+
25
+ /** Was the spec axis able to run at all, and on what basis? */
26
+ export type SpecAxisReach = 'reachable_with_criteria' | 'reachable_no_criteria' | 'unreachable';
27
+
28
+ /**
29
+ * Did the spec axis change the recommendation the craft judges would have
30
+ * produced on their own?
31
+ *
32
+ * `null` is NOT "no" — it means the comparison never happened (the axis did
33
+ * not run, so there is no counterfactual). A `false` here would claim a
34
+ * comparison that was never made, which is the mis-attribution this field
35
+ * exists to expose. Same reasoning as `modelDivergent` in
36
+ * `_lib/orchestration_record.ts`.
37
+ */
38
+ export type SpecAxisEffect = 'changed' | 'unchanged' | null;
39
+
40
+ /** The recommendation word the synthesis emitted. */
41
+ export type ReviewRecommendation = 'block' | 'revise' | 'proceed';
42
+
43
+ /** The criteria-source state, mirroring `judge-spec-compliance`'s three states. */
44
+ export type CriteriaSource = 'supplied' | 'not_provided' | 'supplied_unparseable';
45
+
46
+ /** The closed field set. A member not on this list cannot be written. */
47
+ export const REVIEW_LINE_FIELDS = [
48
+ 'schema',
49
+ 'at',
50
+ 'judges_declared',
51
+ 'judges_ran',
52
+ 'spec_axis_reach',
53
+ 'criteria_source',
54
+ 'criteria_count',
55
+ 'spec_missing',
56
+ 'spec_partial',
57
+ 'recommendation',
58
+ 'spec_axis_effect',
59
+ ] as const;
60
+
61
+ export type ReviewLineField = (typeof REVIEW_LINE_FIELDS)[number];
62
+
63
+ /**
64
+ * Field names that must never appear on this line — the free-form shapes a
65
+ * reviewer would reach for first. Checked as an INPUT rejection, so adding one
66
+ * fails loudly at the call site instead of being stripped downstream.
67
+ */
68
+ export const FORBIDDEN_FREEFORM_FIELDS = [
69
+ 'payload',
70
+ 'notes',
71
+ 'extra',
72
+ 'prompt',
73
+ 'diff',
74
+ 'path',
75
+ 'file',
76
+ 'criteria',
77
+ 'findings',
78
+ 'message',
79
+ 'body',
80
+ 'content',
81
+ 'text',
82
+ ] as const;
83
+
84
+ export interface ReviewLine {
85
+ readonly schema: 'review-axis-v1';
86
+ /** ISO-8601 UTC. Caller-supplied so the line is reproducible in a test. */
87
+ readonly at: string;
88
+ readonly judges_declared: number;
89
+ readonly judges_ran: number;
90
+ readonly spec_axis_reach: SpecAxisReach;
91
+ readonly criteria_source: CriteriaSource;
92
+ readonly criteria_count: number;
93
+ readonly spec_missing: number;
94
+ readonly spec_partial: number;
95
+ readonly recommendation: ReviewRecommendation;
96
+ readonly spec_axis_effect: SpecAxisEffect;
97
+ }
98
+
99
+ export interface ReviewLineInput {
100
+ at: string;
101
+ judges_declared: number;
102
+ judges_ran: number;
103
+ spec_axis_reach: SpecAxisReach;
104
+ criteria_source: CriteriaSource;
105
+ criteria_count: number;
106
+ spec_missing: number;
107
+ spec_partial: number;
108
+ recommendation: ReviewRecommendation;
109
+ spec_axis_effect?: SpecAxisEffect;
110
+ }
111
+
112
+ export interface BuiltReviewLine {
113
+ line: ReviewLine | null;
114
+ errors: string[];
115
+ }
116
+
117
+ const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?Z$/;
118
+ const REACH: readonly SpecAxisReach[] = ['reachable_with_criteria', 'reachable_no_criteria', 'unreachable'];
119
+ const SOURCE: readonly CriteriaSource[] = ['supplied', 'not_provided', 'supplied_unparseable'];
120
+ const RECO: readonly ReviewRecommendation[] = ['block', 'revise', 'proceed'];
121
+
122
+ function isCount(n: unknown): n is number {
123
+ return typeof n === 'number' && Number.isInteger(n) && n >= 0;
124
+ }
125
+
126
+ /**
127
+ * Build + validate one `review-axis-v1` line.
128
+ *
129
+ * Returns `{line, errors}`; when `errors` is non-empty, `line` is null and the
130
+ * caller must NOT write. Never throws, never partially writes, never silently
131
+ * repairs an invalid input — the same contract as `buildOrchestrationLine`.
132
+ */
133
+ export function buildReviewLine(input: ReviewLineInput): BuiltReviewLine {
134
+ const errors: string[] = [];
135
+ const raw = input as unknown as Record<string, unknown>;
136
+
137
+ for (const banned of FORBIDDEN_FREEFORM_FIELDS) {
138
+ if (Object.prototype.hasOwnProperty.call(raw, banned)) {
139
+ errors.push(
140
+ `'${banned}' is a free-form field and review-axis-v1 carries none — ` +
141
+ 'privacy here is a property of the schema shape, not of a scrubbing pass',
142
+ );
143
+ }
144
+ }
145
+
146
+ if (typeof input.at !== 'string' || !ISO_RE.test(input.at)) {
147
+ errors.push('at must be an ISO-8601 UTC timestamp (…Z)');
148
+ }
149
+ for (const k of ['judges_declared', 'judges_ran', 'criteria_count', 'spec_missing', 'spec_partial'] as const) {
150
+ if (!isCount(input[k])) errors.push(`${k} must be a non-negative integer`);
151
+ }
152
+ if (isCount(input.judges_ran) && isCount(input.judges_declared) && input.judges_ran > input.judges_declared) {
153
+ errors.push('judges_ran cannot exceed judges_declared');
154
+ }
155
+ if (!REACH.includes(input.spec_axis_reach)) errors.push(`spec_axis_reach must be one of ${REACH.join(' | ')}`);
156
+ if (!SOURCE.includes(input.criteria_source)) errors.push(`criteria_source must be one of ${SOURCE.join(' | ')}`);
157
+ if (!RECO.includes(input.recommendation)) errors.push(`recommendation must be one of ${RECO.join(' | ')}`);
158
+
159
+ const effect = input.spec_axis_effect ?? null;
160
+ if (effect !== null && effect !== 'changed' && effect !== 'unchanged') {
161
+ errors.push('spec_axis_effect must be changed | unchanged | null');
162
+ }
163
+ // A counterfactual that never ran is `null`, never `unchanged`.
164
+ if (input.spec_axis_reach === 'unreachable' && effect !== null) {
165
+ errors.push('spec_axis_effect must be null when the axis was unreachable — there was no comparison to report');
166
+ }
167
+ // The two honest pairings of reach and source.
168
+ if (input.spec_axis_reach === 'reachable_with_criteria' && input.criteria_source !== 'supplied') {
169
+ errors.push('reachable_with_criteria requires criteria_source=supplied');
170
+ }
171
+ if (input.spec_axis_reach === 'reachable_no_criteria' && input.criteria_source === 'supplied') {
172
+ errors.push('reachable_no_criteria contradicts criteria_source=supplied');
173
+ }
174
+ if (input.criteria_source !== 'supplied' && isCount(input.criteria_count) && input.criteria_count > 0) {
175
+ errors.push('criteria_count must be 0 unless criteria were supplied');
176
+ }
177
+
178
+ if (errors.length > 0) return { line: null, errors };
179
+
180
+ return {
181
+ line: {
182
+ schema: 'review-axis-v1',
183
+ at: input.at,
184
+ judges_declared: input.judges_declared,
185
+ judges_ran: input.judges_ran,
186
+ spec_axis_reach: input.spec_axis_reach,
187
+ criteria_source: input.criteria_source,
188
+ criteria_count: input.criteria_count,
189
+ spec_missing: input.spec_missing,
190
+ spec_partial: input.spec_partial,
191
+ recommendation: input.recommendation,
192
+ spec_axis_effect: effect,
193
+ },
194
+ errors: [],
195
+ };
196
+ }
@@ -61,10 +61,102 @@ export interface SubagentResponse {
61
61
  * the same claim as "no assumptions were made".
62
62
  */
63
63
  assumptions?: CapsuleAssumption[];
64
+ /**
65
+ * Where the tests in this change were authored FROM.
66
+ *
67
+ * `from-spec` — written against the acceptance criteria, before the diff
68
+ * existed. `from-diff` — written after reading the implementation.
69
+ * `unknown` — not recorded.
70
+ *
71
+ * **`unknown` is the default and an ABSENT field resolves to it, never to
72
+ * `from-spec`.** That asymmetry is the whole point of the field. The claim
73
+ * this metric exists to keep measurable is that a suite written by the same
74
+ * context as the implementation inherits its blind spots — so the valuable
75
+ * state is `from-spec`, and a default that let absence read as the valuable
76
+ * state would report the claim as satisfied by silence.
77
+ *
78
+ * Structural enum only: three string values and nothing that can hold
79
+ * free-form content. Same exclusion-by-construction the rest of this
80
+ * envelope uses — a field that cannot carry a prompt or a diff has no
81
+ * scrubber to fail.
82
+ *
83
+ * OPTIONAL, and deliberately not in `RESPONSE_REQUIRED_FIELDS`: that list
84
+ * is calibrated against a recorded ledger equivalence (`error_count: 5`),
85
+ * and adding a sixth required field would break it. A metric is not worth
86
+ * invalidating a measurement for.
87
+ */
88
+ test_authorship?: TestAuthorship;
89
+ }
90
+
91
+ /**
92
+ * Three states. `unknown` is one of them, not the absence of the other two.
93
+ *
94
+ * Ordered by what they license rather than alphabetically: `from-spec` is the
95
+ * only value that supports the independence claim, `from-diff` is the weaker
96
+ * form the claim says is worthless, and `unknown` licenses nothing.
97
+ */
98
+ export type TestAuthorship = 'from-spec' | 'from-diff' | 'unknown';
99
+
100
+ export const TEST_AUTHORSHIP: ReadonlySet<string> = new Set<TestAuthorship>([
101
+ 'from-spec',
102
+ 'from-diff',
103
+ 'unknown',
104
+ ]);
105
+
106
+ /**
107
+ * Resolve the field from a decoded envelope. ABSENT and UNRECOGNISED both
108
+ * resolve to `unknown`.
109
+ *
110
+ * An unrecognised value resolving to `unknown` rather than throwing is
111
+ * deliberate: this is a metric, and a producer that emits `from_spec` with an
112
+ * underscore should show up in the distribution as unrecorded, not stop a
113
+ * dispatch. The report is what surfaces it — a spike of `unknown` IS the
114
+ * finding that the field is not reaching its producers.
115
+ */
116
+ export function resolveTestAuthorship(input: unknown): TestAuthorship {
117
+ if (input === null || typeof input !== 'object') return 'unknown';
118
+ const v = (input as Record<string, unknown>)['test_authorship'];
119
+ return typeof v === 'string' && TEST_AUTHORSHIP.has(v) ? (v as TestAuthorship) : 'unknown';
64
120
  }
65
121
 
66
122
  const CONFIDENCE: ReadonlySet<string> = new Set<Confidence>(['low', 'medium', 'high']);
67
123
 
124
+ /**
125
+ * The five fields `validateResponse` REQUIRES. Exported so the ledger's
126
+ * classifier counts contract fields against the validator's own list rather
127
+ * than a second copy that can drift from it.
128
+ *
129
+ * The optional fields (`artifact_paths`, `assumptions`) are deliberately NOT
130
+ * here. The boundary this list defines is "did the author aim at the envelope
131
+ * at all", and it is calibrated against a recorded measurement: every `fail`
132
+ * in the live ledger carries `error_count: 5`, which is exactly the count of
133
+ * required-field errors an object with none of them produces. Counting an
134
+ * optional field as a hit would break that equivalence, so an object carrying
135
+ * only `assumptions` reads as foreign — recorded here as a known edge, not as
136
+ * an oversight.
137
+ */
138
+ export const RESPONSE_REQUIRED_FIELDS: readonly string[] = [
139
+ 'summary',
140
+ 'handoff',
141
+ 'confidence',
142
+ 'findings',
143
+ 'risks',
144
+ ];
145
+
146
+ /**
147
+ * How many required contract fields are PRESENT on a decoded object.
148
+ *
149
+ * Presence, never validity: a `summary: 123` is a contract attempt with a
150
+ * wrong type, which is a different defect from a fenced tool call that never
151
+ * aimed at the envelope. That distinction is the whole point of the count —
152
+ * `validateResponse` already reports the type errors.
153
+ */
154
+ export function countContractFields(input: unknown): number {
155
+ if (input === null || typeof input !== 'object') return 0;
156
+ const r = input as Record<string, unknown>;
157
+ return RESPONSE_REQUIRED_FIELDS.filter((k) => Object.hasOwn(r, k)).length;
158
+ }
159
+
68
160
  export interface ValidationResult {
69
161
  valid: boolean;
70
162
  errors: string[];
@@ -16,7 +16,7 @@ import * as fs from 'node:fs';
16
16
  import * as path from 'node:path';
17
17
  import process from 'node:process';
18
18
  import { fileURLToPath, pathToFileURL } from 'node:url';
19
- import { readAdrFrontmatterScalars } from '../_lib/adr_frontmatter.js';
19
+ import { readAdrAxisCells, readAdrFrontmatterScalars } from '../_lib/adr_frontmatter.js';
20
20
 
21
21
  // ^ADR-(\d{3})-([a-z0-9-]+)\.md$
22
22
  const NAMED = /^ADR-(\d{3})-([a-z0-9-]+)\.md$/;
@@ -29,8 +29,8 @@ const NAMED = /^ADR-(\d{3})-([a-z0-9-]+)\.md$/;
29
29
  * amendments from one never touched.
30
30
  */
31
31
  const HEAD =
32
- '| # | Title | Status | Date | Supersedes | Superseded by | Amended by |\n' +
33
- '|---|---|---|---|---|---|---|';
32
+ '| # | Title | Status | Date | Provenance | Evidence | Supersedes | Superseded by | Amended by |\n' +
33
+ '|---|---|---|---|---|---|---|---|---|';
34
34
 
35
35
  type Meta = Record<string, string>;
36
36
  interface Row extends Meta {
@@ -130,10 +130,16 @@ export function scan(d: string): [Row[], Row[], string[]] {
130
130
  if (name === 'INDEX.md') {
131
131
  continue;
132
132
  }
133
- const meta = fm(fs.readFileSync(p, 'utf-8'));
133
+ const text = fs.readFileSync(p, 'utf-8');
134
+ const meta = fm(text);
135
+ // Read apart from `meta` on purpose. `provenance` and `evidence` are
136
+ // nested, so the scalar reader keeps them out of its map entirely —
137
+ // spreading `meta` could never produce these two cells, and the axis
138
+ // reader is where the `discovery`-rides-in-the-grade rule lives.
139
+ const axes = readAdrAxisCells(text);
134
140
  const m = NAMED.exec(name);
135
141
  if (!m) {
136
- leg.push({ path: name, ...meta });
142
+ leg.push({ path: name, ...meta, ...axes });
137
143
  continue;
138
144
  }
139
145
  const n = m[1] as string;
@@ -144,7 +150,7 @@ export function scan(d: string): [Row[], Row[], string[]] {
144
150
  errs.push(`ADR-${n} duplicate: ${name} and ${seen[n]}`);
145
151
  }
146
152
  seen[n] = name;
147
- num.push({ num: n, slug: m[2] as string, path: name, ...meta });
153
+ num.push({ num: n, slug: m[2] as string, path: name, ...meta, ...axes });
148
154
  }
149
155
  const nums = new Set(num.map((r) => r.num as string));
150
156
  for (const r of num) {
@@ -172,7 +178,8 @@ export function row(r: Row): string {
172
178
  const label = 'num' in r ? `ADR-${r.num}` : (r.path as string).slice(0, -3);
173
179
  return (
174
180
  `| [${label}](${r.path}) | ${title} | ${r.status ?? '—'} ` +
175
- `| ${r.date ?? '—'} | ${r.supersedes ?? '—'} | ${r.superseded_by ?? '—'} ` +
181
+ `| ${r.date ?? '—'} | ${r.provenance_kind ?? '—'} | ${r.evidence_grade ?? '—'} ` +
182
+ `| ${r.supersedes ?? '—'} | ${r.superseded_by ?? '—'} ` +
176
183
  `| ${r.amended_by ?? '—'} |`
177
184
  );
178
185
  }
@@ -148,7 +148,18 @@ export const CODEX_MEASURED_UNSERVABLE: ReadonlyMap<string, string> = new Map([
148
148
  ]);
149
149
 
150
150
  export const DEFAULT_OPENAI_CLI_MODEL = OPENAI_CLI_VENDOR_DEFAULT;
151
- export const DEFAULT_ANTHROPIC_CLI_MODEL = 'claude-sonnet-4-5';
151
+ // A vendor ALIAS, not a version. `claude --help` documents 'fable', 'opus' and
152
+ // 'sonnet' as "an alias for the latest model" in their band, so an alias cannot
153
+ // go stale the way a dated id can — which is the whole point of the change that
154
+ // put it here.
155
+ //
156
+ // It was `'claude-sonnet-4-5'`, and this line is the half the roadmap that
157
+ // found the defect did not know about: refreshing only the template's pin would
158
+ // have left every member that omits `model:` falling back to the SAME stale id
159
+ // from code. Read from the provider's own CLI surface on 2026-08-22, never from
160
+ // the agent's recall — the source that road-to-council-seat-selection's own
161
+ // Risk 1 declares inadmissible.
162
+ export const DEFAULT_ANTHROPIC_CLI_MODEL = 'sonnet';
152
163
  export const DEFAULT_GEMINI_CLI_MODEL = 'gemini-2.5-pro';
153
164
 
154
165
  // OpenAI-API-compatible endpoints. xAI and Perplexity both expose the