@event4u/agent-config 9.32.0 → 9.34.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 (109) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +85 -72
  3. package/dist/agent-src/commands/agent-handoff.md +15 -3
  4. package/dist/agent-src/commands/review/routing.md +3 -4
  5. package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
  6. package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
  7. package/dist/agent-src/rules/delegation-policy.md +6 -0
  8. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  9. package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
  10. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  11. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  12. package/dist/agent-src/skills/docker/SKILL.md +11 -0
  13. package/dist/agent-src/skills/laravel/SKILL.md +11 -0
  14. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  15. package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
  16. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  17. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  18. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  19. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  20. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  21. package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  22. package/dist/cli/registry.js +4 -4
  23. package/dist/cli/registry.js.map +1 -1
  24. package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
  25. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  26. package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
  27. package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
  28. package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-REMEZSVP.js} +1660 -146
  29. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  30. package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
  31. package/dist/cli-delegate/cmd_conformance.js +256 -226
  32. package/dist/cli-delegate/cmd_doctor.js +3 -2
  33. package/dist/cli-delegate/cmd_export.js +1 -1
  34. package/dist/cli-delegate/cmd_handoff.js +21 -28
  35. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  36. package/dist/cli-delegate/cmd_prune.js +1 -1
  37. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  38. package/dist/cli-delegate/cmd_session_recycle.js +20 -5
  39. package/dist/cli-delegate/cmd_settings_get.js +4 -2
  40. package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
  41. package/dist/cli-delegate/cmd_settings_set.js +5 -3
  42. package/dist/cli-delegate/cmd_sync.js +1 -1
  43. package/dist/cli-delegate/cmd_uninstall.js +1 -1
  44. package/dist/cli-delegate/cmd_validate.js +1 -1
  45. package/dist/discovery/deprecation-report.md +1 -1
  46. package/dist/discovery/discovery-manifest.json +17 -17
  47. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  48. package/dist/discovery/discovery-manifest.summary.md +2 -2
  49. package/dist/discovery/dormancy-report.md +17 -0
  50. package/dist/discovery/orphan-report.md +1 -1
  51. package/dist/discovery/packs.json +3 -3
  52. package/dist/discovery/trust-report.md +1 -1
  53. package/dist/discovery/workspaces.json +3 -3
  54. package/dist/hooks/dispatch.js +114 -7
  55. package/dist/install/install.mjs +7 -4
  56. package/dist/mcp/registry-manifest.json +3 -3
  57. package/dist/mcp/server.json +2 -2
  58. package/dist/mcp/server.mjs +3755 -1168
  59. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  60. package/package.json +2 -2
  61. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  62. package/src/agent-src/scripts/roadmap_gates.ts +107 -3
  63. package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
  64. package/src/config/cost-parity-budget.json +167 -0
  65. package/src/config/gate-coverage.yml +44 -5
  66. package/src/config/quorum-attendance-budget.json +39 -0
  67. package/src/scripts/_cli/cmd_doctor.ts +69 -1
  68. package/src/scripts/_cli/cmd_session_recycle.ts +25 -4
  69. package/src/scripts/_dispatch.bash +5 -3
  70. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  71. package/src/scripts/_lib/orchestration_record.ts +45 -0
  72. package/src/scripts/_lib/security_lint.ts +18 -4
  73. package/src/scripts/_lib/self_repair.ts +60 -0
  74. package/src/scripts/_lib/self_repair_store.ts +65 -5
  75. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  76. package/src/scripts/ai_council/clients.ts +31 -0
  77. package/src/scripts/ai_council/events_log.ts +130 -8
  78. package/src/scripts/ai_council/orchestrator.ts +50 -3
  79. package/src/scripts/ai_council/pricing.ts +48 -1
  80. package/src/scripts/ai_council/prompts.ts +165 -3
  81. package/src/scripts/ai_council/quorum.ts +30 -0
  82. package/src/scripts/ai_council/session.ts +11 -0
  83. package/src/scripts/ai_team/review_gate.ts +2 -2
  84. package/src/scripts/ask_transport.ts +20 -1
  85. package/src/scripts/build_discovery_manifest.ts +165 -0
  86. package/src/scripts/cache_realization_report.ts +38 -0
  87. package/src/scripts/check_claims.ts +51 -2
  88. package/src/scripts/check_release_highlights.ts +7 -1
  89. package/src/scripts/ci_time_ratio.ts +3 -1
  90. package/src/scripts/conformance_scan.ts +105 -0
  91. package/src/scripts/cost/track.mjs +39 -0
  92. package/src/scripts/cost_summary.ts +104 -1
  93. package/src/scripts/council_cli.ts +131 -13
  94. package/src/scripts/discovery_graph.ts +201 -27
  95. package/src/scripts/dispatch_economy_report.ts +5 -2
  96. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  97. package/src/scripts/install.ts +20 -6
  98. package/src/scripts/lint_budget_ownership.ts +81 -0
  99. package/src/scripts/lint_handoffs.ts +92 -2
  100. package/src/scripts/lint_hedge_words.ts +317 -0
  101. package/src/scripts/lint_mcp_config_security.ts +1 -1
  102. package/src/scripts/lint_skill_frontmatter_safety.ts +56 -2
  103. package/src/scripts/lint_workflow_paths.ts +384 -0
  104. package/src/scripts/preamble_byte_census.ts +9 -1
  105. package/src/scripts/report_carrier_divergence.ts +10 -0
  106. package/src/scripts/report_conformance_funnel.ts +40 -0
  107. package/src/scripts/schemas/subagent.schema.json +3 -2
  108. package/src/scripts/self_repair_cli.ts +26 -1
  109. package/src/scripts/skill_linter.ts +58 -2
