@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
@@ -0,0 +1,365 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * CI guard: every candidate row in an ADR evidence sweep carries a route AND a
4
+ * dated follow-up.
5
+ *
6
+ * `road-to-evidence-based-adr-governance` 3.4 asks for exactly this, and the
7
+ * step was once marked `[x]` on the adjudication pass it DID run rather than on
8
+ * the half its own `verify:` named — "zero candidate rows without both a route
9
+ * and a dated follow-up; a lint over sweep artifacts red on a dateless
10
+ * candidate row". Neither the columns nor the lint existed. That is the
11
+ * "prose lifecycle enforcement is satisfied by its weakest honest reading"
12
+ * defect the roadmap cites as its third motivating measurement, so the fix is a
13
+ * gate rather than another sentence.
14
+ *
15
+ * WHAT A CANDIDATE IS. A table row whose disposition cell carries `REVIEW-NOW`
16
+ * or `SUPERSEDE (candidate)` — the two dispositions that assert the record needs
17
+ * further action. Every other disposition in the vocabulary is terminal for the
18
+ * sweep (`KEEP`, `HISTORICAL-ONLY`, …) or names its own mechanism, and a
19
+ * follow-up requirement on those would be noise.
20
+ *
21
+ * WHY A DATE AND NOT A PROMISE. The three accepted follow-up forms are a linked
22
+ * roadmap step, an ADR draft, or an explicit dated defer on the ADR-134 pattern.
23
+ * All three are checkable only through the date: without one, a lapse is a
24
+ * silent extension instead of a compliance finding, which is the property the
25
+ * step was specified to buy.
26
+ *
27
+ * HONEST SCOPE. This gate checks that a route and a date are PRESENT and that
28
+ * the routing section covers every candidate. It cannot check that the route is
29
+ * the right venue or that the date is met — the first is a judgement and the
30
+ * second is a calendar. Read a green run as "every candidate is routed and
31
+ * dated", never as "every follow-up happened".
32
+ *
33
+ * Exit codes: 0 = clean, 1 = violations found, 2 = internal error.
34
+ *
35
+ * Usage:
36
+ * ./scripts-run src/scripts/lint_adr_sweep_routing
37
+ * ./scripts-run src/scripts/lint_adr_sweep_routing --json
38
+ */
39
+
40
+ import * as fs from 'node:fs';
41
+ import * as os from 'node:os';
42
+ import * as path from 'node:path';
43
+ import { fileURLToPath, pathToFileURL } from 'node:url';
44
+
45
+ import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
46
+ import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
47
+
48
+ const _HERE = fileURLToPath(import.meta.url);
49
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
50
+
51
+ let SWEEP_DIR = path.join(REPO_ROOT, 'docs', 'decisions');
52
+
53
+ /** Test seam, mirroring the sibling roadmap lints. */
54
+ export function _setSweepDirForTest(p: string): void {
55
+ SWEEP_DIR = p;
56
+ }
57
+
58
+ /** Sweep artifacts: `adr-evidence-sweep-*.md` under docs/decisions/. */
59
+ const SWEEP_NAME = /^adr-evidence-sweep-.*\.md$/;
60
+
61
+ const CANDIDATE = /REVIEW-NOW|SUPERSEDE \(candidate\)/;
62
+ const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/;
63
+ const ROUTING_HEADING = /^##+\s+Candidate routing\b/;
64
+ const NEXT_HEADING = /^##\s+/;
65
+
66
+ export interface Violation {
67
+ file: string;
68
+ line: number;
69
+ key: string;
70
+ msg: string;
71
+ }
72
+
73
+ /**
74
+ * The record identity a candidate row and its routing row must agree on.
75
+ *
76
+ * Sweep tables label the same record four ways — `208`, `ADR-239`,
77
+ * `ADR-016 installer-architecture`, `docs/adrs/router/0001 three-tier-routing`
78
+ * — so a literal string match would report every row as unrouted. Normalising
79
+ * to the numeric identity is what makes the two sections comparable at all.
80
+ */
81
+ export function recordKey(cell: string): string | null {
82
+ const text = cell.replace(/[`*[\]]/g, '').trim();
83
+ const area = /^docs\/adrs\/([a-z-]+)\/0*(\d+)/.exec(text);
84
+ if (area) return `${area[1]}/${String(Number(area[2]))}`;
85
+ const numbered = /^(?:ADR-)?0*(\d{1,4})\b/.exec(text);
86
+ if (numbered) return `adr/${String(Number(numbered[1]))}`;
87
+ return null;
88
+ }
89
+
90
+ function cells(line: string): string[] {
91
+ return line
92
+ .replace(/^\||\|$/g, '')
93
+ .split('|')
94
+ .map((c) => c.trim());
95
+ }
96
+
97
+ /** Is this a table body row (not a header or a `|---|` separator)? */
98
+ function isBodyRow(line: string): boolean {
99
+ return line.startsWith('|') && !/^\|[\s:-]+\|/.test(line);
100
+ }
101
+
102
+ export interface SweepScan {
103
+ /** key -> first line number the candidate was seen on */
104
+ candidates: Map<string, number>;
105
+ /** key -> { route, followUp, line } */
106
+ routed: Map<string, { route: string; followUp: string; line: number }>;
107
+ /** true when the artifact has a `## Candidate routing` section at all */
108
+ hasRoutingSection: boolean;
109
+ }
110
+
111
+ export function scanSweep(text: string): SweepScan {
112
+ const lines = text.split('\n');
113
+ const candidates = new Map<string, number>();
114
+ const routed = new Map<string, { route: string; followUp: string; line: number }>();
115
+ let inRouting = false;
116
+ let hasRoutingSection = false;
117
+ let inFence = false;
118
+
119
+ for (let i = 0; i < lines.length; i += 1) {
120
+ const line = lines[i] as string;
121
+ if (/^\s*```/.test(line)) {
122
+ inFence = !inFence;
123
+ continue;
124
+ }
125
+ if (inFence) continue;
126
+
127
+ if (ROUTING_HEADING.test(line)) {
128
+ inRouting = true;
129
+ hasRoutingSection = true;
130
+ continue;
131
+ }
132
+ if (inRouting && NEXT_HEADING.test(line) && !ROUTING_HEADING.test(line)) {
133
+ inRouting = false;
134
+ }
135
+ if (!isBodyRow(line)) continue;
136
+
137
+ const c = cells(line);
138
+ const key = recordKey(c[0] ?? '');
139
+ if (key === null) continue;
140
+
141
+ if (inRouting) {
142
+ // | Record | Disposition | Route | Dated follow-up |
143
+ routed.set(key, {
144
+ route: (c[2] ?? '').trim(),
145
+ followUp: (c[3] ?? '').trim(),
146
+ line: i + 1,
147
+ });
148
+ continue;
149
+ }
150
+ if (CANDIDATE.test(line) && !candidates.has(key)) {
151
+ candidates.set(key, i + 1);
152
+ }
153
+ }
154
+ return { candidates, routed, hasRoutingSection };
155
+ }
156
+
157
+ export function lintSweep(rel: string, text: string): [Violation[], number] {
158
+ const { candidates, routed, hasRoutingSection } = scanSweep(text);
159
+ const out: Violation[] = [];
160
+ if (candidates.size > 0 && !hasRoutingSection) {
161
+ out.push({
162
+ file: rel,
163
+ line: 1,
164
+ key: '—',
165
+ msg:
166
+ `${candidates.size} candidate row(s) and no \`## Candidate routing\` section. ` +
167
+ 'Every REVIEW-NOW / SUPERSEDE (candidate) row needs a route and a dated follow-up ' +
168
+ '(road-to-evidence-based-adr-governance 3.4).',
169
+ });
170
+ return [out, candidates.size];
171
+ }
172
+ for (const [key, line] of [...candidates].sort((a, b) => a[1] - b[1])) {
173
+ const row = routed.get(key);
174
+ if (row === undefined) {
175
+ out.push({
176
+ file: rel,
177
+ line,
178
+ key,
179
+ msg: `candidate ${key} has no row in \`## Candidate routing\` — unrouted.`,
180
+ });
181
+ continue;
182
+ }
183
+ if (row.route === '' || row.route === '—') {
184
+ out.push({ file: rel, line: row.line, key, msg: `candidate ${key} has an empty route cell.` });
185
+ }
186
+ if (!ISO_DATE.test(row.followUp)) {
187
+ out.push({
188
+ file: rel,
189
+ line: row.line,
190
+ key,
191
+ msg:
192
+ `candidate ${key} carries no YYYY-MM-DD in its follow-up cell — a dateless ` +
193
+ 'follow-up makes a lapse a silent extension rather than a compliance finding.',
194
+ });
195
+ }
196
+ }
197
+ return [out, candidates.size];
198
+ }
199
+
200
+ /** Floors for `--self-test`, declared here so a truncation is a visible diff. */
201
+ const SELF_TEST_MIN_CASES = 6;
202
+ const SELF_TEST_MIN_REJECT = 4;
203
+
204
+ const SCRIPT_REL = 'src/scripts/lint_adr_sweep_routing.ts';
205
+
206
+ /**
207
+ * Build a one-artifact sweep in a temp dir and run the REAL CLI against it.
208
+ *
209
+ * The candidate table and the routing table are supplied separately because
210
+ * every rejection this gate can make is a disagreement between the two.
211
+ */
212
+ function _selfTestFixture(name: string, candidateRows: string, routingSection: string): number {
213
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `adr-sweep-${name}-`));
214
+ const docs = path.join(dir, 'docs', 'decisions');
215
+ fs.mkdirSync(docs, { recursive: true });
216
+ fs.writeFileSync(
217
+ path.join(docs, 'adr-evidence-sweep-fixture.md'),
218
+ `# fixture\n\n## Tranche dispositions\n\n| ADR | Disposition |\n|---|---|\n${candidateRows}\n${routingSection}\n`,
219
+ 'utf8',
220
+ );
221
+ return runGateCli(REPO_ROOT, SCRIPT_REL, ['--dir', docs], dir);
222
+ }
223
+
224
+ const _GOOD_ROUTING =
225
+ '## Candidate routing\n\n| Record | Disposition | Route | Dated follow-up |\n|---|---|---|---|\n' +
226
+ '| ADR-016 | REVIEW-NOW | council | disposition by **2026-10-03** |\n';
227
+
228
+ function selfTest(): number {
229
+ const cases: SelfTestCase[] = [
230
+ {
231
+ name: 'a candidate with a route and a date is accepted',
232
+ expect: 'accept',
233
+ run: () => _selfTestFixture('ok', '| ADR-016 | REVIEW-NOW |', _GOOD_ROUTING),
234
+ },
235
+ {
236
+ name: 'a candidate with no routing section at all is refused',
237
+ expect: 'reject',
238
+ run: () => _selfTestFixture('nosection', '| ADR-016 | REVIEW-NOW |', ''),
239
+ },
240
+ {
241
+ name: 'a candidate absent from the routing table is refused',
242
+ expect: 'reject',
243
+ run: () =>
244
+ _selfTestFixture(
245
+ 'unrouted',
246
+ '| ADR-016 | REVIEW-NOW |\n| ADR-044 | REVIEW-NOW |',
247
+ _GOOD_ROUTING,
248
+ ),
249
+ },
250
+ {
251
+ name: 'a dateless follow-up is refused',
252
+ expect: 'reject',
253
+ run: () =>
254
+ _selfTestFixture(
255
+ 'dateless',
256
+ '| ADR-016 | REVIEW-NOW |',
257
+ '## Candidate routing\n\n| Record | Disposition | Route | Dated follow-up |\n|---|---|---|---|\n' +
258
+ '| ADR-016 | REVIEW-NOW | council | disposition soon |\n',
259
+ ),
260
+ },
261
+ {
262
+ name: 'an empty route cell is refused',
263
+ expect: 'reject',
264
+ run: () =>
265
+ _selfTestFixture(
266
+ 'noroute',
267
+ '| ADR-016 | REVIEW-NOW |',
268
+ '## Candidate routing\n\n| Record | Disposition | Route | Dated follow-up |\n|---|---|---|---|\n' +
269
+ '| ADR-016 | REVIEW-NOW | — | disposition by **2026-10-03** |\n',
270
+ ),
271
+ },
272
+ {
273
+ // The label-shape case. Sweep tables spell the same record four
274
+ // ways, so a literal match would report every row unrouted; this
275
+ // pins the normalisation rather than trusting it.
276
+ name: 'a bare number in the sweep matches an ADR-NNN routing row',
277
+ expect: 'accept',
278
+ run: () => _selfTestFixture('bare', '| 016 | SUPERSEDE (candidate) |', _GOOD_ROUTING),
279
+ },
280
+ ];
281
+ return runSelfTest({
282
+ gate: 'lint_adr_sweep_routing',
283
+ cases,
284
+ minCases: SELF_TEST_MIN_CASES,
285
+ minRejectCases: SELF_TEST_MIN_REJECT,
286
+ });
287
+ }
288
+
289
+ export function main(argv: string[] = process.argv.slice(2)): number {
290
+ const json = argv.includes('--json');
291
+ if (argv.includes('-h') || argv.includes('--help')) {
292
+ process.stdout.write('usage: lint_adr_sweep_routing [-h] [--json] [--dir DIR] [--self-test]\n');
293
+ return 0;
294
+ }
295
+ if (argv.includes('--self-test')) return selfTest();
296
+ for (let i = 0; i < argv.length; i += 1) {
297
+ const a = argv[i] as string;
298
+ if (a === '--json') continue;
299
+ if (a === '--dir') {
300
+ const next = argv[i + 1];
301
+ if (next === undefined) {
302
+ process.stderr.write('lint_adr_sweep_routing: --dir requires a path\n');
303
+ return 2;
304
+ }
305
+ SWEEP_DIR = path.resolve(next);
306
+ i += 1;
307
+ continue;
308
+ }
309
+ process.stderr.write(`lint_adr_sweep_routing: unrecognized argument: ${a}\n`);
310
+ return 2;
311
+ }
312
+
313
+ let names: string[] = [];
314
+ try {
315
+ names = fs.readdirSync(SWEEP_DIR).filter((n) => SWEEP_NAME.test(n)).sort();
316
+ } catch {
317
+ names = [];
318
+ }
319
+
320
+ const violations: Violation[] = [];
321
+ let scanned = 0;
322
+ for (const name of names) {
323
+ const abs = path.join(SWEEP_DIR, name);
324
+ const rel = path.relative(REPO_ROOT, abs).split(path.sep).join('/');
325
+ const [v, n] = lintSweep(rel, fs.readFileSync(abs, 'utf-8'));
326
+ violations.push(...v);
327
+ scanned += n;
328
+ }
329
+
330
+ process.stdout.write(`scanned: ${scanned}\n`);
331
+ try {
332
+ assertScanned({
333
+ gate: 'lint_adr_sweep_routing',
334
+ scanned,
335
+ units: 'candidate row(s)',
336
+ roots: ['docs/decisions'],
337
+ });
338
+ } catch (exc) {
339
+ if (exc instanceof DeadScopeError) {
340
+ process.stderr.write(`❌ ${exc.message}\n`);
341
+ return 1;
342
+ }
343
+ throw exc;
344
+ }
345
+
346
+ if (json) {
347
+ process.stdout.write(`${JSON.stringify({ violations }, null, 2)}\n`);
348
+ } else {
349
+ for (const v of violations) {
350
+ process.stderr.write(`❌ ${v.file}:${v.line}: ${v.msg}\n`);
351
+ }
352
+ }
353
+ if (violations.length > 0) {
354
+ process.stderr.write(`lint_adr_sweep_routing: ${violations.length} violation(s)\n`);
355
+ return 1;
356
+ }
357
+ process.stdout.write(
358
+ `✅ lint_adr_sweep_routing: every candidate row across ${names.length} sweep artifact(s) carries a route and a dated follow-up\n`,
359
+ );
360
+ return 0;
361
+ }
362
+
363
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
364
+ process.exit(main());
365
+ }
@@ -17,8 +17,17 @@
17
17
  * permissions: write-all;
