peaks-loop 4.0.47 → 4.0.49

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 (126) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/agents/karpathy-reviewer.md +11 -10
  5. package/dist/cli/cli-helpers.d.ts +34 -0
  6. package/dist/cli/cli-helpers.js +57 -0
  7. package/dist/cli/commands/code-job-shape-commands.js +8 -0
  8. package/dist/cli/commands/code-runtime-commands.js +48 -8
  9. package/dist/cli/commands/compact-command.js +110 -0
  10. package/dist/cli/commands/config-commands.js +15 -9
  11. package/dist/cli/commands/dashboard-long-run.js +6 -0
  12. package/dist/cli/commands/dispatch-commands.js +11 -1
  13. package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
  14. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  15. package/dist/cli/commands/feedback-commands.js +49 -17
  16. package/dist/cli/commands/final-review-commands.js +12 -0
  17. package/dist/cli/commands/hooks-commands.js +4 -4
  18. package/dist/cli/commands/job-commands.js +8 -0
  19. package/dist/cli/commands/loop-eval-commands.js +31 -0
  20. package/dist/cli/commands/perf-audit-commands.js +2 -0
  21. package/dist/cli/commands/playwright-commands.js +12 -0
  22. package/dist/cli/commands/prd-commands.js +1 -1
  23. package/dist/cli/commands/qa-commands.js +22 -0
  24. package/dist/cli/commands/request-commands.js +8 -0
  25. package/dist/cli/commands/scan-commands.js +1 -1
  26. package/dist/cli/commands/security-audit-commands.js +2 -0
  27. package/dist/cli/commands/slice-integrate-commands.js +22 -0
  28. package/dist/cli/commands/statusline-commands.js +44 -4
  29. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  30. package/dist/cli/commands/sub-agent/detached.js +47 -22
  31. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  32. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  33. package/dist/cli/commands/workflow-commands.js +1 -1
  34. package/dist/cli/index.js +5 -45
  35. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  36. package/dist/services/artifacts/artifact-prerequisites.js +140 -65
  37. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  38. package/dist/services/artifacts/request-artifact-service.js +77 -46
  39. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  40. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  41. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  42. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  43. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  44. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  45. package/dist/services/audit-independent/security-audit-service.js +28 -6
  46. package/dist/services/code/auto-compact-lifecycle.d.ts +194 -0
  47. package/dist/services/code/auto-compact-lifecycle.js +229 -11
  48. package/dist/services/code/auto-compact-orchestrator.js +118 -7
  49. package/dist/services/code/compact-event-settle.d.ts +134 -0
  50. package/dist/services/code/compact-event-settle.js +240 -0
  51. package/dist/services/compact-history/compact-history-service.d.ts +14 -0
  52. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  53. package/dist/services/config/config-restore.d.ts +12 -1
  54. package/dist/services/config/config-restore.js +35 -4
  55. package/dist/services/config/config-rollback.js +6 -1
  56. package/dist/services/context/auto-compact-types.d.ts +20 -2
  57. package/dist/services/context/harness-context-witness.d.ts +310 -0
  58. package/dist/services/context/harness-context-witness.js +606 -0
  59. package/dist/services/evidence/evidence-generator.js +86 -49
  60. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  61. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  62. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  63. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  64. package/dist/services/final-review/final-review-service.d.ts +9 -0
  65. package/dist/services/final-review/final-review-service.js +36 -12
  66. package/dist/services/ide/ide-registry.d.ts +19 -0
  67. package/dist/services/ide/ide-registry.js +21 -0
  68. package/dist/services/job/job-progress-store.js +18 -3
  69. package/dist/services/job/job-state-store.js +7 -0
  70. package/dist/services/observability/jsonl-store.d.ts +19 -0
  71. package/dist/services/observability/jsonl-store.js +27 -2
  72. package/dist/services/observability/observability-service.d.ts +10 -3
  73. package/dist/services/observability/observability-service.js +16 -3
  74. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  75. package/dist/services/prd/handoff-auto-regen.js +31 -27
  76. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  77. package/dist/services/prd/handoff-frontmatter.js +75 -0
  78. package/dist/services/prd/handoff-service.d.ts +41 -2
  79. package/dist/services/prd/handoff-service.js +124 -8
  80. package/dist/services/prd/handoff-types.d.ts +3 -2
  81. package/dist/services/prd/handoff-types.js +3 -2
  82. package/dist/services/qa/qa-business-review-state.js +23 -0
  83. package/dist/services/sc/sc-service.d.ts +8 -0
  84. package/dist/services/sc/sc-service.js +8 -1
  85. package/dist/services/scan/karpathy-service.js +2 -2
  86. package/dist/services/session/getSessionDir.d.ts +33 -0
  87. package/dist/services/session/getSessionDir.js +60 -0
  88. package/dist/services/session/session-checkpoint-service.js +8 -0
  89. package/dist/services/skill/resume-detector.js +29 -11
  90. package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
  91. package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
  92. package/dist/services/skills/hooks-settings-service.js +14 -4
  93. package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
  94. package/dist/services/skills/session-start-hook-constants.js +45 -0
  95. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  96. package/dist/services/slice/slice-check-service.js +29 -11
  97. package/dist/services/slice/slice-review-state.js +23 -0
  98. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  99. package/dist/services/workflow/pipeline-verify-gate-support.js +221 -103
  100. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  101. package/dist/services/workflow/pipeline-verify-service.js +47 -33
  102. package/dist/services/workflow/pipeline-verify-types.d.ts +15 -6
  103. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  104. package/dist/services/workspace/claude-settings-template.js +98 -20
  105. package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
  106. package/dist/shared/runtime-root.d.ts +73 -0
  107. package/dist/shared/runtime-root.js +77 -0
  108. package/package.json +6 -6
  109. package/skills/bee/peaks-prd/SKILL.md +7 -5
  110. package/skills/bee/peaks-qa/SKILL.md +5 -5
  111. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  112. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  113. package/skills/bee/peaks-rd/SKILL.md +8 -6
  114. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  115. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  116. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  117. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  118. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  119. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  120. package/skills/peaks-code/SKILL.md +1 -1
  121. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  122. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  123. package/skills/peaks-code/references/resume-detection.md +13 -7
  124. package/skills/peaks-code/references/runbook.md +3 -2
  125. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  126. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
