@event4u/agent-config 9.33.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 (60) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +85 -167
  3. package/dist/agent-src/commands/agent-handoff.md +15 -3
  4. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  5. package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
  6. package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
  7. package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
  8. package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
  9. package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
  10. package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
  11. package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
  12. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  13. package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
  14. package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-REMEZSVP.js} +9 -2
  15. package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
  16. package/dist/cli-delegate/cmd_conformance.js +1 -1
  17. package/dist/cli-delegate/cmd_doctor.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +7 -2
  22. package/dist/discovery/deprecation-report.md +1 -1
  23. package/dist/discovery/discovery-manifest.json +11 -11
  24. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  25. package/dist/discovery/discovery-manifest.summary.md +2 -2
  26. package/dist/discovery/dormancy-report.md +17 -0
  27. package/dist/discovery/orphan-report.md +1 -1
  28. package/dist/discovery/packs.json +3 -3
  29. package/dist/discovery/trust-report.md +1 -1
  30. package/dist/discovery/workspaces.json +3 -3
  31. package/dist/hooks/dispatch.js +96 -7
  32. package/dist/mcp/registry-manifest.json +2 -2
  33. package/dist/mcp/server.json +2 -2
  34. package/dist/mcp/server.mjs +118 -10
  35. package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
  36. package/package.json +2 -2
  37. package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
  38. package/src/config/quorum-attendance-budget.json +1 -1
  39. package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
  40. package/src/scripts/_lib/env_kill_switch.ts +37 -0
  41. package/src/scripts/_lib/security_lint.ts +18 -4
  42. package/src/scripts/_lib/self_repair.ts +60 -0
  43. package/src/scripts/_lib/self_repair_store.ts +65 -5
  44. package/src/scripts/_lib/subagent_capsule.ts +78 -1
  45. package/src/scripts/ai_council/events_log.ts +2 -2
  46. package/src/scripts/ai_council/orchestrator.ts +38 -0
  47. package/src/scripts/ai_council/prompts.ts +165 -3
  48. package/src/scripts/ai_council/quorum.ts +10 -0
  49. package/src/scripts/ai_team/review_gate.ts +2 -2
  50. package/src/scripts/build_discovery_manifest.ts +165 -0
  51. package/src/scripts/check_claims.ts +51 -2
  52. package/src/scripts/ci_time_ratio.ts +3 -1
  53. package/src/scripts/discovery_graph.ts +201 -27
  54. package/src/scripts/dispatch_r2_reviewer.ts +102 -1
  55. package/src/scripts/lint_handoffs.ts +92 -2
  56. package/src/scripts/lint_hedge_words.ts +317 -0
  57. package/src/scripts/lint_mcp_config_security.ts +1 -1
  58. package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
  59. package/src/scripts/self_repair_cli.ts +26 -1
  60. package/src/scripts/skill_linter.ts +58 -2
@@ -306,6 +306,84 @@ export function computeReviewScope(runGit: GitRunner, base: string): ReviewScope
306
306
  return { diffText, hash: sha256(diffText), empty: isEmptyScope(diffText) };
307
307
  }
308
308
 
