@event4u/agent-config 14.1.0 → 14.4.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 (120) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +291 -181
  3. package/agents/templates/.ai-council.yml.example +35 -0
  4. package/dist/agent-src/contexts/execution/roadmap-process-loop.md +83 -12
  5. package/dist/agent-src/rules/roadmap-progress-sync.md +48 -4
  6. package/dist/agent-src/scripts/resume_probe.ts +154 -9
  7. package/dist/agent-src/scripts/roadmap_gates.ts +210 -6
  8. package/dist/agent-src/skills/roadmap-management/SKILL.md +9 -4
  9. package/dist/agent-src/templates/agent-settings.md +70 -0
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  12. package/dist/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  13. package/dist/cli/python/workspace_drive.js +8 -3
  14. package/dist/cli/python/workspace_drive.js.map +1 -1
  15. package/dist/cli/python/workspace_hosts.js +16 -12
  16. package/dist/cli/python/workspace_hosts.js.map +1 -1
  17. package/dist/cli/registry.js +2 -0
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-F3YUF7SA.js → chunk-3UCJWMMP.js} +1 -1
  20. package/dist/cli-delegate/chunk-F7DQWYR5.js +69 -0
  21. package/dist/cli-delegate/{chunk-7PZ4FSZB.js → chunk-FBKL6JWT.js} +61 -2
  22. package/dist/cli-delegate/{chunk-MNWRDMR5.js → chunk-PKOBQADP.js} +1 -1
  23. package/dist/cli-delegate/cmd_conformance.js +26 -11
  24. package/dist/cli-delegate/cmd_doctor.js +2 -2
  25. package/dist/cli-delegate/cmd_handoff.js +3 -3
  26. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  27. package/dist/cli-delegate/cmd_settings_set.js +17 -3
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +4 -4
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/dispatch.js +3773 -1731
  38. package/dist/mcp/registry-manifest.json +2 -2
  39. package/dist/mcp/server.json +2 -2
  40. package/dist/mcp/server.mjs +1070 -408
  41. package/dist/scripts/_lib/host_launch.js +66 -0
  42. package/dist/scripts/_lib/host_launch.js.map +1 -0
  43. package/dist/server/io/yamlIO.js +175 -3
  44. package/dist/server/io/yamlIO.js.map +1 -1
  45. package/dist/server/routes/wizard.js +20 -1
  46. package/dist/server/routes/wizard.js.map +1 -1
  47. package/dist/shared/settingsConsent.js +25 -0
  48. package/dist/shared/settingsConsent.js.map +1 -1
  49. package/docs/contracts/settings-classes.md +11 -0
  50. package/docs/guidelines/agent-infra/roadmap-progress-mechanics.md +2 -1
  51. package/package.json +1 -1
  52. package/src/agent-src/scripts/resume_probe.ts +154 -9
  53. package/src/agent-src/scripts/roadmap_gates.ts +210 -6
  54. package/src/agent-src/templates/scripts/telemetry/remote.ts +571 -0
  55. package/src/agent-src/templates/scripts/telemetry/settings.ts +222 -44
  56. package/src/config/estate-count-budget.json +120 -0
  57. package/src/config/evaluator-budgets.json +4 -4
  58. package/src/config/gate-coverage.yml +32 -4
  59. package/src/config/gate-violation-baselines.json +63 -63
  60. package/src/config/gitignore-block.txt +5 -0
  61. package/src/config/hook-latency-budget.json +40 -1
  62. package/src/config/hook-token-budget.json +22 -0
  63. package/src/config/rule-stub-ceilings.json +11 -3
  64. package/src/scripts/_dispatch.bash +42 -1
  65. package/src/scripts/_lib/council_fallback_posture.ts +81 -0
  66. package/src/scripts/_lib/council_fallback_wiring.ts +126 -0
  67. package/src/scripts/_lib/council_settings_block.ts +152 -0
  68. package/src/scripts/_lib/headless_invocation.ts +285 -0
  69. package/src/scripts/_lib/host_launch.ts +72 -0
  70. package/src/scripts/_lib/map_to_object.ts +16 -0
  71. package/src/scripts/_lib/roadmap_checkboxes.ts +88 -0
  72. package/src/scripts/_lib/run_checkpoint.ts +409 -0
  73. package/src/scripts/_lib/session_register.ts +32 -1
  74. package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
  75. package/src/scripts/_lib/stdin.ts +35 -1
  76. package/src/scripts/_lib/turn_end_refusals.ts +12 -4
  77. package/src/scripts/_lib/unattended_guard.ts +371 -0
  78. package/src/scripts/ai_council/config.ts +27 -1
  79. package/src/scripts/ai_council/events_log.ts +12 -1
  80. package/src/scripts/ai_council/fallback_config.ts +114 -0
  81. package/src/scripts/ai_council/mid_flight_fallback.ts +295 -0
  82. package/src/scripts/ai_council/orchestrator.ts +251 -292
  83. package/src/scripts/ai_council/py_parity.ts +139 -0
  84. package/src/scripts/ai_council/response_render.ts +99 -0
  85. package/src/scripts/ai_council/spend_gate.ts +123 -0
  86. package/src/scripts/ai_council/transport_resolver.ts +81 -13
  87. package/src/scripts/bench_hook_injection.ts +100 -6
  88. package/src/scripts/bench_hook_latency.ts +442 -16
  89. package/src/scripts/check_estate_count.ts +924 -0
  90. package/src/scripts/compile_hook_manifest.ts +67 -0
  91. package/src/scripts/council_cli.ts +100 -125
  92. package/src/scripts/decision_memo.ts +325 -0
  93. package/src/scripts/dispatch_r2_reviewer.ts +169 -15
  94. package/src/scripts/hook_manifest.json +1 -0
  95. package/src/scripts/hook_manifest.yaml +172 -25
  96. package/src/scripts/hooks/concern_registry.ts +6 -0
  97. package/src/scripts/hooks/dispatch_hook.ts +183 -10
  98. package/src/scripts/hooks/dispatch_issues.ts +26 -0
  99. package/src/scripts/hooks/end_review_nudge_hook.ts +97 -24
  100. package/src/scripts/hooks/hook_stdin.ts +111 -5
  101. package/src/scripts/hooks/injection_budget.ts +595 -0
  102. package/src/scripts/hooks/payload_stub.ts +446 -0
  103. package/src/scripts/hooks/run_continuation_hook.ts +627 -0
  104. package/src/scripts/hooks/session_eol_hook.ts +52 -0
  105. package/src/scripts/hooks/skill_route_hook.ts +172 -11
  106. package/src/scripts/hooks/telemetry_usage_hook.ts +339 -0
  107. package/src/scripts/hooks/tool_result_bytes_hook.ts +27 -0
  108. package/src/scripts/hooks/turn_end_gate_hook.ts +45 -7
  109. package/src/scripts/hooks_doctor.ts +16 -2
  110. package/src/scripts/install-hooks.sh +26 -0
  111. package/src/scripts/interruption_report.ts +330 -13
  112. package/src/scripts/lint_hook_manifest.ts +167 -0
  113. package/src/scripts/roadmap_progress_hook.ts +254 -46
  114. package/src/scripts/run_supervise.ts +623 -0
  115. package/src/scripts/session_register_hook.ts +108 -8
  116. package/src/scripts/sessions_cli.ts +18 -6
  117. package/src/scripts/telemetry_disclosure_hook.ts +244 -0
  118. package/src/server/io/yamlIO.ts +167 -3
  119. package/src/shared/settingsConsent.ts +49 -2
  120. package/dist/cli-delegate/chunk-MMJL4TIX.js +0 -18
