peaks-loop 4.0.48 → 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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/compact-command.js +1 -3
  5. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  6. package/dist/cli/commands/feedback-commands.js +49 -17
  7. package/dist/cli/commands/final-review-commands.js +12 -0
  8. package/dist/cli/commands/loop-eval-commands.js +22 -6
  9. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  10. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  11. package/dist/services/artifacts/request-artifact-service.js +59 -38
  12. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  13. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  14. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  15. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  16. package/dist/services/code/compact-event-settle.d.ts +20 -8
  17. package/dist/services/code/compact-event-settle.js +21 -0
  18. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  19. package/dist/services/context/auto-compact-types.d.ts +20 -2
  20. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  21. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  22. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  23. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  24. package/dist/services/job/job-progress-store.js +18 -3
  25. package/dist/services/observability/jsonl-store.d.ts +19 -0
  26. package/dist/services/observability/jsonl-store.js +27 -2
  27. package/dist/services/observability/observability-service.d.ts +10 -3
  28. package/dist/services/observability/observability-service.js +16 -3
  29. package/dist/services/prd/handoff-service.js +43 -0
  30. package/dist/services/qa/qa-business-review-state.js +19 -5
  31. package/dist/services/sc/sc-service.d.ts +8 -0
  32. package/dist/services/sc/sc-service.js +8 -1
  33. package/dist/services/session/getSessionDir.d.ts +33 -0
  34. package/dist/services/session/getSessionDir.js +60 -0
  35. package/dist/services/slice/slice-review-state.js +19 -4
  36. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  37. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  38. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  39. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  40. package/dist/shared/runtime-root.d.ts +73 -0
  41. package/dist/shared/runtime-root.js +77 -0
  42. package/package.json +5 -5