18
18
  * npm install / npm ci without --ignore-scripts in a
19
19
  * pull_request_target workflow.
20
- * MEDIUM — third-party actions pinned by mutable tag instead of full SHA
21
- * (first-party actions/* are skipped).
20
+ * MEDIUM — actions pinned by mutable tag instead of full SHA;
21
+ * an actions/checkout step that keeps its credential.
22
+ *
23
+ * The MEDIUM tier's original wording read "third-party actions … (first-party
24
+ * actions/* are skipped)". Corrected 2026-08-22: the first-party exemption is
25
+ * GONE (see the removal note below), so the parenthetical documented an
26
+ * exemption that no longer exists — a false claim in a tracked artefact, which
27
+ * is the defect class the change that removed it was written to repair. The
28
+ * TIERS are untouched; only the description of what MEDIUM covers now matches
29
+ * the code. Re-tiering the locked model is a separate decision, recorded as
30
+ * unresolved in agents/evidence/analysis/workflow-security-net-degraded-decision.md.
22
31
  *
23
32
  * Script-injection detection (regex-based) is intentionally deferred — it
24
33
  * requires an AST-aware pass to avoid false positives on quoted / escaped
@@ -53,8 +62,23 @@ function _setAllowlistPathForTest(p: string): void {
53
62
  // Triggers that expose the repository token to untrusted pull-request context
54
63
  const DANGEROUS_TRIGGERS: ReadonlySet<string> = new Set(['pull_request_target', 'workflow_run']);
55
64
 
56
- // First-party action owners — mutable tags on these are acceptable
57
- const FIRST_PARTY_OWNERS: ReadonlySet<string> = new Set(['actions', 'github']);
65
+ // REMOVED 2026-08-22 (road-to-ci-supply-chain-integrity 1.4). This was
66
+ // `FIRST_PARTY_OWNERS = {'actions', 'github'}` and it exempted first-party
67
+ // owners from the mutable-tag rule. The premise — "mutable tags on these are
68
+ // acceptable" — is the one a supply-chain rule cannot hold: a first-party tag
69
+ // is still a mutable pointer, and `actions/checkout@v7` moving under the repo
70
+ // is the same class of event as any other owner's tag moving.
71
+ //
72
+ // What it cost, measured before removal: 100 of the 112 unpinned references in
73
+ // this tree were first-party, so the gate saw 12 of 112 defects and reported
74
+ // green on the rest. An exemption sized like that is not an exemption, it is
75
+ // the rule being off.
76
+ //
77
+ // The removal ships in the SAME change as the 112 pins, and in that order:
78
+ // pin first, then lift, so the transition is red-then-green inside one
79
+ // reviewable diff. Reverting the pins locally must make this gate exit
80
+ // non-zero — that is the assertion that the gate can still see the defect,
81
+ // which a green-only check cannot make.
58
82
 
59
83
  type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
60
84
  type JsonObject = { [k: string]: JsonValue };
@@ -262,13 +286,33 @@ function _mutable_third_party_actions(doc: JsonObject, _workflow_name: string):
262
286
  if (!uses || !uses.includes('@')) {
263
287
  continue;
264
288
  }
289
+ // `actions/checkout` leaves the repository token in the runner's git
290
+ // config for the rest of the job unless told not to. Measured before
291
+ // this rule existed: 0 of 50 checkouts in this tree set the flag, so
292
+ // every job carried a usable credential it did not need.
293
+ //
294
+ // `true` is a legitimate answer — two jobs here push — so the rule
295
+ // asks for the key to be PRESENT, not for a particular value. An
296
+ // explicit `true` is a reviewable decision; a missing key is an
297
+ // ambient one, and only the second is a finding.
298
+ if (uses.slice(0, uses.indexOf('@')).toLowerCase() === 'actions/checkout') {
299
+ const withObj = _asObject(stepObj['with'] as JsonValue);
300
+ const hasKey =
301
+ withObj !== null &&
302
+ Object.prototype.hasOwnProperty.call(withObj, 'persist-credentials');
303
+ if (!hasKey) {
304
+ findings.push({
305
+ severity: 'MEDIUM',
306
+ rule: 'persist-credentials',
307
+ detail:
308
+ 'actions/checkout without an explicit persist-credentials — ' +
309
+ 'the repository token stays in the runner git config for the rest of the job',
310
+ location: `job:${job_name}/step:${i + 1}`,
311
+ });
312
+ }
313
+ }
265
314
  const at = uses.indexOf('@');
266
- const action_ref = uses.slice(0, at);
267
315
  const pin = uses.slice(at + 1);
268
- const owner = action_ref.includes('/') ? (action_ref.split('/')[0] as string) : action_ref;
269
- if (FIRST_PARTY_OWNERS.has(owner.toLowerCase())) {
270
- continue;
271
- }
272
316
  // A full SHA pin is 40 hex chars; anything else is mutable
273
317
  if (pin.length === 40 && [...pin.toLowerCase()].every((c) => '0123456789abcdef'.includes(c))) {
274
318
  continue;
@@ -650,7 +694,6 @@ export {
650
694
  ALLOWLIST_PATH,
651
695
  ALLOWLIST_CAP,
652
696
  DANGEROUS_TRIGGERS,
653
- FIRST_PARTY_OWNERS,
654
697
  _setWorkflowsDirForTest,
655
698
  _setAllowlistPathForTest,
656
699
  load_allowlist,
@@ -1,20 +1,4 @@
1
1
  {
2
- "_comment": "Legitimate workflow security findings. Hard-capped at 20 entries (lint_workflow_security.py ALLOWLIST_CAP). Exceeding the cap signals the linter is wrong, not the content — tighten the heuristic, do not grow this list (autonomous-execution allowlist-growth antipattern). Each entry MUST cite why the finding is acceptable in `reason`.",
3
- "findings": [
4
- {
5
- "workflow": "bench-drift.yml",
6
- "rule": "mutable-action-tag",
7
- "reason": "marocchino/sticky-pull-request-comment@v2 — sticky PR comment for bench drift reporting; no code execution, output-only action. SHA-pinning tracked in road-to-security-hardening P3."
8
- },
9
- {
10
- "workflow": "skill-lint.yml",
11
- "rule": "mutable-action-tag",
12
- "reason": "marocchino/sticky-pull-request-comment@v2 — sticky PR comment for skill-lint reporting; same rationale as bench-drift.yml. SHA-pinning tracked in road-to-security-hardening P3."
13
- },
14
- {
15
- "workflow": "cloud-release.yml",
16
- "rule": "mutable-action-tag",
17
- "reason": "softprops/action-gh-release@v2 — creates GitHub releases; runs only on push to main (not on pull_request_target), no untrusted input. SHA-pinning tracked in road-to-security-hardening P3."
18
- }
19
- ]
2
+ "_comment": "Legitimate workflow security findings. Hard-capped at 20 entries (lint_workflow_security.py ALLOWLIST_CAP). Exceeding the cap signals the linter is wrong, not the content \u2014 tighten the heuristic, do not grow this list (autonomous-execution allowlist-growth antipattern). Each entry MUST cite why the finding is acceptable in `reason`. EMPTIED 2026-08-22 (road-to-ci-supply-chain-integrity 0.2). It held three mutable-action-tag exemptions for marocchino/sticky-pull-request-comment and softprops/action-gh-release. All three are now pinned to full commit SHAs, so the findings they excused no longer exist and the entries matched nothing \u2014 lint_workflow_security reports \"0 allowlisted\". The roadmap step asked to REPAIR their `reason` text, each of which closed by naming an archived roadmap phase as the tracker for SHA-pinning; that phase is a git-discipline hook and its file mentions pinning nowhere, so the pointer dangled. Deleting a dead exemption is the honest repair \u2014 rewriting the reason on an entry that excuses nothing would have kept the allowlist looking load-bearing. The dangling pointer is also WHY the three survived: each one read as deferred rather than open. The full trace lives in the archived roadmap's completion note.",
3
+ "findings": []
20
4
  }
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Publish `valid_envelope_rate` — three numbers, never one.
4
+ *
5
+ * `road-to-subagent-envelope-adoption` step 1.2: a rate with no denominator is
6
+ * a claim, not a measurement. So every line carries the rate, the window
7
+ * bounds, the stop count, AND the path it read — because the ledger is
8
+ * gitignored and machine-local, so a figure with no path is not reproducible
9
+ * even in principle.
10
+ *
11
+ * Named `report_` and not `check_` deliberately: this publishes a number and
12
+ * gates nothing. `gate_population.matchesGatePattern` classifies on a
13
+ * `lint|check|audit|skill|verify_` prefix, and a reporting script wearing a gate
14
+ * prefix would be required to carry a coverage floor it has no business having.
15
+ *
16
+ * ## Rows this counts, and rows it refuses
17
+ *
18
+ * Only `subagent_stop` rows carrying a POST-SPLIT `envelope_parse` value. The
19
+ * retired `absent` value collapsed `no_message` and `no_envelope` into one
20
+ * bucket, so a rate computed across the boundary would mix two vocabularies and
21
+ * report a number that is not about either. Rows with `absent` are excluded and
22
+ * counted separately, so the exclusion is visible rather than silent.
23
+ *
24
+ * Exit 0 always: a report that fails is a gate, and this is not one.
25
+ */
26
+
27
+ import * as fs from 'node:fs';
28
+ import * as path from 'node:path';
29
+ import { fileURLToPath, pathToFileURL } from 'node:url';
30
+
31
+ const _HERE = fileURLToPath(import.meta.url);
32
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
33
+ const LEDGER_DIR = path.join(REPO_ROOT, 'agents', 'runtime', 'state', 'subagent-ledger');
34
+
35
+ export interface RateReport {
36
+ ledger_paths: string[];
37
+ stops: number;
38
+ ok: number;
39
+ rate: number;
40
+ window_start: string | null;
41
+ window_end: string | null;
42
+ by_verdict: Record<string, number>;
43
+ excluded_absent: number;
44
+ by_agent_type: Record<string, number>;
45
+ }
46
+
47
+ export function computeRate(lines: readonly string[], paths: readonly string[]): RateReport {
48
+ const by: Record<string, number> = {};
49
+ const types: Record<string, number> = {};
50
+ let stops = 0;
51
+ let absent = 0;
52
+ let start: string | null = null;
53
+ let end: string | null = null;
54
+ for (const line of lines) {
55
+ let r: Record<string, unknown>;
56
+ try {
57
+ r = JSON.parse(line) as Record<string, unknown>;
58
+ } catch {
59
+ continue;
60
+ }
61
+ if (r['event'] !== 'subagent_stop') continue;
62
+ const verdict = r['envelope_parse'];
63
+ if (typeof verdict !== 'string') continue;
64
+ if (verdict === 'absent') {
65
+ absent += 1;
66
+ continue;
67
+ }
68
+ stops += 1;
69
+ by[verdict] = (by[verdict] ?? 0) + 1;
70
+ const at = typeof r['agent_type'] === 'string' ? r['agent_type'] : '(null)';
71
+ types[at] = (types[at] ?? 0) + 1;
72
+ const ts = r['ts'];
73
+ if (typeof ts === 'string') {
74
+ if (start === null || ts < start) start = ts;
75
+ if (end === null || ts > end) end = ts;
76
+ }
77
+ }
78
+ const ok = by['ok'] ?? 0;
79
+ return {
80
+ ledger_paths: [...paths],
81
+ stops,
82
+ ok,
83
+ // 0/0 is 0, not NaN: an empty ledger has a rate of zero valid envelopes
84
+ // and a denominator that says so on the same line.
85
+ rate: stops === 0 ? 0 : ok / stops,
86
+ window_start: start,
87
+ window_end: end,
88
+ by_verdict: by,
89
+ excluded_absent: absent,
90
+ by_agent_type: types,
91
+ };
92
+ }
93
+
94
+ function readLedger(dir: string): { lines: string[]; paths: string[] } {
95
+ let names: string[] = [];
96
+ try {
97
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.jsonl')).sort();
98
+ } catch {
99
+ return { lines: [], paths: [] };
100
+ }
101
+ const lines: string[] = [];
102
+ const paths: string[] = [];
103
+ for (const n of names) {
104
+ const abs = path.join(dir, n);
105
+ paths.push(path.relative(REPO_ROOT, abs).split(path.sep).join('/'));
106
+ lines.push(...fs.readFileSync(abs, 'utf-8').split('\n'));
107
+ }
108
+ return { lines, paths };
109
+ }
110
+
111
+ export function main(argv: readonly string[] = process.argv.slice(2)): number {
112
+ // `--dir` so the report is reproducible against a ledger that is not this
113
+ // checkout's. The ledger is gitignored and machine-local, so without it the
114
+ // only readable ledger is the one beside the running copy — and a worktree
115
+ // has none, which would make the figure unobtainable exactly where the work
116
+ // happens.
117
+ const i = argv.indexOf('--dir');
118
+ const dir = i >= 0 && i + 1 < argv.length ? path.resolve(argv[i + 1] as string) : LEDGER_DIR;
119
+ const { lines, paths } = readLedger(dir);
120
+ const r = computeRate(lines, paths);
121
+ if (argv.includes('--json')) {
122
+ process.stdout.write(`${JSON.stringify(r, null, 2)}\n`);
123
+ return 0;
124
+ }
125
+ if (r.ledger_paths.length === 0) {
126
+ // Not a failure, and not a rate either. The ledger is gitignored, so a
127
+ // clean clone has none — reporting 0 % there would be a number about
128
+ // nothing.
129
+ process.stdout.write(
130
+ `valid_envelope_rate: NO LEDGER at ${dir} — ` +
131
+ 'the ledger is gitignored and machine-local, so this is expected in a fresh ' +
132
+ 'clone and is not a measurement of zero.\n',
133
+ );
134
+ return 0;
135
+ }
136
+ const pct = (r.rate * 100).toFixed(2);
137
+ process.stdout.write(
138
+ `valid_envelope_rate: ${pct}% — ${String(r.ok)} ok of ${String(r.stops)} stops, ` +
139
+ `window ${r.window_start ?? '?'} → ${r.window_end ?? '?'}, ` +
140
+ `read from ${r.ledger_paths.join(', ')}\n`,
141
+ );
142
+ const verdicts = Object.entries(r.by_verdict)
143
+ .sort((a, b) => b[1] - a[1])
144
+ .map(([k, v]) => `${k} ${String(v)}`)
145
+ .join(' · ');
146
+ process.stdout.write(` verdicts: ${verdicts}\n`);
147
+ process.stdout.write(
148
+ ` excluded (retired \`absent\` vocabulary): ${String(r.excluded_absent)}\n`,
149
+ );
150
+ const comp = Object.entries(r.by_agent_type)
151
+ .sort((a, b) => b[1] - a[1])
152
+ .map(([k, v]) => `${k} ${String(v)}`)
153
+ .join(' · ');
154
+ process.stdout.write(` agent-type composition: ${comp}\n`);
155
+ process.stdout.write(
156
+ ' CAVEAT: machine-local. This ledger is one machine\'s drain traffic, so the\n' +
157
+ ' composition above is the shape of THIS traffic and the rate does not\n' +
158
+ ' generalise. Published with the composition rather than alone, so the limit\n' +
159
+ ' is readable instead of hidden.\n',
160
+ );
161
+ return 0;
162
+ }
163
+
164
+ if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) {
165
+ process.exit(main());
166
+ }