@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
@@ -34,6 +34,13 @@ import { fileURLToPath, pathToFileURL } from "node:url";
34
34
  import { read_dispatch_issues } from "./hooks/dispatch_issues.js";
35
35
  import { type JsonObject, type JsonValue, _load_yaml } from "./hooks/dispatch_hook.js";
36
36
  import * as hooks_status from "./hooks_status.js";
37
+ import {
38
+ DETECTOR_IDS,
39
+ collectRefusalStats,
40
+ readInstallBoundary,
41
+ type InstallBoundary,
42
+ type RefusalStats,
43
+ } from "./_lib/turn_end_refusals.js";
37
44
 
38
45
  // src/scripts/hooks_doctor.ts → parents[2] (../../..) is the repo root,
39
46
  // mirroring the Python `Path(__file__).resolve().parents[2]`.
@@ -171,6 +178,17 @@ export interface DoctorPayload {
171
178
  concerns: ConcernRow[];
172
179
  trampolines: TrampolineRow[];
173
180
  dispatch_issues: JsonObject[];
181
+ /**
182
+ * Turn-end refusals, per detector, from this workspace's own records.
183
+ *
184
+ * `road-to-stop-gate-honesty` § D-2: every advisory concern in this estate
185
+ * carries a registered kill standard and the one BLOCKING concern carries
186
+ * none, because nobody could see how often it fires. The doctor is where a
187
+ * concern's health is already read, so this is where the number belongs.
188
+ */
189
+ turn_end_refusals: RefusalStats;
190
+ /** The machine's install stamp, for reading the version split below it. */
191
+ install_boundary: InstallBoundary;
174
192
  }
175
193
 
176
194
  /** Build the doctor payload — JSON-serialisable. */
@@ -229,12 +247,31 @@ export function collect(
229
247
  issues = [];
230
248
  }
231
249
 
250
+ let turn_end_refusals: RefusalStats;
251
+ try {
252
+ turn_end_refusals = collectRefusalStats(project_root);
253
+ } catch {
254
+ turn_end_refusals = {
255
+ sessionsWithRefusals: 0,
256
+ total: 0,
257
+ byDetector: { promissory: 0, language: 0, verification: 0, completion: 0 },
258
+ byPeriod: [],
259
+ legacyRecords: 0,
260
+ unversionedRecords: 0,
261
+ byVersion: [],
262
+ earliest: null,
263
+ latest: null,
264
+ };
265
+ }
266
+
232
267
  return {
233
268
  schema_version: 1,
234
269
  platforms: matrix.platforms,
235
270
  concerns,
236
271
  trampolines,
237
272
  dispatch_issues: issues,
273
+ turn_end_refusals,
274
+ install_boundary: readInstallBoundary(),
238
275
  };
239
276
  }
240
277
 
@@ -242,6 +279,79 @@ function _strVal(v: JsonValue | undefined): string {
242
279
  return v === undefined || v === null ? "undefined" : String(v);
243
280
  }
244
281
 
