@event4u/agent-config 9.23.0 → 9.25.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.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +82 -0
  3. package/README.md +2 -2
  4. package/dist/agent-src/commands/agent-handoff.md +10 -3
  5. package/dist/agent-src/commands/analyze/conformance.md +182 -0
  6. package/dist/agent-src/commands/analyze/inbox.md +221 -0
  7. package/dist/agent-src/commands/analyze.md +4 -3
  8. package/dist/agent-src/commands/pr/create.md +6 -6
  9. package/dist/agent-src/rules/context-hygiene.md +3 -0
  10. package/dist/agent-src/rules/decision-revisit-gate.md +2 -0
  11. package/dist/agent-src/rules/evaluator-independence.md +105 -0
  12. package/dist/agent-src/rules/session-canary.md +13 -1
  13. package/dist/agent-src/rules/user-interaction.md +22 -0
  14. package/dist/agent-src/skills/finishing-a-development-branch/SKILL.md +1 -1
  15. package/dist/agent-src/skills/rule-writing/SKILL.md +45 -0
  16. package/dist/agent-src/skills/skill-writing/SKILL.md +114 -0
  17. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  18. package/dist/cli/registry.js +1 -0
  19. package/dist/cli/registry.js.map +1 -1
  20. package/dist/discovery/deprecation-report.md +1 -1
  21. package/dist/discovery/discovery-manifest.json +96 -22
  22. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  23. package/dist/discovery/discovery-manifest.summary.md +4 -4
  24. package/dist/discovery/orphan-report.md +1 -1
  25. package/dist/discovery/packs.json +11 -8
  26. package/dist/discovery/trust-report.md +3 -3
  27. package/dist/discovery/workspaces.json +9 -6
  28. package/dist/hooks/dispatch.js +1026 -328
  29. package/dist/mcp/registry-manifest.json +2 -2
  30. package/dist/mcp/server.json +2 -2
  31. package/dist/router.json +1 -1
  32. package/package.json +1 -1
  33. package/src/config/evaluator-budgets.json +68 -68
  34. package/src/config/gate-coverage.yml +103 -5
  35. package/src/config/gate-violation-baselines.json +11 -6
  36. package/src/scripts/_dispatch.bash +12 -0
  37. package/src/scripts/before_complete_hook.ts +127 -5
  38. package/src/scripts/check_depth_budget.ts +295 -0
  39. package/src/scripts/conformance_scan.ts +392 -0
  40. package/src/scripts/git_authorization_hook.ts +263 -0
  41. package/src/scripts/hook_manifest.yaml +50 -9
  42. package/src/scripts/hooks/block_unauthorized_git.ts +254 -0
  43. package/src/scripts/hooks/concern_registry.ts +8 -0
  44. package/src/scripts/hooks/evidence_independence.ts +334 -0
  45. package/src/scripts/language_mirror_hook.ts +267 -0
  46. package/src/scripts/lint_decision_impact.ts +246 -0
  47. package/src/scripts/lint_example_fences.ts +609 -0
  48. package/src/scripts/lint_rule_interactions.ts +90 -0
  49. package/src/scripts/lint_rule_migration_ledger.ts +480 -0
  50. package/src/scripts/lint_skill_descriptions.ts +86 -2
  51. package/src/scripts/measure_rule_absoluta.ts +231 -0
  52. package/src/scripts/schemas/rule.schema.json +10 -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
