@event4u/agent-config 9.24.0 → 9.26.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 +136 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +182 -0
- package/dist/agent-src/commands/analyze/inbox.md +33 -2
- package/dist/agent-src/commands/review/changes.md +6 -5
- package/dist/agent-src/commands/roadmap/next.md +194 -0
- package/dist/agent-src/commands/roadmap.md +12 -4
- package/dist/agent-src/contexts/execution/subagent-routing.md +4 -1
- package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
- package/dist/agent-src/rules/delegation-policy.md +3 -2
- package/dist/agent-src/rules/design-fidelity.md +17 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +116 -0
- package/dist/agent-src/rules/evaluator-independence.md +111 -0
- package/dist/agent-src/rules/session-canary.md +15 -1
- package/dist/agent-src/rules/settings-ask-protocol.md +176 -0
- package/dist/agent-src/rules/token-budget-discipline.md +6 -3
- package/dist/agent-src/rules/ui-audit-gate.md +3 -0
- package/dist/agent-src/rules/user-interaction.md +23 -0
- package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +4 -1
- package/dist/cli-delegate/cmd_route_explain.js +4 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +146 -28
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +5 -5
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +16 -11
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +12 -7
- package/dist/hooks/dispatch.js +1501 -511
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/nicknameResolver.js +116 -0
- package/dist/server/nicknameResolver.js.map +1 -0
- package/dist/server/routes/ping.js +29 -11
- package/dist/server/routes/ping.js.map +1 -1
- package/dist/shared/interactiveContext.js +73 -0
- package/dist/shared/interactiveContext.js.map +1 -0
- package/dist/shared/nicknamePrefill.js +59 -0
- package/dist/shared/nicknamePrefill.js.map +1 -0
- package/dist/shared/settingsAsks.js +128 -0
- package/dist/shared/settingsAsks.js.map +1 -0
- package/dist/shared/settingsConsent.js +93 -0
- package/dist/shared/settingsConsent.js.map +1 -0
- package/package.json +1 -1
- package/src/config/agents-paths.yml +15 -0
- package/src/config/evaluator-budgets.json +68 -68
- package/src/config/gate-coverage.yml +131 -5
- package/src/config/gate-violation-baselines.json +11 -0
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/_dispatch.bash +12 -0
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +146 -5
- package/src/scripts/bench_ab_v2_run.ts +272 -16
- package/src/scripts/bench_ab_v2_stats.ts +156 -3
- package/src/scripts/check_completion_review.ts +80 -3
- package/src/scripts/check_corpus_staleness.ts +692 -0
- package/src/scripts/check_depth_budget.ts +295 -0
- package/src/scripts/check_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +422 -0
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +328 -0
- package/src/scripts/hook_manifest.yaml +69 -9
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +371 -0
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/design_slop_hook.ts +13 -1
- package/src/scripts/hooks/dispatch_hook.ts +97 -2
- package/src/scripts/hooks/evidence_independence.ts +368 -0
- package/src/scripts/language_mirror_hook.ts +456 -0
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_decision_impact.ts +246 -0
- package/src/scripts/lint_example_fences.ts +609 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- package/src/scripts/lint_rule_interactions.ts +90 -0
- package/src/scripts/lint_rule_migration_ledger.ts +480 -0
- package/src/scripts/lint_skill_descriptions.ts +86 -2
- package/src/scripts/memory_learn_hook.ts +109 -6
- package/src/scripts/pr_url_reminder_hook.ts +17 -2
- package/src/scripts/release.ts +38 -1
- package/src/scripts/schemas/review-findings.schema.json +99 -0
- package/src/scripts/schemas/rule.schema.json +10 -0
- package/src/shared/interactiveContext.ts +98 -0
- package/src/shared/nicknamePrefill.ts +96 -0
- package/src/shared/settingsAsks.ts +176 -0
- package/src/shared/settingsConsent.ts +136 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* check_depth_budget — a per-file ceiling on the on-demand depth layer.
|
|
4
|
+
*
|
|
5
|
+
* ## The gap
|
|
6
|
+
*
|
|
7
|
+
* Rules carry a char cap (`lint_load_context.cap_for`). Skills carry a token
|
|
8
|
+
* budget class (`lint_token_budget_discipline`). The always-loaded set carries
|
|
9
|
+
* two budgets (`check_always_budget`). The 161 context and guideline files those
|
|
10
|
+
* rules and skills *route to* carry none — which is where the budget the other
|
|
11
|
+
* three gates defend leaks back in as on-demand bloat.
|
|
12
|
+
*
|
|
13
|
+
* The existence proof is in the corpus and is self-documenting:
|
|
14
|
+
* `contexts/execution/roadmap-process-loop.md` states **"Size budget: ≤ 4,000
|
|
15
|
+
* chars"** in its own header and is over 20,000. It declared a budget, nothing
|
|
16
|
+
* measured it, and it went 5× over.
|
|
17
|
+
*
|
|
18
|
+
* ## What this number is — and what it is NOT
|
|
19
|
+
*
|
|
20
|
+
* 16,000 chars is a **growth ratchet, not a quality threshold.** Say it plainly
|
|
21
|
+
* because the temptation to dress it up is exactly the failure this suite
|
|
22
|
+
* records elsewhere: a published measurement over 7,308 trajectories was used
|
|
23
|
+
* two days ago to set a *skill* ceiling at 3,500 tokens, and that measurement
|
|
24
|
+
* does not transfer here. Skills are procedural and load together; depth files
|
|
25
|
+
* are declarative and load one or two at a time. Applying the skill number to
|
|
26
|
+
* this layer would be a percentile wearing a study's clothes.
|
|
27
|
+
*
|
|
28
|
+
* So the honest claim is narrow: 16,000 sits just above the current p99, it
|
|
29
|
+
* makes the four existing outliers visible, and it stops a fifth from arriving
|
|
30
|
+
* unnoticed. Nothing here claims a reader comprehends 15,999 chars and fails at
|
|
31
|
+
* 16,001. If someone later measures comprehension degradation on *this* layer,
|
|
32
|
+
* that measurement replaces this number and this paragraph.
|
|
33
|
+
*
|
|
34
|
+
* ## Why a ratchet rather than advisory
|
|
35
|
+
*
|
|
36
|
+
* The plan said advisory-until-classified. Advisory is right when a new gate
|
|
37
|
+
* would land as an unfixable blocker — but it has a second-order cost this
|
|
38
|
+
* repository has already paid: an advisory line in a gate that exits 0 is a
|
|
39
|
+
* number nobody is obliged to move, and the four outliers would still be four
|
|
40
|
+
* outliers a year from now.
|
|
41
|
+
*
|
|
42
|
+
* The ratchet gives both halves. The four current over-ceiling files are
|
|
43
|
+
* recorded as a baseline and do not block; a fifth does. Shrink-only, so the
|
|
44
|
+
* count may fall and never rise. That is enforcement without a flag day, and it
|
|
45
|
+
* is the mechanism this suite already uses for exactly this shape.
|
|
46
|
+
*
|
|
47
|
+
* Exit codes: 0 = at or under baseline, 1 = a new over-ceiling file, 2 = usage.
|
|
48
|
+
*/
|
|
49
|
+
import * as fs from 'node:fs';
|
|
50
|
+
import * as os from 'node:os';
|
|
51
|
+
import * as path from 'node:path';
|
|
52
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
53
|
+
|
|
54
|
+
import { checkRatchet } from './_lib/gate_baseline.js';
|
|
55
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
56
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
57
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
58
|
+
|
|
59
|
+
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
60
|
+
const REAL_REPO_ROOT = path.dirname(path.dirname(SCRIPTS_DIR));
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The ceiling, in characters.
|
|
64
|
+
*
|
|
65
|
+
* Deliberately expressed in chars, not tokens: every sibling budget in this
|
|
66
|
+
* suite that must be cheap to check uses chars, and a char count is the one
|
|
67
|
+
* measure that cannot drift with a tokenizer version.
|
|
68
|
+
*/
|
|
69
|
+
export const DEPTH_CEILING_CHARS = 16_000;
|
|
70
|
+
|
|
71
|
+
/** The two roots that make up the on-demand depth layer. */
|
|
72
|
+
export const DEPTH_ROOTS = ['src/agent-src/contexts', 'docs/guidelines'] as const;
|
|
73
|
+
|
|
74
|
+
export interface DepthFile {
|
|
75
|
+
readonly file: string;
|
|
76
|
+
readonly chars: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function* walk(dir: string): Generator<string> {
|
|
80
|
+
let entries: fs.Dirent[];
|
|
81
|
+
try {
|
|
82
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
83
|
+
} catch {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
87
|
+
const full = path.join(dir, e.name);
|
|
88
|
+
if (e.isDirectory()) {
|
|
89
|
+
yield* walk(full);
|
|
90
|
+
} else if (e.isFile() && e.name.endsWith('.md')) {
|
|
91
|
+
yield full;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function collectDepthFiles(repoRoot: string): string[] {
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
for (const root of DEPTH_ROOTS) {
|
|
99
|
+
for (const f of walk(path.join(repoRoot, root))) {
|
|
100
|
+
out.push(f);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// A file reachable from both roots would be counted twice; dedupe on the
|
|
104
|
+
// resolved path so the published `scanned:` count is the number of distinct
|
|
105
|
+
// artefacts judged.
|
|
106
|
+
return [...new Set(out)].sort();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function measure(repoRoot: string, files: readonly string[]): DepthFile[] {
|
|
110
|
+
const out: DepthFile[] = [];
|
|
111
|
+
for (const f of files) {
|
|
112
|
+
let chars: number;
|
|
113
|
+
try {
|
|
114
|
+
chars = fs.readFileSync(f, 'utf-8').length;
|
|
115
|
+
} catch {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
out.push({ file: path.relative(repoRoot, f).split(path.sep).join('/'), chars });
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface Args {
|
|
124
|
+
readonly json: boolean;
|
|
125
|
+
readonly quiet: boolean;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function parseArgs(argv: readonly string[]): Args {
|
|
129
|
+
let json = false;
|
|
130
|
+
let quiet = false;
|
|
131
|
+
for (const arg of argv) {
|
|
132
|
+
if (arg === '--json') { json = true; }
|
|
133
|
+
else if (arg === '--quiet') { quiet = true; }
|
|
134
|
+
else if (arg === '-h' || arg === '--help') {
|
|
135
|
+
process.stdout.write('usage: check_depth_budget [--json] [--quiet]\n');
|
|
136
|
+
process.exit(0);
|
|
137
|
+
} else {
|
|
138
|
+
process.stderr.write(`check_depth_budget: unrecognized argument: ${arg}\n`);
|
|
139
|
+
process.exit(2);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { json, quiet };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function selfTest(): number {
|
|
146
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'cdb-selftest-'));
|
|
147
|
+
const mkRepo = (bodies: Record<string, number>): string => {
|
|
148
|
+
const root = fs.mkdtempSync(path.join(tmp, 'repo-'));
|
|
149
|
+
for (const [rel, size] of Object.entries(bodies)) {
|
|
150
|
+
const p = path.join(root, rel);
|
|
151
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
152
|
+
fs.writeFileSync(p, 'x'.repeat(size), 'utf-8');
|
|
153
|
+
}
|
|
154
|
+
// A baseline of 0 makes any over-ceiling file a violation, which is the
|
|
155
|
+
// discrimination the self-test needs to prove.
|
|
156
|
+
const cfg = path.join(root, 'src', 'config');
|
|
157
|
+
fs.mkdirSync(cfg, { recursive: true });
|
|
158
|
+
fs.writeFileSync(
|
|
159
|
+
path.join(cfg, 'gate-violation-baselines.json'),
|
|
160
|
+
JSON.stringify({ gates: {} }),
|
|
161
|
+
'utf-8',
|
|
162
|
+
);
|
|
163
|
+
return root;
|
|
164
|
+
};
|
|
165
|
+
const run = (root: string): number => {
|
|
166
|
+
// `runGateCli` forwards the parent env, so this is how the child is
|
|
167
|
+
// pointed at the synthetic tree.
|
|
168
|
+
process.env['CHECK_DEPTH_BUDGET_ROOT'] = root;
|
|
169
|
+
try {
|
|
170
|
+
return runGateCli(REAL_REPO_ROOT, 'src/scripts/check_depth_budget.ts', ['--quiet'], root);
|
|
171
|
+
} finally {
|
|
172
|
+
delete process.env['CHECK_DEPTH_BUDGET_ROOT'];
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
const over = mkRepo({ 'docs/guidelines/big.md': DEPTH_CEILING_CHARS + 1 });
|
|
178
|
+
const under = mkRepo({ 'docs/guidelines/ok.md': DEPTH_CEILING_CHARS - 1 });
|
|
179
|
+
const exact = mkRepo({ 'docs/guidelines/edge.md': DEPTH_CEILING_CHARS });
|
|
180
|
+
const empty = mkRepo({ 'README.md': 10 });
|
|
181
|
+
return runSelfTest({
|
|
182
|
+
gate: 'check_depth_budget',
|
|
183
|
+
minCases: 3,
|
|
184
|
+
minRejectCases: 2,
|
|
185
|
+
cases: [
|
|
186
|
+
{ name: 'a file one char over the ceiling is rejected', expect: 'reject', run: () => run(over) },
|
|
187
|
+
{ name: 'a file one char under the ceiling passes', expect: 'accept', run: () => run(under) },
|
|
188
|
+
{
|
|
189
|
+
name: 'the ceiling itself is inclusive — exactly at the limit passes',
|
|
190
|
+
expect: 'accept',
|
|
191
|
+
run: () => run(exact),
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
name: 'a dead scan root is rejected, not reported as clean',
|
|
195
|
+
expect: 'reject',
|
|
196
|
+
run: () => run(empty),
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
} finally {
|
|
201
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function main(argv?: readonly string[]): number {
|
|
206
|
+
const raw = argv ?? process.argv.slice(2);
|
|
207
|
+
if (raw.includes('--self-test')) {
|
|
208
|
+
return selfTest();
|
|
209
|
+
}
|
|
210
|
+
const args = parseArgs(raw);
|
|
211
|
+
// Default to the root derived from this file's own location — robust to
|
|
212
|
+
// whatever cwd a wrapper or hook invokes the gate from. The env override
|
|
213
|
+
// exists so the self-test can drive the REAL binary against a synthetic
|
|
214
|
+
// tree; deliberately NOT a cwd fallback, because a "use the real repo when
|
|
215
|
+
// the given root looks empty" rule would make the dead-scan-root case
|
|
216
|
+
// silently pass by scanning the wrong corpus — the exact failure the
|
|
217
|
+
// `assertScanned` contract exists to catch.
|
|
218
|
+
const root = process.env['CHECK_DEPTH_BUDGET_ROOT'] ?? REAL_REPO_ROOT;
|
|
219
|
+
|
|
220
|
+
const files = collectDepthFiles(root);
|
|
221
|
+
const ledger = new GateLedger('check_depth_budget');
|
|
222
|
+
ledger.plan(files.map((f) => path.relative(root, f).split(path.sep).join('/')));
|
|
223
|
+
|
|
224
|
+
const measured = measure(root, files);
|
|
225
|
+
const over = measured.filter((m) => m.chars > DEPTH_CEILING_CHARS)
|
|
226
|
+
.sort((a, b) => b.chars - a.chars);
|
|
227
|
+
const overSet = new Set(over.map((o) => o.file));
|
|
228
|
+
for (const m of measured) {
|
|
229
|
+
if (overSet.has(m.file)) {
|
|
230
|
+
ledger.fail(m.file, `${String(m.chars)} chars > ceiling ${String(DEPTH_CEILING_CHARS)}`);
|
|
231
|
+
} else {
|
|
232
|
+
ledger.complete(m.file);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const tally = ledger.finalize();
|
|
236
|
+
|
|
237
|
+
if (args.json) {
|
|
238
|
+
process.stdout.write(
|
|
239
|
+
`${JSON.stringify(
|
|
240
|
+
{ version: 1, ceiling: DEPTH_CEILING_CHARS, over, scanned: measured.length, ledger: tally },
|
|
241
|
+
null,
|
|
242
|
+
2,
|
|
243
|
+
)}\n`,
|
|
244
|
+
);
|
|
245
|
+
} else if (!args.quiet && over.length > 0) {
|
|
246
|
+
process.stdout.write(
|
|
247
|
+
`\nOver the ${String(DEPTH_CEILING_CHARS)}-char depth ceiling ` +
|
|
248
|
+
'(a growth ratchet, NOT a measured quality threshold):\n',
|
|
249
|
+
);
|
|
250
|
+
for (const o of over) {
|
|
251
|
+
process.stdout.write(` ${String(o.chars).padStart(7, ' ')} ${o.file}\n`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
reportScanned({
|
|
256
|
+
gate: 'check_depth_budget',
|
|
257
|
+
scanned: measured.length,
|
|
258
|
+
units: 'depth file(s)',
|
|
259
|
+
roots: [...DEPTH_ROOTS],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const verdict = checkRatchet({
|
|
263
|
+
gate: 'check_depth_budget',
|
|
264
|
+
actual: over.length,
|
|
265
|
+
repoRoot: root,
|
|
266
|
+
});
|
|
267
|
+
if (verdict.ok) {
|
|
268
|
+
process.stdout.write(`\n⚠️ ${verdict.message}\n`);
|
|
269
|
+
return 0;
|
|
270
|
+
}
|
|
271
|
+
process.stderr.write(`\n❌ ${verdict.message}\n`);
|
|
272
|
+
return 1;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
276
|
+
function _isCliEntry(): boolean {
|
|
277
|
+
if (process.argv[1] === undefined) {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
try {
|
|
284
|
+
return (
|
|
285
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
286
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
287
|
+
);
|
|
288
|
+
} catch {
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
294
|
+
process.exit(main());
|
|
295
|
+
}
|
|
@@ -363,6 +363,7 @@ export function main(argv: readonly string[]): number {
|
|
|
363
363
|
);
|
|
364
364
|
}
|
|
365
365
|
const hardening = report_hardening_ratchet();
|
|
366
|
+
const selfTest = report_self_test_ratchet();
|
|
366
367
|
if (failed.length > 0) {
|
|
367
368
|
process.stdout.write(`❌ ${String(failed.length)} gate(s) failed the coverage floor.\n`);
|
|
368
369
|
return 1;
|
|
@@ -370,6 +371,9 @@ export function main(argv: readonly string[]): number {
|
|
|
370
371
|
if (hardening !== 0) {
|
|
371
372
|
return hardening;
|
|
372
373
|
}
|
|
374
|
+
if (selfTest !== 0) {
|
|
375
|
+
return selfTest;
|
|
376
|
+
}
|
|
373
377
|
process.stdout.write('✅ every enforced gate cleared its coverage floor.\n');
|
|
374
378
|
return 0;
|
|
375
379
|
}
|
|
@@ -400,6 +404,69 @@ export function report_hardening_ratchet(): number {
|
|
|
400
404
|
return verdict.ok ? 0 : 1;
|
|
401
405
|
}
|
|
402
406
|
|
|
407
|
+
/**
|
|
408
|
+
* Registered gates that carry no `--self-test` and no exemption — the count
|
|
409
|
+
* that must not RISE.
|
|
410
|
+
*
|
|
411
|
+
* `_lib/gate_self_test.ts` proves a gate DISCRIMINATES: it drives the real
|
|
412
|
+
* binary through cases that must reject and cases that must accept, with a
|
|
413
|
+
* `minRejectCases` floor so an all-accepting suite cannot satisfy it. A gate
|
|
414
|
+
* that emits an enforced `scanned:` count has proven it read something; only a
|
|
415
|
+
* self-test proves the reading changes the verdict.
|
|
416
|
+
*
|
|
417
|
+
* ## Why this is a NON-ADOPTER count and not an adoption column
|
|
418
|
+
*
|
|
419
|
+
* The roadmap item asked for adoption as a column in `gate-coverage.yml`,
|
|
420
|
+
* ratcheted upward. That is the exact shape `report_hardening_ratchet` above
|
|
421
|
+
* rejects in this same file — a coverage-percentage ratchet "tracks how far the
|
|
422
|
+
* fix has spread and can never regress, so it grades the solution instead of
|
|
423
|
+
* the problem". Inverting it fixes that: the number below can regress (a new
|
|
424
|
+
* registered gate written without a self-test raises it), and the target is 0.
|
|
425
|
+
* That is also the shape the sibling adoption ratchet already uses —
|
|
426
|
+
* `check_gate_completeness`'s ledger baseline is a shrink-only count of
|
|
427
|
+
* non-adopters, not a rising percentage.
|
|
428
|
+
*
|
|
429
|
+
* Population is the MANIFEST, not every script under `src/scripts`: a self-test
|
|
430
|
+
* is a claim about a gate CI runs against a floor. Padding the manifest to move
|
|
431
|
+
* this number would add a floor per row, which is the opposite of gaming.
|
|
432
|
+
*
|
|
433
|
+
* Exemption marker: `// self-test-exempt: <reason>` with a non-empty reason. A
|
|
434
|
+
* bare marker does not count — same discipline as `// no-index:` and
|
|
435
|
+
* `// ledger-exempt:`.
|
|
436
|
+
*/
|
|
437
|
+
export function list_self_test_non_adopters(
|
|
438
|
+
dir = path.join(REPO_ROOT, 'src/scripts'),
|
|
439
|
+
ids: ReadonlySet<string> = enforced_manifest_ids(),
|
|
440
|
+
): string[] {
|
|
441
|
+
const out: string[] = [];
|
|
442
|
+
for (const id of [...ids].sort()) {
|
|
443
|
+
let src: string;
|
|
444
|
+
try {
|
|
445
|
+
src = fs.readFileSync(path.join(dir, `${id}.ts`), 'utf8');
|
|
446
|
+
} catch {
|
|
447
|
+
// A registered id with no readable script cannot prove discrimination.
|
|
448
|
+
out.push(id);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const adopts = /_lib\/gate_self_test\.js/.test(src);
|
|
452
|
+
const exempt = /\/\/\s*self-test-exempt:\s*\S/.test(src);
|
|
453
|
+
if (!adopts && !exempt) out.push(id);
|
|
454
|
+
}
|
|
455
|
+
return out;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Ratchet wrapper for {@link list_self_test_non_adopters}. */
|
|
459
|
+
export function report_self_test_ratchet(): number {
|
|
460
|
+
const missing = list_self_test_non_adopters();
|
|
461
|
+
const verdict = checkRatchet({
|
|
462
|
+
gate: 'gate-self-test:registered-non-adopters',
|
|
463
|
+
actual: missing.length,
|
|
464
|
+
repoRoot: REPO_ROOT,
|
|
465
|
+
});
|
|
466
|
+
process.stdout.write(` ${verdict.ok ? '✅' : '❌'} ${verdict.message}\n`);
|
|
467
|
+
return verdict.ok ? 0 : 1;
|
|
468
|
+
}
|
|
469
|
+
|
|
403
470
|
/**
|
|
404
471
|
* Manifest ids whose `scanned:` line is actually ENFORCED against a floor.
|
|
405
472
|
*
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Fail when the self-hosted hook bundle is older than the hook sources it
|
|
4
|
+
* bundles — i.e. when this repo's own agent is running a hook fix that has
|
|
5
|
+
* not been built.
|
|
6
|
+
*
|
|
7
|
+
* WHY THIS EXISTS — measured, not hypothetical.
|
|
8
|
+
*
|
|
9
|
+
* The Claude hook command resolves its dispatcher in two steps: the installed
|
|
10
|
+
* package copy (`node_modules/@event4u/agent-config/dist/hooks/dispatch.js`)
|
|
11
|
+
* if present, else this repo's OWN `dist/hooks/dispatch.js`. In the
|
|
12
|
+
* agent-config repo the second branch always wins — that is the dogfooding
|
|
13
|
+
* path, and it is the only place these hooks are exercised before release.
|
|
14
|
+
*
|
|
15
|
+
* `dist/hooks/dispatch.js` is UNTRACKED (a build artifact) and is produced
|
|
16
|
+
* only by `npm run build:hooks`, which no Taskfile target, no `task ci`
|
|
17
|
+
* target, and no workflow invokes. `prepack` runs the full build, so a
|
|
18
|
+
* PUBLISHED package is always fresh and consumers are unaffected. The gap is
|
|
19
|
+
* local and it is exactly where the testing happens.
|
|
20
|
+
*
|
|
21
|
+
* Consequence, observed on 2026-08-06: the conformance round-2 PR (#1195)
|
|
22
|
+
* merged four hook fixes — among them the language-mirror pin — while the
|
|
23
|
+
* running bundle was from 07:27 that morning, before any of them existed.
|
|
24
|
+
* Ten hook sources were newer than the bundle. In the very session that
|
|
25
|
+
* shipped the language fix, the scanner counted four German-prompt /
|
|
26
|
+
* English-reply violations: the fix was merged, correct, and not loaded.
|
|
27
|
+
*
|
|
28
|
+
* A hook fix that cannot reach the agent that wrote it is indistinguishable
|
|
29
|
+
* from no fix, and nothing said so. This gate says so.
|
|
30
|
+
*
|
|
31
|
+
* WHAT IT DOES NOT DO: it never builds. Building into a live hook path is a
|
|
32
|
+
* documented hazard in this repo (an esbuild run that overwrites the
|
|
33
|
+
* dispatcher a running hook is executing can wedge the tool loop), so the
|
|
34
|
+
* remedy is printed for a human to run deliberately.
|
|
35
|
+
*
|
|
36
|
+
* Exit codes: 0 = fresh, or no self-hosted bundle to check · 1 = stale.
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from "node:fs";
|
|
39
|
+
import * as path from "node:path";
|
|
40
|
+
import { fileURLToPath } from "node:url";
|
|
41
|
+
|
|
42
|
+
import { parse as parseYaml } from "yaml";
|
|
43
|
+
|
|
44
|
+
import { reportScanned } from "./_lib/scan_scope.js";
|
|
45
|
+
|
|
46
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const REPO_ROOT = path.resolve(HERE, "..", "..");
|
|
48
|
+
const BUNDLE = path.join("dist", "hooks", "dispatch.js");
|
|
49
|
+
const MANIFEST = path.join("src", "scripts", "hook_manifest.yaml");
|
|
50
|
+
const HOOK_DIR = path.join("src", "scripts", "hooks");
|
|
51
|
+
const REMEDY = "npm run build:hooks";
|
|
52
|
+
|
|
53
|
+
/** Every source whose edit changes the bundle's behaviour. */
|
|
54
|
+
export function bundledSources(root: string): string[] {
|
|
55
|
+
const out = new Set<string>();
|
|
56
|
+
|
|
57
|
+
const manifestAbs = path.join(root, MANIFEST);
|
|
58
|
+
if (fs.existsSync(manifestAbs)) {
|
|
59
|
+
out.add(MANIFEST);
|
|
60
|
+
try {
|
|
61
|
+
const doc = parseYaml(fs.readFileSync(manifestAbs, "utf-8")) as {
|
|
62
|
+
concerns?: Record<string, { script?: string }>;
|
|
63
|
+
};
|
|
64
|
+
for (const def of Object.values(doc?.concerns ?? {})) {
|
|
65
|
+
const s = def?.script;
|
|
66
|
+
// A concern script is bundled wherever it lives, not only under hooks/.
|
|
67
|
+
if (typeof s === "string" && s.endsWith(".ts")) out.add(s);
|
|
68
|
+
}
|
|
69
|
+
} catch {
|
|
70
|
+
// A malformed manifest is another gate's problem; still check the dir.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hookDirAbs = path.join(root, HOOK_DIR);
|
|
75
|
+
if (fs.existsSync(hookDirAbs)) {
|
|
76
|
+
for (const e of fs.readdirSync(hookDirAbs, { withFileTypes: true })) {
|
|
77
|
+
if (e.isFile() && e.name.endsWith(".ts")) out.add(path.join(HOOK_DIR, e.name));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return [...out].filter((rel) => fs.existsSync(path.join(root, rel))).sort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Result {
|
|
85
|
+
/** No self-hosted bundle → the installed package copy is authoritative. */
|
|
86
|
+
skipped: boolean;
|
|
87
|
+
bundleMtimeMs: number;
|
|
88
|
+
/** Sources strictly newer than the bundle, newest first. */
|
|
89
|
+
stale: { file: string; mtimeMs: number }[];
|
|
90
|
+
checked: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function check(root: string): Result {
|
|
94
|
+
const bundleAbs = path.join(root, BUNDLE);
|
|
95
|
+
if (!fs.existsSync(bundleAbs)) {
|
|
96
|
+
return { skipped: true, bundleMtimeMs: 0, stale: [], checked: 0 };
|
|
97
|
+
}
|
|
98
|
+
const bundleMtimeMs = fs.statSync(bundleAbs).mtimeMs;
|
|
99
|
+
const sources = bundledSources(root);
|
|
100
|
+
const stale = sources
|
|
101
|
+
.map((file) => ({ file, mtimeMs: fs.statSync(path.join(root, file)).mtimeMs }))
|
|
102
|
+
.filter((s) => s.mtimeMs > bundleMtimeMs)
|
|
103
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
104
|
+
return { skipped: false, bundleMtimeMs, stale, checked: sources.length };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function stamp(ms: number): string {
|
|
108
|
+
return new Date(ms).toISOString().replace("T", " ").slice(0, 16);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function main(argv: string[] = process.argv.slice(2)): number {
|
|
112
|
+
const root = REPO_ROOT;
|
|
113
|
+
const quiet = argv.includes("--quiet");
|
|
114
|
+
const r = check(root);
|
|
115
|
+
|
|
116
|
+
// Scope hardening: publish the count and refuse a silent zero. The one
|
|
117
|
+
// legitimate zero is the absent self-hosted bundle — an optional input, and
|
|
118
|
+
// the reason survives the manifest's own test ("if the scan root were
|
|
119
|
+
// deleted, would this still make sense?"): with no bundle there is no stale
|
|
120
|
+
// dispatcher to catch, which is a correct verdict rather than blindness.
|
|
121
|
+
reportScanned({
|
|
122
|
+
gate: "check_hook_bundle_freshness",
|
|
123
|
+
scanned: r.checked,
|
|
124
|
+
units: "bundled hook source(s)",
|
|
125
|
+
roots: [BUNDLE, HOOK_DIR, MANIFEST],
|
|
126
|
+
...(r.skipped
|
|
127
|
+
? {
|
|
128
|
+
allowEmpty:
|
|
129
|
+
"OPTIONAL_INPUT: no self-hosted dist/hooks/dispatch.js — this repo is not " +
|
|
130
|
+
"running its own bundle (fresh checkout / CI), so there is nothing to compare against.",
|
|
131
|
+
}
|
|
132
|
+
: {}),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// A gate that scans nothing must SAY it scanned nothing — a silent green
|
|
136
|
+
// here is indistinguishable from a pass.
|
|
137
|
+
if (r.skipped) {
|
|
138
|
+
if (!quiet) {
|
|
139
|
+
process.stdout.write(
|
|
140
|
+
`✅ OK hook bundle: no self-hosted ${BUNDLE} — the installed package copy is in use, nothing to check\n`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (r.stale.length === 0) {
|
|
147
|
+
if (!quiet) {
|
|
148
|
+
process.stdout.write(
|
|
149
|
+
`✅ OK hook bundle: fresh (built ${stamp(r.bundleMtimeMs)}, ${String(r.checked)} bundled source(s) checked)\n`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return 0;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
console.error(
|
|
156
|
+
`❌ hook bundle is STALE — the running dispatcher predates ${r.stale.length} of ${r.checked} bundled source(s).`,
|
|
157
|
+
);
|
|
158
|
+
console.error(` ${BUNDLE} built ${stamp(r.bundleMtimeMs)}; newer sources:`);
|
|
159
|
+
for (const s of r.stale.slice(0, 10)) {
|
|
160
|
+
console.error(` ${stamp(s.mtimeMs)} ${s.file}`);
|
|
161
|
+
}
|
|
162
|
+
if (r.stale.length > 10) console.error(` … and ${r.stale.length - 10} more`);
|
|
163
|
+
console.error("");
|
|
164
|
+
console.error(` Your hooks are running the OLD code. Rebuild: ${REMEDY}`);
|
|
165
|
+
console.error(
|
|
166
|
+
" (This gate never builds for you — writing into a live hook path can wedge the tool loop.)",
|
|
167
|
+
);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const invokedDirectly = ((): boolean => {
|
|
172
|
+
const entry = process.argv[1];
|
|
173
|
+
if (entry === undefined) return false;
|
|
174
|
+
try {
|
|
175
|
+
return fs.realpathSync(path.resolve(entry)) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
176
|
+
} catch {
|
|
177
|
+
return false;
|
|
178
|
+
}
|
|
179
|
+
})();
|
|
180
|
+
|
|
181
|
+
if (invokedDirectly) {
|
|
182
|
+
process.exit(main());
|
|
183
|
+
}
|