@@ -360,6 +360,91 @@ export function validate_handoff_artifact(text: string): string[] {
360
360
  return missing;
361
361
  }
362
362
 
363
+ /**
364
+ * The body of a `## <heading>` section, up to the next heading of the same or a
365
+ * higher level. Fenced blocks are stripped first so a `##` inside a code fence
366
+ * cannot end the section early — a resume artefact routinely quotes commands.
367
+ */
368
+ export function handoff_section_body(text: string, heading: string): string | null {
369
+ const withoutFences = text.replace(/^```[\s\S]*?^```\s*$/gm, '');
370
+ const start = new RegExp(`^##\\s+${heading}\\s*$`, 'mi').exec(withoutFences);
371
+ if (start === null) {
372
+ return null;
373
+ }
374
+ const rest = withoutFences.slice(start.index + start[0].length);
375
+ const next = /^#{1,2}\s+\S/m.exec(rest);
376
+ return next === null ? rest : rest.slice(0, next.index);
377
+ }
378
+
379
+ // An explicit "there are none" answer. Kept as a closed list rather than a
380
+ // heuristic so the escape cannot be widened by accident: a section saying
381
+ // nothing and a section saying "none" are different facts, and only the first is
382
+ // a defect.
383
+ const _NO_OPEN_QUESTIONS = new Set(['none', 'keine', 'n/a', 'na', 'no open questions', 'nothing']);
384
+
385
+ /**
386
+ * Strip list markers, checkbox glyphs, emphasis and trailing sentence
387
+ * punctuation. The trailing strip is skipped when it would empty the line: a
388
+ * line that is ONLY punctuation (`...`) is a placeholder to name, not an empty
389
+ * section to report as blank, and the two findings say different things.
390
+ */
391
+ function _bareLine(line: string): string {
392
+ const stripped = line
393
+ .trim()
394
+ .replace(/^[-*+]\s*/, '')
395
+ .replace(/^\[[ x~-]\]\s*/i, '')
396
+ .replace(/[*_`]/g, '')
397
+ .trim()
398
+ .toLowerCase();
399
+ const trimmed = stripped.replace(/[.!]+$/, '').trim();
400
+ return trimmed.length > 0 ? trimmed : stripped;
401
+ }
402
+
403
+ /**
404
+ * Shape check on the one required section whose emptiness is the defect rather
405
+ * than a style nit. `validate_handoff_artifact` only tests that the heading
406
+ * exists, so `## Open questions` followed by nothing passed while carrying
407
+ * nothing — and a resume artefact whose open questions are blank hands the next
408
+ * session a false all-clear.
409
+ *
410
+ * **The step this implements claimed the false-positive class was empty because
411
+ * one `?`-terminated line is required. It is not, and the counterexample ships
412
+ * in this repo:** `tests/scripts/lint_handoffs_artifact.test.ts` carries
413
+ * `## Open questions` / `- none`, which is an honest answer and would be forced
414
+ * to grow a fake question. So the check accepts EITHER a real question OR an
415
+ * explicit none-marker, and fires only on a section that answers neither —
416
+ * blank, or a bare placeholder. That keeps the defect the step names without
417
+ * turning a truthful "none" into a lint error.
418
+ *
419
+ * Returns a finding string, or null when the shape holds. A MISSING section is
420
+ * not this check's business — `validate_handoff_artifact` already reports it.
421
+ */
422
+ export function validate_handoff_open_questions(text: string): string | null {
423
+ const body = handoff_section_body(text, 'Open questions');
424
+ if (body === null) {
425
+ return null;
426
+ }
427
+ const lines = body
428
+ .split('\n')
429
+ .map((l) => l.trim())
430
+ .filter((l) => l.length > 0);
431
+ if (lines.some((l) => l.length > 1 && l.endsWith('?'))) {
432
+ return null;
433
+ }
434
+ const bare = lines.map(_bareLine).filter((l) => l.length > 0);
435
+ if (bare.some((l) => _NO_OPEN_QUESTIONS.has(l))) {
436
+ return null;
437
+ }
438
+ if (bare.length === 0) {
439
+ return 'section `## Open questions` is empty — carry a `?`-terminated question, or state `none`';
440
+ }
441
+ const placeholder = new Set(['tbd', 'todo', 'wip', '...', '…', '?']);
442
+ if (bare.every((l) => placeholder.has(l))) {
443
+ return 'section `## Open questions` carries only a placeholder — carry a `?`-terminated question, or state `none`';
444
+ }
445
+ return null;
446
+ }
447
+
363
448
  export function main(argv?: readonly string[]): number {
364
449
  let skills_dir = SKILLS_DIR;
365
450
  // Positional-only: flags are NOT paths. Before this filter the CI invocation
@@ -373,14 +458,19 @@ export function main(argv?: readonly string[]): number {
373
458
  // artifact mode — validate the workflow-resume file's required fields
374
459
  const text = fs.readFileSync(_resolve(args[0] as string), 'utf-8');
375
460
  const missing = validate_handoff_artifact(text);
376
- if (missing.length === 0) {
461
+ const shape = validate_handoff_open_questions(text);
462
+ if (missing.length === 0 && shape === null) {
377
463
  if (!QUIET) process.stdout.write('✅ HANDOFF artifact: all required fields present\n');
378
464
  return 0;
379
465
  }
380
466
  for (const m of missing) {
381
467
  process.stdout.write(`${args[0]}:1:handoff-artifact-missing-field: missing required section \`## ${m}\`\n`);
382
468
  }
383
- process.stderr.write(`❌ ${missing.length} missing HANDOFF field(s)\n`);
469
+ if (shape !== null) {
470
+ process.stdout.write(`${args[0]}:1:handoff-artifact-empty-section: ${shape}\n`);
471
+ }
472
+ const problems = missing.length + (shape === null ? 0 : 1);
473
+ process.stderr.write(`❌ ${problems} HANDOFF artifact problem(s)\n`);
384
474
  return 1;
385
475
  }
386
476
  if (args.length > 0) {
@@ -0,0 +1,317 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Hedge-word lint — diff-scoped, ADVISORY, with a declared escalation trigger.
4
+ *
5
+ * `bench_honesty_score.ts` has measured hedging in agent OUTPUT since it was
6
+ * written (`HEDGE_WORDS`, `countHedgeWords`). Nothing has ever looked at the
7
+ * hedging in this package's own authored prose, which is where a hedge does the
8
+ * most damage: a rule that says a gate "should probably" fire is a rule nobody
9
+ * can be held to. This linter closes that half, reusing the same lexicon rather
10
+ * than restating a second list that would drift from it.
11
+ *
12
+ * ── Why diff-scoped, not corpus-wide ────────────────────────────────────────
13
+ * ADR-218:93 settled the question for prose-shape tooling outright: the wanted
14
+ * instrument is "one that watches diffs, not one that counts the corpus". A
15
+ * corpus count over 116 rules and 289 skills produces a number nobody can act
16
+ * on — every reading is dominated by prose written years apart under different
17
+ * conventions, and no single commit can move it.
18
+ *
19
+ * ── Why ADVISORY, and what would change that ────────────────────────────────
20
+ * `check_suppression_hygiene.ts` states the rule this gate is measured against:
21
+ * a gate whose corpus has findings on day one ships advisory; a gate whose
22
+ * corpus is already clean ships enforced, "because shipping it advisory would
23
+ * have been a hedge rather than a stage". This one has findings on day one and,
24
+ * unlike the `scripts/`-directory predicate, its false-positive class is NOT
25
+ * empty and cannot be made empty: hedging is legitimate — required, even — in
26
+ * this tree's own calibrated-honesty prose. "May", "typically" and "in doubt"
27
+ * appear in the very rules that teach an agent not to overclaim, and flagging
28
+ * them would be the gate arguing against the house style it exists to protect.
29
+ *
30
+ * So it ships advisory WITH A STAGE, not advisory forever. The escalation
31
+ * trigger is declared here, before any data, so that a later maintainer inherits
32
+ * a condition rather than a judgement call:
33
+ *
34
+ * ADVISORY → ERROR when ALL THREE hold, and not before:
35
+ * (a) at least 30 merged PRs have been measured by this gate;
36
+ * (b) across a sample of at least 50 of the findings those PRs produced, a
37
+ * human read classifies ≤ 10% as legitimate calibrated hedging — i.e.
38
+ * the check is mostly right about the prose it flags;
39
+ * (c) a maintainer records the measured rate and the resulting per-diff
40
+ * threshold in `docs/CLAIMS.md`, so the number that starts failing
41
+ * builds is itself bound to evidence.
42
+ *
43
+ * If (b) fails — the FP rate stays above 10% — the honest outcome is that this
44
+ * check STAYS advisory permanently, or is removed. It does not get a lowered
45
+ * bar. That branch is pre-authorised here so that missing the threshold is a
46
+ * publishable result rather than an awkward conversation.
47
+ *
48
+ * ── Carve-outs (modelled on lint_provenance_vocabulary's quote-the-ban) ─────
49
+ * A line that NAMES a hedge word rather than using one — the word inside
50
+ * backticks or double quotes — is exempt; without it, a rule teaching an agent
51
+ * which words to avoid would be flagged for listing them. Fenced code and
52
+ * indented blocks are skipped: a hedge in a code comment is a different surface
53
+ * with a different reviewer. The lexicon-bearing sources need no exemption list:
54
+ * they are `.ts`, and only `.md` is ever read.
55
+ *
56
+ * Scan surfaces: added (`+`) lines in `src/**\/*.md` and `docs/**\/*.md`.
57
+ *
58
+ * Exit codes: 0 always, in the advisory stage — including when findings exist.
59
+ * A non-zero exit is reserved for a usage error or a git failure, so that
60
+ * wiring this into a pipeline today cannot redden a build.
61
+ *
62
+ * Usage:
63
+ * ./scripts-run src/scripts/lint_hedge_words
64
+ * ./scripts-run src/scripts/lint_hedge_words --base origin/main
65
+ * git diff origin/main | ./scripts-run src/scripts/lint_hedge_words --stdin
66
+ */
67
+ import { spawnSync } from 'node:child_process';
68
+ import * as path from 'node:path';
69
+ import process from 'node:process';
70
+ import { fileURLToPath, pathToFileURL } from 'node:url';
71
+
72
+ import { HEDGE_WORDS } from './bench_honesty_score.js';
73
+ import { reportScanned } from './_lib/scan_scope.js';
74
+ import { readStdinText } from './_lib/stdin.js';
75
+
76
+ const _HERE = fileURLToPath(import.meta.url);
77
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
78
+
79
+ /** Prose surfaces this gate reads. Code and generated trees are out of scope. */
80
+ const SCAN_ROOTS = ['src/', 'docs/'] as const;
81
+
82
+ export interface HedgeFinding {
83
+ file: string;
84
+ /** The added line, trimmed — line numbers are not recoverable from a `+` line alone. */
85
+ line: string;
86
+ words: string[];
87
+ }
88
+
89
+ export interface HedgeReport {
90
+ findings: HedgeFinding[];
91
+ filesScanned: number;
92
+ addedProseLines: number;
93
+ addedWords: number;
94
+ }
95
+
96
+ function escapeRegExp(s: string): string {
97
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
+ }
99
+
100
+ const HEDGE_RES: readonly { word: string; re: RegExp }[] = HEDGE_WORDS.map((w) => ({
101
+ word: w,
102
+ re: new RegExp(`\\b${escapeRegExp(w)}\\b`, 'g'),
103
+ }));
104
+
105
+ /**
106
+ * Spans where a hedge word is being NAMED, not used: backticked code spans and
107
+ * quoted strings. Mirrors `lint_provenance_vocabulary`'s carve-out for a line
108
+ * that quotes the ban it enforces.
109
+ *
110
+ * The straight single quote is deliberately NOT a delimiter here. Prose is full
111
+ * of apostrophes, and `'[^']*'` cannot tell `it's … that's` from a quoted span —
112
+ * it matches across the middle and blanks the text between two contractions,
113
+ * which silently drops hedges AND shrinks the word denominator the reported rate
114
+ * divides by. Backticks and double quotes carry the carve-out; a hedge word
115
+ * inside single quotes is rare enough to be worth a false positive.
116
+ */
117
+ const QUOTED_SPAN = /`[^`]*`|"[^"]*"|“[^”]*”|«[^»]*»/g;
118
+
119
+ function stripQuotedSpans(line: string): string {
120
+ return line.replace(QUOTED_SPAN, ' ');
121
+ }
122
+
123
+ /**
124
+ * True for a line whose surface is code rather than prose.
125
+ *
126
+ * The comment alternatives are anchored to two characters (`/*`, `*&#47;`) rather
127
+ * than to a bare `*`. A lone leading asterisk is a markdown bullet far more often
128
+ * than a comment continuation, and in the only surface this gate reads — `.md`
129
+ * under `src/` and `docs/` — treating it as code skips most of the prose,
130
+ * including this suite's own `* `-bulleted sections.
131
+ */
132
+ function isCodeSurface(line: string): boolean {
133
+ if (/^\s{4,}\S/.test(line)) return true; // indented block
134
+ if (/^\s*(?:```|~~~)/.test(line)) return true; // fence marker
135
+ if (/^\s*(?:\/\/|\/\*|\*\/|#!)/.test(line)) return true; // comment
136
+ if (/^\s*<!--/.test(line)) return true; // html comment / verify marker
137
+ return false;
138
+ }
139
+
140
+ function inScope(file: string): boolean {
141
+ if (!file.endsWith('.md')) return false;
142
+ return SCAN_ROOTS.some((r) => file.startsWith(r));
143
+ }
144
+
145
+ /**
146
+ * Scan a unified diff for hedge words in added prose.
147
+ *
148
+ * Pure over the diff string — no filesystem access — so the whole gate is
149
+ * testable from a fixture diff without a repo.
150
+ */
151
+ export function scanDiff(diffText: string): HedgeReport {
152
+ const findings: HedgeFinding[] = [];
153
+ const files = new Set<string>();
154
+ let curFile: string | null = null;
155
+ // Fence state is tracked across ADDED lines only, because a unified diff is
156
+ // all this gate sees. A hunk that adds prose INSIDE an already-open fence
157
+ // therefore reads as prose. Known and accepted: reconstructing true fence
158
+ // state needs the whole file, which would make the gate file-scoped rather
159
+ // than diff-scoped — the axis ADR-218 already settled the other way.
160
+ let inFence = false;
161
+ let addedProseLines = 0;
162
+ let addedWords = 0;
163
+
164
+ for (const raw of diffText.split('\n')) {
165
+ if (raw.startsWith('+++ b/')) {
166
+ curFile = raw.slice(6);
167
+ inFence = false;
168
+ if (inScope(curFile)) files.add(curFile);
169
+ continue;
170
+ }
171
+ if (raw.startsWith('@@')) {
172
+ // A hunk boundary loses fence context; assume prose rather than
173
+ // silently swallowing a whole file because one fence opened above.
174
+ inFence = false;
175
+ continue;
176
+ }
177
+ if (!raw.startsWith('+') || raw.startsWith('+++')) continue;
178
+ if (curFile === null || !inScope(curFile)) continue;
179
+
180
+ const line = raw.slice(1);
181
+ if (/^\s*(?:```|~~~)/.test(line)) {
182
+ inFence = !inFence;
183
+ continue;
184
+ }
185
+ if (inFence || isCodeSurface(line)) continue;
186
+
187
+ const prose = stripQuotedSpans(line);
188
+ if (prose.trim() === '') continue;
189
+ addedProseLines += 1;
190
+ addedWords += prose.trim().split(/\s+/).length;
191
+
192
+ const lower = prose.toLowerCase();
193
+ const hits: string[] = [];
194
+ for (const { word, re } of HEDGE_RES) {
195
+ re.lastIndex = 0;
196
+ if (re.test(lower)) hits.push(word);
197
+ }
198
+ if (hits.length > 0) {
199
+ findings.push({ file: curFile, line: line.trim(), words: hits });
200
+ }
201
+ }
202
+
203
+ return { findings, filesScanned: files.size, addedProseLines, addedWords };
204
+ }
205
+
206
+ function baseRef(argv: string[]): string {
207
+ const i = argv.indexOf('--base');
208
+ if (i !== -1 && i + 1 < argv.length) return argv[i + 1]!;
209
+ return 'origin/main';
210
+ }
211
+
212
+ export function main(argv: string[] = process.argv.slice(2)): number {
213
+ if (argv.includes('-h') || argv.includes('--help')) {
214
+ process.stdout.write(
215
+ 'usage: lint_hedge_words [--base <ref>] [--stdin] [--quiet]\n' +
216
+ ' Advisory: reports hedge words in added prose. Always exits 0 on findings.\n',
217
+ );
218
+ return 0;
219
+ }
220
+ const quiet = argv.includes('--quiet');
221
+
222
+ let diffText: string;
223
+ if (argv.includes('--stdin')) {
224
+ diffText = readStdinText();
225
+ } else {
226
+ const base = baseRef(argv);
227
+ const proc = spawnSync('git', ['diff', base, '--', ...SCAN_ROOTS], {
228
+ cwd: REPO_ROOT,
229
+ encoding: 'utf-8',
230
+ maxBuffer: 64 * 1024 * 1024,
231
+ });
232
+ if (proc.error) {
233
+ process.stderr.write(`lint_hedge_words: git diff failed: ${proc.error.message}\n`);
234
+ return 2;
235
+ }
236
+ if (proc.status !== 0 && proc.status !== 1) {
237
+ // Base ref absent (shallow clone, detached CI checkout) — a missing
238
+ // base is not a finding, and inventing one would be worse.
239
+ process.stdout.write(
240
+ `lint_hedge_words: base ref '${base}' unavailable — advisory scan skipped.\n`,
241
+ );
242
+ return 0;
243
+ }
244
+ diffText = proc.stdout ?? '';
245
+ }
246
+
247
+ const report = scanDiff(diffText);
248
+
249
+ // Scope assertion + the machine-readable count, via the shared helper rather
250
+ // than a hand-written `scanned:` line: `check_gate_coverage`'s ratchet treats
251
+ // an emitted count with no assertion behind it as decoration, and it is right
252
+ // to — a diff-scoped gate is exactly the shape that can print `scanned: 0`
253
+ // out of a dead root forever. `EMPTY_VALID` is the honest prefix here: a diff
254
+ // that touches no prose under src/ or docs/ HAS zero hedged lines, and the
255
+ // reason survives the operational test — if the scan roots were deleted, "a
256
+ // diff with no prose in it" would still mean what it says.
257
+ const scanScope = {
258
+ gate: 'lint_hedge_words',
259
+ scanned: report.filesScanned,
260
+ units: 'changed prose file(s)',
261
+ roots: SCAN_ROOTS,
262
+ allowEmpty: 'EMPTY_VALID: a diff that changes no prose under src/ or docs/ has no hedged lines to report; the gate is diff-scoped by ADR-218 and its corpus is the diff, not a tree.',
263
+ } as const;
264
+
265
+ if (report.addedProseLines === 0) {
266
+ if (!quiet) {
267
+ process.stdout.write('lint_hedge_words: no added prose under src/ or docs/ — nothing to read.\n');
268
+ reportScanned(scanScope);
269
+ }
270
+ return 0;
271
+ }
272
+
273
+ // Lines, not occurrences. `bench_honesty_score` publishes `hedge_per_100_words`
274
+ // over hedge-word HITS; this counts hedged LINES, so it carries a different
275
+ // name. Two metrics sharing one name is how an escalation trigger ends up
276
+ // reading a number that was never the one it was calibrated against.
277
+ const hedgedLinesPer100 = (report.findings.length / report.addedWords) * 100;
278
+ if (report.findings.length > 0 && !quiet) {
279
+ process.stdout.write(
280
+ `lint_hedge_words (ADVISORY): ${report.findings.length} hedged line(s) ` +
281
+ `in ${report.filesScanned} file(s):\n`,
282
+ );
283
+ for (const f of report.findings) {
284
+ const clipped = f.line.length > 140 ? `${f.line.slice(0, 137)}…` : f.line;
285
+ process.stdout.write(` ${f.file} · [${f.words.join(', ')}] ${clipped}\n`);
286
+ }
287
+ process.stdout.write(
288
+ '\n Not every hit is a defect: calibrated hedging is house style in honesty\n' +
289
+ ' prose. Read each one; this gate never fails a build in its current stage.\n' +
290
+ ' Escalation trigger: see the header of src/scripts/lint_hedge_words.ts.\n',
291
+ );
292
+ } else if (!quiet) {
293
+ process.stdout.write('lint_hedge_words (ADVISORY): no hedged lines in added prose.\n');
294
+ }
295
+
296
+ if (!quiet) {
297
+ process.stdout.write(
298
+ `hedged_lines: ${report.findings.length} · added_prose_lines: ${report.addedProseLines} ` +
299
+ `· added_words: ${report.addedWords} · hedged_lines_per_100_words: ${hedgedLinesPer100.toFixed(2)}\n`,
300
+ );
301
+ reportScanned(scanScope);
302
+ }
303
+ return 0;
304
+ }
305
+
306
+ /* c8 ignore start */
307
+ function isCliEntry(): boolean {
308
+ if (process.argv[1] === undefined) return false;
309
+ return pathToFileURL(path.resolve(process.argv[1])).href === pathToFileURL(_FILEPATH()).href;
310
+ }
311
+ function _FILEPATH(): string {
312
+ return _HERE;
313
+ }
314
+ if (isCliEntry()) {
315
+ process.exit(main());
316
+ }
317
+ /* c8 ignore stop */
@@ -239,7 +239,7 @@ export function main(argv: readonly string[] | null = null): number {
239
239
  process.stdout.write(payload + '\n');
240
240
  return findings.some((f) => f.is_fail) ? 1 : 0;
241
241
  }
242
- return sl.report(findings, { check_label: 'mcp-config-security' });
242
+ return sl.report(findings, { check_label: 'mcp-config-security', scanned_roots: roots });
243
243
  }
244
244
 
245
245
  function _isCliEntry(): boolean {
@@ -47,6 +47,19 @@ const _RE_PERMISSION_MODE = /^\s*permissionMode:\s*['"]?bypassPermissions/;
47
47
  // Python: re.match(r"\s*allowed-tools:\s*(.+)$", text)
48
48
  const _RE_ALLOWED_TOOLS = /^\s*allowed-tools:\s*(.+)$/;
49
49
 
50
+ // Subagent definitions grant tools under a TOP-LEVEL `tools:` key (subagent-v1,
51
+ // `src/scripts/schemas/subagent.schema.json`), not under `execution.allowed_tools`
52
+ // and not as `allowed-tools`. Both other spellings were already read here; this
53
+ // one was not, and `src/subagents` was not a scan root either — so the two
54
+ // over-broad-grant checks below had never inspected a subagent definition.
55
+ // Top-level anchored (no leading whitespace) so a nested `tools:` inside some
56
+ // other block cannot be mistaken for a grant; the key is unused by skills,
57
+ // commands and domain artefacts, so the anchor costs no coverage.
58
+ const _RE_TOOLS_KEY = /^tools:\s*(.*)$/;
59
+ // A YAML block-sequence item under it (` - Bash`). Inline flow (`tools: [Bash]`)
60
+ // is captured by the key regex's own trailing group.
61
+ const _RE_SEQ_ITEM = /^\s+-\s*(.+)$/;
62
+
50
63
  /** Mirror Python `str.strip("'\"")` — strip any leading/trailing `'` or `"`. */
51
64
  function _stripQuotes(s: string): string {
52
65
  let lo = 0;
@@ -151,6 +164,44 @@ export function _scan(sf: sl.ScannedFile): sl.Finding[] {
151
164
  }
152
165
  }
153
166
 
167
+ // Top-level `tools:` — the subagent-v1 grant key. Collect the inline value
168
+ // plus any block-sequence items that follow, and run the same two checks the
169
+ // other two spellings get. A subagent is never an execution skill (no
170
+ // `execution:` block), so the bare-Bash arm applies by construction.
171
+ for (let i = 0; i < body.length; i += 1) {
172
+ const [lineno, text] = body[i] as [number, string];
173
+ const key = _RE_TOOLS_KEY.exec(text);
174
+ if (!key) {
175
+ continue;
176
+ }
177
+ const grants: string[] = [];
178
+ const inline = (key[1] ?? '').trim();
179
+ if (inline !== '') {
180
+ grants.push(inline);
181
+ }
182
+ for (let j = i + 1; j < body.length; j += 1) {
183
+ const [, next] = body[j] as [number, string];
184
+ const item = _RE_SEQ_ITEM.exec(next);
185
+ if (!item) {
186
+ break;
187
+ }
188
+ grants.push(_stripQuotes((item[1] as string).trim()));
189
+ }
190
+ const joined = grants.join(', ');
191
+ if (joined !== '' && (_WILDCARD_TOOL.test(joined) || _BARE_BASH.test(joined))) {
192
+ out.push(
193
+ new sl.Finding(
194
+ sf.rel,
195
+ lineno,
196
+ CHECK,
197
+ 'HIGH',
198
+ 'wildcard / bare-Bash tool grant (over-broad)',
199
+ sf.weight,
200
+ ),
201
+ );
202
+ }
203
+ }
204
+
154
205
  if (Object.keys(ex).length === 0) {
155
206
  return out;
156
207
  }
@@ -258,7 +309,10 @@ export function main(argv: readonly string[] | null = null): number {
258
309
  const args = parse_args(argv ?? process.argv.slice(2));
259
310
 
260
311
  const findings: sl.Finding[] = [];
261
- const roots = ['src/skills', 'src/agent-src', 'src/domains'];
312
+ // `src/subagents` is the fourth root, added because its omission is what made
313
+ // the over-broad-grant checks blind: the only artefact in the tree granting a
314
+ // bare shell lives there, and the gate had never read it.
315
+ const roots = ['src/skills', 'src/agent-src', 'src/domains', 'src/subagents'];
262
316
  let scanned = 0;
263
317
  for (const sf of sl.iter_corpus(roots, ['.md'])) {
264
318
  scanned += 1;
@@ -293,7 +347,7 @@ export function main(argv: readonly string[] | null = null): number {
293
347
  process.stdout.write(payload + '\n');
294
348
  return findings.some((f) => f.is_fail) ? 1 : 0;
295
349
  }
296
- return sl.report(findings, { check_label: 'dangerous-frontmatter' });
350
+ return sl.report(findings, { check_label: 'dangerous-frontmatter', scanned_roots: roots });
297
351
  }
298
352
 
299
353
  function _isCliEntry(): boolean {