@event4u/agent-config 9.25.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 +87 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +2 -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/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 +9 -3
- package/dist/agent-src/rules/session-canary.md +2 -0
- 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 +3 -2
- package/dist/agent-src/skills/recursive-verification/SKILL.md +13 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +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 +94 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +11 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +9 -6
- package/dist/hooks/dispatch.js +355 -63
- 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/gate-coverage.yml +29 -1
- package/src/config/gate-violation-baselines.json +13 -7
- package/src/scripts/_cli/cmd_route_audit.ts +4 -1
- package/src/scripts/_cli/cmd_route_explain.ts +9 -1
- package/src/scripts/ai-video/stitch.sh +27 -9
- package/src/scripts/before_complete_hook.ts +23 -4
- 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_gate_coverage.ts +67 -0
- package/src/scripts/check_hook_bundle_freshness.ts +183 -0
- package/src/scripts/conformance_scan.ts +47 -17
- package/src/scripts/dispatch_r2_reviewer.ts +31 -2
- package/src/scripts/git_authorization_hook.ts +75 -10
- package/src/scripts/hook_manifest.yaml +19 -0
- package/src/scripts/hooks/block_no_verify.ts +3 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +133 -16
- 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 +40 -6
- package/src/scripts/language_mirror_hook.ts +200 -11
- package/src/scripts/lint_abstraction_thresholds.ts +41 -8
- package/src/scripts/lint_agents_layout.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +29 -0
- 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/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
|
@@ -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
|
+
}
|
|
@@ -139,6 +139,15 @@ export function isInjectedBody(text: string): boolean {
|
|
|
139
139
|
return text.length > 2500 && classify(text).language === "en";
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Harness-generated text that occupies an assistant turn but is not the
|
|
144
|
+
* assistant writing. Round-2 self-scan: 8 of 9 "violations" in one session were
|
|
145
|
+
* `API Error: 529 Overloaded` retry banners. Counting the harness as the model
|
|
146
|
+
* inflates the very number this scan exists to report honestly.
|
|
147
|
+
*/
|
|
148
|
+
const HARNESS_TEXT =
|
|
149
|
+
/^(API Error:|Request (timed out|was aborted)|\[Request interrupted|Error: |Credit balance is too low|You've hit your|Prompt is too long)/i;
|
|
150
|
+
|
|
142
151
|
/** Rough English-opener test for the language check (first prose line only). */
|
|
143
152
|
const EN_OPENER =
|
|
144
153
|
/^(let me|i'?ll |i'?m |found it|ok[,. ]|okay|alright|here'?s|now[,. ]|looking|checking|reading|running|the |this |that |there |we |you |good |right[,. ]|perfect|done[.,]|all |confirmed|correct)/i;
|
|
@@ -152,6 +161,7 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
152
161
|
};
|
|
153
162
|
|
|
154
163
|
let pinned: "de" | "en" | null = null;
|
|
164
|
+
let pendingPoll: { at: string; cmd: string } | null = null;
|
|
155
165
|
let authorized = new Set<GitOp>();
|
|
156
166
|
let sawPending = false;
|
|
157
167
|
let evaluationsThisTurn = 0;
|
|
@@ -167,6 +177,25 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
167
177
|
continue;
|
|
168
178
|
}
|
|
169
179
|
|
|
180
|
+
// Tool results arrive as `user` entries carrying `toolUseResult`.
|
|
181
|
+
if (entry["type"] === "user" && entry["toolUseResult"] !== undefined && pendingPoll !== null) {
|
|
182
|
+
const raw = entry["toolUseResult"];
|
|
183
|
+
const out = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
184
|
+
const pending = pendingCount(out);
|
|
185
|
+
if (pending !== null && pending > 0) {
|
|
186
|
+
sawPending = true;
|
|
187
|
+
} else if (isVacuousOutput(out) || (pending === 0 && !sawPending)) {
|
|
188
|
+
report.violations.push({
|
|
189
|
+
check: "vacuous-evidence",
|
|
190
|
+
session: sessionId,
|
|
191
|
+
at: pendingPoll.at,
|
|
192
|
+
detail: `CI poll read as settled without an in-flight observation: ${out.slice(0, 90)}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
pendingPoll = null;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
|
|
170
199
|
const ut = userText(entry);
|
|
171
200
|
if (ut !== null) {
|
|
172
201
|
if (isInjectedBody(ut)) {
|
|
@@ -181,6 +210,7 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
181
210
|
authorized = new Set(classifyAuthorization(ut).authorized);
|
|
182
211
|
sawPending = false;
|
|
183
212
|
evaluationsThisTurn = 0;
|
|
213
|
+
pendingPoll = null;
|
|
184
214
|
continue;
|
|
185
215
|
}
|
|
186
216
|
|
|
@@ -194,6 +224,9 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
194
224
|
report.assistant_turns += 1;
|
|
195
225
|
if (pinned === "de") {
|
|
196
226
|
const first = prose.split("\n").find((l) => l.trim()) ?? "";
|
|
227
|
+
if (HARNESS_TEXT.test(first.trim())) {
|
|
228
|
+
continue; // harness banner, not the assistant's prose
|
|
229
|
+
}
|
|
197
230
|
const c = classify(first);
|
|
198
231
|
if (first.length > 12 && (c.language === "en" || EN_OPENER.test(first.trim()))) {
|
|
199
232
|
report.violations.push({
|
|
@@ -220,21 +253,14 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
220
253
|
});
|
|
221
254
|
}
|
|
222
255
|
if (isCiPoll(cmd)) {
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
report.violations.push({
|
|
232
|
-
check: "vacuous-evidence",
|
|
233
|
-
session: sessionId,
|
|
234
|
-
at,
|
|
235
|
-
detail: `CI poll read as settled without an in-flight observation: ${out.slice(0, 90)}`,
|
|
236
|
-
});
|
|
237
|
-
}
|
|
256
|
+
// The RESULT of this call arrives on the NEXT entry, and that entry is
|
|
257
|
+
// role `user` — not assistant. The first version read
|
|
258
|
+
// `entry["toolUseResult"]` off the assistant entry, where it never
|
|
259
|
+
// exists: measured on a 20 MB transcript, 1913 entries carry the key
|
|
260
|
+
// and all 1913 are `user`. So this check could not fire at all and the
|
|
261
|
+
// scan printed a permanent `✅ vacuous-evidence 0` — a false green in
|
|
262
|
+
// the one report whose job is to say whether the gates bite.
|
|
263
|
+
pendingPoll = { at, cmd };
|
|
238
264
|
}
|
|
239
265
|
}
|
|
240
266
|
|
|
@@ -269,7 +295,8 @@ export function scanSession(sessionId: string, lines: string[]): SessionReport {
|
|
|
269
295
|
|
|
270
296
|
/** Default Claude Code transcript store for a project directory. */
|
|
271
297
|
export function defaultStore(projectDir: string): string {
|
|
272
|
-
|
|
298
|
+
// Claude Code slugs BOTH separators and dots: /Users/x/.claude → -Users-x--claude.
|
|
299
|
+
const slug = projectDir.replace(/[/.]/g, "-");
|
|
273
300
|
return path.join(process.env["HOME"] ?? "", ".claude", "projects", slug);
|
|
274
301
|
}
|
|
275
302
|
|
|
@@ -362,7 +389,10 @@ export function main(argv?: string[]): number {
|
|
|
362
389
|
if (out) {
|
|
363
390
|
fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`, "utf8");
|
|
364
391
|
}
|
|
365
|
-
process.stdout.write
|
|
392
|
+
// fs.writeSync, not process.stdout.write: `process.exit()` below does not
|
|
393
|
+
// flush an async pipe write, so `--json | jq` reproducibly received a
|
|
394
|
+
// truncated 64 KB document with no error (measured: 85,169 vs 65,536 bytes).
|
|
395
|
+
fs.writeSync(1, json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
|
|
366
396
|
return 0;
|
|
367
397
|
}
|
|
368
398
|
|
|
@@ -548,16 +548,38 @@ function reviewerPrompt(args: {
|
|
|
548
548
|
].join('\n');
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
+
/**
|
|
552
|
+
* The findings skeleton, carrying `prompt_hash`.
|
|
553
|
+
*
|
|
554
|
+
* The contract's most dangerous residual is the PROMPT channel (§ 5): the
|
|
555
|
+
* dispatcher fixes the reviewer's inputs but not the instructions wrapped around
|
|
556
|
+
* them, so "a hash-verified artefact is not evidence of an unbiased review" — its
|
|
557
|
+
* recorded case zero is an honest-null whose prompt stated prior rounds were
|
|
558
|
+
* clean, where a later unsteered round on the identical scope found seven
|
|
559
|
+
* defects.
|
|
560
|
+
*
|
|
561
|
+
* Writing the hash of the prompt THIS dispatcher built makes the channel
|
|
562
|
+
* attributable: the artefact now records which prompt produced it, so a review
|
|
563
|
+
* run against a different prompt is distinguishable from one run against the
|
|
564
|
+
* dispatcher's. `--verify` re-derives it from the same inputs and compares.
|
|
565
|
+
*
|
|
566
|
+
* It does not CLOSE the residual and is not sold as doing so: the same host that
|
|
567
|
+
* authors a steered prompt could hash the steered text. The gain is an
|
|
568
|
+
* attributable record where there was none, which is what turns the audit trail
|
|
569
|
+
* (§ 2.7, round records are never edited in place) into something a contradicting
|
|
570
|
+
* later round can be checked against.
|
|
571
|
+
*/
|
|
551
572
|
function findingsSkeleton(args: {
|
|
552
573
|
slug: string;
|
|
553
574
|
headSha: string;
|
|
554
575
|
scopeHash: string;
|
|
555
576
|
reviewedDate: string;
|
|
556
577
|
manifest: string;
|
|
578
|
+
promptHash: string;
|
|
557
579
|
}): string {
|
|
558
580
|
return [
|
|
559
581
|
`# Findings: ${args.slug}`,
|
|
560
|
-
`<!-- completion-review: v1 | reviewed: ${args.reviewedDate} | scope: ${args.scopeHash} | diff: ${args.headSha} | reviewer: r2-fresh-subagent-${args.slug} -->`,
|
|
582
|
+
`<!-- completion-review: v1 | reviewed: ${args.reviewedDate} | scope: ${args.scopeHash} | diff: ${args.headSha} | reviewer: r2-fresh-subagent-${args.slug} | prompt_hash: ${args.promptHash} -->`,
|
|
561
583
|
'',
|
|
562
584
|
args.manifest,
|
|
563
585
|
'',
|
|
@@ -890,7 +912,14 @@ function runDispatch(args: Args): number {
|
|
|
890
912
|
acHash: hashes.ac_hash,
|
|
891
913
|
dispatched,
|
|
892
914
|
});
|
|
893
|
-
const skeleton = findingsSkeleton({
|
|
915
|
+
const skeleton = findingsSkeleton({
|
|
916
|
+
slug,
|
|
917
|
+
headSha,
|
|
918
|
+
scopeHash: hashes.scope_hash,
|
|
919
|
+
reviewedDate,
|
|
920
|
+
manifest,
|
|
921
|
+
promptHash: sha256(promptText),
|
|
922
|
+
});
|
|
894
923
|
|
|
895
924
|
fs.mkdirSync(inputDirAbs, { recursive: true });
|
|
896
925
|
fs.writeFileSync(path.join(inputDirAbs, 'diff.patch'), scopeDiffText, 'utf-8');
|
|
@@ -83,16 +83,65 @@ export const ALL_OPS: readonly GitOp[] = [
|
|
|
83
83
|
* cost latency on every turn and be unauditable.
|
|
84
84
|
*/
|
|
85
85
|
const PHRASES: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
86
|
-
{ op: "commit", re: /\b(commit(e|et|ten
|
|
87
|
-
{ op: "push", re: /\b(push(e|en|
|
|
88
|
-
{ op: "branch", re: /\b(branch
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
86
|
+
{ op: "commit", re: /\b(commit(e|et|te|ten)?|committe|einchecken)\b/i },
|
|
87
|
+
{ op: "push", re: /\b(push(e|en|t)?|hochladen|hochschieben|raufschieben)\b/i },
|
|
88
|
+
{ op: "branch", re: /\b(branch|feature-branch|zweig)\b/i },
|
|
89
|
+
// A creation verb is required — "schau dir den PR an" is not "open a PR".
|
|
90
|
+
{
|
|
91
|
+
op: "pr-create",
|
|
92
|
+
re: /\b(erstell(e|en)?|mach(e|en)?|leg(e|en)?\s+an|(er)?(ö|oe)ffne|open|create|raise|aufmachen)\b[^.\n]{0,30}\b(pr|pull[- ]request)\b|\b(pr|pull[- ]request)\b[^.\n]{0,20}\b(erstellen|aufmachen|anlegen|(er)?(ö|oe)ffnen)\b/i,
|
|
93
|
+
},
|
|
94
|
+
// `merge` as an ACTION, never as the noun in "merge conflict" / "merge commit".
|
|
95
|
+
{ op: "pr-merge", re: /\b(merge|merg(e|en|st|t)|zusammenf(ü|ue)hren|reinmergen)\b(?!\s*[- ]?(conflict|konflikt|commit|base|queue|state|status))/i },
|
|
96
|
+
// A tag is an ACTION here — bare "Tag" is the German word for day, and
|
|
97
|
+
// "Version" is an ordinary noun. Both authorized a BLOCK op before this.
|
|
98
|
+
{ op: "tag", re: /\b(tagge(n|st)?|tag\s+(setzen|anlegen|erstellen)|git\s+tag|--tags|--follow-tags)\b/i },
|
|
99
|
+
// "die release notes sind falsch" is a noun phrase, not an authorization.
|
|
100
|
+
{
|
|
101
|
+
op: "release",
|
|
102
|
+
re: /\b(releasen?|ver(ö|oe)ffentlich(e|en))\b(?!\s*[- ]?(notes?|branch|candidate|pr\b|datum|date|zweig))/i,
|
|
103
|
+
},
|
|
104
|
+
{ op: "publish", re: /\b(publish(e|en)?|publiziere[n]?)\b/i },
|
|
94
105
|
];
|
|
95
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Lines that are pasted TOOL OUTPUT rather than the user instructing.
|
|
109
|
+
*
|
|
110
|
+
* Round-2 adversarial review: an UNFENCED paste of a `git push … rejected`
|
|
111
|
+
* trace authorized `push` through the prose matcher — i.e. the gate
|
|
112
|
+
* pre-authorized the exact scenario it was built to stop. Fenced pastes were
|
|
113
|
+
* already handled; prose was not.
|
|
114
|
+
*/
|
|
115
|
+
const OUTPUT_LINE =
|
|
116
|
+
/^\s*(To\s+\S+|remote:|error:|fatal:|hint:|warning:|!\s|\?\?\s|\s*\^|[-+]{3}\s|@@\s|\$\s|>\s|\d+\s+(pass|fail)|npm ERR!|Error:|Traceback|at\s+\S+:\d+)/i;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A prompt that ASKS about an operation does not authorize it.
|
|
120
|
+
*
|
|
121
|
+
* `question-not-instruction` states this for the agent; the ledger needs it
|
|
122
|
+
* too. "was macht npm publish eigentlich genau?" authorized a real publish
|
|
123
|
+
* before this check.
|
|
124
|
+
*/
|
|
125
|
+
export function isInterrogative(prose: string): boolean {
|
|
126
|
+
const t = prose.trim();
|
|
127
|
+
if (!t) {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
const hasImperative =
|
|
131
|
+
/\b(mach|mache|bitte|leg|lege|erstell|erstelle|f(ü|ue)hr|f(ü|ue)hre|setz|setze|starte|los|jetzt|go ahead|do it|ja[,.]?\s|ok[,.]?\s)\b/i.test(
|
|
132
|
+
t,
|
|
133
|
+
);
|
|
134
|
+
if (hasImperative) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
return (
|
|
138
|
+
/\?\s*$/.test(t) ||
|
|
139
|
+
/^(was|wie|warum|wieso|weshalb|wann|wer|welche[rs]?|wo|ist|sind|kann|kannst|k(ö|oe)nnen|soll|sollen|darf|d(ü|ue)rfen|why|what|how|when|which|who|is|are|can|could|should|does|do)\b/i.test(
|
|
140
|
+
t,
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
96
145
|
/** Executable commands a user may paste, mapped to the op they authorize. */
|
|
97
146
|
const PASTED_COMMANDS: ReadonlyArray<{ op: GitOp; re: RegExp }> = [
|
|
98
147
|
{ op: "publish", re: /\bnpm\s+publish\b/i },
|
|
@@ -155,8 +204,15 @@ export function classifyAuthorization(prompt: string): {
|
|
|
155
204
|
const authorized = new Set<GitOp>();
|
|
156
205
|
const evidence: Record<string, string> = {};
|
|
157
206
|
|
|
158
|
-
|
|
159
|
-
|
|
207
|
+
// Drop pasted tool output from the prose before matching (C4), then refuse
|
|
208
|
+
// to read a question as an instruction (C3).
|
|
209
|
+
const instruction = prose
|
|
210
|
+
.split("\n")
|
|
211
|
+
.filter((l) => !OUTPUT_LINE.test(l))
|
|
212
|
+
.join("\n");
|
|
213
|
+
|
|
214
|
+
for (const { op, re } of isInterrogative(instruction) ? [] : PHRASES) {
|
|
215
|
+
const m = re.exec(instruction);
|
|
160
216
|
if (m) {
|
|
161
217
|
authorized.add(op);
|
|
162
218
|
evidence[op] = `prose: "${m[0]}"`;
|
|
@@ -164,6 +220,15 @@ export function classifyAuthorization(prompt: string): {
|
|
|
164
220
|
}
|
|
165
221
|
|
|
166
222
|
for (const fence of fences) {
|
|
223
|
+
// A fence that also carries tool OUTPUT is a transcript of something that
|
|
224
|
+
// already happened, not a command the user is handing over. Pasting
|
|
225
|
+
// $ git push origin main
|
|
226
|
+
// ! [rejected] …
|
|
227
|
+
// is showing a failure, and reading it as "authorized push" is exactly the
|
|
228
|
+
// implicit-continuation misread this gate exists to stop.
|
|
229
|
+
if (fence.split("\n").some((l) => OUTPUT_LINE.test(l))) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
167
232
|
for (const line of _commandLines(fence)) {
|
|
168
233
|
for (const { op, re } of PASTED_COMMANDS) {
|
|
169
234
|
if (re.test(line)) {
|
|
@@ -163,6 +163,25 @@ concerns:
|
|
|
163
163
|
args: []
|
|
164
164
|
fail_closed: false
|
|
165
165
|
severity: advisory
|
|
166
|
+
# Per-concern tool filter — the dispatcher skips this concern in-process on
|
|
167
|
+
# any other tool. See `_concern_matches_tool` in hooks/dispatch_hook.ts for
|
|
168
|
+
# the semantics (absent = every event, "*" = the same, non-tool events are
|
|
169
|
+
# never filtered) and for why the filter is NOT a host `matcher`.
|
|
170
|
+
#
|
|
171
|
+
# This set is not a guess: the hook's entire branch surface is
|
|
172
|
+
# `name === 'Grep' || name === 'Glob'` and `name === 'Read'`
|
|
173
|
+
# (code_graph_nudge_hook.ts), and a test pins the manifest list against it.
|
|
174
|
+
#
|
|
175
|
+
# Deliberately NOT declared on the three blocking PreToolUse guards
|
|
176
|
+
# (block-no-verify, block-kernel-rule-writes, block-config-weakening): their
|
|
177
|
+
# tool sets span host-specific naming variants (`Bash` / `BashTool` /
|
|
178
|
+
# `launch-process` / `launch_process`, `str-replace-editor` /
|
|
179
|
+
# `str_replace_editor` / `save-file` / …), so a list that misses one variant
|
|
180
|
+
# silently disables a security guard on that host. The concerns already
|
|
181
|
+
# re-read `tool_name` and return early; paying one dispatch to keep a guard
|
|
182
|
+
# correct is the right trade, and the cheap filter is reserved for advisory
|
|
183
|
+
# concerns whose set is provable from their own source.
|
|
184
|
+
tools: [Grep, Glob, Read]
|
|
166
185
|
# road-to-reachable-code-memory Phase 4 — session_end learning-sidecar
|
|
167
186
|
# aggregation. Joins the EXISTING session_end concern set (no new platform
|
|
168
187
|
# wiring). Aggregates agents/memory/intake/*.jsonl through the learning
|
|
@@ -339,7 +339,9 @@ function _check_command(cmd: string): [boolean, string] {
|
|
|
339
339
|
if (/\bgit\b/.test(cmd)) {
|
|
340
340
|
return [
|
|
341
341
|
true,
|
|
342
|
-
'command parse failed (shlex) on a git-containing command — fail-closed (git-history-discipline)'
|
|
342
|
+
'command parse failed (shlex) on a git-containing command — fail-closed (git-history-discipline). \n'
|
|
343
|
+
+ 'Most common cause: an apostrophe or backtick inside a heredoc commit message in the SAME compound command as `git`. \n'
|
|
344
|
+
+ 'Fix: write the message to a file and use `git commit -F <file>` — do not retry the heredoc.',
|
|
343
345
|
];
|
|
344
346
|
}
|
|
345
347
|
return [false, ''];
|