@@ -0,0 +1,240 @@
1
+ /**
2
+ * rid `2026-09-13-compact-event-settle` — what runs when the HARNESS says a
3
+ * compaction completed.
4
+ *
5
+ * THE PROBLEM THIS EXISTS TO DELETE. Before this slice peaks-loop learned that
6
+ * a compaction had happened by INFERENCE: `src/services/code/auto-compact-orchestrator.ts`
7
+ * probes the context ratio on every run, and a ratio that has fallen back below
8
+ * the auto-fire threshold is taken as proof that a previously-dispatched
9
+ * compact landed. That is a guess with three failure modes, all silent — the
10
+ * probe may not measure at all (`conservative-fallback`), the next probe may
11
+ * not come for minutes (during which the ratio has already climbed back up),
12
+ * and a compaction that left the ratio high is invisible.
13
+ *
14
+ * `PostCompact` is the harness STATING it. This module turns that statement
15
+ * into the same lifecycle settlement the probe path produces, plus an
16
+ * `observed` row carrying the one fact the probe path can never produce: what
17
+ * the harness said the compaction WAS — `manual` or `auto`.
18
+ *
19
+ * FOUR THINGS THIS MODULE REFUSES TO DO, EACH FOR A REASON:
20
+ *
21
+ * 1. It does not invent a `trigger`. `PostCompact`'s payload schema is
22
+ * truncated in the retrievable docs, so a payload without the field is an
23
+ * expected input. Absent stays absent. Defaulting it to `'auto'` would
24
+ * answer "has this machine ever auto-compacted?" with a fabricated yes,
25
+ * which is worse than the current "no answer".
26
+ * 2. It does not settle when no run is open. A `PostCompact` on a session
27
+ * peaks-loop never dispatched for has nothing to attribute, and
28
+ * `CompactHistoryEvent.beforeRatio` is a required number that could only
29
+ * be filled with a ratio measured AFTER the compaction — a fabricated
30
+ * "before". See the RD tech-doc §D8: covering that case needs a
31
+ * `PreCompact` snapshot and is a separate slice, not a widened contract.
32
+ * 3. It does not let a stale measurement become an `afterRatio`. The probe's
33
+ * statusline source may still hold the pre-compact value at the instant
34
+ * this runs; `settleOpenLifecycleRunOnCompactEvent` drops anything that is
35
+ * not below the dispatch ratio.
36
+ * 4. It does not settle a run off an event that names a DIFFERENT harness
37
+ * session, and it does not label one `'main'` without checking. A payload
38
+ * whose `session_id` is another session's would otherwise close this
39
+ * project's open run and file the row as the main session's. The check is
40
+ * best-effort in one direction only: an absent `session_id`, or a project
41
+ * whose own harness session id cannot be resolved, is accepted rather than
42
+ * refused — a guard that cannot see the name it is checking against must
43
+ * not turn a missing field into a hook that never settles anything.
44
+ *
45
+ * NOTHING HERE THROWS. The caller is a hook on the harness's compaction path,
46
+ * where a failure must cost a telemetry row and nothing else.
47
+ */
48
+ import { resolveAutoCompactProfile } from '../mode/mode-status-service.js';
49
+ import { readContextPercent } from '../context/auto-compact-reader.js';
50
+ import { resolveOuterSessionId } from '../session/binding-status-service.js';
51
+ import { appendCompactHistoryEvent } from './auto-compact-orchestrator.js';
52
+ import { settleOpenLifecycleRunOnCompactEvent } from './auto-compact-lifecycle.js';
53
+ /**
54
+ * Which layer settled the run. `post-compact-hook` = the harness's event;
55
+ * `post-compact-probe` (written by the orchestrator) = a later probe's
56
+ * measurement. The two are deliberately distinct strings so a reader of
57
+ * `compact-history.jsonl` can tell a NOTIFICATION from an INFERENCE — which is
58
+ * the entire instrument this slice exists to build.
59
+ */
60
+ export const COMPACT_EVENT_PATHWAY = 'post-compact-hook';
61
+ /**
62
+ * Read `trigger` out of an already-parsed `PostCompact` payload.
63
+ *
64
+ * Anything that is not exactly `'manual'` or `'auto'` yields `undefined` — a
65
+ * missing field, a `null`, a number, another string, a payload that is not an
66
+ * object at all. This function never throws and never guesses (see this file's
67
+ * header, refusal 1).
68
+ */
69
+ export function readTriggerFromHookPayload(payload) {
70
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
71
+ return undefined;
72
+ const raw = payload.trigger;
73
+ return raw === 'manual' || raw === 'auto' ? raw : undefined;
74
+ }
75
+ /**
76
+ * Read the harness session id out of an already-parsed `PostCompact` payload.
77
+ *
78
+ * Same contract as `readTriggerFromHookPayload`: a missing field, an empty
79
+ * string, a number, a non-object payload — every one of those is `undefined`,
80
+ * and "absent" is an expected input rather than an error (see this file's
81
+ * header, refusal 4, for what the caller does with it and what it does with
82
+ * `undefined`).
83
+ */
84
+ export function readSessionIdFromHookPayload(payload) {
85
+ if (typeof payload !== 'object' || payload === null || Array.isArray(payload))
86
+ return undefined;
87
+ const raw = payload.session_id;
88
+ return typeof raw === 'string' && raw.length > 0 ? raw : undefined;
89
+ }
90
+ /**
91
+ * The post-compact ruler: the same probe the orchestrator uses, so this row
92
+ * divides by the same denominator the dispatch did.
93
+ *
94
+ * `ratio` is `null` when nothing could be measured — a `conservative-fallback`
95
+ * probe reports `0`, but that `0` means "unknown", and recording it here would
96
+ * publish a real, infinitely-deep drop. The rest of the reading (adapter, the
97
+ * window the ratio divides by) is still carried, because knowing WHICH adapter
98
+ * could not measure is diagnostic.
99
+ */
100
+ export function measurePostCompact(input) {
101
+ const probe = readContextPercent({
102
+ projectRoot: input.projectRoot,
103
+ sessionId: input.sessionId,
104
+ outerSessionId: resolveOuterSessionId(input.projectRoot, input.sessionId, input.env),
105
+ env: input.env
106
+ });
107
+ const unmeasurable = probe.source === 'conservative-fallback';
108
+ return {
109
+ ratio: unmeasurable ? null : probe.ratio,
110
+ ide: probe.ide,
111
+ windowTokens: unmeasurable || typeof probe.capacityTokens !== 'number' ? null : probe.capacityTokens,
112
+ windowSource: unmeasurable ? null : (probe.capacitySource ?? null)
113
+ };
114
+ }
115
+ /**
116
+ * Settle whatever compact run is open in `sessionId`, because the harness just
117
+ * reported one.
118
+ *
119
+ * `measure` is the ruler, injected rather than called directly for one concrete
120
+ * reason: the production ruler prefers `~/.claude/statusline-state.json`, a real
121
+ * file whose contents differ on every machine that runs the test suite. A test
122
+ * asserting this row's `afterRatio` against the developer's own statusline would
123
+ * pass or fail by accident. It is called at most once — the IDE tag and the
124
+ * window come from the same reading, so they cannot disagree with each other.
125
+ *
126
+ * Returns `{ settled: false }` when there is no open run — the honest answer,
127
+ * not an error — and when the payload names another harness session. The two
128
+ * say different things in `reason`, because they are different facts.
129
+ */
130
+ export function settleCompactFromHarnessEvent(input) {
131
+ const env = input.env ?? process.env;
132
+ const measure = input.measure ??
133
+ (() => measurePostCompact({ projectRoot: input.projectRoot, sessionId: input.sessionId, env }));
134
+ // Attribution first, and before the probe: an event that is not about this
135
+ // session must not spend a measurement, and must not touch this run at all.
136
+ // The comparison is harness-id to harness-id — the payload's `session_id` and
137
+ // this project's own outer session id as `resolveOuterSessionId` resolves it
138
+ // (env signal, then the binding's recorded id). It is never compared against
139
+ // the peaks-loop session id, which is a different namespace and would differ
140
+ // on every legitimate event.
141
+ if (input.hookSessionId !== undefined) {
142
+ const ownOuterSessionId = resolveOuterSessionId(input.projectRoot, input.sessionId, env);
143
+ if (ownOuterSessionId !== undefined && ownOuterSessionId !== input.hookSessionId) {
144
+ return { settled: false, reason: 'different-session' };
145
+ }
146
+ }
147
+ let measurement = null;
148
+ try {
149
+ measurement = measure();
150
+ }
151
+ catch {
152
+ // A ruler that broke is not a reason to refuse the harness's statement.
153
+ measurement = null;
154
+ }
155
+ const settled = settleOpenLifecycleRunOnCompactEvent({
156
+ projectRoot: input.projectRoot,
157
+ sessionId: input.sessionId,
158
+ measuredRatio: measurement?.ratio ?? null,
159
+ failLifecycleWrite: input.failLifecycleWrite
160
+ });
161
+ if (settled === null) {
162
+ return { settled: false, reason: 'nothing-to-settle' };
163
+ }
164
+ if (!settled.lifecycleWritten) {
165
+ // Repair R9 (AC2): a settle whose lifecycle write FAILED has not settled the
166
+ // run. The record is still resting at `armed` / `compacting`, so the next
167
+ // arrival — another `PostCompact`, or a probe — finds the SAME run open and
168
+ // settles it again. Appending the row anyway writes one per arrival for a
169
+ // compaction the lifecycle store never recorded, each one carrying an
170
+ // `afterRatio` that reads as a settled measurement; and when the store does
171
+ // recover, the retry appends a SECOND row for the same compaction, which
172
+ // `computeWindowCalibration` is free to attach to a later dispatch pair.
173
+ // Nothing is lost by deferring: `probe.ratio` is re-measured at the retry,
174
+ // and the retry is the settlement this row is about.
175
+ return {
176
+ settled: true,
177
+ runId: settled.runId,
178
+ beforeRatio: settled.triggerRatio,
179
+ afterRatio: settled.afterRatio,
180
+ historyWritten: false,
181
+ lifecycleWritten: false,
182
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {})
183
+ };
184
+ }
185
+ const event = {
186
+ schemaVersion: 1,
187
+ kind: 'observed',
188
+ ts: new Date().toISOString(),
189
+ // The guard above refused every payload that named another session, so
190
+ // reaching here means the event named this one or named none. The row is
191
+ // therefore attributable to the main session, which is the only session
192
+ // this command settles.
193
+ target: 'main',
194
+ mode: resolveAutoCompactProfile(input.projectRoot),
195
+ ide: measurement?.ide ?? 'claude-code',
196
+ pathway: COMPACT_EVENT_PATHWAY,
197
+ beforeRatio: settled.triggerRatio,
198
+ ...(settled.afterRatio !== null ? { afterRatio: settled.afterRatio } : {}),
199
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {}),
200
+ redLine: false,
201
+ ok: true,
202
+ checkpointPath: '',
203
+ dispatchMessage: `PostCompact fired: the harness reported a ${input.trigger ?? 'unreported-trigger'} compaction, ` +
204
+ `settling the run dispatched at ${(settled.triggerRatio * 100).toFixed(1)}%` +
205
+ (settled.afterRatio !== null
206
+ ? ` (measured now at ${(settled.afterRatio * 100).toFixed(1)}%)`
207
+ : ' (no post-compact measurement available)'),
208
+ ...(settled.afterRatio !== null
209
+ ? { windowTokens: measurement?.windowTokens ?? null, windowSource: measurement?.windowSource ?? null }
210
+ : {})
211
+ };
212
+ try {
213
+ appendCompactHistoryEvent({ projectRoot: input.projectRoot, sessionId: input.sessionId, event });
214
+ }
215
+ catch {
216
+ // The lifecycle run is already settled; losing the history row is the
217
+ // smaller loss, and a throwing hook is the larger one. Reported rather
218
+ // than silenced: "the run settled but the row is missing" and "the row is
219
+ // on disk" are different facts, and a caller that cannot tell them apart
220
+ // cannot diagnose a hook that has stopped recording anything.
221
+ return {
222
+ settled: true,
223
+ runId: settled.runId,
224
+ beforeRatio: settled.triggerRatio,
225
+ afterRatio: settled.afterRatio,
226
+ historyWritten: false,
227
+ lifecycleWritten: settled.lifecycleWritten,
228
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {})
229
+ };
230
+ }
231
+ return {
232
+ settled: true,
233
+ runId: settled.runId,
234
+ beforeRatio: settled.triggerRatio,
235
+ afterRatio: settled.afterRatio,
236
+ historyWritten: true,
237
+ lifecycleWritten: settled.lifecycleWritten,
238
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {})
239
+ };
240
+ }
@@ -26,6 +26,20 @@ export interface CompactHistoryEvent {
26
26
  readonly kind?: 'dispatch' | 'observed';
27
27
  /** `observed` rows only: the measured post-compact ratio. */
28
28
  readonly afterRatio?: number;
29
+ /**
30
+ * rid `2026-09-13-compact-event-settle`: what the HARNESS said caused the
31
+ * compaction — `manual` (a user ran `/compact`) or `auto` (the harness's own
32
+ * window fired). Written only by the `PostCompact` hook path, which is the
33
+ * only path a harness reports it on.
34
+ *
35
+ * OPTIONAL, AND ABSENT MEANS "NOT REPORTED" — never "manual", never "auto".
36
+ * `PostCompact`'s payload schema is truncated in the retrievable docs, so a
37
+ * payload without the field is an expected input, not an error; the whole
38
+ * reason this column exists is that "has this machine ever auto-compacted?"
39
+ * has no answer today, and a defaulted value would answer it falsely.
40
+ * Rows that predate the slice omit it, exactly like `windowTokens` / `kind`.
41
+ */
42
+ readonly trigger?: 'manual' | 'auto';
29
43
  }