309
+ /**
310
+ * Staleness verdict for a findings artefact already sitting at the dispatch
311
+ * target, decided by the ONE review-scope hash both dispatcher and validator
312
+ * bind to — never by a second definition of "same review".
313
+ *
314
+ * - `current` — its manifest claims the scope being dispatched now. This is
315
+ * the live review; overwriting it would destroy the record of the review
316
+ * that is still in force, so the refusal stands.
317
+ * - `stale` — it claims a DIFFERENT scope. The fixes moved the scope past it,
318
+ * which is exactly the §2.7 re-bind case, and it must not be mistaken for
319
+ * the current review.
320
+ * - `unreadable` — no parseable manifest. Treated like `current`: refusing is
321
+ * the conservative branch when the artefact cannot be identified, since the
322
+ * alternative is renaming a file whose contents nobody established.
323
+ */
324
+ export type ArtefactStaleness = 'current' | 'stale' | 'unreadable';
325
+
326
+ export function artefactStaleness(text: string, currentScopeHash: string): ArtefactStaleness {
327
+ const manifest = parseManifest(text);
328
+ if (manifest === null) {
329
+ return 'unreadable';
330
+ }
331
+ return manifest.scope_hash === currentScopeHash ? 'current' : 'stale';
332
+ }
333
+
334
+ /**
335
+ * The refusal text for a leftover artefact at the dispatch target, naming which
336
+ * of the contract's two paths applies instead of offering only `--force`.
337
+ *
338
+ * **Why this is a message and NOT an automatic rename.** Renaming a stale
339
+ * artefact aside looks like the obvious fix and contract §2.7 forbids it: a fix
340
+ * pass changes the review scope, so an artefact bound to the previous scope is
341
+ * the NORMAL in-place re-bind case, and "renaming instead of re-binding here
342
+ * would leave the shipping content with no review at all → `missing-artifact`".
343
+ * The archival rename is a separate, later step, it has a prescribed name
344
+ * (`<slug>.round<N>-review.md`), and it is gated on every finding already being
345
+ * terminal (`check_review_dispositions`, which recognises an archived record by
346
+ * `-review.md`). An invented quarantine name would have slipped past that gate
347
+ * too, creating an archive path with no terminal check on it.
348
+ *
349
+ * So the dispatcher classifies and explains; the operator performs whichever
350
+ * contract-conform step actually applies.
351
+ */
352
+ export function leftoverArtefactRefusal(
353
+ findingsPathRel: string,
354
+ verdict: ArtefactStaleness,
355
+ staleScopeHash: string,
356
+ currentScopeHash: string,
357
+ ): string {
358
+ const head = `❌ Refusing to overwrite existing findings artifact: ${findingsPathRel}\n`;
359
+ if (verdict === 'unreadable') {
360
+ return (
361
+ head +
362
+ ' Its manifest is unreadable, so it cannot be identified as superseded.\n' +
363
+ ' Inspect it before doing anything: --force OVERWRITES, and an unidentified\n' +
364
+ ' artefact may be the only record of a review that happened.\n'
365
+ );
366
+ }
367
+ if (verdict === 'current') {
368
+ return (
369
+ head +
370
+ ` It already binds the scope being dispatched now (${currentScopeHash.slice(0, 12)}),\n` +
371
+ ' so it is the LIVE review of this content. Re-dispatching would discard it.\n'
372
+ );
373
+ }
374
+ return (
375
+ head +
376
+ ` It binds scope ${staleScopeHash.slice(0, 12)}; this dispatch binds ${currentScopeHash.slice(0, 12)},\n` +
377
+ ' so the reviewed content moved under it. That is the normal re-bind case, and\n' +
378
+ ' contract §2.7 gives it two paths — neither of which is a fresh skeleton:\n' +
379
+ ' 1. RE-BIND IN PLACE (the usual one): edit this artefact — new `scope:`, rows\n' +
380
+ ' flipped to terminal — and re-commit it. §2.5 expects exactly that.\n' +
381
+ ' 2. ARCHIVE the closed round: rename it to `<slug>.round<N>-review.md` ONCE every\n' +
382
+ ' finding is terminal, which `check_review_dispositions` enforces. Then dispatch.\n' +
383
+ ' --force writes a fresh skeleton OVER this file and destroys the record.\n'
384
+ );
385
+ }
386
+
309
387
  /** Manifest comment block — exactly the contract §5 shape. */