@@ -38,6 +38,15 @@
38
38
  * The result is measured, never read: `_resultBytes` computes a length and
39
39
  * discards the value in the same expression.
40
40
  *
41
+ * payload-bodies-waiver: result — measured, never read. This concern
42
+ * deliberately declares NO `needs_payload_bodies` in `hook_manifest.yaml`: the
43
+ * dispatcher omits the body and passes its exact UTF-8 byte length in the stub,
44
+ * which is the only thing this file ever wanted. Declaring `result` here would
45
+ * silently restore the 2 MB payload for the one concern the omission was built
46
+ * for (`road-to-per-turn-hook-economy` step 2.2), and the census would look
47
+ * identical while the cost came back. The `RESULT_KEYS` literals below are the
48
+ * key NAMES this concern looks under, not a read of the body.
49
+ *
41
50
  * ── Always exit 0 ─────────────────────────────────────────────────────
42
51
  *
43
52
  * A `warn` (exit 2) is read as a hard BLOCK on this host, and this concern
@@ -62,6 +71,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
62
71
 
63
72
  import { is_replay_mode } from './state_io.js';
64
73
  import { readHookStdin } from './hook_stdin.js';
74
+ import { stubbedBytes } from './payload_stub.js';
65
75
 
66
76
  const EXIT_ALLOW = 0;
67
77
 