282
+ /**
283
+ * The turn-end-gate's own number.
284
+ *
285
+ * Reports what the records can support and refuses the number they cannot: the
286
+ * denominator is *sessions that had at least one refusal*, never all sessions,
287
+ * because a session that was never refused writes no record. Labelling a
288
+ * per-refused-session figure as a per-session rate would be the same
289
+ * denominator inflation this estate's measurement discipline exists to stop.
290
+ */
291
+ export function _render_refusals(payload: DoctorPayload): string[] {
292
+ const s = payload.turn_end_refusals;
293
+ const lines: string[] = [];
294
+ lines.push("Turn-end refusals");
295
+ lines.push("-".repeat(60));
296
+ if (s.sessionsWithRefusals === 0) {
297
+ lines.push("· no refusal records under agents/runtime/state/turn-end-gate/");
298
+ return lines;
299
+ }
300
+ const perSession = (s.total / s.sessionsWithRefusals).toFixed(2);
301
+ lines.push(
302
+ ` ${s.total} refusal(s) across ${s.sessionsWithRefusals} session(s) ` +
303
+ `that were refused at least once — ${perSession} per such session.`,
304
+ );
305
+ lines.push(
306
+ " NOT a per-session rate: sessions with no refusal write no record, so " +
307
+ "the wider denominator is unavailable here by construction.",
308
+ );
309
+ if (s.earliest && s.latest) {
310
+ lines.push(` window: ${s.earliest.slice(0, 10)} … ${s.latest.slice(0, 10)}`);
311
+ }
312
+ for (const id of DETECTOR_IDS) {
313
+ const n = s.byDetector[id];
314
+ const share = s.total > 0 ? Math.round((n / s.total) * 100) : 0;
315
+ lines.push(` ${id.padEnd(14)} ${String(n).padStart(4)} ${share}%`);
316
+ }
317
+ if (s.legacyRecords > 0) {
318
+ lines.push(
319
+ ` ${s.legacyRecords} record(s) predate per-detector counting and ` +
320
+ "contribute one refusal each — the total is a FLOOR, not an exact count.",
321
+ );
322
+ }
323
+ const boundary = payload.install_boundary;
324
+ if (boundary.version || boundary.installed_at) {
325
+ lines.push(
326
+ ` installed: ${boundary.version ?? "(unknown version)"} at ` +
327
+ `${boundary.installed_at ?? "(unknown date)"}`,
328
+ );
329
+ }
330
+ if (s.byVersion.length > 0) {
331
+ lines.push(" by recorded version:");
332
+ for (const v of s.byVersion) {
333
+ lines.push(` ${v.version.padEnd(16)} ${String(v.total).padStart(4)}`);
334
+ }
335
+ if (s.unversionedRecords > 0) {
336
+ lines.push(
337
+ " (unrecorded) rows predate version stamping — they cannot test " +
338
+ "the install-date prediction in either direction.",
339
+ );
340
+ }
341
+ }
342
+ const recent = s.byPeriod.slice(0, 7);
343
+ if (recent.length > 0) {
344
+ lines.push(" most recent days:");
345
+ for (const p of recent) {
346
+ lines.push(
347
+ ` ${p.period} ${String(p.total).padStart(3)} refusal(s) / ` +
348
+ `${p.sessions} session(s)`,
349
+ );
350
+ }
351
+ }
352
+ return lines;
353
+ }
354
+
245
355
  export function _render_table(payload: DoctorPayload): string {
246
356
  const lines: string[] = [];
247
357
  // Phase 1 CTA — surfaces at the TOP when issues exist, so a user
@@ -286,6 +396,8 @@ export function _render_table(payload: DoctorPayload): string {
286
396
  const suffix = t.required ? "" : " (not required)";
287
397
  lines.push(`${marker}${t.platform.padEnd(9)} ${t.expected}${suffix}`);
288
398
  }
399
+ lines.push("");
400
+ lines.push(..._render_refusals(payload));
289
401
  // Dispatch-issues detail — last 20 grouped by concern.
290
402
  if (payload.dispatch_issues.length > 0) {
291
403
  lines.push("");
@@ -0,0 +1,443 @@
1
+ /**
2
+ * interruption_report — road-to-user-out-of-the-loop Phase 0 Step 2.
3
+ *
4
+ * Reports the two axes the roadmap's Goal names and pre-registers as separate
5
+ * claims, because a run can ask zero questions and still be slow:
6
+ *
7
+ * CONTACT AXIS asks per run, hand-backs per run, halts per run, and the
8
+ * median user wait between an agent turn and the user's reply.
9
+ * WALL-CLOCK AXIS total elapsed per run versus agent working time — elapsed
10
+ * minus the time the run spent waiting on a human.
11
+ *
12
+ * ── WHAT THIS REPORT REFUSES TO DO ────────────────────────────────────────
13
+ *
14
+ * The step specifies a 30-session window "matching the conformance window".
15
+ * Measured on this tree the day it was written, the rolling history held
16
+ * **5 sessions, all from one day** — the file is a rolling buffer, not an
17
+ * archive, so 30 is a request rather than a guarantee.
18
+ *
19
+ * So the window is a REQUEST and `sessions_found` is reported next to it, with
20
+ * an explicit `window_short` flag when fewer sessions exist than were asked
21
+ * for. A baseline computed over 5 sessions and labelled "30" is exactly the
22
+ * measurement-artifact-as-decision-input failure this repository has already
23
+ * recorded once (`skill-usage-report.md` publishing "Active: 0"). A short
24
+ * window is a real finding about retention; hiding it would make the number
25
+ * look authoritative and the retention problem invisible.
26
+ *
27
+ * ── THE JOIN ──────────────────────────────────────────────────────────────
28
+ *
29
+ * Contacts come from `agents/runtime/state/interruptions.jsonl` (the Phase 0
30
+ * Step 1 concern). Timestamps come from `agents/runtime/.agent-chat-history`.
31
+ * They join on the session tag: the ledger writes `run_id` using
32
+ * `derive_session_tag`, the same derivation that file writes as `s`.
33
+ *
34
+ * A run present in one source and not the other is reported, never silently
35
+ * dropped — a ledger entry with no timestamps yields contact counts and a null
36
+ * wall clock, and history with no ledger entry yields timing and a null contact
37
+ * count. Dropping either would bias the medians in a direction nobody could see.
38
+ *
39
+ * ── SYNTHETIC USER TURNS ──────────────────────────────────────────────────
40
+ *
41
+ * The harness writes task notifications and system reminders into the USER
42
+ * role. Counting those as "the user replied" would collapse every measured wait
43
+ * to near zero and make the contact axis read as free. They are excluded by
44
+ * marker, and the count of what was excluded is reported so the exclusion is
45
+ * auditable rather than a silent filter.
46
+ */
47
+
48
+ import * as fs from 'node:fs';
49
+ import * as path from 'node:path';
50
+ import { fileURLToPath, pathToFileURL } from 'node:url';
51
+
52
+ export const DEFAULT_WINDOW = 30;
53
+
54
+ export interface LedgerRow {
55
+ run_id: string;
56
+ turn: number;
57
+ kind: string;
58
+ class: string;
59
+ roadmap: string | null;
60
+ at: string;
61
+ }
62
+
63
+ export interface HistoryTurn {
64
+ session: string;
65
+ role: 'user' | 'agent';
66
+ ts: string;
67
+ synthetic: boolean;
68
+ }
69
+
70
+ export interface RunReport {
71
+ run_id: string;
72
+ roadmap: string | null;
73
+ asks: number;
74
+ handbacks: number;
75
+ contacts: number;
76
+ halts: number;
77
+ elapsed_minutes: number | null;
78
+ waiting_minutes: number | null;
79
+ working_minutes: number | null;
80
+ }
81
+
82
+ export interface Report {
83
+ window_requested: number;
84
+ sessions_found: number;
85
+ window_short: boolean;
86
+ synthetic_user_turns_excluded: number;
87
+ runs: RunReport[];
88
+ median_contacts_per_run: number | null;
89
+ median_user_wait_minutes: number | null;
90
+ median_elapsed_minutes: number | null;
91
+ median_working_minutes: number | null;
92
+ notes: string[];
93
+ }
94
+
95
+ /**
96
+ * A user-role entry the harness produced, not the human. Matched on the markers
97
+ * the harness actually emits; anything else counts as a real reply.
98
+ */
99
+ const SYNTHETIC_MARKERS = [
100
+ '<task-notification>',
101
+ '<system-reminder>',
102
+ '<local-command-caveat>',
103
+ '<command-name>',
104
+ ];
105
+
106
+ export function isSyntheticUserText(text: string): boolean {
107
+ return SYNTHETIC_MARKERS.some((m) => text.includes(m));
108
+ }
109
+
110
+ export function median(values: readonly number[]): number | null {
111
+ if (values.length === 0) return null;
112
+ const sorted = [...values].sort((a, b) => a - b);
113
+ const mid = Math.floor(sorted.length / 2);
114
+ return sorted.length % 2 === 1 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
115
+ }
116
+
117
+ export function readLedger(root: string): LedgerRow[] {
118
+ const file = path.join(root, 'agents', 'runtime', 'state', 'interruptions.jsonl');
119
+ let raw: string;
120
+ try {
121
+ raw = fs.readFileSync(file, 'utf8');
122
+ } catch {
123
+ return [];
124
+ }
125
+ const out: LedgerRow[] = [];
126
+ for (const line of raw.split('\n')) {
127
+ if (!line.trim()) continue;
128
+ try {
129
+ const o: unknown = JSON.parse(line);
130
+ if (o === null || typeof o !== 'object') continue;
131
+ const r = o as Record<string, unknown>;
132
+ if (typeof r['run_id'] !== 'string') continue;
133
+ out.push({
134
+ run_id: r['run_id'],
135
+ turn: typeof r['turn'] === 'number' ? r['turn'] : 0,
136
+ kind: typeof r['kind'] === 'string' ? r['kind'] : 'none',
137
+ class: typeof r['class'] === 'string' ? r['class'] : 'none',
138
+ roadmap: typeof r['roadmap'] === 'string' ? r['roadmap'] : null,
139
+ at: typeof r['at'] === 'string' ? r['at'] : '',
140
+ });
141
+ } catch {
142
+ // A corrupt line is one lost observation, never a failed report.
143
+ }
144
+ }
145
+ return out;
146
+ }
147
+
148
+ export function readHistory(root: string): { turns: HistoryTurn[]; synthetic: number } {
149
+ const file = process.env['AGENT_CHAT_HISTORY_FILE'] || path.join(root, 'agents', 'runtime', '.agent-chat-history');
150
+ let raw: string;
151
+ try {
152
+ raw = fs.readFileSync(file, 'utf8');
153
+ } catch {
154
+ return { turns: [], synthetic: 0 };
155
+ }
156
+ const turns: HistoryTurn[] = [];
157
+ let synthetic = 0;
158
+ for (const line of raw.split('\n')) {
159
+ if (!line.trim()) continue;
160
+ try {
161
+ const o: unknown = JSON.parse(line);
162
+ if (o === null || typeof o !== 'object') continue;
163
+ const r = o as Record<string, unknown>;
164
+ const t = r['t'];
165
+ if (t !== 'user' && t !== 'agent') continue;
166
+ const session = typeof r['s'] === 'string' ? r['s'] : '';
167
+ const ts = typeof r['ts'] === 'string' ? r['ts'] : '';
168
+ if (!session || !ts) continue;
169
+ const text = typeof r['text'] === 'string' ? r['text'] : '';
170
+ const isSynthetic = t === 'user' && isSyntheticUserText(text);
171
+ if (isSynthetic) synthetic += 1;
172
+ turns.push({ session, role: t, ts, synthetic: isSynthetic });
173
+ } catch {
174
+ // Same reasoning as the ledger: one bad line is not a failed report.
175
+ }
176
+ }
177
+ return { turns, synthetic };
178
+ }
179
+
180
+ const MINUTE_MS = 60_000;
181
+
182
+ function minutesBetween(a: string, b: string): number | null {
183
+ const ta = Date.parse(a);
184
+ const tb = Date.parse(b);
185
+ if (Number.isNaN(ta) || Number.isNaN(tb)) return null;
186
+ return (tb - ta) / MINUTE_MS;
187
+ }
188
+
189
+ /**
190
+ * Per-session wait gaps: an agent turn followed by a REAL user turn. Synthetic
191
+ * user entries are skipped rather than treated as replies, so a run the harness
192
+ * woke up four times still shows the one human wait it actually had.
193
+ */
194
+ export function waitGaps(turns: readonly HistoryTurn[]): number[] {
195
+ const gaps: number[] = [];
196
+ let lastAgentTs: string | null = null;
197
+ for (const turn of turns) {
198
+ if (turn.role === 'agent') {
199
+ lastAgentTs = turn.ts;
200
+ continue;
201
+ }
202
+ if (turn.synthetic) continue;
203
+ if (lastAgentTs === null) continue;
204
+ const gap = minutesBetween(lastAgentTs, turn.ts);
205
+ if (gap !== null && gap >= 0) gaps.push(gap);
206
+ lastAgentTs = null;
207
+ }
208
+ return gaps;
209
+ }
210
+
211
+ export function buildReport(root: string, windowRequested: number): Report {
212
+ const ledger = readLedger(root);
213
+ const { turns, synthetic } = readHistory(root);
214
+
215
+ const bySession = new Map<string, HistoryTurn[]>();
216
+ for (const turn of turns) {
217
+ const list = bySession.get(turn.session);
218
+ if (list) list.push(turn);
219
+ else bySession.set(turn.session, [turn]);
220
+ }
221
+ for (const list of bySession.values()) list.sort((a, b) => a.ts.localeCompare(b.ts));
222
+
223
+ // Newest sessions first, capped at the requested window. Ordering is by the
224
+ // session's LAST turn, so a long-running session that started earliest is
225
+ // still current.
226
+ const ordered = [...bySession.entries()].sort((a, b) => {
227
+ const la = a[1][a[1].length - 1]?.ts ?? '';
228
+ const lb = b[1][b[1].length - 1]?.ts ?? '';
229
+ return lb.localeCompare(la);
230
+ });
231
+ const windowed = ordered.slice(0, windowRequested);
232
+ const windowedIds = new Set(windowed.map(([id]) => id));
233
+
234
+ const ledgerRuns = new Set(ledger.map((r) => r.run_id));
235
+ const runIds = new Set<string>([...windowedIds]);
236
+ // A ledger run with no history is still a run; it contributes contacts and a
237
+ // null wall clock rather than disappearing from the denominator.
238
+ for (const id of ledgerRuns) if (!windowedIds.has(id)) runIds.add(id);
239
+
240
+ const allGaps: number[] = [];
241
+ const runs: RunReport[] = [];
242
+
243
+ for (const runId of runIds) {
244
+ const rows = ledger.filter((r) => r.run_id === runId);
245
+ const contacts = rows.filter((r) => r.kind !== 'none');
246
+ const asks = contacts.filter((r) => r.kind === 'ask').length;
247
+ const handbacks = contacts.filter((r) => r.kind === 'handback').length;
248
+
249
+ // A halt is a contact on the run's LAST recorded turn: the run stopped
250
+ // there and did not resume inside this window. Every other contact was
251
+ // answered and the run continued, which is a different, cheaper event.
252
+ const maxTurn = rows.reduce((m, r) => Math.max(m, r.turn), -1);
253
+ const halts = contacts.filter((r) => r.turn === maxTurn).length;
254
+
255
+ const sessionTurns = bySession.get(runId) ?? [];
256
+ const gaps = waitGaps(sessionTurns);
257
+ allGaps.push(...gaps);
258
+
259
+ let elapsed: number | null = null;
260
+ let waiting: number | null = null;
261
+ let working: number | null = null;
262
+ if (sessionTurns.length >= 2) {
263
+ elapsed = minutesBetween(sessionTurns[0]!.ts, sessionTurns[sessionTurns.length - 1]!.ts);
264
+ waiting = gaps.reduce((a, b) => a + b, 0);
265
+ if (elapsed !== null) working = Math.max(0, elapsed - waiting);
266
+ }
267
+
268
+ runs.push({
269
+ run_id: runId,
270
+ roadmap: rows.find((r) => r.roadmap !== null)?.roadmap ?? null,
271
+ asks,
272
+ handbacks,
273
+ contacts: contacts.length,
274
+ halts,
275
+ elapsed_minutes: elapsed,
276
+ waiting_minutes: waiting,
277
+ working_minutes: working,
278
+ });
279
+ }
280
+
281
+ runs.sort((a, b) => a.run_id.localeCompare(b.run_id));
282
+
283
+ const notes: string[] = [];
284
+ if (ledger.length === 0) {
285
+ notes.push(
286
+ 'interruptions.jsonl is empty or absent — the contact axis has no observations yet. ' +
287
+ 'The Phase 0 concern records on `stop`; a baseline needs sessions to have run since it landed.',
288
+ );
289
+ }
290
+ if (bySession.size === 0) {
291
+ notes.push('no chat history with session tags — the wall-clock axis has no observations.');
292
+ }
293
+ const runsWithLedger = runs.filter((r) => ledgerRuns.has(r.run_id)).length;
294
+ if (runsWithLedger < runs.length) {
295
+ notes.push(
296
+ `${runs.length - runsWithLedger} run(s) in the window have timing but no ledger entry ` +
297
+ '— reported with null contact counts rather than as zero contacts.',
298
+ );
299
+ }
300
+
301
+ return {
302
+ window_requested: windowRequested,
303
+ sessions_found: bySession.size,
304
+ window_short: bySession.size < windowRequested,
305
+ synthetic_user_turns_excluded: synthetic,
306
+ runs,
307
+ notes,
308
+ median_contacts_per_run: median(
309
+ runs.filter((r) => ledgerRuns.has(r.run_id)).map((r) => r.contacts),
310
+ ),
311
+ median_user_wait_minutes: median(allGaps),
312
+ median_elapsed_minutes: median(
313
+ runs.map((r) => r.elapsed_minutes).filter((v): v is number => v !== null),
314
+ ),
315
+ median_working_minutes: median(
316
+ runs.map((r) => r.working_minutes).filter((v): v is number => v !== null),
317
+ ),
318
+ };
319
+ }
320
+
321
+ function fmt(v: number | null, unit = ''): string {
322
+ return v === null ? 'n/a' : `${Math.round(v * 10) / 10}${unit}`;
323
+ }
324
+
325
+ export function renderText(report: Report): string {
326
+ const lines: string[] = [];
327
+ lines.push('interruption_report — road-to-user-out-of-the-loop Phase 0');
328
+ lines.push('');
329
+ lines.push(
330
+ `window: ${report.sessions_found} session(s) found / ${report.window_requested} requested` +
331
+ (report.window_short ? ' ⚠️ SHORT WINDOW — see notes' : ''),
332
+ );
333
+ lines.push(`synthetic user turns excluded: ${report.synthetic_user_turns_excluded}`);
334
+ lines.push('');
335
+ lines.push('CONTACT AXIS');
336
+ lines.push(` median contacts per run: ${fmt(report.median_contacts_per_run)}`);
337
+ lines.push(` median user wait: ${fmt(report.median_user_wait_minutes, ' min')}`);
338
+ lines.push('');
339
+ lines.push('WALL-CLOCK AXIS');
340
+ lines.push(` median elapsed per run: ${fmt(report.median_elapsed_minutes, ' min')}`);
341
+ lines.push(` median agent working time: ${fmt(report.median_working_minutes, ' min')}`);
342
+ lines.push('');
343
+ lines.push(`runs: ${report.runs.length}`);
344
+ for (const run of report.runs) {
345
+ lines.push(
346
+ ` ${run.run_id} asks=${run.asks} handbacks=${run.handbacks} halts=${run.halts}` +
347
+ ` elapsed=${fmt(run.elapsed_minutes)} working=${fmt(run.working_minutes)}` +
348
+ ` roadmap=${run.roadmap ?? '-'}`,
349
+ );
350
+ }
351
+ if (report.window_short) {
352
+ lines.push('');
353
+ lines.push(
354
+ 'SHORT WINDOW — the rolling chat history is a buffer, not an archive. The number ' +
355
+ 'above is computed over the sessions that exist, and is labelled with that count ' +
356
+ 'rather than with the requested window. Do not cite it as an N-session baseline.',
357
+ );
358
+ }
359
+ for (const note of report.notes) {
360
+ lines.push('');
361
+ lines.push(`note: ${note}`);
362
+ }
363
+ return lines.join('\n');
364
+ }
365
+
366
+ const USAGE = `usage: interruption_report [--window N] [--json] [--root PATH]
367
+
368
+ Reports the two axes road-to-user-out-of-the-loop pre-registers:
369
+ contact axis asks / hand-backs / halts per run, median user wait
370
+ wall-clock axis elapsed per run versus agent working time
371
+
372
+ --window N sessions to include, newest first (default ${DEFAULT_WINDOW}).
373
+ A REQUEST, not a guarantee: the chat history is a rolling
374
+ buffer, so the report states how many sessions it actually
375
+ found and flags a short window rather than mislabelling it.
376
+ --json machine-readable output.
377
+ --root PATH repository root (default: cwd).
378
+ `;
379
+
380
+ export function main(argv: string[] = process.argv.slice(2)): number {
381
+ if (argv.includes('-h') || argv.includes('--help')) {
382
+ process.stdout.write(USAGE);
383
+ return 0;
384
+ }
385
+ let windowRequested = DEFAULT_WINDOW;
386
+ let root = process.cwd();
387
+ let asJson = false;
388
+ for (let i = 0; i < argv.length; i += 1) {
389
+ const arg = argv[i];
390
+ if (arg === '--json') {
391
+ asJson = true;
392
+ } else if (arg === '--window') {
393
+ const raw = argv[i + 1];
394
+ const parsed = Number(raw);
395
+ if (raw === undefined || !Number.isFinite(parsed) || parsed <= 0) {
396
+ process.stderr.write(`interruption_report: --window needs a positive number\n`);
397
+ return 2;
398
+ }
399
+ windowRequested = Math.floor(parsed);
400
+ i += 1;
401
+ } else if (arg === '--root') {
402
+ const raw = argv[i + 1];
403
+ if (raw === undefined) {
404
+ process.stderr.write('interruption_report: --root needs a path\n');
405
+ return 2;
406
+ }
407
+ root = raw;
408
+ i += 1;
409
+ } else {
410
+ process.stderr.write(`interruption_report: unknown argument '${String(arg)}'\n`);
411
+ process.stderr.write(USAGE);
412
+ return 2;
413
+ }
414
+ }
415
+
416
+ const report = buildReport(root, windowRequested);
417
+ process.stdout.write(asJson ? `${JSON.stringify(report, null, 2)}\n` : `${renderText(report)}\n`);
418
+ return 0;
419
+ }
420
+
421
+ function _isCliEntry(): boolean {
422
+ if (process.argv[1] === undefined) {
423
+ return false;
424
+ }
425
+ const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
426
+ if (import.meta.url === argvUrl) {
427
+ return true;
428
+ }
429
+ // Symlinked invocation (an installed projection, or macOS /var → /private/var):
430
+ // import.meta.url is the real path while argv[1] keeps the symlink, so the raw
431
+ // URLs differ and the CLI would silently no-op. Compare realpaths.
432
+ try {
433
+ const here = fs.realpathSync(fileURLToPath(import.meta.url));
434
+ const argv = fs.realpathSync(path.resolve(process.argv[1]));
435
+ return here === argv;
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
440
+
441
+ if (_isCliEntry()) {
442
+ process.exit(main());
443
+ }