310
388
  export function deriveManifest(inputs: ManifestInputs): string {
311
389
  return [
@@ -895,8 +973,31 @@ function runDispatch(args: Args): number {
895
973
  const outDirAbs = path.resolve(args.repo, args.outDir);
896
974
  const inputDirAbs = path.join(outDirAbs, `${slug}.review-input`);
897
975
  const findingsAbs = path.join(outDirAbs, `${slug}.findings.md`);
976
+ // A leftover artefact at the target is one of three very different things and
977
+ // the pre-existing refusal could not tell them apart: it said "use --force"
978
+ // for all of them, and `--force` overwrites — so the only escape it offered
979
+ // destroys the record of a review that happened. Classify by the ONE
980
+ // review-scope hash both dispatcher and validator bind to, and name the
981
+ // contract-conform step for what was actually found.
898
982
  if (fs.existsSync(findingsAbs) && !args.force) {
899
- process.stderr.write(`❌ Refusing to overwrite existing findings artifact: ${findingsAbs} (use --force)\n`);
983
+ let existingText: string | null = null;
984
+ try {
985
+ existingText = fs.readFileSync(findingsAbs, 'utf-8');
986
+ } catch {
987
+ existingText = null;
988
+ }
989
+ const verdict: ArtefactStaleness =
990
+ existingText === null ? 'unreadable' : artefactStaleness(existingText, hashes.scope_hash);
991
+ const staleHash =
992
+ existingText === null ? '' : (parseManifest(existingText)?.scope_hash ?? '');
993
+ process.stderr.write(
994
+ leftoverArtefactRefusal(
995
+ path.relative(args.repo, findingsAbs),
996
+ verdict,
997
+ staleHash,
998
+ hashes.scope_hash,
999
+ ),
1000
+ );
900
1001
  return 1;
901
1002
  }
902
1003
 
@@ -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 {
@@ -347,7 +347,7 @@ export function main(argv: readonly string[] | null = null): number {
347
347
  process.stdout.write(payload + '\n');
348
348
  return findings.some((f) => f.is_fail) ? 1 : 0;
349
349
  }
350
- return sl.report(findings, { check_label: 'dangerous-frontmatter' });
350
+ return sl.report(findings, { check_label: 'dangerous-frontmatter', scanned_roots: roots });
351
351
  }
352
352
 
353
353
  function _isCliEntry(): boolean {
@@ -37,6 +37,7 @@ import {
37
37
  type EgressChoice,
38
38
  type EgressRoute,
39
39
  egressBlockedReason,
40
+ NEW_RECORDS_PER_SOURCE_PER_WINDOW,
40
41
  patchDeniedReason,
41
42
  type PushVia,
42
43
  renderReport,
@@ -48,6 +49,7 @@ import {
48
49
  listRecords,
49
50
  markReleased,
50
51
  openRecords,
52
+ readOverflow,
51
53
  readRecord,
52
54
  } from './_lib/self_repair_store.js';
53
55
 
@@ -400,11 +402,33 @@ export function executeRelease(
400
402
  return { published: null, attempts };
401
403
  }
402
404
 
405
+ /**
406
+ * The creation cap's drop tally, printed whenever it is non-zero. This is the
407
+ * half that keeps the cap honest: a bounded store is only an improvement if
408
+ * "records were refused" is readable, otherwise the bound is indistinguishable
409
+ * from defects that never happened.
410
+ */
411
+ function overflowLines(root: string): string {
412
+ const counts = readOverflow(root);
413
+ const rows = Object.entries(counts).filter(([, v]) => (v?.dropped ?? 0) > 0);
414
+ if (rows.length === 0) {
415
+ return '';
416
+ }
417
+ let out = '\n';
418
+ for (const [source, v] of rows) {
419
+ out +=
420
+ ` ! creation cap refused ${v?.dropped ?? 0} new ${source} record(s) ` +
421
+ `(cap ${NEW_RECORDS_PER_SOURCE_PER_WINDOW} per source per 24h, last ${v?.last_seen ?? '?'})\n`;
422
+ }
423
+ return out;
424
+ }
425
+
403
426
  function statusCmd(root: string): number {
404
427
  const open = openRecords(root);
405
428
  const all = listRecords(root);
406
429
  if (all.length === 0) {
407
- process.stdout.write('self-repair: no records.\n');
430
+ const overflow = overflowLines(root);
431
+ process.stdout.write(`self-repair: no records.\n${overflow}`);
408
432
  return EXIT_OK;
409
433
  }
410
434
  process.stdout.write(`self-repair: ${open.length} open of ${all.length} record(s)\n\n`);
@@ -415,6 +439,7 @@ function statusCmd(root: string): number {
415
439
  ` ${r.evidence}\n`,
416
440
  );
417
441
  }
442
+ process.stdout.write(overflowLines(root));
418
443
  process.stdout.write('\nRelease one: agent-config self-repair:release <fingerprint>\n');
419
444
  return EXIT_OK;
420
445
  }