+ }
@@ -0,0 +1,392 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Behavioural conformance scan — replays the local transcript store through the
4
+ * checks this suite MECHANISES, and nothing else.
5
+ *
6
+ * The scope constraint is the point, and it is a council ruling
7
+ * (2026-08-06): *"a conformance scan that checks un-mechanised rules is
8
+ * theatre — if you can't gate it, don't pretend measuring it post-hoc is
9
+ * enforcement."* So this scan carries exactly four checks, one per shipped
10
+ * gate, and every classifier is IMPORTED from the gate it measures rather than
11
+ * re-implemented here. A second copy of a classifier is the "second artefact to
12
+ * keep in sync" the repo's own principle forbids, and it would let the scan and
13
+ * the gate disagree silently.
14
+ *
15
+ * language-pin ← language_mirror_hook.classify
16
+ * git-authorization ← git_authorization_hook.classifyAuthorization
17
+ * + block_unauthorized_git.commandOp / BLOCK_OPS
18
+ * vacuous-evidence ← before_complete_hook.isVacuousOutput / isCiPoll / pendingCount
19
+ * evidence-steering ← evidence_independence.isEvaluationPrompt / preloadedVerdict
20
+ *
21
+ * Deliberately NOT scanned: ask-shape, session-canary, promissory closings,
22
+ * checkbox batching, symptom-vs-root-cause. Those are left as prose by
23
+ * `road-to-agent-behavior-conformance`, and measuring them here would claim a
24
+ * rigour the tree does not have.
25
+ *
26
+ * This is a REPORT, not a gate: exit 0 on every path except an unreadable
27
+ * store. It answers "did the shipped gates have anything to bite on?" —
28
+ * which is also the only honest way to find out whether they help.
29
+ */
30
+ import fs from "node:fs";
31
+ import path from "node:path";
32
+ import { fileURLToPath, pathToFileURL } from "node:url";
33
+
34
+ import { classify } from "./language_mirror_hook.js";
35
+ import { classifyAuthorization, type GitOp } from "./git_authorization_hook.js";
36
+ import { BLOCK_OPS, commandOp } from "./hooks/block_unauthorized_git.js";
37
+ import { isVacuousOutput, isCiPoll, pendingCount } from "./before_complete_hook.js";
38
+ import { isEvaluationPrompt, isSelfScoped, preloadedVerdict } from "./hooks/evidence_independence.js";
39
+
40
+ export interface Violation {
41
+ check: "language-pin" | "git-authorization" | "vacuous-evidence" | "evidence-steering";
42
+ session: string;
43
+ at: string;
44
+ detail: string;
45
+ }
46
+
47
+ export interface SessionReport {
48
+ session: string;
49
+ user_turns: number;
50
+ assistant_turns: number;
51
+ violations: Violation[];
52
+ }
53
+
54
+ export interface ScanReport {
55
+ scanned_at: string;
56
+ store: string;
57
+ sessions: number;
58
+ totals: Record<string, number>;
59
+ per_session: SessionReport[];
60
+ }
61
+
62
+ type Entry = Record<string, unknown>;
63
+
64
+ function _isObject(v: unknown): v is Record<string, unknown> {
65
+ return typeof v === "object" && v !== null && !Array.isArray(v);
66
+ }
67
+
68
+ /** Text of a user-role entry, or null when it is not a real chat message. */
69
+ export function userText(entry: Entry): string | null {
70
+ if (entry["type"] !== "user" || entry["isSidechain"] === true) {
71
+ return null;
72
+ }
73
+ const msg = entry["message"];
74
+ const content = _isObject(msg) ? msg["content"] : undefined;
75
+ let text = "";
76
+ if (typeof content === "string") {
77
+ text = content;
78
+ } else if (Array.isArray(content)) {
79
+ text = content
80
+ .filter((b) => _isObject(b) && b["type"] === "text")
81
+ .map((b) => String((b as Record<string, unknown>)["text"] ?? ""))
82
+ .join("\n");
83
+ }
84
+ if (!text.trim()) {
85
+ return null;
86
+ }
87
+ if (text.includes("<system-reminder>") || text.startsWith("<local-command-stdout>")) {
88
+ return null;
89
+ }
90
+ return text;
91
+ }
92
+
93
+ /** Assistant prose, or null. */
94
+ export function assistantText(entry: Entry): string | null {
95
+ if (entry["type"] !== "assistant" || entry["isSidechain"] === true) {
96
+ return null;
97
+ }
98
+ const msg = entry["message"];
99
+ const content = _isObject(msg) ? msg["content"] : undefined;
100
+ if (!Array.isArray(content)) {
101
+ return null;
102
+ }
103
+ const text = content
104
+ .filter((b) => _isObject(b) && b["type"] === "text")
105
+ .map((b) => String((b as Record<string, unknown>)["text"] ?? ""))
106
+ .join("\n")
107
+ .trim();
108
+ return text || null;
109
+ }
110
+
111
+ /** Tool-use blocks from an assistant entry. */
112
+ function toolUses(entry: Entry): Array<{ name: string; input: Record<string, unknown> }> {
113
+ const msg = entry["message"];
114
+ const content = _isObject(msg) ? msg["content"] : undefined;
115
+ if (!Array.isArray(content)) {
116
+ return [];
117
+ }
118
+ return content
119
+ .filter((b) => _isObject(b) && b["type"] === "tool_use")
120
+ .map((b) => {
121
+ const o = b as Record<string, unknown>;
122
+ return {
123
+ name: String(o["name"] ?? ""),
124
+ input: _isObject(o["input"]) ? (o["input"] as Record<string, unknown>) : {},
125
+ };
126
+ });
127
+ }
128
+
129
+ /**
130
+ * A skill / slash-command body arriving in the user role. This is the
131
+ * transcript shape that made the language defect invisible: it occupies the
132
+ * user role but is not a chat message, so it must never become the language
133
+ * trigger — the same reason `language_mirror_hook` pins at prompt-submit time.
134
+ */
135
+ export function isInjectedBody(text: string): boolean {
136
+ if (/^(Base directory for this skill|<command-message>|<command-name>|<local-command)/.test(text)) {
137
+ return true;
138
+ }
139
+ return text.length > 2500 && classify(text).language === "en";
140
+ }
141
+
142
+ /** Rough English-opener test for the language check (first prose line only). */
143
+ const EN_OPENER =
144
+ /^(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;
145
+
146
+ export function scanSession(sessionId: string, lines: string[]): SessionReport {
147
+ const report: SessionReport = {
148
+ session: sessionId,
149
+ user_turns: 0,
150
+ assistant_turns: 0,
151
+ violations: [],
152
+ };
153
+
154
+ let pinned: "de" | "en" | null = null;
155
+ let authorized = new Set<GitOp>();
156
+ let sawPending = false;
157
+ let evaluationsThisTurn = 0;
158
+
159
+ for (const line of lines) {
160
+ if (!line.trim()) {
161
+ continue;
162
+ }
163
+ let entry: Entry;
164
+ try {
165
+ entry = JSON.parse(line) as Entry;
166
+ } catch {
167
+ continue;
168
+ }
169
+
170
+ const ut = userText(entry);
171
+ if (ut !== null) {
172
+ if (isInjectedBody(ut)) {
173
+ // Not a chat message — it changes neither the pin nor the ledger.
174
+ continue;
175
+ }
176
+ report.user_turns += 1;
177
+ const c = classify(ut);
178
+ if (c.language !== "und") {
179
+ pinned = c.language;
180
+ }
181
+ authorized = new Set(classifyAuthorization(ut).authorized);
182
+ sawPending = false;
183
+ evaluationsThisTurn = 0;
184
+ continue;
185
+ }
186
+
187
+ if (entry["type"] !== "assistant" || entry["isSidechain"] === true) {
188
+ continue;
189
+ }
190
+ const at = String(entry["timestamp"] ?? "");
191
+
192
+ const prose = assistantText(entry);
193
+ if (prose !== null) {
194
+ report.assistant_turns += 1;
195
+ if (pinned === "de") {
196
+ const first = prose.split("\n").find((l) => l.trim()) ?? "";
197
+ const c = classify(first);
198
+ if (first.length > 12 && (c.language === "en" || EN_OPENER.test(first.trim()))) {
199
+ report.violations.push({
200
+ check: "language-pin",
201
+ session: sessionId,
202
+ at,
203
+ detail: `German pin, English reply opener: "${first.slice(0, 110)}"`,
204
+ });
205
+ }
206
+ }
207
+ }
208
+
209
+ for (const tu of toolUses(entry)) {
210
+ const cmd =
211
+ typeof tu.input["command"] === "string" ? (tu.input["command"] as string) : null;
212
+ if (cmd !== null) {
213
+ const op = commandOp(cmd);
214
+ if (op !== null && !authorized.has(op) && BLOCK_OPS.has(op)) {
215
+ report.violations.push({
216
+ check: "git-authorization",
217
+ session: sessionId,
218
+ at,
219
+ detail: `irreversible \`${op}\` with no authorization in the turn's prompt: ${cmd.slice(0, 110)}`,
220
+ });
221
+ }
222
+ if (isCiPoll(cmd)) {
223
+ // A settle claim needs a prior in-flight observation in the same turn.
224
+ // The transcript carries the poll but not always its output, so this
225
+ // check only fires on the shape it can actually read.
226
+ const out = typeof entry["toolUseResult"] === "string" ? entry["toolUseResult"] : "";
227
+ const pending = out ? pendingCount(out) : null;
228
+ if (pending !== null && pending > 0) {
229
+ sawPending = true;
230
+ } else if (out && (isVacuousOutput(out) || (pending === 0 && !sawPending))) {
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
+ }
238
+ }
239
+ }
240
+
241
+ const prompt =
242
+ typeof tu.input["prompt"] === "string" ? (tu.input["prompt"] as string) : null;
243
+ if (prompt !== null && isEvaluationPrompt(prompt)) {
244
+ const preloaded = preloadedVerdict(prompt);
245
+ if (preloaded !== null) {
246
+ report.violations.push({
247
+ check: "evidence-steering",
248
+ session: sessionId,
249
+ at,
250
+ detail: `evaluation prompt pre-loads its verdict: "${preloaded}"`,
251
+ });
252
+ } else if (isSelfScoped(prompt)) {
253
+ if (evaluationsThisTurn >= 1) {
254
+ report.violations.push({
255
+ check: "evidence-steering",
256
+ session: sessionId,
257
+ at,
258
+ detail: "second self-review dispatch in one turn (verdict shopping)",
259
+ });
260
+ }
261
+ evaluationsThisTurn += 1;
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ return report;
268
+ }
269
+
270
+ /** Default Claude Code transcript store for a project directory. */
271
+ export function defaultStore(projectDir: string): string {
272
+ const slug = projectDir.replace(/\//g, "-");
273
+ return path.join(process.env["HOME"] ?? "", ".claude", "projects", slug);
274
+ }
275
+
276
+ export function scanStore(store: string, limit: number): ScanReport {
277
+ const files = fs
278
+ .readdirSync(store)
279
+ .filter((f) => f.endsWith(".jsonl"))
280
+ .map((f) => ({ f, m: fs.statSync(path.join(store, f)).mtimeMs }))
281
+ .sort((a, b) => b.m - a.m)
282
+ .slice(0, limit)
283
+ .map((x) => x.f);
284
+
285
+ const per_session: SessionReport[] = [];
286
+ for (const f of files) {
287
+ const lines = fs.readFileSync(path.join(store, f), "utf8").split("\n");
288
+ const r = scanSession(f.replace(/\.jsonl$/, "").slice(0, 8), lines);
289
+ if (r.assistant_turns > 0) {
290
+ per_session.push(r);
291
+ }
292
+ }
293
+
294
+ const totals: Record<string, number> = {
295
+ "language-pin": 0,
296
+ "git-authorization": 0,
297
+ "vacuous-evidence": 0,
298
+ "evidence-steering": 0,
299
+ };
300
+ for (const s of per_session) {
301
+ for (const v of s.violations) {
302
+ totals[v.check] = (totals[v.check] ?? 0) + 1;
303
+ }
304
+ }
305
+
306
+ return {
307
+ scanned_at: new Date().toISOString(),
308
+ store,
309
+ sessions: per_session.length,
310
+ totals,
311
+ per_session,
312
+ };
313
+ }
314
+
315
+ export function render(report: ScanReport): string {
316
+ const lines: string[] = [];
317
+ lines.push(`conformance:behavior · ${report.sessions} session(s) · ${report.store}`);
318
+ for (const [k, v] of Object.entries(report.totals)) {
319
+ lines.push(` ${v === 0 ? "✅" : "⚠️ "} ${k.padEnd(18)} ${v}`);
320
+ }
321
+ const worst = [...report.per_session]
322
+ .filter((s) => s.violations.length > 0)
323
+ .sort((a, b) => b.violations.length - a.violations.length)
324
+ .slice(0, 5);
325
+ if (worst.length > 0) {
326
+ lines.push("");
327
+ lines.push(" Highest-violation sessions:");
328
+ for (const s of worst) {
329
+ lines.push(` ${s.session} ${s.violations.length} across ${s.assistant_turns} turns`);
330
+ }
331
+ }
332
+ lines.push("");
333
+ lines.push(" Scanned checks are exactly the mechanised ones. Ask-shape, session-canary,");
334
+ lines.push(" promissory closings and checkbox batching are left as prose and NOT measured.");
335
+ return lines.join("\n");
336
+ }
337
+
338
+ export function main(argv?: string[]): number {
339
+ const args = argv ?? process.argv.slice(2);
340
+ let store: string | null = null;
341
+ let limit = 30;
342
+ let json = false;
343
+ let out: string | null = null;
344
+ for (let i = 0; i < args.length; i += 1) {
345
+ const a = args[i];
346
+ if (a === "--store" && args[i + 1] !== undefined) {
347
+ store = args[++i] as string;
348
+ } else if (a === "--limit" && args[i + 1] !== undefined) {
349
+ limit = Number(args[++i]);
350
+ } else if (a === "--json") {
351
+ json = true;
352
+ } else if (a === "--output" && args[i + 1] !== undefined) {
353
+ out = args[++i] as string;
354
+ }
355
+ }
356
+ const resolved = store ?? defaultStore(process.cwd());
357
+ if (!fs.existsSync(resolved)) {
358
+ process.stderr.write(`conformance:behavior: no transcript store at ${resolved}\n`);
359
+ return 1;
360
+ }
361
+ const report = scanStore(resolved, Number.isFinite(limit) && limit > 0 ? limit : 30);
362
+ if (out) {
363
+ fs.writeFileSync(out, `${JSON.stringify(report, null, 2)}\n`, "utf8");
364
+ }
365
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${render(report)}\n`);
366
+ return 0;
367
+ }
368
+
369
+ declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
370
+ function _isCliEntry(): boolean {
371
+ if (typeof __AGENT_CONFIG_BUNDLE__ !== "undefined" && __AGENT_CONFIG_BUNDLE__) {
372
+ return false;
373
+ }
374
+ if (process.argv[1] === undefined) {
375
+ return false;
376
+ }
377
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
378
+ if (import.meta.url === argvUrl) {
379
+ return true;
380
+ }
381
+ try {
382
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
383
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
384
+ return here === argv;
385
+ } catch {
386
+ return false;
387
+ }
388
+ }
389
+
390
+ if (_isCliEntry()) {
391
+ process.exit(main());
392
+ }