@@ -98,11 +108,28 @@ const RESULT_KEYS = ['tool_response', 'toolResponse', 'tool_result', 'toolUseRes
98
108
  *
99
109
  * Bytes, not characters: a multibyte result costs what it costs on the
100
110
  * wire, and `String.length` would under-report it.
111
+ *
112
+ * ## The dispatcher may omit the body, and then it passes the length instead
113
+ *
114
+ * `road-to-per-turn-hook-economy` step 2.2. This concern declares no
115
+ * `needs_payload_bodies` in `hook_manifest.yaml` — its own header says the
116
+ * result is "measured, never read", and that is exactly the concern the
117
+ * payload opt-in exists for. So the dispatcher replaces the body with a stub
118
+ * carrying its UTF-8 byte length, computed the same way this function
119
+ * computes it, and this function reads it back.
120
+ *
121
+ * Without the stub branch the census would keep filling — with the ~120-byte
122
+ * length of the STUB, silently, forever. An instrument that reports a wrong
123
+ * number is worse than one that reports none, which is why the branch is
124
+ * first and why `stubbedBytes` distinguishes "omitted, this many bytes" from
125
+ * "present, measure it".
101
126
  */
102
127
  export function _resultBytes(payload: JsonObject): number | null {
103
128
  for (const key of RESULT_KEYS) {
104
129
  const v = payload[key];
105
130
  if (v === undefined || v === null) continue;
131
+ const omitted = stubbedBytes(v);
132
+ if (omitted !== undefined) return omitted;
106
133
  if (typeof v === 'string') return Buffer.byteLength(v, 'utf8');
107
134
  try {
108
135
  return Buffer.byteLength(JSON.stringify(v), 'utf8');
@@ -9,7 +9,7 @@
9
9
  * is deliberately not another reminder. It is a check at the point of
10
10
  * delivery that can say no.
11
11
  *
12
- * Three detectors ride on one guard, because building the unsafe part twice
12
+ * FOUR detectors ride on one guard, because building the unsafe part twice
13
13
  * is how a second detector becomes a second outage:
14
14
  *
15
15
  * A — promissory closing (FC-5, 20 measured occurrences)
@@ -20,6 +20,33 @@
20
20
  * prose, which is why `readTranscriptTail` collects
21
21
  * them: `_messageText` keeps `type === 'text'` blocks
22
22
  * only, so tool activity is invisible to A and B.)
23
+ * D — completion claim (a claim of done carrying no fresh evidence; landed
24
+ * under conformance round 7 § Phase 1.)
25
+ *
26
+ * TWO of the four are CONDITIONAL, and saying "unconditional" here would be the
27
+ * same stale-header defect this block corrects below. A and D are the
28
+ * completion-adjacent pair an open subagent dispatch excuses, so `main()` runs
29
+ * them only when `dispatchOpen` is false; B and C run on every turn-end. That
30
+ * narrowing is deliberate (Phase 3 Step 2, narrowed again by R2 round 2) and is
31
+ * the third allow path, alongside the two re-entrancy layers — see the
32
+ * per-detector list in `main()`.
33
+ *
34
+ * This block said "Three" and listed A/B/C until 2026-08-18, while `DetectorId`
35
+ * below has carried four since round 7. `DETECTOR_IDS` in
36
+ * `_lib/turn_end_refusals.ts` is read off that union rather than off this
37
+ * comment for exactly that reason, and the count is corrected here rather than
38
+ * left as a stale header the next reader has to disbelieve.
39
+ *
40
+ * ## Removal condition
41
+ *
42
+ * This is a BLOCKING concern, so it owes one — see
43
+ * `docs/contracts/turn-end-detector-demotion.md` for the pre-registered
44
+ * per-detector bars, their sample floors, and the reason a crossed bar
45
+ * authorises a staged study rather than a demotion. Two things that contract
46
+ * says about the guard below are worth meeting here: the re-entrancy layers cap
47
+ * a turn at ONE refusal, which makes a re-refusal share unobservable and the
48
+ * three-strikes non-termination valve unreachable. Both are properties of this
49
+ * design, not gaps in the counters.
23
50
  *
24
51
  * ## Re-entrancy — the shape, stated before registration
25
52
  *
@@ -997,10 +1024,18 @@ export function main(): number {
997
1024
  );
998
1025
  if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
999
1026
 
1000
- // Every detector runs on every turn-end. The gating is INSIDE each one —
1001
- // no promise, no pin mismatch, no unverified edit ⇒ no finding ⇒ the turn
1002
- // ends. That is the whole of "fires when it is warranted"; there is no
1003
- // second, configurable notion of warranted layered on top of it.
1027
+ // B and C run on every turn-end; A and D run only when no dispatch is open
1028
+ // (the ternaries below). For the detectors that DO run, the gating is INSIDE
1029
+ // each one — no promise, no pin mismatch, no unverified edit, no unsettled
1030
+ // completion claim ⇒ no finding ⇒ the turn ends. That is the whole of "fires
1031
+ // when it is warranted"; there is no second, configurable notion of warranted
1032
+ // layered on top of it.
1033
+ //
1034
+ // This said "Every detector runs on every turn-end" and enumerated three of
1035
+ // four, three lines above the two `dispatchOpen` ternaries that refute it.
1036
+ // Corrected 2026-08-19 with the header block at the top of this file: a reader
1037
+ // arriving here met the false claim first, and the D comment below deferred to
1038
+ // "the note at the top of this loop" — which was this sentence.
1004
1039
  const findings: Finding[] = [];
1005
1040
  for (const f of [
1006
1041
  // A and D are the completion-adjacent pair a pending dispatch excuses
@@ -1009,8 +1044,11 @@ export function main(): number {
1009
1044
  dispatchOpen ? null : detectPromissory(lastAssistant),
1010
1045
  detectLanguage(lastAssistant, readLanguagePin(workspaceRoot)),
1011
1046
  detectUnverifiedEdit(toolCalls),
1012
- // Round 7 § Phase 1 — detector D, in the same unconditional list as the
1013
- // other three. It shipped with its own settings flag one commit earlier
1047
+ // Round 7 § Phase 1 — detector D. It is NOT unconditional, and this
1048
+ // comment said it was while sitting one line above the `dispatchOpen`
1049
+ // ternary that conditions it: A and D are both excused by an open
1050
+ // dispatch, per the note at the top of this loop. Corrected 2026-08-18.
1051
+ // It shipped with its own settings flag one commit earlier
1014
1052
  // and that flag is gone: `hooks.turn_end_gate.*` was deleted on
1015
1053
  // 2026-08-12 because a default-off safety gate is an absent one. Its
1016
1054
  // gating is where the comment above says gating belongs — inside the
@@ -31,7 +31,10 @@ import fs from "node:fs";
31
31
  import path from "node:path";
32
32
  import { fileURLToPath, pathToFileURL } from "node:url";
33
33
 
34
- import { read_dispatch_issues } from "./hooks/dispatch_issues.js";
34
+ import {
35
+ POLICY_OUTCOME_ISSUES,
36
+ read_dispatch_issues,
37
+ } from "./hooks/dispatch_issues.js";
35
38
  import { type JsonObject, type JsonValue, _load_yaml } from "./hooks/dispatch_hook.js";
36
39
  import * as hooks_status from "./hooks_status.js";
37
40
  import {
@@ -240,9 +243,20 @@ export function collect(
240
243
 
241
244
  // Phase 1 of road-to-hooks-actually-fire-in-consumers: surface
242
245
  // the dispatch-issues log so users see hooks that tried and failed.
246
+ //
247
+ // POLICY OUTCOMES ARE FILTERED OUT, and the filter is the point: this view
248
+ // means "a hook tried to fire and couldn't", and its CTA is a reinstall. The
249
+ // two emission-shaping codes mean the opposite — the concern ran correctly and
250
+ // the dispatcher chose not to emit it — so they are neither a failure nor
251
+ // fixable by reinstalling. They fire as a routine per-dispatch outcome, so
252
+ // leaving them in would let normal traffic push a real `script_not_found` out
253
+ // of the last-20 window. They stay in the log, which is where the "why did
254
+ // that advisory not appear" question is answered. (R2 review, 2026-08-19.)
243
255
  let issues: JsonObject[] = [];
244
256
  try {
245
- issues = read_dispatch_issues(REPO_ROOT).slice(-20) as unknown as JsonObject[];
257
+ issues = read_dispatch_issues(REPO_ROOT)
258
+ .filter((e) => !POLICY_OUTCOME_ISSUES.has(String(e["issue"])))
259
+ .slice(-20) as unknown as JsonObject[];
246
260
  } catch {
247
261
  issues = [];
248
262
  }
@@ -35,6 +35,32 @@ cat > "$HOOKS_DIR/pre-push" << 'EOF'
35
35
  # so any derived-output drift blocks the push here. Runtime ~15-40s (it
36
36
  # regenerates the tool trees) — cheaper than a red CI run and a fixup re-push.
37
37
 
38
+ # A delete-only push has no content to gate, so every check below is answering
39
+ # a question nobody asked. Measured 2026-08-18 during the 14.0.0 release:
40
+ # `git push origin --delete release/14.0.0` was refused by the branch-freshness
41
+ # gate because the CHECKED-OUT branch was behind main — a fact with no bearing
42
+ # on removing a remote ref. The deletion had to go through the GitHub API to
43
+ # happen at all, which routes around the whole hook rather than around one gate.
44
+ #
45
+ # Git feeds pre-push one line per ref on stdin:
46
+ # <local ref> <local sha> <remote ref> <remote sha>
47
+ # and a deletion carries the all-zero local sha. Reading stdin is safe here:
48
+ # nothing below consumes it.
49
+ delete_only=1
50
+ saw_ref=0
51
+ while read -r _local_ref local_sha _remote_ref _remote_sha; do
52
+ [ -z "${local_sha:-}" ] && continue
53
+ saw_ref=1
54
+ # Any non-zero character means a real object is being pushed.
55
+ case "$local_sha" in
56
+ *[!0]*) delete_only=0 ;;
57
+ esac
58
+ done
59
+ if [ "$saw_ref" = "1" ] && [ "$delete_only" = "1" ]; then
60
+ echo "🗑️ Delete-only push — no ref advances, so the content gates have nothing to check. Skipping."
61
+ exit 0
62
+ fi
63
+
38
64
  fail=0
39
65
 
40
66
  if ! command -v task >/dev/null 2>&1; then
@@ -49,6 +49,8 @@ import * as fs from 'node:fs';
49
49
  import * as path from 'node:path';
50
50
  import { fileURLToPath, pathToFileURL } from 'node:url';
51
51
 
52
+ import { SESSION_ID_LEN, derive_session_tag } from './chat_history.js';
53
+
52
54
  export const DEFAULT_WINDOW = 30;
53
55
 
54
56
  export interface LedgerRow {
@@ -70,25 +72,79 @@ export interface HistoryTurn {
70
72
  export interface RunReport {
71
73
  run_id: string;
72
74
  roadmap: string | null;
73
- asks: number;
74
- handbacks: number;
75
- contacts: number;
76
- halts: number;
75
+ /**
76
+ * Contact counts, or `null` when this run has NO interruption-ledger
77
+ * entry — a session predating the ledger, or one whose stop hook never
78
+ * fired.
79
+ *
80
+ * R2 round 6, finding 8. These were built unconditionally from the ledger
81
+ * rows, so an absent run emitted `asks: 0, contacts: 0, halts: 0` — while
82
+ * this report's own note tells the reader such a run is "reported with the
83
+ * missing axis null". The aggregate median already excluded them; the ROWS
84
+ * did not, and a reader consuming rows would score an unmeasured run as
85
+ * zero contacts. That is precisely the arithmetic falsification criterion
86
+ * (3) of the pre-registered `user-out-of-loop-baseline` claim forbids —
87
+ * "scoring an unmeasured run as zero contacts is the arithmetic that would
88
+ * manufacture the result".
89
+ */
90
+ asks: number | null;
91
+ handbacks: number | null;
92
+ contacts: number | null;
93
+ halts: number | null;
77
94
  elapsed_minutes: number | null;
78
95
  waiting_minutes: number | null;
79
96
  working_minutes: number | null;
97
+ /** `engage` events for this run in `run-continuation.jsonl`. */
98
+ reengagements: number;
99
+ /** `halt-stall` events — the anti-stall rung firing, not the loop working. */
100
+ stall_halts: number;
101
+ /** Relaunches the supervisor has spent on this run. */
102
+ relaunches: number;
103
+ /** Decision memos this run wrote instead of contacting the user. */
104
+ memos: number;
80
105
  }
81
106
 
107
+ /**
108
+ * Runs required before either pre-registered claim may be compared.
109
+ *
110
+ * Fixed BEFORE any data by `user-out-of-loop-baseline` and
111
+ * `roadmap-wall-clock-baseline` in `docs/CLAIMS.md`, both falsification
112
+ * criterion (3): "≥ 20 recorded runs before any comparison, else
113
+ * UNDERPOWERED". Named here so the report states the floor it is judging
114
+ * itself against rather than leaving a reader to look it up.
115
+ */
116
+ export const POWER_FLOOR_RUNS = 20;
117
+
82
118
  export interface Report {
83
119
  window_requested: number;
84
120
  sessions_found: number;
85
121
  window_short: boolean;
86
122
  synthetic_user_turns_excluded: number;
123
+ /** Runs carrying a contact count — the CONTACT axis's own denominator. */
124
+ contact_axis_runs: number;
125
+ /** Runs carrying timing — the WALL-CLOCK axis's own, much smaller, denominator. */
126
+ wall_clock_axis_runs: number;
127
+ /**
128
+ * Agent→user turnarounds behind `median_user_wait_minutes`.
129
+ *
130
+ * A THIRD denominator, and it is neither axis's: one run contributes as
131
+ * many waits as it had turnarounds, so this count is in gaps where the two
132
+ * above are in runs. Kept separate rather than folded into either.
133
+ */
134
+ user_wait_gaps: number;
135
+ /** The pre-registered floor both claims fix before any comparison. */
136
+ power_floor_runs: number;
87
137
  runs: RunReport[];
88
138
  median_contacts_per_run: number | null;
89
139
  median_user_wait_minutes: number | null;
90
140
  median_elapsed_minutes: number | null;
91
141
  median_working_minutes: number | null;
142
+ /** Phase 5.0 — the autonomy axis. */
143
+ median_reengagements_per_run: number | null;
144
+ /** Share of runs whose loop hit the stall rung. `null` with no runs. */
145
+ stall_halt_rate: number | null;
146
+ median_relaunches_per_run: number | null;
147
+ median_memos_per_run: number | null;
92
148
  notes: string[];
93
149
  }
94
150
 
@@ -107,6 +163,140 @@ export function isSyntheticUserText(text: string): boolean {
107
163
  return SYNTHETIC_MARKERS.some((m) => text.includes(m));
108
164
  }
109
165
 
166
+ /**
167
+ * Look one run's value up in a source keyed differently from the ledger.
168
+ *
169
+ * R2 review, finding 2. Three producers write three key spaces for the same
170
+ * run and the report joined them with exact lookups, so `reengagements`,
171
+ * `stall_halts`, `stall_halt_rate` and `relaunches` read 0 / 0 % for every
172
+ * real run while the data sat on disk:
173
+ *
174
+ * - the interruption ledger `derive_session_tag(id)` = 16 hex
175
+ * - run-continuation.jsonl `deriveSessionKey(id)` = 32 hex
176
+ * - supervise-relaunches the RAW `session_id`
177
+ *
178
+ * A zero meaning "no instrument" and a zero meaning "the key did not match"
179
+ * are indistinguishable in the output, and this report prints an explicit
180
+ * NO INSTRUMENT for two other axes precisely to avoid the first — so a silent
181
+ * second kind was the worse failure.
182
+ *
183
+ * Resolution is EXACT-FIRST and lives here rather than in the readers: a
184
+ * reader that rewrote its own keys would have to guess what an unrecognised
185
+ * string means, and guessing "hash it" turns a would-have-matched key into a
186
+ * guaranteed miss. The readers keep reporting what is on disk; the join owns
187
+ * the reconciliation, because only the join knows the ledger id it is
188
+ * looking for.
189
+ *
190
+ * The 32-hex key is a prefix-extension of the 16-hex tag ONLY because a
191
+ * session id carries no whitespace for `fingerprint` to normalise away.
192
+ * `interruption_report.test.ts` pins that equivalence rather than assuming it.
193
+ */
194
+ export function lookupByRunId<T>(
195
+ source: ReadonlyMap<string, T> | Readonly<Record<string, T>>,
196
+ runId: string,
197
+ ): T | undefined {
198
+ const get = (k: string): T | undefined =>
199
+ source instanceof Map ? source.get(k) : (source as Record<string, T>)[k];
200
+ // 1. The key as the ledger spells it. Covers a source that already agrees,
201
+ // and every fixture that writes one id everywhere.
202
+ const exact = get(runId);
203
+ if (exact !== undefined) return exact;
204
+ // 2. A 32-hex continuation key whose first SESSION_ID_LEN chars are this tag.
205
+ const keys: string[] = source instanceof Map ? [...source.keys()] : Object.keys(source);
206
+ for (const k of keys) {
207
+ if (k.length === 32 && /^[0-9a-f]+$/.test(k) && k.slice(0, SESSION_ID_LEN) === runId) {
208
+ return get(k);
209
+ }
210
+ }
211
+ // 3. A raw session id that hashes to this tag.
212
+ for (const k of keys) {
213
+ if (derive_session_tag(k) === runId) return get(k);
214
+ }
215
+ return undefined;
216
+ }
217
+
218
+ /**
219
+ * Phase 5.0 — the autonomy axis, from three runtime sources.
220
+ *
221
+ * Each reader is best-effort and returns an empty map on any failure. That is
222
+ * correct rather than lenient: a run that predates the mechanism genuinely had
223
+ * zero re-engagements, and refusing to report the other axes because one
224
+ * source is absent would make the whole report unavailable on a fresh install.
225
+ */
226
+ export function readContinuationEvents(
227
+ root: string,
228
+ ): Map<string, { engage: number; stall: number }> {
229
+ const out = new Map<string, { engage: number; stall: number }>();
230
+ let text: string;
231
+ try {
232
+ text = fs.readFileSync(
233
+ path.join(root, 'agents', 'runtime', 'state', 'run-continuation.jsonl'),
234
+ 'utf-8',
235
+ );
236
+ } catch {
237
+ return out;
238
+ }
239
+ for (const line of text.split('\n')) {
240
+ if (line.trim() === '') continue;
241
+ let rec: unknown;
242
+ try {
243
+ rec = JSON.parse(line);
244
+ } catch {
245
+ continue;
246
+ }
247
+ if (rec === null || typeof rec !== 'object' || Array.isArray(rec)) continue;
248
+ const o = rec as Record<string, unknown>;
249
+ const id = o['run_id'];
250
+ const ev = o['event'];
251
+ if (typeof id !== 'string' || typeof ev !== 'string') continue;
252
+ const cur = out.get(id) ?? { engage: 0, stall: 0 };
253
+ if (ev === 'engage') cur.engage += 1;
254
+ else if (ev === 'halt-stall') cur.stall += 1;
255
+ out.set(id, cur);
256
+ }
257
+ return out;
258
+ }
259
+
260
+ export function readRelaunchLedger(root: string): Record<string, number> {
261
+ try {
262
+ const raw: unknown = JSON.parse(
263
+ fs.readFileSync(
264
+ path.join(root, 'agents', 'runtime', 'state', 'supervise-relaunches.json'),
265
+ 'utf-8',
266
+ ),
267
+ );
268
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return {};
269
+ const out: Record<string, number> = {};
270
+ for (const [k, v] of Object.entries(raw as Record<string, unknown>)) {
271
+ if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[k] = v;
272
+ }
273
+ return out;
274
+ } catch {
275
+ return {};
276
+ }
277
+ }
278
+
279
+ export function readMemoCounts(root: string): Record<string, number> {
280
+ const base = path.join(root, 'agents', 'runtime', 'state', 'decisions');
281
+ const out: Record<string, number> = {};
282
+ let runs: string[];
283
+ try {
284
+ runs = fs.readdirSync(base);
285
+ } catch {
286
+ return out;
287
+ }
288
+ for (const run of runs) {
289
+ try {
290
+ out[run] = fs
291
+ .readdirSync(path.join(base, run))
292
+ .filter((n) => /^\d{3}\.md$/.test(n)).length;
293
+ } catch {
294
+ // a file where a directory was expected — not a run
295
+ }
296
+ }
297
+ return out;
298
+ }
299
+
110
300
  export function median(values: readonly number[]): number | null {
111
301
  if (values.length === 0) return null;
112
302
  const sorted = [...values].sort((a, b) => a - b);
@@ -237,6 +427,13 @@ export function buildReport(root: string, windowRequested: number): Report {
237
427
  // null wall clock rather than disappearing from the denominator.
238
428
  for (const id of ledgerRuns) if (!windowedIds.has(id)) runIds.add(id);
239
429
 
430
+ // Phase 5.0 sources. All three are gitignored runtime state, all three are
431
+ // read best-effort, and an absent one yields zeros rather than an error —
432
+ // a run predating the mechanism genuinely had zero re-engagements.
433
+ const continuation = readContinuationEvents(root);
434
+ const relaunches = readRelaunchLedger(root);
435
+ const memosByRun = readMemoCounts(root);
436
+
240
437
  const allGaps: number[] = [];
241
438
  const runs: RunReport[] = [];
242
439
 
@@ -265,16 +462,31 @@ export function buildReport(root: string, windowRequested: number): Report {
265
462
  if (elapsed !== null) working = Math.max(0, elapsed - waiting);
266
463
  }
267
464
 
465
+ const cont = lookupByRunId(continuation, runId) ?? { engage: 0, stall: 0 };
466
+ const inLedger = ledgerRuns.has(runId);
268
467
  runs.push({
269
468
  run_id: runId,
270
469
  roadmap: rows.find((r) => r.roadmap !== null)?.roadmap ?? null,
271
- asks,
272
- handbacks,
273
- contacts: contacts.length,
274
- halts,
470
+ // `null`, not 0, when this run is absent from the ledger — see
471
+ // the field docs above and falsification criterion (3).
472
+ asks: inLedger ? asks : null,
473
+ handbacks: inLedger ? handbacks : null,
474
+ contacts: inLedger ? contacts.length : null,
475
+ halts: inLedger ? halts : null,
275
476
  elapsed_minutes: elapsed,
276
477
  waiting_minutes: waiting,
277
478
  working_minutes: working,
479
+ reengagements: cont.engage,
480
+ stall_halts: cont.stall,
481
+ // By ROADMAP, not by run id: the relaunch ledger is keyed on the
482
+ // roadmap slug so its per-run cap survives the new session id a
483
+ // relaunch produces (see `run_supervise.RelaunchLedger`). Joining
484
+ // it on the run id would read 0 for every run — the same
485
+ // permanent-zero shape finding 2 fixed one source over.
486
+ relaunches: (rows.find((r) => r.roadmap !== null)?.roadmap ?? null) === null
487
+ ? 0
488
+ : (relaunches[rows.find((r) => r.roadmap !== null)?.roadmap as string] ?? 0),
489
+ memos: lookupByRunId(memosByRun, runId) ?? 0,
278
490
  });
279
491
  }
280
492
 
@@ -303,10 +515,40 @@ export function buildReport(root: string, windowRequested: number): Report {
303
515
  sessions_found: bySession.size,
304
516
  window_short: bySession.size < windowRequested,
305
517
  synthetic_user_turns_excluded: synthetic,
518
+ // Per-axis N against the pre-registered ≥ 20-run floor. The two axes
519
+ // read DIFFERENT sources — the ledger is a committed append-only file,
520
+ // the chat history is a rolling buffer — so their effective N diverges
521
+ // hard, and one banner over both cannot say which is underpowered.
522
+ //
523
+ // Measured 2026-08-19: **19** runs carried contacts and 4 carried
524
+ // timing, out of 21 runs in the window. NEITHER axis cleared the
525
+ // floor. The two runs in the gap carry timing and no ledger entry, so
526
+ // they raise the total and not the contact axis — which is the whole
527
+ // reason the total may not be read as either axis's N.
528
+ //
529
+ // R2 round 1, finding 1: this comment first said "21 runs carried
530
+ // contacts … the contact axis had cleared its own floor", i.e. it
531
+ // shipped the exact misreading this field exists to prevent, as a
532
+ // fact, inside the fix for it. The roadmap and CLAIMS.md in the same
533
+ // change said 19 and "it had not". Worth leaving on the record: the
534
+ // number is easy to get wrong even while writing the guard against
535
+ // getting it wrong, which is the argument for reading it off the
536
+ // field rather than off the total.
537
+ //
538
+ // `runsWithLedger` is reused rather than re-filtered (finding 4):
539
+ // `r.contacts` is null exactly when the run is absent from
540
+ // `ledgerRuns`, so a second predicate is a second thing to keep true.
541
+ contact_axis_runs: runsWithLedger,
542
+ wall_clock_axis_runs: runs.filter((r) => r.elapsed_minutes !== null).length,
543
+ user_wait_gaps: allGaps.length,
544
+ power_floor_runs: POWER_FLOOR_RUNS,
306
545
  runs,
307
546
  notes,
308
547
  median_contacts_per_run: median(
309
- runs.filter((r) => ledgerRuns.has(r.run_id)).map((r) => r.contacts),
548
+ runs
549
+ .filter((r) => ledgerRuns.has(r.run_id))
550
+ .map((r) => r.contacts)
551
+ .filter((v): v is number => v !== null),
310
552
  ),
311
553
  median_user_wait_minutes: median(allGaps),
312
554
  median_elapsed_minutes: median(
@@ -315,6 +557,14 @@ export function buildReport(root: string, windowRequested: number): Report {
315
557
  median_working_minutes: median(
316
558
  runs.map((r) => r.working_minutes).filter((v): v is number => v !== null),
317
559
  ),
560
+ median_reengagements_per_run: median(runs.map((r) => r.reengagements)),
561
+ // A RATE, not a count: the question is what share of runs hit the
562
+ // anti-stall rung, and a raw count would rise with the window size and
563
+ // read as a regression when nothing changed.
564
+ stall_halt_rate:
565
+ runs.length === 0 ? null : runs.filter((r) => r.stall_halts > 0).length / runs.length,
566
+ median_relaunches_per_run: median(runs.map((r) => r.relaunches)),
567
+ median_memos_per_run: median(runs.map((r) => r.memos)),
318
568
  };
319
569
  }
320
570
 
@@ -322,6 +572,20 @@ function fmt(v: number | null, unit = ''): string {
322
572
  return v === null ? 'n/a' : `${Math.round(v * 10) / 10}${unit}`;
323
573
  }
324
574
 
575
+ /**
576
+ * The power verdict for ONE axis, printed on that axis's own header.
577
+ *
578
+ * `UNDERPOWERED` is the word the claims themselves use, so the reader meets
579
+ * the same term the falsification criterion is written in. The count is always
580
+ * shown, including when the floor is cleared — a bare "OK" would leave a
581
+ * reader unable to tell 20 from 200.
582
+ */
583
+ function power(n: number, floor: number): string {
584
+ return n >= floor
585
+ ? ` · n=${n} runs (≥ ${floor} floor cleared)`
586
+ : ` · n=${n} runs — ⚠️ UNDERPOWERED, floor is ${floor}`;
587
+ }
588
+
325
589
  export function renderText(report: Report): string {
326
590
  const lines: string[] = [];
327
591
  lines.push('interruption_report — road-to-user-out-of-the-loop Phase 0');
@@ -332,19 +596,72 @@ export function renderText(report: Report): string {
332
596
  );
333
597
  lines.push(`synthetic user turns excluded: ${report.synthetic_user_turns_excluded}`);
334
598
  lines.push('');
335
- lines.push('CONTACT AXIS');
599
+ lines.push(`CONTACT AXIS${power(report.contact_axis_runs, report.power_floor_runs)}`);
336
600
  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
601
  lines.push('');
339
- lines.push('WALL-CLOCK AXIS');
602
+ lines.push(`WALL-CLOCK AXIS${power(report.wall_clock_axis_runs, report.power_floor_runs)}`);
340
603
  lines.push(` median elapsed per run: ${fmt(report.median_elapsed_minutes, ' min')}`);
341
604
  lines.push(` median agent working time: ${fmt(report.median_working_minutes, ' min')}`);
605
+ // `user wait` sits HERE and not under the contact axis, and it carries a
606
+ // third denominator of its own.
607
+ //
608
+ // R2 round 2, finding 1. It used to print under CONTACT AXIS, directly
609
+ // beneath that axis's ledger-sourced n — so the per-axis fix reproduced
610
+ // the very conflation it was written to remove, one line further down.
611
+ // `roadmap-wall-clock-baseline` settles which axis owns it: WAITING is
612
+ // named as that claim's own instrument, derived from chat-history
613
+ // timestamps, i.e. the source whose N is the small one.
614
+ //
615
+ // And the unit is gaps, not runs: one run contributes as many waits as it
616
+ // had agent→user turnarounds. Printing a run-count next to it would be the
617
+ // second half of the same error, so it carries its own count.
618
+ lines.push(
619
+ ` median user wait: ${fmt(report.median_user_wait_minutes, ' min')}` +
620
+ ` · n=${report.user_wait_gaps} gap(s), not runs`,
621
+ );
342
622
  lines.push('');
343
- lines.push(`runs: ${report.runs.length}`);
623
+ lines.push('AUTONOMY AXIS (Phase 5.0)');
624
+ lines.push(` median re-engagements: ${fmt(report.median_reengagements_per_run)}`);
625
+ lines.push(
626
+ ` stall-halt rate: ${
627
+ report.stall_halt_rate === null
628
+ ? 'n/a'
629
+ : `${Math.round(report.stall_halt_rate * 1000) / 10}%`
630
+ }`,
631
+ );
632
+ lines.push(` median relaunches per run: ${fmt(report.median_relaunches_per_run)}`);
633
+ lines.push(` median memos per run: ${fmt(report.median_memos_per_run)}`);
634
+ // Two axes the roadmap names and this instrument CANNOT report. Stated as
635
+ // no-instrument rather than printed as 0, because a zero here would read
636
+ // as "measured, and it is zero" — the exact confusion between an absent
637
+ // record and an absent event that this suite records elsewhere.
638
+ // "cannot run YET" and "pre-registered" were both true when written and
639
+ // are both false now: the spawn is a published refusal
640
+ // (road-to-long-horizon-execution 4.0) and `unattended-demotion-gate` took
641
+ // its honest-null path in the same change-set that refused it. R2 round 2,
642
+ // finding 2 — `run_supervise.ts` had its identical wording rewritten and
643
+ // this sibling site in the same diff was missed, which is what the
644
+ // defect-pattern sweep exists to catch and did not.
645
+ lines.push(' unattended-vs-attended rework: NO INSTRUMENT, and permanently so — the');
646
+ lines.push(' unattended lane will not run: the spawn is a published REFUSAL, not an');
647
+ lines.push(' unbuilt feature (road-to-long-horizon-execution 4.0). `unattended-demotion-gate`');
648
+ lines.push(' took its honest-null path and is CLOSED — do not read it as a live threshold.');
649
+ lines.push(' memo revisit rate: NO INSTRUMENT — a memo carries no revisit marker,');
650
+ lines.push(' so a revisited decision is indistinguishable from one nobody reopened.');
651
+ lines.push('');
652
+ // The total carries its caveat inline, because the roadmap blames THIS
653
+ // line for the live misreading and round 1 left it untouched (R2 round 2,
654
+ // finding 11): a per-axis n printed thirty lines earlier does not reach a
655
+ // reader who scrolled to the number that looks like the sample size.
656
+ lines.push(
657
+ `runs: ${report.runs.length} · NOT either axis's N — see the per-axis n above ` +
658
+ `(contact ${report.contact_axis_runs}, wall-clock ${report.wall_clock_axis_runs})`,
659
+ );
344
660
  for (const run of report.runs) {
345
661
  lines.push(
346
662
  ` ${run.run_id} asks=${run.asks} handbacks=${run.handbacks} halts=${run.halts}` +
347
663
  ` elapsed=${fmt(run.elapsed_minutes)} working=${fmt(run.working_minutes)}` +
664
+ ` re=${run.reengagements} stall=${run.stall_halts} relaunch=${run.relaunches} memo=${run.memos}` +
348
665
  ` roadmap=${run.roadmap ?? '-'}`,
349
666
  );
350
667
  }