@event4u/agent-config 13.0.0 → 14.1.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 (123) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +195 -315
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/evals/roadmap.json +8 -0
  5. package/dist/agent-src/commands/fix/ci.md +39 -1
  6. package/dist/agent-src/commands/pr/create.md +65 -4
  7. package/dist/agent-src/commands/roadmap/next.md +28 -5
  8. package/dist/agent-src/commands/work.md +16 -0
  9. package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
  10. package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
  11. package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
  12. package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
  13. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
  14. package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
  15. package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
  16. package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
  17. package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
  18. package/dist/agent-src/rules/ui-audit-gate.md +25 -8
  19. package/dist/agent-src/scripts/gate_execute.ts +395 -0
  20. package/dist/agent-src/scripts/resume_probe.ts +422 -0
  21. package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
  22. package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
  23. package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
  24. package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
  25. package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
  26. package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
  27. package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
  28. package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
  29. package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
  30. package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
  31. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  32. package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
  33. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  34. package/dist/agent-src/templates/roadmaps.md +43 -3
  35. package/dist/cli/registry.js +1 -0
  36. package/dist/cli/registry.js.map +1 -1
  37. package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
  38. package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
  39. package/dist/cli-delegate/cmd_conformance.js +336 -112
  40. package/dist/cli-delegate/cmd_doctor.js +2 -2
  41. package/dist/cli-delegate/cmd_handoff.js +1 -1
  42. package/dist/discovery/deprecation-report.md +1 -1
  43. package/dist/discovery/discovery-manifest.json +40 -18
  44. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  45. package/dist/discovery/discovery-manifest.summary.md +3 -3
  46. package/dist/discovery/dormancy-report.md +1 -1
  47. package/dist/discovery/orphan-report.md +1 -1
  48. package/dist/discovery/packs.json +6 -5
  49. package/dist/discovery/trust-report.md +2 -2
  50. package/dist/discovery/workspaces.json +5 -4
  51. package/dist/hooks/dispatch.js +2128 -1326
  52. package/dist/mcp/registry-manifest.json +3 -3
  53. package/dist/mcp/server.json +2 -2
  54. package/dist/mcp/server.mjs +1764 -899
  55. package/dist/router.json +1 -1
  56. package/dist/scripts/condense.js +1 -1
  57. package/dist/scripts/condense.js.map +1 -1
  58. package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
  59. package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
  60. package/dist/ui/index.html +1 -1
  61. package/package.json +1 -1
  62. package/src/agent-src/scripts/gate_execute.ts +395 -0
  63. package/src/agent-src/scripts/resume_probe.ts +422 -0
  64. package/src/agent-src/scripts/roadmap_gates.ts +138 -4
  65. package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
  66. package/src/config/ci-local-parity.yml +13 -0
  67. package/src/config/evaluator-budgets.json +4 -4
  68. package/src/config/gate-coverage.yml +111 -5
  69. package/src/config/gate-violation-baselines.json +5 -5
  70. package/src/config/rule-activation-census.json +41 -0
  71. package/src/config/rule-stub-ceilings.json +52 -0
  72. package/src/scripts/_dispatch.bash +4 -0
  73. package/src/scripts/_lib/evaluator_contract.ts +159 -0
  74. package/src/scripts/_lib/gate_ledger.ts +5 -1
  75. package/src/scripts/_lib/gh_transient.ts +80 -0
  76. package/src/scripts/_lib/release_material.ts +16 -5
  77. package/src/scripts/_lib/review_independence.ts +111 -0
  78. package/src/scripts/_lib/session_register.ts +14 -0
  79. package/src/scripts/_lib/skill_catalogue.ts +20 -0
  80. package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
  81. package/src/scripts/_lib/turn_end_refusals.ts +482 -0
  82. package/src/scripts/ai_council/_py_json.ts +96 -0
  83. package/src/scripts/ai_council/cli_call_budget.ts +357 -0
  84. package/src/scripts/ai_council/clients.ts +80 -143
  85. package/src/scripts/ai_council/config.ts +25 -11
  86. package/src/scripts/ai_council/probe_store.ts +196 -0
  87. package/src/scripts/ai_council/qualification.ts +425 -0
  88. package/src/scripts/ai_council/qualification_wiring.ts +243 -0
  89. package/src/scripts/ai_council/quorum_wiring.ts +211 -0
  90. package/src/scripts/ai_team/team_dispatch.ts +40 -6
  91. package/src/scripts/build_archive_index.ts +62 -3
  92. package/src/scripts/capture_skill_catalogue.ts +264 -4
  93. package/src/scripts/check_evaluator_schema.ts +249 -0
  94. package/src/scripts/check_gate_coverage.ts +93 -0
  95. package/src/scripts/check_pr_ci_current.ts +492 -0
  96. package/src/scripts/check_review_schema.ts +195 -0
  97. package/src/scripts/check_rule_activation_census.ts +451 -0
  98. package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
  99. package/src/scripts/condense.ts +1 -1
  100. package/src/scripts/council_cli.ts +141 -191
  101. package/src/scripts/dispatch_r2_reviewer.ts +8 -1
  102. package/src/scripts/git_authorization_hook.ts +186 -1
  103. package/src/scripts/hook_manifest.yaml +72 -5
  104. package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
  105. package/src/scripts/hooks/concern_registry.ts +4 -0
  106. package/src/scripts/hooks/dispatch_hook.ts +43 -1
  107. package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
  108. package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
  109. package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
  110. package/src/scripts/hooks_doctor.ts +112 -0
  111. package/src/scripts/interruption_report.ts +443 -0
  112. package/src/scripts/lint_evidence_artifacts.ts +359 -0
  113. package/src/scripts/lint_roadmap_blockers.ts +65 -0
  114. package/src/scripts/lint_roadmap_complexity.ts +7 -1
  115. package/src/scripts/release.ts +38 -53
  116. package/src/scripts/rule_activation_census.ts +292 -0
  117. package/src/scripts/schemas/evaluator-output.schema.json +60 -0
  118. package/src/scripts/schemas/review-findings.schema.json +31 -0
  119. package/src/scripts/self_review_gate.ts +6 -0
  120. package/src/scripts/session_register_hook.ts +30 -1
  121. package/src/scripts/sync_pr_branch.ts +271 -0
  122. package/src/scripts/test_council_qualification.ts +213 -0
  123. package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