30
44
  /**
31
45
  * One dispatch paired with the measurement that followed it — the
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // Decision priority (explicit, no implicit fall-through):
11
11
  // 1. lifecycle missing → fall back to legacy (pending → queued,
12
- // recent history → completed WITHOUT an invented after-ratio,
13
- // else none)
12
+ // a recent `observed` history row → completed WITHOUT an invented
13
+ // after-ratio, else none)
14
14
  // 2. lifecycle invalid → 'invalid' (NEVER fall back to legacy
15
15
  // — a corrupted lifecycle is not a green progress bar)
16
16
  // 3. lifecycle valid → map stage to filledCells via the
@@ -37,9 +37,10 @@
37
37
  // ratios. After-ratio is only rendered when the lifecycle record
38
38
  // carries a real one; otherwise the bar shows a stable "no
39
39
  // measurement" hint.
40
- import { existsSync, readFileSync, statSync } from 'node:fs';
40
+ import { existsSync, readFileSync } from 'node:fs';
41
41
  import { getSessionDir } from '../session/getSessionDir.js';
42
42
  import { AUTO_COMPACT_RED_LINE_RATIO } from '../context/auto-compact-types.js';
43
+ import { readCompactHistory } from '../compact-history/compact-history-service.js';
43
44
  import { readCompactLifecycle, } from './compact-lifecycle-store.js';
44
45
  /**
45
46
  * Concrete first-version stale timeout. Adjustable after real timing
@@ -58,7 +59,12 @@ const DEFAULT_STALE_AFTER_MS = 120_000;
58
59
  * to not flap on subsequent reads. Adjustable after real timing feedback.
59
60
  */
