@event4u/agent-config 14.8.0 → 14.9.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 +165 -237
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +34 -4
- package/dist/agent-src/commands/pr/merge.md +9 -5
- package/dist/agent-src/commands/review/changes.md +95 -14
- package/dist/agent-src/commands/roadmap/process-full.md +2 -2
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +77 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +36 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +80 -0
- package/dist/agent-src/rules/untrusted-input-defense.md +9 -3
- package/dist/agent-src/skills/code-review/evals/evals.json +87 -46
- package/dist/agent-src/skills/humanizer/SKILL.md +39 -0
- package/dist/agent-src/skills/humanizer/evals/strip_fixtures.json +57 -0
- package/dist/agent-src/skills/humanizer/references/fixtures.md +54 -0
- package/dist/agent-src/skills/judge-spec-compliance/SKILL.md +154 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +45 -0
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +18 -18
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +8 -0
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-MMSGVZKV.js → chunk-77GR3VD4.js} +65 -40
- package/dist/cli-delegate/{chunk-Q26HT4QT.js → chunk-E46KPN6K.js} +251 -150
- package/dist/cli-delegate/{chunk-EEJUMCDZ.js → chunk-PLSG6UL5.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +89 -13
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- 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_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +37 -15
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +562 -204
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +808 -331
- package/dist/scripts/_lib/claude_plugin.js +162 -0
- package/dist/scripts/_lib/claude_plugin.js.map +1 -0
- package/dist/scripts/_lib/environment_detector.js +397 -0
- package/dist/scripts/_lib/environment_detector.js.map +1 -0
- package/dist/scripts/_lib/spawn_env.js +129 -0
- package/dist/scripts/_lib/spawn_env.js.map +1 -0
- package/dist/scripts/ai_council/modes.js +199 -0
- package/dist/scripts/ai_council/modes.js.map +1 -0
- package/dist/scripts/ai_council/transport_resolver.js +418 -0
- package/dist/scripts/ai_council/transport_resolver.js.map +1 -0
- package/dist/server/routes/wizard.js +3 -2
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/ui/assets/index-Cxgy63kz.js +26 -0
- package/dist/ui/assets/index-Cxgy63kz.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/guidelines/agent-infra/traceability-field-mechanics.md +50 -0
- package/docs/guidelines/agent-infra/untrusted-input-spotlighting.md +17 -0
- package/package.json +7 -3
- package/src/config/estate-count-budget.json +13 -744
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +247 -9
- package/src/config/gate-violation-baselines.json +72 -72
- package/src/config/publish-surface.json +60 -0
- package/src/config/requirements-trace-budget.json +32 -0
- package/src/config/rule-stub-ceilings.json +8 -1
- package/src/scripts/_dispatch.bash +7 -0
- package/src/scripts/_lib/base_tree.ts +175 -0
- package/src/scripts/_lib/billing_grant.ts +165 -0
- package/src/scripts/_lib/billing_grant_cli.ts +123 -0
- package/src/scripts/_lib/council_fallback_posture.ts +53 -6
- package/src/scripts/_lib/council_fallback_wiring.ts +61 -1
- package/src/scripts/_lib/host_capability.ts +59 -5
- package/src/scripts/_lib/quota_parked.ts +133 -0
- package/src/scripts/_lib/retrieval_sanitize.ts +62 -0
- package/src/scripts/_lib/review_telemetry.ts +196 -0
- package/src/scripts/_lib/subagent_response.ts +92 -0
- package/src/scripts/adr/regenerate_index.ts +14 -7
- package/src/scripts/ai_council/clients.ts +12 -1
- package/src/scripts/ai_council/config.ts +72 -115
- package/src/scripts/ai_council/fallback_config.ts +19 -7
- package/src/scripts/ai_council/mid_flight_fallback.ts +88 -7
- package/src/scripts/ai_council/orchestrator.ts +2 -2
- package/src/scripts/ai_council/py_format.ts +124 -0
- package/src/scripts/ai_council/transport_resolver.ts +65 -2
- package/src/scripts/audit_adr_coverage.ts +33 -6
- package/src/scripts/check_council_pin_staleness.ts +300 -0
- package/src/scripts/check_estate_count.ts +420 -293
- package/src/scripts/check_gate_coverage.ts +91 -0
- package/src/scripts/check_no_stub_inventory_table.ts +292 -0
- package/src/scripts/check_pack_size.ts +234 -1
- package/src/scripts/check_publish_surface.ts +297 -0
- package/src/scripts/check_requirements_trace.ts +378 -0
- package/src/scripts/check_secret_leak.ts +166 -12
- package/src/scripts/code_graph/cli.ts +44 -5
- package/src/scripts/code_graph/extract.ts +87 -0
- package/src/scripts/council_cli.ts +12 -16
- package/src/scripts/detect_ai_tells.ts +87 -0
- package/src/scripts/dispatch_r2_reviewer.ts +42 -0
- package/src/scripts/generate_capabilities_index.ts +41 -0
- package/src/scripts/git_authorization_hook.ts +54 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +19 -1
- package/src/scripts/hooks/run_continuation_hook.ts +3 -1
- package/src/scripts/hooks/subagent_ledger_hook.ts +76 -11
- package/src/scripts/injection_scan_hook.ts +120 -10
- package/src/scripts/lint_adr_sweep_routing.ts +365 -0
- package/src/scripts/lint_workflow_security.ts +53 -10
- package/src/scripts/lint_workflow_security_allowlist.json +2 -18
- package/src/scripts/report_envelope_rate.ts +166 -0
- package/src/scripts/review_axis_report.ts +218 -0
- package/src/scripts/run_supervise.ts +46 -1
- package/src/scripts/schemas/evals.schema.json +5 -0
- package/src/scripts/sync_pr_branch.ts +14 -7
- package/dist/cli/commands/commands.candidates.test.js +0 -257
- package/dist/cli/commands/commands.candidates.test.js.map +0 -1
- package/dist/cli/commands/profiles.test.js +0 -88
- package/dist/cli/commands/profiles.test.js.map +0 -1
- package/dist/cli/commands/recordTriggerEval.test.js +0 -113
- package/dist/cli/commands/recordTriggerEval.test.js.map +0 -1
- package/dist/cli/configRoot.test.js +0 -97
- package/dist/cli/configRoot.test.js.map +0 -1
- package/dist/cli/mcp/content.test.js +0 -54
- package/dist/cli/mcp/content.test.js.map +0 -1
- package/dist/cli/mcp/dispatch.test.js +0 -108
- package/dist/cli/mcp/dispatch.test.js.map +0 -1
- package/dist/server/writeRoot.test.js +0 -91
- package/dist/server/writeRoot.test.js.map +0 -1
- package/dist/shared/capabilities.test.js +0 -39
- package/dist/shared/capabilities.test.js.map +0 -1
- package/dist/ui/assets/index-C1pBRm1f.js +0 -26
- package/dist/ui/assets/index-C1pBRm1f.js.map +0 -1
- /package/dist/cli-delegate/{chunk-N7CDDGI3.js → chunk-CYH5SP6M.js} +0 -0
|
@@ -89,6 +89,19 @@ export interface GateSpec {
|
|
|
89
89
|
status: 'enforced' | 'pending';
|
|
90
90
|
/** Absent = this gate has no canary recipe; reported, never hidden. */
|
|
91
91
|
canary?: CanarySpec;
|
|
92
|
+
/**
|
|
93
|
+
* Why this entry has no `canary:` recipe — REQUIRED when one is absent.
|
|
94
|
+
*
|
|
95
|
+
* The measurement that forced this field: over 44 enforced entries, only
|
|
96
|
+
* 20 of 44 by a wide detector (and 13 by a narrow one) can be invoked over an
|
|
97
|
+
* injected path at all, so a non-mutating in-memory negative-control mode
|
|
98
|
+
* would cover under half the manifest. Rather than build a per-PR step named
|
|
99
|
+
* "negative controls" that exercises a minority, the gap is REPORTED — and a
|
|
100
|
+
* reported gap is only honest if every row carries its reason. A silently
|
|
101
|
+
* absent row reads as coverage, which is the exact failure this whole file
|
|
102
|
+
* exists to prevent, one level up.
|
|
103
|
+
*/
|
|
104
|
+
no_canary_reason?: string;
|
|
92
105
|
/**
|
|
93
106
|
* Exit code meaning "my prerequisite is absent, so I inspected nothing" — e.g.
|
|
94
107
|
* check_site_links exits 2 when the site is unbuilt or stale. Without this the
|
|
@@ -120,6 +133,13 @@ export interface GateResult {
|
|
|
120
133
|
message: string;
|
|
121
134
|
}
|
|
122
135
|
|
|
136
|
+
function _require_str(v: unknown, field: string, id: string, i: number): string {
|
|
137
|
+
if (typeof v !== 'string' || v.trim() === '') {
|
|
138
|
+
throw new Error(`gates[${String(i)}] (${id}): ${field} must be a non-empty string`);
|
|
139
|
+
}
|
|
140
|
+
return v;
|
|
141
|
+
}
|
|
142
|
+
|
|
123
143
|
function _require_int(v: unknown, id: string, i: number): number {
|
|
124
144
|
const n = Number(v);
|
|
125
145
|
if (!Number.isInteger(n)) {
|
|
@@ -147,6 +167,16 @@ export function load_manifest(file = MANIFEST): GateSpec[] {
|
|
|
147
167
|
if (!Number.isInteger(min) || min < 0) {
|
|
148
168
|
throw new Error(`gates[${i}] (${id}): min_scanned must be a non-negative integer`);
|
|
149
169
|
}
|
|
170
|
+
if (
|
|
171
|
+
String(e['status'] ?? 'enforced') === 'enforced' &&
|
|
172
|
+
e['canary'] === undefined &&
|
|
173
|
+
e['no_canary_reason'] === undefined
|
|
174
|
+
) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`gates[${String(i)}] (${id}): an enforced entry with no 'canary:' recipe must carry ` +
|
|
177
|
+
`'no_canary_reason:' — a silently absent negative control reads as coverage`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
150
180
|
const status = String(e['status'] ?? 'enforced');
|
|
151
181
|
if (status !== 'enforced' && status !== 'pending') {
|
|
152
182
|
throw new Error(`gates[${i}] (${id}): status must be 'enforced' or 'pending'`);
|
|
@@ -162,6 +192,9 @@ export function load_manifest(file = MANIFEST): GateSpec[] {
|
|
|
162
192
|
: { unavailable_exit: _require_int(e['unavailable_exit'], id, i) }),
|
|
163
193
|
...(e['note'] === undefined ? {} : { note: String(e['note']) }),
|
|
164
194
|
...(e['canary'] === undefined ? {} : { canary: _require_canary(e['canary'], id, i) }),
|
|
195
|
+
...(e['no_canary_reason'] === undefined
|
|
196
|
+
? {}
|
|
197
|
+
: { no_canary_reason: _require_str(e['no_canary_reason'], 'no_canary_reason', id, i) }),
|
|
165
198
|
};
|
|
166
199
|
});
|
|
167
200
|
}
|
|
@@ -338,6 +371,39 @@ const ICON: Record<Verdict, string> = {
|
|
|
338
371
|
unavailable: '⚠️',
|
|
339
372
|
};
|
|
340
373
|
|
|
374
|
+
/**
|
|
375
|
+
* Report which enforced gates carry a negative control and which do not.
|
|
376
|
+
*
|
|
377
|
+
* OBSERVABILITY, NEVER PROOF — and the distinction is the whole point of the
|
|
378
|
+
* wording below. A `canary:` recipe DECLARED is not a recipe RUN: the mutating
|
|
379
|
+
* `--canary` path plants a real file and is deliberately off the per-PR
|
|
380
|
+
* workflow, so on a normal PR the "with recipe" number describes what COULD be
|
|
381
|
+
* exercised, not what was. Reporting it as though it were coverage would be the
|
|
382
|
+
* inflation this file exists to prevent, one level up from where it prevents it.
|
|
383
|
+
*
|
|
384
|
+
* The two populations are reported separately on purpose: whether a gate can be
|
|
385
|
+
* invoked over an injected path and whether it has a recipe are different facts,
|
|
386
|
+
* and an earlier draft of this work conflated them.
|
|
387
|
+
*/
|
|
388
|
+
function report_negative_control_inventory(specs: readonly GateSpec[]): void {
|
|
389
|
+
const enforced = specs.filter((s) => s.status === 'enforced');
|
|
390
|
+
const withRecipe = enforced.filter((s) => s.canary !== undefined);
|
|
391
|
+
const without = enforced.filter((s) => s.canary === undefined);
|
|
392
|
+
process.stdout.write(
|
|
393
|
+
`\nnegative controls: ${String(withRecipe.length)} of ${String(enforced.length)} enforced ` +
|
|
394
|
+
`entries carry a \`canary:\` recipe · ${String(without.length)} do not\n`,
|
|
395
|
+
);
|
|
396
|
+
// The arithmetic is the guard against skimming: two numbers that reconcile
|
|
397
|
+
// against the enforced total cannot be read as "all of them checked".
|
|
398
|
+
process.stdout.write(
|
|
399
|
+
` ⚠️ declared, not run — the mutating --canary path is operator-invoked and off the per-PR workflow, ` +
|
|
400
|
+
`so no negative control ran here.\n`,
|
|
401
|
+
);
|
|
402
|
+
for (const s of without) {
|
|
403
|
+
process.stdout.write(` · ${s.id}: ${s.no_canary_reason ?? '(no reason recorded)'}\n`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
341
407
|
export function main(argv: readonly string[]): number {
|
|
342
408
|
const wantJson = argv.includes('--format') && argv[argv.indexOf('--format') + 1] === 'json';
|
|
343
409
|
const listOnly = argv.includes('--list');
|
|
@@ -455,6 +521,7 @@ export function main(argv: readonly string[]): number {
|
|
|
455
521
|
`⚠️ ${String(pending.length)} gate(s) are listed but NOT enforced — this guard's coverage is partial by declaration.\n`,
|
|
456
522
|
);
|
|
457
523
|
}
|
|
524
|
+
report_negative_control_inventory(specs);
|
|
458
525
|
const hardening = report_hardening_ratchet();
|
|
459
526
|
const selfTest = report_self_test_ratchet();
|
|
460
527
|
if (failed.length > 0) {
|
|
@@ -771,6 +838,30 @@ export function parse_census(md: string): Map<string, number | null> {
|
|
|
771
838
|
if (gateCol < 0 || cs.length <= Math.max(gateCol, unitCol)) continue;
|
|
772
839
|
const idm = /`([A-Za-z0-9_.-]+)`/.exec(cs[gateCol] as string);
|
|
773
840
|
if (idm === null) continue;
|
|
841
|
+
// A row the census could not extract a root for is NOT a row that read
|
|
842
|
+
// nothing — the two are indistinguishable in the units column, which prints
|
|
843
|
+
// `**0**` for both, and conflating them produced a false `census_stale`
|
|
844
|
+
// disagreement on every dynamically-scoped gate. `check_secret_leak`
|
|
845
|
+
// resolves its corpus by spawning `git diff` / `npm pack`, and
|
|
846
|
+
// `check_no_roadmap_refs` likewise: a static root extractor finds nothing to
|
|
847
|
+
// count, so the canary correctly reds them while the census correctly says
|
|
848
|
+
// 0, and the comparison then calls a correct pair a defect.
|
|
849
|
+
//
|
|
850
|
+
// `null` already means "no measurable count" here and is already skipped by
|
|
851
|
+
// the disagreement check, so this maps the un-extractable case onto the
|
|
852
|
+
// meaning it always had.
|
|
853
|
+
if (/\(none extracted\)/.test(line)) {
|
|
854
|
+
// Left UNSET, not set to `null`. The disagreement check treats
|
|
855
|
+
// `undefined` as "not censused — nothing to disagree with" and `null` as
|
|
856
|
+
// "censused and read nothing", which is a stale row. An un-extractable
|
|
857
|
+
// root is the first, not the second: nothing was measured, so there is no
|
|
858
|
+
// measurement to disagree with the canary about.
|
|
859
|
+
//
|
|
860
|
+
// Setting `null` here was tried first and made it WORSE — it turned every
|
|
861
|
+
// dynamically-scoped gate into a fresh false `census_stale`, six of them
|
|
862
|
+
// at once. The distinction between the two absences is the whole fix.
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
774
865
|
const raw = cs[unitCol] as string;
|
|
775
866
|
const num = /(\d[\d,]*)/.exec(raw.replace(/\*/g, ''));
|
|
776
867
|
out.set(idm[1] as string, num === null ? null : Number((num[1] as string).replace(/,/g, '')));
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Stub-inventory-table guard.
|
|
4
|
+
*
|
|
5
|
+
* `agents/roadmaps/stubs/README.md` carried a hand-maintained index of the stub
|
|
6
|
+
* files sitting beside it: two tables, 6 demand-gated rows and 27 transfer rows,
|
|
7
|
+
* appended by hand by every roadmap-transfer session. It was the largest
|
|
8
|
+
* *authored* merge-conflict path in the repository — everything above it in the
|
|
9
|
+
* ranking is generated — and every row duplicated facts already in the stub it
|
|
10
|
+
* pointed at. `3793855b3` deleted it on 2026-08-21, after the AI council chose
|
|
11
|
+
* deletion over building a generator (both seats) and after all 33 rows were
|
|
12
|
+
* checked cell by cell against their stub file.
|
|
13
|
+
*
|
|
14
|
+
* It came back on 2026-08-22. A merge (`28ba2f592`, PR #1505) reintroduced a
|
|
15
|
+
* 12-row version; both parents of that merge lacked the table, so it was not a
|
|
16
|
+
* mis-resolved `modify/delete` but a deliberate restore — taken against a note
|
|
17
|
+
* in `agents/evidence/notes/drain-run-handoff.md` written in the same change as
|
|
18
|
+
* the deletion for the express purpose of preventing one. Three further rows
|
|
19
|
+
* were then appended by later sessions.
|
|
20
|
+
*
|
|
21
|
+
* That is the whole reason this gate exists rather than another paragraph: the
|
|
22
|
+
* paragraph was tried, and it lost to a merge. Prose cannot refuse.
|
|
23
|
+
*
|
|
24
|
+
* WHAT IT REJECTS — the structure, never "tables"
|
|
25
|
+
* ----------------------------------------------
|
|
26
|
+
* Two signatures, both keyed to an inventory of the guarded file's own
|
|
27
|
+
* directory:
|
|
28
|
+
*
|
|
29
|
+
* 1. a table header row whose first cell is `Stub` — the header both
|
|
30
|
+
* historical tables used (`| Stub | Transferred from | … |` and
|
|
31
|
+
* `| Stub | Triggers org-mode surface | Gates |`);
|
|
32
|
+
* 2. a table body row whose FIRST cell links to a `.md` file in the SAME
|
|
33
|
+
* directory — an inventory row by construction.
|
|
34
|
+
*
|
|
35
|
+
* A `../`-prefixed or otherwise pathed link does not match: a prose table that
|
|
36
|
+
* cites parent roadmaps is not an index of this directory. A future two-column
|
|
37
|
+
* glossary, a decision matrix, or any table that does not enumerate the
|
|
38
|
+
* neighbouring stubs is unaffected. This narrowness is deliberate and was asked
|
|
39
|
+
* for by review: a blanket ban on markdown tables in the file would block
|
|
40
|
+
* legitimate documentation to stop one specific regression.
|
|
41
|
+
*
|
|
42
|
+
* WHY A WATCH LIST AND NOT A TREE WALK
|
|
43
|
+
* ------------------------------------
|
|
44
|
+
* The guarded set is one known file, so there is no corpus to count, and a
|
|
45
|
+
* one-path guard is exactly the shape that rots into a permanent pass when the
|
|
46
|
+
* path moves — `check_safety_floor_untouched` announced "4 rules guarded" while
|
|
47
|
+
* guarding none for months. `assertWatchlistResolves` refuses to report clean when its
|
|
48
|
+
* targets do not resolve, so a rename reds this gate instead of retiring it
|
|
49
|
+
* silently.
|
|
50
|
+
*
|
|
51
|
+
* Exit codes: 0 clean · 1 findings or a dead watch list.
|
|
52
|
+
*/
|
|
53
|
+
// ledger-exempt: the scope is ONE watch-list file, not a collected population — there is
|
|
54
|
+
// no per-target accounting to publish, and the empty path is already refused by
|
|
55
|
+
// assertWatchlistResolves rather than skipped. Same shape as check_branch_freshness.
|
|
56
|
+
|
|
57
|
+
import * as fs from 'node:fs';
|
|
58
|
+
import * as os from 'node:os';
|
|
59
|
+
import * as path from 'node:path';
|
|
60
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
|
+
|
|
62
|
+
import { runGateCli, runSelfTest, type SelfTestCase } from './_lib/gate_self_test.js';
|
|
63
|
+
import { assertWatchlistResolves, DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
64
|
+
|
|
65
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
66
|
+
export const ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Self-test floors. Below these, `--self-test` fails instead of printing success —
|
|
70
|
+
* deleting cases would otherwise be the cheapest route to a green self-test.
|
|
71
|
+
*/
|
|
72
|
+
const SELF_TEST_MIN_CASES = 5;
|
|
73
|
+
const SELF_TEST_MIN_REJECT = 3;
|
|
74
|
+
|
|
75
|
+
/** This script's repo-relative path, for the self-test's CLI invocations. */
|
|
76
|
+
const SELF = 'src/scripts/check_no_stub_inventory_table.ts';
|
|
77
|
+
|
|
78
|
+
/** Files whose inventory tables are banned. Repo-relative. */
|
|
79
|
+
export const GUARDED: readonly string[] = ['agents/roadmaps/stubs/README.md'];
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A header row that opens with the literal `Stub` column.
|
|
83
|
+
*
|
|
84
|
+
* Both deleted tables used it, and it is not a phrase a prose table reaches for
|
|
85
|
+
* by accident: a first cell of exactly `Stub` announces a per-stub listing.
|
|
86
|
+
*/
|
|
87
|
+
const INVENTORY_HEADER = /^\|\s*Stub\s*\|/;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A body row whose first cell links to a `.md` file in the same directory.
|
|
91
|
+
*
|
|
92
|
+
* `[^)/]*` is the load-bearing part: it excludes `../road-to-x.md` and any other
|
|
93
|
+
* pathed target, so only a link to a sibling of the guarded file matches. That
|
|
94
|
+
* is what makes this an inventory signature rather than a link count.
|
|
95
|
+
*/
|
|
96
|
+
const INVENTORY_ROW = /^\|\s*\[`?[^\]]*`?\]\([^)/]*\.md\)/;
|
|
97
|
+
|
|
98
|
+
export interface Finding {
|
|
99
|
+
readonly file: string;
|
|
100
|
+
readonly line: number;
|
|
101
|
+
readonly kind: 'inventory-header' | 'inventory-row';
|
|
102
|
+
readonly text: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function scan_file(rel: string, text: string): Finding[] {
|
|
106
|
+
const findings: Finding[] = [];
|
|
107
|
+
const lines = text.split('\n');
|
|
108
|
+
for (let i = 0; i < lines.length; i++) {
|
|
109
|
+
const line = lines[i]!;
|
|
110
|
+
if (INVENTORY_HEADER.test(line)) {
|
|
111
|
+
findings.push({ file: rel, line: i + 1, kind: 'inventory-header', text: line.trim() });
|
|
112
|
+
} else if (INVENTORY_ROW.test(line)) {
|
|
113
|
+
findings.push({ file: rel, line: i + 1, kind: 'inventory-row', text: line.trim() });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return findings;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Read `--root <path>` / `--root=<path>` out of argv.
|
|
121
|
+
*
|
|
122
|
+
* Parsed by NAME, never by position. `lint_handoffs` took `args[0]` as its scan
|
|
123
|
+
* root, and the Taskfile injects `--quiet` first — so the flag became the root,
|
|
124
|
+
* the scan found nothing, and the gate was red under the argv CI runs while
|
|
125
|
+
* green under a bare probe. A named flag cannot make that mistake.
|
|
126
|
+
*/
|
|
127
|
+
export function parseRoot(argv: readonly string[], fallback: string): string {
|
|
128
|
+
for (let i = 0; i < argv.length; i++) {
|
|
129
|
+
const a = argv[i]!;
|
|
130
|
+
if (a.startsWith('--root=')) return path.resolve(a.slice('--root='.length));
|
|
131
|
+
if (a === '--root' && argv[i + 1] !== undefined) return path.resolve(argv[i + 1]!);
|
|
132
|
+
}
|
|
133
|
+
return fallback;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function main(argv: readonly string[] = process.argv.slice(2), root?: string): number {
|
|
137
|
+
const QUIET = argv.includes('--quiet');
|
|
138
|
+
root = root ?? parseRoot(argv, ROOT);
|
|
139
|
+
let present: readonly string[];
|
|
140
|
+
try {
|
|
141
|
+
present = assertWatchlistResolves({
|
|
142
|
+
gate: 'check_no_stub_inventory_table',
|
|
143
|
+
candidates: GUARDED,
|
|
144
|
+
repoRoot: root,
|
|
145
|
+
});
|
|
146
|
+
} catch (exc) {
|
|
147
|
+
if (exc instanceof DeadScopeError) {
|
|
148
|
+
process.stdout.write(`❌ ${exc.message}\n`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
throw exc;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const findings: Finding[] = [];
|
|
155
|
+
for (const rel of present) {
|
|
156
|
+
findings.push(...scan_file(rel, fs.readFileSync(path.join(root, rel), 'utf-8')));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Outside the --quiet guard on purpose: CI passes --quiet, and a gate whose
|
|
160
|
+
// count disappears under the real argv reads as `silent` to the coverage guard.
|
|
161
|
+
reportScanned({
|
|
162
|
+
gate: 'check_no_stub_inventory_table',
|
|
163
|
+
scanned: present.length,
|
|
164
|
+
units: 'guarded file(s)',
|
|
165
|
+
roots: GUARDED,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (findings.length === 0) {
|
|
169
|
+
if (!QUIET) process.stdout.write('✅ No stub-inventory table in the guarded file(s).\n');
|
|
170
|
+
return 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
process.stdout.write(
|
|
174
|
+
`❌ Stub-inventory table reintroduced — ${String(findings.length)} row(s):\n`,
|
|
175
|
+
);
|
|
176
|
+
for (const f of findings) {
|
|
177
|
+
process.stdout.write(` ${f.file}:${String(f.line)}: ${f.kind} — ${f.text.slice(0, 100)}\n`);
|
|
178
|
+
}
|
|
179
|
+
process.stdout.write(
|
|
180
|
+
'\nThe stub files ARE the inventory; `ls agents/roadmaps/stubs/*.md` lists them.\n' +
|
|
181
|
+
'If you reached this while resolving a merge conflict, the resolution\n' +
|
|
182
|
+
'WITHOUT a table is the correct one — the table was deleted deliberately\n' +
|
|
183
|
+
'(3793855b3, AI council 2026-08-21) and restored once by a merge already.\n' +
|
|
184
|
+
'If you need a per-stub fact, it is in that stub; all 33 rows were verified\n' +
|
|
185
|
+
'against their stub file before deletion and all 15 again before the second.\n',
|
|
186
|
+
);
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Build a fixture repository holding one guarded file with `body`, or none at all
|
|
192
|
+
* when `body` is null (the dead-watch-list case).
|
|
193
|
+
*/
|
|
194
|
+
function _fixture(body: string | null): string {
|
|
195
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'stub-inv-self-'));
|
|
196
|
+
if (body !== null) {
|
|
197
|
+
const target = path.join(dir, GUARDED[0]!);
|
|
198
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
199
|
+
fs.writeFileSync(target, body);
|
|
200
|
+
}
|
|
201
|
+
return dir;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Prove the gate's rejections still fire, through the CLI a contributor runs.
|
|
206
|
+
*
|
|
207
|
+
* The three rejecting cases are the sabotage probes this gate was verified with
|
|
208
|
+
* by hand before it landed; keeping them here is what stops the verification
|
|
209
|
+
* from being a one-time claim in a commit message.
|
|
210
|
+
*/
|
|
211
|
+
export function selfTest(): number {
|
|
212
|
+
const cases: SelfTestCase[] = [
|
|
213
|
+
{
|
|
214
|
+
name: 'rejects a restored transfer table (header + rows)',
|
|
215
|
+
expect: 'reject',
|
|
216
|
+
run: () =>
|
|
217
|
+
runGateCli(
|
|
218
|
+
ROOT,
|
|
219
|
+
SELF,
|
|
220
|
+
['--quiet', '--root', _fixture(
|
|
221
|
+
'| Stub | Transferred from |\n|---|---|\n' +
|
|
222
|
+
'| [`road-to-a.md`](road-to-a.md) | p |\n',
|
|
223
|
+
)],
|
|
224
|
+
ROOT,
|
|
225
|
+
),
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
name: 'rejects the inventory header on its own',
|
|
229
|
+
expect: 'reject',
|
|
230
|
+
run: () => runGateCli(ROOT, SELF, ['--quiet', '--root', _fixture('| Stub | Gates |\n')], ROOT),
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'rejects a dead watch list rather than reporting clean',
|
|
234
|
+
expect: 'reject',
|
|
235
|
+
run: () => runGateCli(ROOT, SELF, ['--quiet', '--root', _fixture(null)], ROOT),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
name: 'accepts a guarded file with no inventory table',
|
|
239
|
+
expect: 'accept',
|
|
240
|
+
run: () =>
|
|
241
|
+
runGateCli(
|
|
242
|
+
ROOT,
|
|
243
|
+
SELF,
|
|
244
|
+
['--quiet', '--root', _fixture('# Stubs\n\nThe directory is the index.\n')],
|
|
245
|
+
ROOT,
|
|
246
|
+
),
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
name: 'accepts a glossary table and a table citing parent roadmaps',
|
|
250
|
+
expect: 'accept',
|
|
251
|
+
run: () =>
|
|
252
|
+
runGateCli(
|
|
253
|
+
ROOT,
|
|
254
|
+
SELF,
|
|
255
|
+
['--quiet', '--root', _fixture(
|
|
256
|
+
'| Term | Meaning |\n|---|---|\n| capability-gated | env missing |\n\n' +
|
|
257
|
+
'| Parent | Phase |\n|---|---|\n' +
|
|
258
|
+
'| [`road-to-x.md`](../archive/road-to-x.md) | 2.1 |\n',
|
|
259
|
+
)],
|
|
260
|
+
ROOT,
|
|
261
|
+
),
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
return runSelfTest({
|
|
265
|
+
gate: 'check_no_stub_inventory_table',
|
|
266
|
+
cases,
|
|
267
|
+
minCases: SELF_TEST_MIN_CASES,
|
|
268
|
+
minRejectCases: SELF_TEST_MIN_REJECT,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _isCliEntry(): boolean {
|
|
273
|
+
if (process.argv[1] === undefined) return false;
|
|
274
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
275
|
+
if (import.meta.url === argvUrl) return true;
|
|
276
|
+
try {
|
|
277
|
+
return (
|
|
278
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
279
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
280
|
+
);
|
|
281
|
+
} catch {
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
287
|
+
const argv = process.argv.slice(2);
|
|
288
|
+
if (argv.includes('--self-test') && process.env['GATE_SELF_TEST_CHILD'] !== '1') {
|
|
289
|
+
process.exit(selfTest());
|
|
290
|
+
}
|
|
291
|
+
process.exit(main(argv));
|
|
292
|
+
}
|