@@ -0,0 +1,256 @@
1
+ /**
2
+ * interruption_ledger — road-to-user-out-of-the-loop Phase 0 Step 1.
3
+ *
4
+ * CAPTURE ONLY. Every `stop`, classifies whether the turn that just ended put a
5
+ * synchronous contact to the user, and appends one line to
6
+ * `agents/runtime/state/interruptions.jsonl`. It never emits context, never
7
+ * warns, and always returns 0 — the whole point of Phase 0 is a window that no
8
+ * mechanism reacted to, so a ledger that could change a run would corrupt the
9
+ * baseline it exists to measure.
10
+ *
11
+ * WHY THIS EXISTS. The roadmap's Goal names two axes — synchronous contacts per
12
+ * delivered roadmap, and wall-clock to open PR — and pre-registers both as
13
+ * claims. Neither is derivable today: nothing in the tree records that a turn
14
+ * ended by asking the user something. `check_enforcement_coverage` counts
15
+ * carriers, `gate-metrics.jsonl` counts gate verdicts, and the conformance suite
16
+ * scores reply SHAPE without ever asking whether the reply stopped the run.
17
+ *
18
+ * REUSE, NOT REIMPLEMENTATION. Reply-shape detection is `turn_end_gate_hook`'s,
19
+ * imported rather than copied: `readTranscriptTail` for the last assistant text,
20
+ * `finalParagraph` for the closing paragraph, and its `HANDBACK` list for the
21
+ * yields-without-asking shape. That file is the measured corpus's detector and
22
+ * has an R2 history behind each regex; a second copy here would drift from it
23
+ * silently and the drift would land in the baseline, not in a test.
24
+ *
25
+ * WHAT COUNTS AS A CONTACT, and why it is three classes rather than two. A
26
+ * question mark is the obvious one. It is not the only one: `scope-control`
27
+ * requires a hand-back — "das entscheidest Du", "your call" — which ends the
28
+ * turn and waits for the user just as hard as a question does, with no `?`
29
+ * anywhere. Counting only questions would report the package's own preferred
30
+ * hand-back shape as zero contacts, which is precisely the metric-that-flatters-
31
+ * the-design failure the roadmap's Risk 6 names.
32
+ *
33
+ * ask — the closing paragraph ends in a question mark.
34
+ * handback — no question, but the closing paragraph yields the decision.
35
+ * none — the turn ended without putting anything to the user.
36
+ *
37
+ * `class` refines an `ask` by the shape the user has to answer in:
38
+ * `numbered-options` when the reply carries a numbered block (the
39
+ * `user-interaction` shape — one decision point, cheap to answer), or
40
+ * `open-question` when it does not (free text, the expensive shape). The split
41
+ * exists because the two are not equal contacts and the report separates them.
42
+ *
43
+ * DEDUPE is on the turn ordinal, the same identity `turn_end_gate_hook` derives
44
+ * and for the same reason: a host may fire `stop` more than once for one turn,
45
+ * and a per-turn ledger that double-counts inflates exactly the number the
46
+ * baseline is pre-registered on.
47
+ *
48
+ * The roadmap slug comes from this session's own claim file — the one
49
+ * `agent-config sessions:claim` writes — so a contact is attributable to a
50
+ * delivered roadmap without this hook knowing anything about roadmaps. No claim
51
+ * means `roadmap: null`, which is a real and common state (any session that is
52
+ * not a roadmap run) and never a reason to skip the line.
53
+ */
54
+
55
+ import * as fs from 'node:fs';
56
+ import * as path from 'node:path';
57
+ import { fileURLToPath, pathToFileURL } from 'node:url';
58
+
59
+ import { derive_session_tag } from '../chat_history.js';
60
+ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
61
+ import { readHookStdin } from './hook_stdin.js';
62
+ import { HANDBACK, finalParagraph, readTranscriptTail } from './turn_end_gate_hook.js';
63
+
64
+ const EXIT_OK = 0;
65
+
66
+ /** Same cap the turn-end gate reads its tail under. */
67
+ const TRANSCRIPT_READ_MAX_BYTES = 2 * 1024 * 1024;
68
+
69
+ export const LEDGER_RELPATH = path.join('agents', 'runtime', 'state', 'interruptions.jsonl');
70
+
71
+ export type ContactKind = 'ask' | 'handback' | 'none';
72
+ export type ContactClass = 'numbered-options' | 'open-question' | 'handback' | 'none';
73
+
74
+ export interface InterruptionRecord {
75
+ run_id: string;
76
+ turn: number;
77
+ kind: ContactKind;
78
+ class: ContactClass;
79
+ roadmap: string | null;
80
+ at: string;
81
+ }
82
+
83
+ /**
84
+ * A numbered-options block: two or more numbered lines. One numbered line is an
85
+ * ordinary enumeration inside prose and is not a decision surface — requiring
86
+ * two is what keeps "1. read the file" from reading as an ask.
87
+ */
88
+ export function hasNumberedOptions(reply: string): boolean {
89
+ let count = 0;
90
+ for (const line of reply.split('\n')) {
91
+ if (/^[ \t]{0,3}\d+[.)]\s+\S/.test(line)) count += 1;
92
+ if (count >= 2) return true;
93
+ }
94
+ return false;
95
+ }
96
+
97
+ /**
98
+ * Classify the reply. The question test is `turn_end_gate_hook`'s exactly — a
99
+ * question that ENDS the paragraph, never `includes('?')`, because a rhetorical
100
+ * or quoted question mid-reply is not a contact and counting it would inflate
101
+ * the baseline in the direction that makes the roadmap look successful later.
102
+ */
103
+ export function classifyReply(reply: string): { kind: ContactKind; class: ContactClass } {
104
+ const tail = finalParagraph(reply);
105
+ if (!tail) return { kind: 'none', class: 'none' };
106
+
107
+ if (/\?["'’)\]]*\s*$/.test(tail)) {
108
+ return {
109
+ kind: 'ask',
110
+ class: hasNumberedOptions(reply) ? 'numbered-options' : 'open-question',
111
+ };
112
+ }
113
+ if (HANDBACK.some((re) => re.test(tail))) return { kind: 'handback', class: 'handback' };
114
+ return { kind: 'none', class: 'none' };
115
+ }
116
+
117
+ /** The roadmap this session claimed, or null. Never throws. */
118
+ export function readClaimedRoadmap(workspaceRoot: string, sessionId: string): string | null {
119
+ if (!sessionId) return null;
120
+ try {
121
+ const file = path.join(
122
+ workspaceRoot,
123
+ 'agents',
124
+ 'runtime',
125
+ 'state',
126
+ `roadmap-claim-${sessionId}.json`,
127
+ );
128
+ const parsed: unknown = JSON.parse(fs.readFileSync(file, 'utf8'));
129
+ if (parsed === null || typeof parsed !== 'object') return null;
130
+ const slug = (parsed as Record<string, unknown>)['slug'];
131
+ return typeof slug === 'string' && slug.length > 0 ? slug : null;
132
+ } catch {
133
+ return null;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * True when this run has already recorded this turn. Reads only the tail of the
139
+ * ledger: the duplicate a repeated `stop` produces is always the most recent
140
+ * line, so scanning the whole file would cost more on every turn to catch
141
+ * nothing extra.
142
+ */
143
+ export function alreadyRecorded(ledgerPath: string, runId: string, turn: number): boolean {
144
+ let raw: string;
145
+ try {
146
+ raw = fs.readFileSync(ledgerPath, 'utf8');
147
+ } catch {
148
+ return false;
149
+ }
150
+ const lines = raw.split('\n').filter((l) => l.trim().length > 0);
151
+ for (const line of lines.slice(-20)) {
152
+ try {
153
+ const rec: unknown = JSON.parse(line);
154
+ if (rec === null || typeof rec !== 'object') continue;
155
+ const r = rec as Record<string, unknown>;
156
+ if (r['run_id'] === runId && r['turn'] === turn) return true;
157
+ } catch {
158
+ // A corrupt line is not a reason to skip the write.
159
+ }
160
+ }
161
+ return false;
162
+ }
163
+
164
+ function str(v: JsonValue | undefined): string {
165
+ return typeof v === 'string' ? v : '';
166
+ }
167
+
168
+ export function main(): number {
169
+ let envelope: JsonObject;
170
+ let payload: JsonObject;
171
+ try {
172
+ [envelope, payload] = unwrap(readHookStdin(), 'claude');
173
+ } catch {
174
+ return EXIT_OK;
175
+ }
176
+
177
+ const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
178
+ const transcriptPath = str(
179
+ (payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
180
+ );
181
+ if (!transcriptPath) return EXIT_OK;
182
+
183
+ let lastAssistant = '';
184
+ let turnOrdinal = 0;
185
+ try {
186
+ const tail = readTranscriptTail(transcriptPath, { maxBytes: TRANSCRIPT_READ_MAX_BYTES });
187
+ lastAssistant = tail.lastAssistant;
188
+ turnOrdinal = tail.turnOrdinal;
189
+ } catch {
190
+ return EXIT_OK;
191
+ }
192
+ if (!lastAssistant) return EXIT_OK;
193
+
194
+ const sessionId =
195
+ str(payload['session_id'] as JsonValue | undefined) ||
196
+ str(envelope['session_id'] as JsonValue | undefined);
197
+ // `derive_session_tag`, NOT the turn-end gate's `deriveSessionKey`. Both
198
+ // hash the session id, and only this one produces the tag `chat_history`
199
+ // already writes as `s`. The wall-clock axis is a join between this ledger
200
+ // and those timestamps, so a 32-char key from the other derivation would
201
+ // have made the roadmap's second pre-registered claim underivable — while
202
+ // looking correct in every test that never crossed the two files.
203
+ const runId = sessionId ? derive_session_tag(sessionId) : 'unknown';
204
+
205
+ const ledgerPath = path.join(workspaceRoot, LEDGER_RELPATH);
206
+ if (alreadyRecorded(ledgerPath, runId, turnOrdinal)) return EXIT_OK;
207
+
208
+ const verdict = classifyReply(lastAssistant);
209
+ const record: InterruptionRecord = {
210
+ run_id: runId,
211
+ turn: turnOrdinal,
212
+ kind: verdict.kind,
213
+ class: verdict.class,
214
+ roadmap: readClaimedRoadmap(workspaceRoot, sessionId),
215
+ at: new Date().toISOString(),
216
+ };
217
+
218
+ try {
219
+ fs.mkdirSync(path.dirname(ledgerPath), { recursive: true });
220
+ fs.appendFileSync(ledgerPath, `${JSON.stringify(record)}\n`, 'utf8');
221
+ } catch {
222
+ // A ledger that cannot be written is a lost measurement, never a
223
+ // reason to affect the turn. Phase 0 is advisory by construction.
224
+ }
225
+ return EXIT_OK;
226
+ }
227
+
228
+ /**
229
+ * The concern runs in-process via `CONCERN_REGISTRY`, but the manifest's
230
+ * `script:` path is also the SPAWN FALLBACK — and without this guard that path
231
+ * imports the module, calls nothing, and exits 0. A silent no-op on a
232
+ * capture-only concern is invisible by construction: the ledger simply stays
233
+ * empty and the baseline reads as "no contacts". Caught by running the hook
234
+ * against a real envelope rather than by any unit test, which is why the probe
235
+ * is worth the two minutes.
236
+ */
237
+ function _isCliEntry(): boolean {
238
+ if (process.argv[1] === undefined) {
239
+ return false;
240
+ }
241
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
242
+ if (import.meta.url === argvUrl) {
243
+ return true;
244
+ }
245
+ try {
246
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
247
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
248
+ return here === argv;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ if (_isCliEntry()) {
255
+ process.exit(main());
256
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `ship-diff-volume` — a shipping-intent gate on diff VOLUME.
4
+ *
5
+ * Complementary to `minimal-safe-diff`, not a duplicate of it, and the
6
+ * distinction is the reason this exists: that concern counts unique FILES
7
+ * TOUCHED PER TURN, which is a within-session shape signal. This one measures
8
+ * total changed LINES on the branch AT A SHIP VERB — the moment the change
9
+ * leaves for review. A twelve-turn change of four files each time trips
10
+ * neither; a single-turn 3,000-line change trips only this one.
11
+ *
12
+ * THE THRESHOLD IS DERIVED FROM THIS REPOSITORY'S OWN HISTORY, never copied
13
+ * from the reference that suggested the gate. Replay of the 40 most recently
14
+ * merged PRs (agents/evidence/eval-findings/metric-loop-s04.md) put the
15
+ * corrected p90 at 1,695 lines.
16
+ *
17
+ * THE EXCLUSION SET IS PART OF THE DEFINITION, NOT A TUNING PARAMETER. The
18
+ * same replay found the naive metric double-counting: four of the five largest
19
+ * PRs were dominated by `*.review-input/diff.patch` — a committed copy of the
20
+ * very diff being measured — and the fifth by a generated index. Precision at
21
+ * the raw p90 was 2/5, below the pre-registered 0.50 floor; excluding the
22
+ * repository's own bookkeeping moved it to 5/5. Removing an input that was
23
+ * never shipping volume is a correction. Growing this set until a firing goes
24
+ * away would be tuning, and the kill criterion applies to that.
25
+ *
26
+ * WARN-LEVEL FIRST. It never blocks; escalation waits for one release of
27
+ * recorded firings, per the phase. Exit code is always 0 on the advisory path;
28
+ * 2 is the warn-in-context channel this estate's dispatcher reads.
29
+ */
30
+ import { execFileSync } from 'node:child_process';
31
+ import fs from 'node:fs';
32
+ import path from 'node:path';
33
+ import { fileURLToPath, pathToFileURL } from 'node:url';
34
+
35
+ import { readHookStdin } from './hook_stdin.js';
36
+ import { atomic_write_json } from './state_io.js';
37
+
38
+ export const STATE_FILE = path.join('agents', 'state', 'ship-diff-volume.json');
39
+
40
+ /** Derived: corrected p90 over the last 40 merged PRs (spike s04). */
41
+ export const DEFAULT_THRESHOLD = 1695;
42
+
43
+ /**
44
+ * Paths excluded from the volume because they are not shipping volume.
45
+ *
46
+ * Each entry is a category of artefact the change did not author: a snapshot of
47
+ * the diff itself, a generated index, or a projection tree regenerated from
48
+ * source. None is a threshold in disguise.
49
+ */
50
+ export const EXCLUDED = [
51
+ '.review-input/', // a committed copy of the diff being measured
52
+ 'agents/roadmaps/archive/index.json', // generated index
53
+ 'dist/', // projection of src/
54
+ '.augment/',
55
+ '.claude/',
56
+ '.cursor/',
57
+ '.clinerules/',
58
+ ] as const;
59
+
60
+ /** Ship verbs — the moment a change leaves for review. */
61
+ const SHIP_PATTERNS = [/\bgit\s+push\b/, /\bgh\s+pr\s+create\b/] as const;
62
+
63
+ export function isShipCommand(cmd: string): boolean {
64
+ return SHIP_PATTERNS.some((re) => re.test(cmd));
65
+ }
66
+
67
+ export function isExcluded(p: string): boolean {
68
+ return EXCLUDED.some((e) => (e.endsWith('/') ? p.startsWith(e) || p.includes(e) : p === e));
69
+ }
70
+
71
+ /** `git diff --numstat` output → corrected volume plus what was excluded. */
72
+ export function correctedVolume(numstat: string): { volume: number; excluded: number; files: number } {
73
+ let volume = 0;
74
+ let excluded = 0;
75
+ let files = 0;
76
+ for (const line of numstat.split('\n')) {
77
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
78
+ if (m === null) continue;
79
+ const add = m[1] === '-' ? 0 : Number(m[1]);
80
+ const del = m[2] === '-' ? 0 : Number(m[2]);
81
+ const p = m[3]!;
82
+ if (isExcluded(p)) {
83
+ excluded += add + del;
84
+ continue;
85
+ }
86
+ volume += add + del;
87
+ files += 1;
88
+ }
89
+ return { volume, excluded, files };
90
+ }
91
+
92
+ function extractCommand(envelope: Record<string, unknown>): string {
93
+ const ti = envelope['tool_input'];
94
+ if (ti !== null && typeof ti === 'object') {
95
+ const c = (ti as Record<string, unknown>)['command'];
96
+ if (typeof c === 'string') return c;
97
+ }
98
+ const c = envelope['command'];
99
+ return typeof c === 'string' ? c : '';
100
+ }
101
+
102
+ export function main(argv?: string[]): number {
103
+ const args = argv ?? process.argv.slice(2);
104
+ const idx = args.indexOf('--command');
105
+ let cmd = idx >= 0 && args[idx + 1] !== undefined ? args[idx + 1]! : '';
106
+ if (!cmd) {
107
+ const raw = readHookStdin();
108
+ let envelope: Record<string, unknown> = {};
109
+ if (raw.trim()) {
110
+ try {
111
+ envelope = JSON.parse(raw) as Record<string, unknown>;
112
+ } catch {
113
+ envelope = {};
114
+ }
115
+ }
116
+ cmd = extractCommand(envelope);
117
+ }
118
+ if (!isShipCommand(cmd)) return 0;
119
+
120
+ let numstat: string;
121
+ let base: string;
122
+ try {
123
+ base = execFileSync('git', ['merge-base', 'HEAD', 'origin/main'], { encoding: 'utf-8' }).trim();
124
+ numstat = execFileSync('git', ['diff', '--numstat', `${base}...HEAD`], {
125
+ encoding: 'utf-8',
126
+ maxBuffer: 64 * 1024 * 1024,
127
+ });
128
+ } catch {
129
+ // No merge base, no origin, a detached probe — nothing to say. An
130
+ // advisory concern that guesses on missing input is worse than silent.
131
+ return 0;
132
+ }
133
+
134
+ const { volume, excluded, files } = correctedVolume(numstat);
135
+ const over = volume >= DEFAULT_THRESHOLD;
136
+
137
+ try {
138
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
139
+ atomic_write_json(STATE_FILE, {
140
+ schema_version: 1,
141
+ base,
142
+ corrected_volume: volume,
143
+ excluded_volume: excluded,
144
+ files_counted: files,
145
+ threshold: DEFAULT_THRESHOLD,
146
+ warning: over,
147
+ checked_at: new Date().toISOString(),
148
+ });
149
+ } catch {
150
+ // State is observability, never the gate. A read-only tree must not
151
+ // turn an advisory warning into a failed ship.
152
+ }
153
+
154
+ if (!over) return 0;
155
+
156
+ process.stderr.write(
157
+ `ship-diff-volume: ${String(volume)} changed lines against ${DEFAULT_THRESHOLD} ` +
158
+ `(${String(files)} file(s); ${String(excluded)} line(s) excluded as bookkeeping).\n` +
159
+ `Every PR above this line in the last 40 named two or more concerns in its own title. ` +
160
+ `Splitting is a suggestion, not a requirement — this warns and never blocks.\n`,
161
+ );
162
+ return 2; // warn-in-context; advisory severity means the dispatcher does not block
163
+ }
164
+
165
+ function _isCliEntry(): boolean {
166
+ if (process.argv[1] === undefined) return false;
167
+ if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
168
+ try {
169
+ return (
170
+ fs.realpathSync(fileURLToPath(import.meta.url)) ===
171
+ fs.realpathSync(path.resolve(process.argv[1]))
172
+ );
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ if (_isCliEntry()) {
179
+ process.exit(main());
180
+ }
@@ -47,9 +47,11 @@
47
47
  * non-sidechain user entries — is distinct for every real turn whatever
48
48
  * the user typed, and stable across harness injections.
49
49
  *
50
- * Still true and still worth stating: per-session files are bounded per
51
- * session but not pruned, so a long-lived workspace accumulates one small
52
- * file per session. No TTL ships here.
50
+ * No longer true, and corrected here rather than left as a stale admission:
51
+ * the files ARE pruned. `road-to-stop-gate-honesty` step 1.2 added a
52
+ * 90-day retention (`pruneAgedRefusalState`), run at `session_start` by the
53
+ * session-register concern. The record also COUNTS now instead of
54
+ * overwriting itself — see `markRefusedTurn`.
53
55
  *
54
56
  * The failure this ordering prevents is not a loop but a wedge: a turn that
55
57
  * can never end. Layer 2 also covers the host that does not send
@@ -111,7 +113,6 @@
111
113
  * not an advisory, so there is no advisory path here to put on the wrong code.
112
114
  * Probed, not assumed.
113
115
  */
114
- import * as crypto from 'node:crypto';
115
116
  import * as fs from 'node:fs';
116
117
  import * as path from 'node:path';
117
118
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -130,6 +131,14 @@ import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
130
131
  import { readHookStdin } from './hook_stdin.js';
131
132
  import { atomic_write_json, is_replay_mode } from './state_io.js';
132
133
  import { openRecordStats } from './subagent_ledger_hook.js';
134
+ import {
135
+ deriveSessionKey,
136
+ foldRefusal,
137
+ parseRecord,
138
+ readInstallBoundary,
139
+ sessionRefusalFile,
140
+ type RefusalRecord,
141
+ } from '../_lib/turn_end_refusals.js';
133
142
 
134
143
  /** Dispatcher-internal block code. Pinned to 1 by `concern_block_exit_parity`. */
135
144
  const EXIT_BLOCK = 1;
@@ -294,8 +303,12 @@ const PROMISSORY = [
294
303
  * A legitimate hand-back is the opposite speech act: it gives the decision
295
304
  * to the user and ends the turn on purpose. `scope-control` requires
296
305
  * exactly this shape, so refusing it would put two rules in direct conflict.
306
+ *
307
+ * EXPORTED because `interruption_ledger_hook` classifies the same shape as a
308
+ * synchronous contact. Narrowing this list therefore changes a measurement as
309
+ * well as a refusal — check that hook before you touch it.
297
310
  */
298
- const HANDBACK = [
311
+ export const HANDBACK = [
299
312
  /\bdas entscheidest (du|Du)\b/i,
300
313
  /\bdeine Entscheidung\b/i,
301
314
  /\bich fasse .{0,40}nicht ungefragt an\b/i,
@@ -527,9 +540,49 @@ const _EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
527
540
  * detection when a project verifies by some command not listed here, and that is
528
541
  * the safe direction for a gate that can refuse a turn: a false negative costs
529
542
  * one unguarded turn, a false positive teaches the user to switch the gate off.
543
+ *
544
+ * ## Audited 2026-08-17 — `road-to-stop-gate-honesty` step 2.2
545
+ *
546
+ * 52 commands this project's own surface actually produces (its `package.json`
547
+ * scripts, its `Taskfile` targets, `./scripts-run`, `./agent-config`, and the
548
+ * PHP / Python / Go / Rust toolchains) were run through
549
+ * `isVerificationCommand`. The table of every one, with its verdict, is
550
+ * `turn_end_verify_allowlist.test.ts`; that file is the fixture step 2.2
551
+ * requires, and every addition below has its own row in it.
552
+ *
553
+ * The draft that opened the roadmap expected to add `phpunit` and `pest`. Claim
554
+ * 4 had already overtaken that — both matched before this audit, as did
555
+ * `composer test` and `php artisan test` — so what the audit actually found was
556
+ * two different gaps:
557
+ *
558
+ * · `phpstan` — a static analyser of exactly the class already listed
559
+ * (`mypy`, `pyright`, `clippy`). Added.
560
+ * · `lint` followed by a WORD character — `lint_persistence`,
561
+ * `lint_provenance`, and every other `lint_*` script in `src/scripts/`
562
+ * missed, because `\blint\b` needs a boundary and `_` is a word character.
563
+ * `lint[-_:a-z]*` is the exact mirror of the `check[-_:a-z]*` this list
564
+ * already carried, which is why it is the narrow fix rather than a new idea.
565
+ *
566
+ * Four further misses were found and deliberately NOT added, because Risk 2 of
567
+ * that roadmap is that every addition is a way to satisfy the gate without
568
+ * verifying anything:
569
+ *
570
+ * · `npm run prepack` — a lifecycle hook whose content is per-project. Here it
571
+ * validates; elsewhere it copies files. Recognising it would clear an
572
+ * unverified edit in any repo whose prepack is a build step.
573
+ * · `task sync` / `task generate-tools` / `agent-config roadmap:progress` —
574
+ * GENERATORS. They rewrite the tree; they check nothing. A generator that
575
+ * cleared the gate would be the rubber stamp in its purest form.
576
+ * · `agent-config gates --all` — enumerates gates, runs none.
577
+ * · `vendor/bin/rector process --dry-run` — a refactoring tool. Its dry run
578
+ * prints a diff; it does not assert anything holds.
579
+ *
580
+ * `psalm` is the obvious sibling of `phpstan` and is also NOT added: nothing in
581
+ * the audited surface runs it, and "the team actually runs" is the step's own
582
+ * standard. It is named here so the next audit does not re-derive the question.
530
583
  */
531
584
  const _VERIFY_RE =
532
- /\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|typecheck|lint|build|ci|preflight|smoke[-_:a-z]*|check[-_:a-z]*|validate[-_:a-z]*)\b|\b(task|npm|pnpm|yarn|composer|cargo|go|make|php artisan|bun)\s+(run\s+)?\S*(test|check|lint|build|ci|typecheck)/i;
585
+ /\b(test|tests|vitest|jest|pytest|phpunit|pest|tsc|eslint|ruff|mypy|pyright|clippy|phpstan|typecheck|lint[-_:a-z]*|build|ci|preflight|smoke[-_:a-z]*|check[-_:a-z]*|validate[-_:a-z]*)\b|\b(task|npm|pnpm|yarn|composer|cargo|go|make|php artisan|bun)\s+(run\s+)?\S*(test|check|lint|build|ci|typecheck)/i;
533
586
 
534
587
  export function isVerificationCommand(command: string): boolean {
535
588
  return _VERIFY_RE.test(command);
@@ -579,29 +632,30 @@ export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding |
579
632
  // Re-entrancy — the guard, keyed on the turn, not on the reply
580
633
  // ---------------------------------------------------------------------------
581
634
 
582
- function stateDir(workspaceRoot: string): string {
583
- return path.join(workspaceRoot, 'agents', 'runtime', 'state', 'turn-end-gate');
584
- }
585
-
586
635
  /**
587
636
  * ONE file per session, not one per refused turn. R2 round 1, finding 17:
588
637
  * per-turn files accumulated without bound and without a TTL, because "re-arms
589
638
  * itself with no cleanup" described the key rotating, not the files being
590
639
  * removed.
591
640
  *
592
- * HALF of that is fixed and half is not, stated rather than implied (R2 round 2,
593
- * finding 12): the per-turn MULTIPLICITY is gone, so a session no longer leaves
594
- * one file per refused turn. There is still no TTL and no pruning, so a
595
- * long-lived workspace accumulates one small file per session that ever had a
596
- * turn refused. Adding expiry is new behaviour and belongs with its own test.
641
+ * Both halves are now closed. The per-turn MULTIPLICITY went first; the missing
642
+ * TTL was the other half and `road-to-stop-gate-honesty` step 1.2 shipped it as
643
+ * `pruneAgedRefusalState`, run at `session_start` by the session-register
644
+ * concern — the one slot that already prunes and therefore adds no spawn. The
645
+ * path and the retention constant live with the reader
646
+ * (`_lib/turn_end_refusals.ts`), because a pruner in one module and a writer in
647
+ * another must not each own their own idea of where the files are.
597
648
  */
598
649
  function sessionStateFile(workspaceRoot: string, sessionKey: string): string {
599
- return path.join(stateDir(workspaceRoot), `${sessionKey}.json`);
650
+ return sessionRefusalFile(workspaceRoot, sessionKey);
600
651
  }
601
652
 
602
- export function deriveSessionKey(sessionId: string): string {
603
- return crypto.createHash('sha256').update(sessionId).digest('hex').slice(0, 32);
604
- }
653
+ /**
654
+ * Re-exported, not re-derived: the session register reads this session's own
655
+ * refusal record back for live per-session visibility, so the stem has two
656
+ * consumers and exactly one definition (`_lib/turn_end_refusals.ts`).
657
+ */
658
+ export { deriveSessionKey };
605
659
 
606
660
  /**
607
661
  * A turn's identity is its ORDINAL — how many genuine user prompts the
@@ -641,19 +695,53 @@ export function alreadyRefusedTurn(
641
695
  return false;
642
696
  }
643
697
 
698
+ /**
699
+ * Write the re-entrancy marker AND count the refusal.
700
+ *
701
+ * The marker half is unchanged: `refused_turn` is what `alreadyRefusedTurn`
702
+ * reads, and nothing about the counting can alter whether a turn is refused
703
+ * twice. The counting half is `road-to-stop-gate-honesty` step 1.1 — the old
704
+ * record overwrote itself, so a session refused nine times looked exactly like a
705
+ * session refused once, and D-2's "refusal frequency is invisible" was true of
706
+ * the state file as much as of the absent reader.
707
+ *
708
+ * EVERY finding's detector is counted, not just the first. The first still lands
709
+ * in `detector` for compatibility with the 36 field records written before this,
710
+ * but a turn that trips B and C at once is two observations, and pooling them
711
+ * into one is what step 1.1 forbids in the reader — doing it in the writer would
712
+ * put the same defect somewhere the reader cannot fix.
713
+ */
644
714
  function markRefusedTurn(
645
715
  workspaceRoot: string,
646
716
  sessionKey: string,
647
717
  turnOrdinal: number,
648
- detector: DetectorId,
718
+ detectors: readonly DetectorId[],
649
719
  ): void {
650
720
  if (is_replay_mode()) return;
721
+ const file = sessionStateFile(workspaceRoot, sessionKey);
722
+ let prev: RefusalRecord | null = null;
723
+ try {
724
+ prev = parseRecord(fs.readFileSync(file, 'utf-8'));
725
+ } catch {
726
+ // Absent or unreadable prior state starts a fresh count. Never a reason
727
+ // to skip the marker — the marker is the wedge guard.
728
+ }
729
+ let version: string | undefined;
730
+ try {
731
+ version = readInstallBoundary().version ?? undefined;
732
+ } catch {
733
+ version = undefined;
734
+ }
651
735
  try {
652
- atomic_write_json(sessionStateFile(workspaceRoot, sessionKey), {
653
- refused_at: new Date().toISOString(),
654
- refused_turn: turnOrdinal,
655
- detector,
656
- });
736
+ atomic_write_json(
737
+ file,
738
+ foldRefusal(prev, {
739
+ detectors,
740
+ turnOrdinal,
741
+ at: new Date().toISOString(),
742
+ version,
743
+ }) as unknown as Record<string, unknown>,
744
+ );
657
745
  } catch {
658
746
  // A state-write failure must never wedge the turn. Losing the marker
659
747
  // costs at most one extra refusal; failing closed here would cost the
@@ -934,7 +1022,12 @@ export function main(): number {
934
1022
  }
935
1023
  if (findings.length === 0) return EXIT_ALLOW;
936
1024
 
937
- markRefusedTurn(workspaceRoot, sessionKey, turnOrdinal, findings[0]!.detector);
1025
+ markRefusedTurn(
1026
+ workspaceRoot,
1027
+ sessionKey,
1028
+ turnOrdinal,
1029
+ findings.map((f) => f.detector),
1030
+ );
938
1031
 
939
1032
  const lines = findings.map(
940
1033
  (f) => ` · ${f.detector}: ${f.reason}\n evidence: ${JSON.stringify(f.evidence)}`,