60
61
  export const COMPLETED_EXPIRY_MS = 10_000;
61
- /** Legacy mtime window for the "just compacted" indicator. */
62
+ /**
63
+ * How recently the last history row must itself have been written for the
64
+ * legacy path to report it as "just compacted". Measured against the row's own
65
+ * `ts` — the fact — rather than the file's mtime, which is only a proxy for it
66
+ * and is also moved by rows that record no compaction (repair R9).
67
+ */
62
68
  const LEGACY_JUST_COMPACTED_WINDOW_MS = 30_000;
63
69
  // PRD-002b slice 2 — extract cell-table magic numbers (4/6/8) into named
64
70
  // consts so the no-magic-numbers lint rule stops flagging the typed
@@ -142,6 +148,7 @@ export function decideCompactStatusline(input) {
142
148
  // lifecycle.kind === 'missing' → fall back to legacy files.
143
149
  return decideLegacyFallback({
144
150
  projectRoot: input.projectRoot,
151
+ sessionId: input.sessionId,
145
152
  sessionDir,
146
153
  now: input.now,
147
154
  });
@@ -212,26 +219,53 @@ function decideLegacyFallback(input) {
212
219
  // fall through to history check
213
220
  }
214
221
  }
215
- // Priority 2 within legacy: a recent history event within 30s.
216
- if (existsSync(historyPath)) {
217
- try {
218
- const mtimeMs = statSync(historyPath).mtimeMs;
219
- if (now - mtimeMs <= LEGACY_JUST_COMPACTED_WINDOW_MS) {
220
- // CRITICAL: no invented after-ratio. The history event may
221
- // carry a beforeRatio, but never a measured after-ratio;
222
- // this is the legacy path and we honour the "no measurement"
223
- // default.
224
- return {
225
- kind: 'completed',
226
- filledCells: 8,
227
- detail: historyPath,
228
- };
229
- }
230
- }
231
- catch {
232
- // fall through to idle
222
+ // Priority 2 within legacy: a history row TESTIFIES that a compaction was
223
+ // witnessed, recently enough to still be the one being reported.
224
+ //
225
+ // Repair R9 (AC4). This used to read the file's mtime — freshness taken as
226
+ // evidence that "a compact just landed". Freshness is evidence of neither:
227
+ // the same file takes a `dispatch` row every time peaks-loop ASKS for a
228
+ // compact, and an ask is an intent, not an outcome. One real session
229
+ // (2026-09-13, ~15.5 h) holds 1075 dispatch rows and ZERO compactions
230
+ // (`auto-compact-orchestrator.ts`), so a fresh file was the NORMAL state of a
231
+ // session in which nothing had compacted at all — and each of those asks
232
+ // painted this 8-cell "completed" bar.
233
+ //
234
+ // So the row's own testimony is read instead, through the same reader the CLI
235
+ // uses. Only `kind: 'observed'` means a compaction was witnessed (a row with
236
+ // no `kind` is a dispatch — every pre-`kind` row is one), and the row's `ts`
237
+ // is the moment compared, not the filesystem's.
238
+ //
239
+ // Repair R11. The testimony is looked for in EVERY row inside the window, not
240
+ // only in the last one. Reading the last row alone made the indicator depend
241
+ // on the ASK: a `dispatch` row lands on every probe, so an `observed` row
242
+ // stopped counting the moment peaks-loop asked again — `[observed, dispatch]`
243
+ // one second apart answered `none` for a compaction that had just been
244
+ // witnessed — i.e. it deleted the very indicator the mtime read had shown,
245
+ // the one the replacement was meant to keep honest. A `dispatch` row alone
246
+ // still testifies
247
+ // to nothing (the direction R9 closed): what is required is an `observed` row
248
+ // inside the window, wherever in the file it sits.
249
+ try {
250
+ // The reader answers `file-missing` / `empty` itself; the `catch` is for the
251
+ // read itself, which it does not guard.
252
+ const read = readCompactHistory({ projectRoot: input.projectRoot, sessionId: input.sessionId });
253
+ const witnessed = read.kind === 'ok' &&
254
+ read.events.some((row) => row.kind === 'observed' && now - Date.parse(row.ts) <= LEGACY_JUST_COMPACTED_WINDOW_MS);
255
+ if (witnessed) {
256
+ // CRITICAL: no invented after-ratio. The row may carry a measured
257
+ // afterRatio, but this is the legacy path and we honour the
258
+ // "no measurement" default.
259
+ return {
260
+ kind: 'completed',
261
+ filledCells: 8,
262
+ detail: historyPath,
263
+ };
233
264
  }
234
265
  }
266
+ catch {
267
+ // fall through to idle
268
+ }
235
269
  return { kind: 'none', filledCells: 0 };
236
270
  }
237
271
  /**
@@ -1,9 +1,20 @@
1
+ export interface RestoreListResult {
2
+ /**
3
+ * True when `~/.peaks/config.json.1.x.bak` exists — i.e. whether there is
4
+ * anything to restore from at all. Meaning is identical to
5
+ * `RollbackPlan.available`.
6
+ */
7
+ available: boolean;
8
+ fields: string[];
9
+ }
1
10
  export interface RestoreResult {
11
+ /** See `RestoreListResult.available`. */
12
+ available: boolean;
2
13
  field: string;
3
14
  applied: boolean;
4
15
  sidecarPath?: string;
5
16
  }
