@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +85 -72
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +6 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- 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/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- 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/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/dist/cli/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-REMEZSVP.js} +1660 -146
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +256 -226
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +21 -28
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +20 -5
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +17 -17
- 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 +114 -7
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3755 -1168
- 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/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/src/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +25 -4
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/orchestration_record.ts +45 -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/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +130 -8
- package/src/scripts/ai_council/orchestrator.ts +50 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +30 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/ci_time_ratio.ts +3 -1
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- 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 +56 -2
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +10 -0
- package/src/scripts/report_conformance_funnel.ts +40 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
- package/src/scripts/self_repair_cli.ts +26 -1
- 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
|
-
|
|
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 {
|
|
@@ -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
|
-
|
|
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 {
|