@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +85 -167
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
- package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-PMQ6Z3T3.js → chunk-REMEZSVP.js} +9 -2
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +7 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +11 -11
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +17 -0
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +96 -7
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +118 -10
- package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
- package/package.json +2 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/src/config/quorum-attendance-budget.json +1 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +6 -1
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/security_lint.ts +18 -4
- package/src/scripts/_lib/self_repair.ts +60 -0
- package/src/scripts/_lib/self_repair_store.ts +65 -5
- package/src/scripts/_lib/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/events_log.ts +2 -2
- package/src/scripts/ai_council/orchestrator.ts +38 -0
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +10 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/ci_time_ratio.ts +3 -1
- package/src/scripts/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- package/src/scripts/lint_handoffs.ts +92 -2
- package/src/scripts/lint_hedge_words.ts +317 -0
- package/src/scripts/lint_mcp_config_security.ts +1 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +1 -1
- package/src/scripts/self_repair_cli.ts +26 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (`/*`, `*/`) 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
|
-
|
|
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
|
}
|