@@ -51,6 +51,65 @@ export declare function resolveDispatchedStage(input: {
51
51
  * attempts inside the same millisecond distinct.
52
52
  */
53
53
  export declare function newCompactRunId(now: Date): string;
54
+ /**
55
+ * rid `2026-09-14-compact-dispatch-backoff`: the compact run this session has
56
+ * already DISPATCHED and whose outcome is still unknown — the backoff token.
57
+ *
58
+ * WHY A BACKOFF IS NEEDED AT ALL. Once the ratio crosses the auto-fire
59
+ * threshold it STAYS crossed: nothing in peaks-loop can compact a running
60
+ * session, and the harness fires only at its own red line. So the dispatch
61
+ * obligation became unsatisfiable and fired on every probe. Measured in one
62
+ * real session (2026-09-13T22:43:33Z → 2026-09-14T14:15:02Z, ~15.5 h):
63
+ * 1035 `dispatch` rows, 444 checkpoints, and ZERO compactions — 1075 by
64
+ * 14:22:09.112Z. The dispatch itself is idempotent — `ide-native` only
65
+ * installs a PreToolUse hook, and a second install of the same hook is a
66
+ * documented no-op — so 1074 of those rows installed nothing (their own
67
+ * `dispatchMessage` says `already installed`) and carried no new information.
68
+ * Only noise: a signal that fires a thousand times is not a signal.
69
+ *
70
+ * WHY NO NEW STORE. The one-record-per-session lifecycle store already holds
71
+ * the one fact the backoff needs: is a compact attempt dispatched and not yet
72
+ * superseded? `armed` and `compacting` are exactly those two stages, and
73
+ * `completed` / `failed` mean the attempt is over and the next crossing is a
74
+ * legitimate new one. The state lives here rather than in
75
+ * `compact-history.jsonl`, which is append-only and never rewritten — so the
76
+ * backoff cannot be built by editing history, which is the point.
77
+ *
78
+ * STALENESS IS DELIBERATELY IGNORED, for `settleOpenLifecycleRun`'s reason: a
79
+ * probe arriving after `staleAfterMs` has learned nothing about whether the run
80
+ * is still live. Age cannot make re-dispatching honest, so the stale window
81
+ * must not decide it. (`settleOpenLifecycleRun` reads with the same window for
82
+ * the same reason; the two must not grow two policies.)
83
+ *
84
+ * `queued` / `preparing` / `verifying` are NOT open here: they are transient
85
+ * stages inside a dispatch or a settle, and a process that died in one of them
86
+ * left no compact attempt to protect — the next probe should be free to
87
+ * dispatch. `failed` is not open either, by the same argument: a failure is a
88
+ * reason to try again, not a reason to stay quiet.
89
+ */
90
+ /**
91
+ * The answer to "is a compact attempt outstanding for this session?".
92
+ *
93
+ * `none` and `unresolvable` must not be the same value. The caller uses
94
+ * `none` to ADMIT a dispatch; `unresolvable` means the question could
95
+ * not be asked, and a caller that admits on "could not ask" has a gate
96
+ * that reads a string rather than the artifact it names.
97
+ */
98
+ export type OpenDispatchRunRead = {
99
+ readonly kind: 'open';
100
+ readonly runId: string;
101
+ readonly stage: 'armed' | 'compacting';
102
+ readonly triggerRatio: number;
103
+ } | {
104
+ readonly kind: 'none';
105
+ } | {
106
+ readonly kind: 'unresolvable';
107
+ readonly reason: string;
108
+ };
109
+ export declare function readOpenDispatchRun(input: {
110
+ readonly projectRoot: string;
111
+ readonly sessionId: string;
112
+ }): OpenDispatchRunRead;
54
113
  /**
55
114
  * Slice 2026-08-01-compact-lifecycle (Task 5): the local transition
56
115
  * builder. Carries `runId`, `triggerRatio` and `redLine` forward from
@@ -132,6 +191,18 @@ export declare function summarizeLifecycleError(error: unknown): string;
132
191
  * `afterRatio` to append the "observed compaction point" row that makes the
133
192
  * intent-vs-observed gap readable after a real session. The return value is
134
193
  * telemetry only — callers that ignore it are unaffected.
194
+ *
195
+ * `lifecycleWritten` (repair R6) makes the returned record honest about
196
+ * whether the RUN actually moved. Before it, a failed write still returned
197
+ * the full envelope, so a store that could not be written reported a settle
198
+ * on every probe, forever: the run stayed `armed`, the next probe found it
199
+ * open again, and each one added another "observed compaction point" row —
200
+ * an unbounded append driven by a write that never happened. A failed write
201
+ * is not an absent run (the facts are real and stay true, so the caller may
202
+ * still record the measurement), but it is also not a settled one. This is
203
+ * the same field `settleOpenLifecycleRunOnCompactEvent` carries, for the
204
+ * same reason, so the two settle paths no longer disagree about what a write
205
+ * failure means.
135
206
  */
136
207
  export declare function settleOpenLifecycleRun(input: {
137
208
  readonly projectRoot: string;
@@ -140,10 +211,14 @@ export declare function settleOpenLifecycleRun(input: {
140
211
  readonly source: string;
141
212
  readonly autoFireThreshold: number;
142
213
  readonly onLifecycleStage?: ((stage: CompactLifecycleStage, record: CompactLifecycleRecord) => void) | undefined;
214
+ /** Failure injection for the write below — the seam `CompactLifecyclePublisher` already takes. */
215
+ readonly failLifecycleWrite?: boolean | undefined;
143
216
  }): {
144
217
  readonly runId: string;
145
218
  readonly triggerRatio: number;
146
219
  readonly afterRatio: number;
220
+ /** `false` when the `completed` write threw, so the run is STILL open. */
221
+ readonly lifecycleWritten: boolean;
147
222
  } | null;
148
223
  /**
149
224
  * rid `2026-09-13-compact-event-settle`: close out an open compact run because
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { readCompactLifecycle, writeCompactLifecycle } from '../compact-statusline/compact-lifecycle-store.js';
15
15
  import { AUTO_COMPACT_RED_LINE_RATIO } from '../context/auto-compact-types.js';
16
+ import { tryGetSessionDir } from '../session/getSessionDir.js';
16
17
  /**
17
18
  * PRD-002b slice 2 — extract the few cross-cutting magic numbers
18
19
  * that actually appear at runtime call sites in this orchestrator.
@@ -68,11 +69,18 @@ export function newCompactRunId(now) {
68
69
  * later. Wrapping that unbounded read here keeps the magic number out
69
70
  * of the settle path and makes the intent self-documenting.
70
71
  *
71
- * Returns `null` for any non-`valid` kind (missing / invalid /
72
- * stalled). Errors from the underlying read are swallowed — settling
73
- * is best-effort telemetry and must never bubble.
72
+ * The session id is resolved through `tryGetSessionDir` FIRST, so the
73
+ * guard's throw is answered before the store is entered and the failing
74
+ * branch is a value the caller must handle rather than a `catch` nobody
75
+ * reads. The residual `catch` is therefore unreachable for a bad id;
76
+ * anything that reaches it is a store fault, and a store fault is still
77
+ * not "there is no open run" — hence `unresolvable`, never `none`.
74
78
  */
75
79
  function readOpenCompactLifecycle(input) {
80
+ const resolved = tryGetSessionDir(input.projectRoot, input.sessionId);
81
+ if (!resolved.ok) {
82
+ return { kind: 'unresolvable', reason: resolved.reason };
83
+ }
76
84
  let out;
77
85
  try {
78
86
  out = readCompactLifecycle({
@@ -85,10 +93,21 @@ function readOpenCompactLifecycle(input) {
85
93
  staleAfterMs: Number.MAX_SAFE_INTEGER
86
94
  });
87
95
  }
88
- catch {
89
- return null;
96
+ catch (error) {
97
+ return { kind: 'unresolvable', reason: summarizeLifecycleError(error) };
90
98
  }
91
- return out.kind === 'valid' ? out.record : null;
99
+ return out.kind === 'valid' ? { kind: 'found', record: out.record } : { kind: 'none' };
100
+ }
101
+ export function readOpenDispatchRun(input) {
102
+ const read = readOpenCompactLifecycle(input);
103
+ if (read.kind === 'unresolvable')
104
+ return read;
105
+ if (read.kind === 'none')
106
+ return { kind: 'none' };
107
+ const record = read.record;
108
+ if (record.stage !== 'armed' && record.stage !== 'compacting')
109
+ return { kind: 'none' };
110
+ return { kind: 'open', runId: record.runId, stage: record.stage, triggerRatio: record.triggerRatio };
92
111
  }
93
112
  /**
94
113
  * Slice 2026-08-01-compact-lifecycle (Task 5): the local transition
@@ -220,6 +239,18 @@ export function summarizeLifecycleError(error) {
220
239
  * `afterRatio` to append the "observed compaction point" row that makes the
221
240
  * intent-vs-observed gap readable after a real session. The return value is
222
241
  * telemetry only — callers that ignore it are unaffected.
242
+ *
243
+ * `lifecycleWritten` (repair R6) makes the returned record honest about
244
+ * whether the RUN actually moved. Before it, a failed write still returned
245
+ * the full envelope, so a store that could not be written reported a settle
246
+ * on every probe, forever: the run stayed `armed`, the next probe found it
247
+ * open again, and each one added another "observed compaction point" row —
248
+ * an unbounded append driven by a write that never happened. A failed write
249
+ * is not an absent run (the facts are real and stay true, so the caller may
250
+ * still record the measurement), but it is also not a settled one. This is
251
+ * the same field `settleOpenLifecycleRunOnCompactEvent` carries, for the
252
+ * same reason, so the two settle paths no longer disagree about what a write
253
+ * failure means.
223
254
  */
224
255
  export function settleOpenLifecycleRun(input) {
225
256
  // A `conservative-fallback` probe means no signal was available at
@@ -229,12 +260,17 @@ export function settleOpenLifecycleRun(input) {
229
260
  return null;
230
261
  if (input.measuredRatio >= input.autoFireThreshold)
231
262
  return null;
232
- const prior = readOpenCompactLifecycle({
263
+ const openRead = readOpenCompactLifecycle({
233
264
  projectRoot: input.projectRoot,
234
265
  sessionId: input.sessionId
235
266
  });
236
- if (prior === null)
267
+ // Both `none` and `unresolvable` mean "nothing to settle" HERE, and the
268
+ // collapse is legitimate on this path rather than a fail-open: settling
269
+ // ADMITS nothing. No record can exist under an id that names no session
270
+ // directory, so there is nothing this probe could have been about.
271
+ if (openRead.kind !== 'found')
237
272
  return null;
273
+ const prior = openRead.record;
238
274
  // Only a run that was actually dispatched can be completed by a
239
275
  // post-compact measurement. Both `compacting` (the in-band trigger is
240
276
  // satisfied) and `armed` (a trigger was registered and could fire at
@@ -253,6 +289,8 @@ export function settleOpenLifecycleRun(input) {
253
289
  ...(withAfterRatio ? { afterRatio: input.measuredRatio } : {})
254
290
  };
255
291
  try {
292
+ if (input.failLifecycleWrite)
293
+ throw new Error('lifecycle store unavailable');
256
294
  writeCompactLifecycle({
257
295
  projectRoot: input.projectRoot,
258
296
  sessionId: input.sessionId,
@@ -260,7 +298,10 @@ export function settleOpenLifecycleRun(input) {
260
298
  });
261
299
  }
262
300
  catch {
263
- return;
301
+ // Best-effort telemetry, as everywhere in this file — but the failure is
302
+ // REPORTED to the caller as `lifecycleWritten: false` rather than folded
303
+ // into a record that reads as settled.
304
+ return false;
264
305
  }
265
306
  try {
266
307
  input.onLifecycleStage?.(stage, record);
@@ -268,12 +309,14 @@ export function settleOpenLifecycleRun(input) {
268
309
  catch {
269
310
  // Observer failures are not ours to propagate.
270
311
  }
312
+ return true;
271
313
  };
272
314
  // `verifying` = we hold a measurement and are checking it.
273
315
  emit('verifying', false);
274
- // `completed` = the measurement confirms the drop; publish it.
275
- emit('completed', true);
276
- return { runId: prior.runId, triggerRatio: prior.triggerRatio, afterRatio: input.measuredRatio };
316
+ // `completed` = the measurement confirms the drop; publish it. This write
317
+ // is the one that closes the run, so it is the one that is reported.
318
+ const lifecycleWritten = emit('completed', true);
319
+ return { runId: prior.runId, triggerRatio: prior.triggerRatio, afterRatio: input.measuredRatio, lifecycleWritten };
277
320
  }
278
321
  /**
279
322
  * rid `2026-09-13-compact-event-settle`: close out an open compact run because
@@ -332,12 +375,16 @@ export function settleOpenLifecycleRun(input) {
332
375
  * two apart ends up telling the user a falsehood (see `compact-event-settle.ts`).
333
376
  */
334
377
  export function settleOpenLifecycleRunOnCompactEvent(input) {
335
- const prior = readOpenCompactLifecycle({
378
+ const openRead = readOpenCompactLifecycle({
336
379
  projectRoot: input.projectRoot,
337
380
  sessionId: input.sessionId
338
381
  });
339
- if (prior === null)
382
+ // Same collapse as `settleOpenLifecycleRun` above, for the same reason:
383
+ // an unresolvable id names no session directory, so no open run exists
384
+ // for this event to be about, and `null` here admits nothing.
385
+ if (openRead.kind !== 'found')
340
386
  return null;
387
+ const prior = openRead.record;
341
388
  if (prior.stage !== 'compacting' && prior.stage !== 'armed')
342
389
  return null;
343
390
  const afterRatio = input.measuredRatio !== null && input.measuredRatio < prior.triggerRatio ? input.measuredRatio : null;
@@ -410,12 +457,14 @@ export function settleOpenLifecycleRunOnCompactEvent(input) {
410
457
  export function fillEventSettledMeasurement(input) {
411
458
  if (input.source === 'conservative-fallback')
412
459
  return null;
413
- const prior = readOpenCompactLifecycle({
460
+ const openRead = readOpenCompactLifecycle({
414
461
  projectRoot: input.projectRoot,
415
462
  sessionId: input.sessionId
416
463
  });
417
- if (prior === null)
464
+ // Same collapse as the two settle paths above — `null` here admits nothing.
465
+ if (openRead.kind !== 'found')
418
466
  return null;
467
+ const prior = openRead.record;
419
468
  // Exactly one shape is owed a number: the one the EVENT path leaves behind.
420
469
  // `settleOpenLifecycleRun` never writes it (it always carries `afterRatio`),
421
470
  // and a `failed` run never dispatched, so it has no row to pair with.
@@ -36,13 +36,14 @@
36
36
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
37
37
  import { dirname, join } from 'node:path';
38
38
  import { getSessionIdCanonical } from '../session/session-manager.js';
39
+ import { getSessionDir } from '../session/getSessionDir.js';
39
40
  import { resolveOuterSessionId } from '../session/binding-status-service.js';
40
41
  import { resolveCanonicalProjectRoot } from '../config/config-service.js';
41
42
  import { describeHarnessWindowSync, harnessWindowSyncWarning } from '../context/harness-window-config.js';
42
43
  import { AUTO_COMPACT_PRE_COMPACT_RATIO, DEPRECATED_ENVELOPE_FIELDS } from '../context/auto-compact-types.js';
43
44
  import { describeMode, thresholdFor } from './auto-compact-modes.js';
44
45
  import { resolveAutoCompactProfile } from '../mode/mode-status-service.js';
45
- import { CompactLifecyclePublisher, fillEventSettledMeasurement, newCompactRunId, resolveDispatchedStage, settleOpenLifecycleRun, summarizeLifecycleError } from './auto-compact-lifecycle.js';
46
+ import { CompactLifecyclePublisher, fillEventSettledMeasurement, newCompactRunId, readOpenDispatchRun, resolveDispatchedStage, settleOpenLifecycleRun, summarizeLifecycleError } from './auto-compact-lifecycle.js';
46
47
  const PRE_COMPACT_REASON = 'pre-compact-auto';
47
48
  /**
48
49
  * Map a context ratio to a `CompactTrigger` action. Pure; the side
@@ -195,7 +196,10 @@ export function buildConvergencePlan(input) {
195
196
  * transcript).
196
197
  */
197
198
  function appendAutoDecisionLog(input) {
198
- const dir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'txt');
199
+ // Repair R6: same hand-rolled join as `writePreCompactCheckpoint`. All four
200
+ // session-scoped paths in this file now go through the axis builder; none
201
+ // composes the join itself, so none can resolve outside the project root.
202
+ const dir = join(getSessionDir(input.projectRoot, input.sessionId), 'txt');
199
203
  if (!existsSync(dir))
200
204
  mkdirSync(dir, { recursive: true });
201
205
  const logPath = join(dir, 'auto-decisions.md');
@@ -235,7 +239,7 @@ function appendAutoDecisionLog(input) {
235
239
  * state re-injection. Not a capability peaks-loop has today.
236
240
  */
237
241
  function writeMainSessionCompactIntent(input) {
238
- const dir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'txt');
242
+ const dir = join(getSessionDir(input.projectRoot, input.sessionId), 'txt');
239
243
  if (!existsSync(dir))
240
244
  mkdirSync(dir, { recursive: true });
241
245
  const path = join(dir, 'auto-compact-pending.json');
@@ -255,7 +259,12 @@ function writeMainSessionCompactIntent(input) {
255
259
  * checkpoint` so D7's post-compact-detect picks it up unchanged.
256
260
  */
257
261
  function writePreCompactCheckpoint(input) {
258
- const dir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'checkpoints');
262
+ // Repair R6: this used to hand-roll the session join, which is the one shape
263
+ // `getSessionDir`'s own header records as NOT covered by the guard — an
264
+ // unsafe id resolved outside the project root and the checkpoint was written
265
+ // there. The gate above now refuses an unresolvable id before this point, but
266
+ // `--force` outranks that gate, so this join has to hold on its own.
267
+ const dir = join(getSessionDir(input.projectRoot, input.sessionId), 'checkpoints');
259
268
  if (!existsSync(dir))
260
269
  mkdirSync(dir, { recursive: true });
261
270
  const prefix = input.redLine === true ? 'red-line-' : 'pre-compact-';
@@ -382,25 +391,37 @@ export async function runAutoCompact(input) {
382
391
  // still open at `compacting`. Nothing is written when there is no
383
392
  // open run, when the ratio is still high, or when the probe could
384
393
  // not measure at all.
385
- const settled = settleOpenLifecycleRun({
394
+ const settleRead = settleOpenLifecycleRun({
386
395
  projectRoot: input.projectRoot,
387
396
  sessionId,
388
397
  measuredRatio: probe.ratio,
389
398
  source: probe.source,
390
399
  autoFireThreshold: thresholdFor(mode, 'autoFire'),
391
- onLifecycleStage: input.onLifecycleStage
392
- }) ??
393
- // Repair R1 (`2026-09-13-compact-event-settle`): the HARNESS event may
394
- // already have closed this run WITHOUT an honest post-compact number —
395
- // in which case the call above finds nothing open, and without this the
396
- // calibration pair stays blank for exactly the compactions the event path
397
- // exists to witness. Fills the number the event owed.
398
- fillEventSettledMeasurement({
399
- projectRoot: input.projectRoot,
400
- sessionId,
401
- measuredRatio: probe.ratio,
402
- source: probe.source
403
- });
400
+ onLifecycleStage: input.onLifecycleStage,
401
+ failLifecycleWrite: input.testHooks?.failLifecycleWrite
402
+ });
403
+ // Repair R6 (AC3): a settle whose lifecycle write FAILED has not settled the
404
+ // run — the record is still resting at `armed` / `compacting`, so the next
405
+ // probe finds the same run open and settles it again. Appending the
406
+ // "observed compaction point" row anyway writes one row per probe for a
407
+ // compaction the lifecycle store never recorded: an unbounded append driven
408
+ // by a write that did not happen, and a history row whose paired lifecycle
409
+ // record does not exist. Nothing is lost by deferring the row — `probe.ratio`
410
+ // is re-measured on every probe, so the retry carries a fresh number.
411
+ const settled = settleRead !== null && !settleRead.lifecycleWritten
412
+ ? null
413
+ : (settleRead ??
414
+ // Repair R1 (`2026-09-13-compact-event-settle`): the HARNESS event may
415
+ // already have closed this run WITHOUT an honest post-compact number —
416
+ // in which case the call above finds nothing open, and without this the
417
+ // calibration pair stays blank for exactly the compactions the event path
418
+ // exists to witness. Fills the number the event owed.
419
+ fillEventSettledMeasurement({
420
+ projectRoot: input.projectRoot,
421
+ sessionId,
422
+ measuredRatio: probe.ratio,
423
+ source: probe.source
424
+ }));
404
425
  // Slice 2026-09-13-auto-compact-trigger-ownership (T4): a settle means a
405
426
  // dispatched compact demonstrably landed. Append an `observed` row
406
427
  // carrying the measured ratio, so `peaks compact history` can show
@@ -460,6 +481,85 @@ export async function runAutoCompact(input) {
460
481
  }
461
482
  const isRedLine = decision.reason === 'red-line';
462
483
  const now = input.now ?? new Date();
484
+ // rid `2026-09-14-compact-dispatch-backoff`: ONE dispatch per compact
485
+ // attempt.
486
+ //
487
+ // The decision above fires whenever the ratio is at or over the auto-fire
488
+ // threshold, and the ratio does not come back down on its own — nothing in
489
+ // peaks-loop can compact a running session, and the harness fires only at
490
+ // its own red line. So "shouldCompact" was true on every probe, forever: one
491
+ // real session's `compact-history.jsonl` holds 1075 dispatch rows, 444
492
+ // checkpoints and ZERO compactions over 15.5 h — and the file was still
493
+ // growing while this slice ran. Re-dispatching bought nothing, because the
494
+ // `ide-native` dispatch only installs a PreToolUse hook and installing it
495
+ // again is a documented no-op: 1074 of those 1075 rows say `already
496
+ // installed` in their own `dispatchMessage`. It produced rows, not
497
+ // compactions.
498
+ //
499
+ // The lifecycle store already knows whether an attempt is outstanding (see
500
+ // `readOpenDispatchRun` — `armed`/`compacting` = dispatched, `completed`/
501
+ // `failed` = over), so the gate needs no new state and no threshold: it is
502
+ // keyed on the STAGE of the open run, never on a ratio, which is why it
503
+ // survives any future realignment of the 0.65/0.70/0.85/0.95 table.
504
+ //
505
+ // What is NOT suppressed: the probe still measures and still reports. The
506
+ // envelope carries the LIVE ratio plus the ratio the open ask was made at,
507
+ // so "it crossed and it is still high, unanswered" stays legible on every
508
+ // turn — the difference between a quiet signal and a silenced one.
509
+ //
510
+ // `force` (the `--force` test seam, "force compact at any ratio") outranks
511
+ // the inference: an explicit instruction must not be silently reduced to a
512
+ // no-op, which would make the published flag a lie.
513
+ // Repair R6: `readOpenDispatchRun` answers in THREE states, not two. `none`
514
+ // admits the dispatch; `unresolvable` means the backoff's question could not
515
+ // be asked — the session id names no session directory, so no lifecycle
516
+ // record can exist under it and the gate used to read that as "nothing is
517
+ // outstanding". Measured on ONE directory before this: a legal sid found the
518
+ // open `armed` run and suppressed the dispatch; `./<legal sid>`, which joins
519
+ // to the identical path, returned `null` and dispatched. A gate that reads a
520
+ // string instead of the artifact the string names is the defect the whole
521
+ // id axis exists to close, so an unanswerable question is NOT an admit: the
522
+ // conservative direction is to leave the dispatch undone and say why.
523
+ const openRun = readOpenDispatchRun({ projectRoot: input.projectRoot, sessionId });
524
+ if (openRun.kind === 'unresolvable' && input.force !== true) {
525
+ return {
526
+ ok: true,
527
+ code: 'AUTO_COMPACT_UNRESOLVED_SESSION',
528
+ message: `Context at ${(probe.ratio * 100).toFixed(1)}%; a compact may be warranted, but this session's ` +
529
+ `directory could not be resolved (${openRun.reason}), so whether a compact was already dispatched ` +
530
+ `for this crossing cannot be answered. Not dispatching: an unanswered question is not a 'no'. ` +
531
+ `The session id comes from the active binding or from the caller.`,
532
+ data: {
533
+ sessionId,
534
+ ratio: probe.ratio,
535
+ source: probe.source,
536
+ decision: 'unresolved-session',
537
+ harnessWindow
538
+ }
539
+ };
540
+ }
541
+ if (openRun.kind === 'open' && input.force !== true) {
542
+ return {
543
+ ok: true,
544
+ code: 'AUTO_COMPACT_ALREADY_ARMED',
545
+ message: `Context at ${(probe.ratio * 100).toFixed(1)}% — still above the ` +
546
+ `${(thresholdFor(mode, 'autoFire') * 100).toFixed(0)}% auto-fire threshold (mode=${mode}); ` +
547
+ `a compact was already dispatched for this crossing at ${(openRun.triggerRatio * 100).toFixed(1)}% ` +
548
+ `(run ${openRun.runId}, resting at '${openRun.stage}') and nothing has compacted since. ` +
549
+ `Not dispatching again: the trigger is already registered, so a second dispatch would install the ` +
550
+ `same hook and add a checkpoint and a history row without adding a capability. ` +
551
+ `Re-probe with \`peaks code context-now\`.`,
552
+ data: {
553
+ sessionId,
554
+ ratio: probe.ratio,
555
+ source: probe.source,
556
+ decision: 'already-armed',
557
+ armedAtRatio: openRun.triggerRatio,
558
+ armedRunId: openRun.runId,
559
+ harnessWindow
560
+ }
561
+ };
562
+ }
463
563
  // Slice 2026-08-01-compact-lifecycle (Task 5): the decision has now
464
564
  // committed to compacting, so the run is `queued`. One runId per
465
565
  // attempt; every later transition carries it forward.
@@ -665,7 +765,7 @@ export async function runAutoCompact(input) {
665
765
  };
666
766
  }
667
767
  export function appendCompactHistoryEvent(input) {
668
- const dir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId);
768
+ const dir = getSessionDir(input.projectRoot, input.sessionId);
669
769
  if (!existsSync(dir))
670
770
  mkdirSync(dir, { recursive: true });
671
771
  const path = join(dir, 'compact-history.jsonl');
@@ -29,18 +29,30 @@ export type CompactSettleResult = {
29
29
  /** Present only when the harness reported one. */
30
30
  readonly trigger?: CompactTrigger;
31
31
  /**
32
- * False when the run settled but the `observed` row could not be
33
- * appended. The settlement is the load-bearing half and is never undone
34
- * for this, but the two outcomes are not the same fact.
32
+ * Whether the `observed` row for this event is on disk. `false` means
33
+ * there is none, for one of two DIFFERENT reasons, told apart by
34
+ * `lifecycleWritten`:
35
+ *
36
+ * - `lifecycleWritten: true` — the append was attempted and threw.
37
+ * - `lifecycleWritten: false` — the append was deliberately not made,
38
+ * because the settle it would describe is one the store never
39
+ * recorded (see `lifecycleWritten`).
35
40
  */
36
41
  readonly historyWritten: boolean;
37
42
  /**
38
43
  * False when the lifecycle record could not be written. The run is then
39
- * still open, so a later probe will settle it from its own measurement —
40
- * but the `observed` row is appended anyway, because the harness's
41
- * statement is evidence independent of that write. The two failures are
42
- * reported separately rather than collapsed: a caller that sees only one
43
- * of them cannot say which half of the settlement landed.
44
+ * still open, and this path does NOT append the `observed` row: the row
45
+ * would assert a settled measurement for a run the store still holds at
46
+ * `armed` / `compacting`, and each arrival would add another one. The
47
+ * row is deferred rather than lost — the run stays open, so the retry
48
+ * that does land owns the row and measures the ratio at that moment.
49
+ * This is repair R6's answer on the probe path
50
+ * (`auto-compact-orchestrator.ts`), applied here so the two settle
51
+ * paths agree.
52
+ *
53
+ * The two halves are reported separately rather than collapsed: a caller
54
+ * that sees only one of them cannot say which half of the settlement
55
+ * landed.
44
56
  */
45
57
  readonly lifecycleWritten: boolean;
46
58
  } | {
@@ -161,6 +161,27 @@ export function settleCompactFromHarnessEvent(input) {
161
161
  if (settled === null) {
162
162
  return { settled: false, reason: 'nothing-to-settle' };
163
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
+ }
164
185
  const event = {
165
186
  schemaVersion: 1,
166
187
  kind: 'observed',