6
- export declare function listAvailableFields(): string[];
17
+ export declare function listAvailableFields(): RestoreListResult;
7
18
  export declare function restoreField(opts: {
8
19
  field: string;
9
20
  apply: boolean;
@@ -9,21 +9,52 @@ import { backupConfigPath } from './config-migration.js';
9
9
  * can review before adopting. Fields in the deferred-design set
10
10
  * (workspaces, providers, proxy) throw RESTORE_GUARDED so the user has
11
11
  * to acknowledge explicitly.
12
+ *
13
+ * MISSING BACKUP IS NOT A FAILURE (rid 2026-09-13-two-decisions ①, user-
14
+ * decided). `~/.peaks/config.json.1.x.bak` only exists on a machine that ran
15
+ * `peaks config migrate --apply`; a machine that never did is in its NORMAL
16
+ * initial state, not in an error state. Both subcommands therefore report it
17
+ * as a SUCCESS carrying `available: false` rather than throwing — the sibling
18
+ * `rollback` already did, and `restore` exited 1 for the same state, so a
19
+ * script could not ask either one "was there ever a backup?" without knowing
20
+ * which subcommand it was talking to.
21
+ *
22
+ * ⚠️ THE COST, ACCEPTED BY THE USER: an existing script that read exit 1 as
23
+ * "never backed up" loses that signal. The replacement is `available` — it is
24
+ * on EVERY envelope this module produces, on the success path (`false` ⇒
25
+ * nothing to restore, exit 0) and on the failure path (`true` ⇒ a backup IS
26
+ * there and the field/guard was the problem, exit 1), so one JSON key answers
27
+ * the question that used to take the exit code, and it distinguishes "no
28
+ * backup" from "backup exists but the field is not in it".
12
29
  */
13
30
  const GUARDED_FIELDS = new Set(['workspaces', 'providers', 'proxy']);
31
+ /**
32
+ * The parsed `.bak`, or `null` when there is none. `null` is the normal
33
+ * "nothing was ever migrated" state (see the module comment), not an error.
34
+ *
35
+ * A `.bak` that exists but does not parse still throws: `available` means
36
+ * "the backup file is present", and a present-but-malformed one is a real
37
+ * failure the caller must surface.
38
+ */
14
39
  function readBakContent() {
15
40
  const bak = backupConfigPath();
16
41
  if (!existsSync(bak)) {
17
- throw new Error('NO_BACKUP: ~/.peaks/config.json.1.x.bak not found');
42
+ return null;
18
43
  }
19
44
  return JSON.parse(readFileSync(bak, 'utf8'));
20
45
  }
21
46
  export function listAvailableFields() {
22
47
  const bak = readBakContent();
23
- return Object.keys(bak).filter((k) => k !== 'version');
48
+ if (bak === null) {
49
+ return { available: false, fields: [] };
50
+ }
51
+ return { available: true, fields: Object.keys(bak).filter((k) => k !== 'version') };
24
52
  }
25
53
  export function restoreField(opts) {
26
54
  const bak = readBakContent();
55
+ if (bak === null) {
56
+ return { available: false, field: opts.field, applied: false };
57
+ }
27
58
  if (!(opts.field in bak)) {
28
59
  throw new Error(`FIELD_NOT_FOUND: ${opts.field} is not in config.json.1.x.bak`);
29
60
  }
@@ -33,7 +64,7 @@ export function restoreField(opts) {
33
64
  const home = homedir();
34
65
  const sidecar = join(home, '.peaks', `config.json.restore-${opts.field}.json`);
35
66
  if (!opts.apply) {
36
- return { field: opts.field, applied: false };
67
+ return { available: true, field: opts.field, applied: false };
37
68
  }
38
69
  mkdirSync(join(home, '.peaks'), { recursive: true });
39
70
  const payload = {
@@ -43,5 +74,5 @@ export function restoreField(opts) {
43
74
  restoredAt: new Date().toISOString(),
44
75
  };
45
76
  writeFileSync(sidecar, JSON.stringify(payload, null, 2) + '\n', 'utf8');
46
- return { field: opts.field, applied: true, sidecarPath: sidecar };
77
+ return { available: true, field: opts.field, applied: true, sidecarPath: sidecar };
47
78
  }
@@ -15,7 +15,12 @@ export function planRollback() {
15
15
  export function executeRollback(opts) {
16
16
  const plan = planRollback();
17
17
  if (!plan.available) {
18
- throw new Error('NO_BACKUP: ~/.peaks/config.json.1.x.bak not found');
18
+ // rid 2026-09-13-two-decisions ①: a machine that never migrated has no
19
+ // `.bak`, and that is its normal state — `--apply` on such a machine is
20
+ // "nothing to roll back", not a failure. Returned instead of thrown so the
21
+ // exit status and the `available` key agree on every path; see
22
+ // `config-restore.ts` for the full rationale and the accepted cost.
23
+ return { ...plan, applied: false };
19
24
  }
20
25
  if (!opts.apply) {
21
26
  return { ...plan, applied: false };
@@ -128,13 +128,31 @@ export type AutoCompactResult = {
128
128
  readonly nextActions: readonly string[];
129
129
  } | {
130
130
  readonly ok: true;
131
- readonly code: 'AUTO_COMPACT_SKIP' | 'AUTO_COMPACT_WAIT';
131
+ readonly code: 'AUTO_COMPACT_SKIP' | 'AUTO_COMPACT_WAIT' | 'AUTO_COMPACT_ALREADY_ARMED' | 'AUTO_COMPACT_UNRESOLVED_SESSION';
132
132
  readonly message: string;
133
133
  readonly data: {
134
134
  readonly sessionId: string;
135
135
  readonly ratio: number;
136
136
  readonly source: string;
137
- readonly decision: 'below-threshold' | 'in-flight-batch';
137
+ /**
138
+ * `unresolved-session` (repair R6): the session id named no session
139
+ * directory, so the compact backoff's question — "is an attempt already
140
+ * outstanding?" — could not be asked. It is NOT `already-armed` (no run
141
+ * was found; the record could not be read at all) and NOT an admit: the
142
+ * dispatch is left undone because an unanswerable question is not a
143
+ * "no", and admitting on one is how a gate ends up reading a string
144
+ * instead of the artifact the string names.
145
+ */
146
+ readonly decision: 'below-threshold' | 'in-flight-batch' | 'already-armed' | 'unresolved-session';
147
+ /**
148
+ * rid `2026-09-14-compact-dispatch-backoff`, `already-armed` only: the
149
+ * ratio the open run was dispatched at, and its id. `ratio` above is the
150
+ * LIVE reading — the pair is what keeps "it is still high" legible after
151
+ * the backoff drops the per-probe rows: the ask is at `armedAtRatio`, and
152
+ * the context is now at `ratio`, above it and not yet compacted.
153
+ */
154
+ readonly armedAtRatio?: number;
155
+ readonly armedRunId?: string;
138
156
  /**
139
157
  * Slice 2026-09-13-auto-compact-trigger-ownership: what syncing the
140
158
  * harness auto-compact window did on this probe. `null` = the active