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
@@ -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,10 +309,187 @@ 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);
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 };
320
+ }
321
+ /**
322
+ * rid `2026-09-13-compact-event-settle`: close out an open compact run because
323
+ * the HARNESS said one completed — `PostCompact` — rather than because a later
324
+ * probe noticed the ratio had fallen.
325
+ *
326
+ * WHY THIS IS A SECOND FUNCTION AND NOT A FLAG ON THE ONE ABOVE. The function
327
+ * above is defined by two MEASUREMENT gates: it refuses when nothing could be
328
+ * measured, and refuses when the number it got has not dropped far enough. Both
329
+ * are correct for a probe, whose ratio is an INFERENCE about whether something
330
+ * happened. Handed a harness event, both are wrong in the same direction —
331
+ * the harness has already stated that the compaction happened, so a probe that
332
+ * could not measure, or measured something larger, contradicts nothing. The
333
+ * event is the evidence; the ratio is a consequence.
334
+ *
335
+ * What survives from the probe path is the ATTRIBUTION gate, and only that:
336
+ * there must be an open run (`compacting` / `armed`) for this event to be
337
+ * about. A `PostCompact` on a session where peaks-loop never dispatched has
338
+ * nothing to settle — objectively, the run the event would complete does not
339
+ * exist. (`queued` / `preparing` are excluded for the probe path's reason: a
340
+ * run that died before dispatch never had a compaction to complete.)
341
+ *
342
+ * `afterRatio` is recorded ONLY when it is a genuine DROP below the ratio the
343
+ * dispatch was made at. Immediately after a compaction, `readContextPercent`
344
+ * prefers the statusline file, which may still hold the PRE-compact value; the
345
+ * one thing this row must not do is launder that stale reading into an
346
+ * `afterRatio` and publish "the context did not shrink" as a measurement. A
347
+ * `null` here means "no honest post-compact number was available at the moment
348
+ * the event fired" — and that is NOT self-healing: the record is left at
349
+ * `completed` with no number, `computeWindowCalibration` skips `observed` rows
350
+ * that carry none, and the probe path refuses a run that is no longer open. The
351
+ * pair is then closed by `fillEventSettledMeasurement` below — but only on the
352
+ * probes that reach it, which is not all of them: that call sits in the
353
+ * BELOW-THRESHOLD branch of `runAutoCompact` (`auto-compact-orchestrator.ts:567`
354
+ * guards it, `:589` calls it). A probe that instead commits to compacting does
355
+ * not merely defer the measurement: `advance('queued')` writes a fresh run to
356
+ * the same one-record-per-session store (`auto-compact-orchestrator.ts:668`), so
357
+ * the `completed`-without-`afterRatio` record this pair was owed is gone and the
358
+ * pair stays unmeasured. That loss is inherent rather than an oversight — once a
359
+ * second compaction has happened, no later ratio can be attributed to the first,
360
+ * so there is nothing honest left to fill — and it is visible as `unmeasured` in
361
+ * `peaks compact history` (QA residual R9). A fabricated number is not
362
+ * recoverable at all, which is why the stale reading is dropped rather than
363
+ * corrected.
364
+ *
365
+ * `verifying` is deliberately NOT emitted: its documented meaning is "we hold a
366
+ * measurement and are checking it", and on this path there may be no
367
+ * measurement at all. Emitting it would move the same untruth from the history
368
+ * row into the lifecycle record.
369
+ *
370
+ * Returns the settled facts, or `null` when there was nothing to settle. `null`
371
+ * means exactly ONE thing here — there was no OPEN run for this event to be
372
+ * about. A run that was open but whose record could not be written is not
373
+ * `null`: it returns the facts read off that run with `lifecycleWritten: false`,
374
+ * because a failed write is not an absent run, and a caller that cannot tell the
375
+ * two apart ends up telling the user a falsehood (see `compact-event-settle.ts`).
376
+ */
377
+ export function settleOpenLifecycleRunOnCompactEvent(input) {
378
+ const openRead = readOpenCompactLifecycle({
379
+ projectRoot: input.projectRoot,
380
+ sessionId: input.sessionId
381
+ });
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')
386
+ return null;
387
+ const prior = openRead.record;
388
+ if (prior.stage !== 'compacting' && prior.stage !== 'armed')
389
+ return null;
390
+ const afterRatio = input.measuredRatio !== null && input.measuredRatio < prior.triggerRatio ? input.measuredRatio : null;
391
+ const record = {
392
+ schemaVersion: 1,
393
+ runId: prior.runId,
394
+ stage: 'completed',
395
+ updatedAt: new Date().toISOString(),
396
+ triggerRatio: prior.triggerRatio,
397
+ redLine: prior.redLine,
398
+ ...(afterRatio !== null ? { afterRatio } : {})
399
+ };
400
+ const facts = { runId: prior.runId, triggerRatio: prior.triggerRatio, afterRatio };
401
+ try {
402
+ if (input.failLifecycleWrite)
403
+ throw new Error('lifecycle store unavailable');
404
+ writeCompactLifecycle({
405
+ projectRoot: input.projectRoot,
406
+ sessionId: input.sessionId,
407
+ record
408
+ });
409
+ }
410
+ catch {
411
+ // Deliberately NOT `null`. The run WAS open and the write FAILED; returning
412
+ // the same value as "no run is open" is the conflation the repo's own lint
413
+ // names at this exact line (`catch-return-null — caller cannot distinguish
414
+ // failure from success`), and it reached the user as "No compact run was
415
+ // open", which is false.
416
+ return { ...facts, lifecycleWritten: false };
417
+ }
418
+ try {
419
+ input.onLifecycleStage?.('completed', record);
420
+ }
421
+ catch {
422
+ // Observer failures are not ours to propagate.
423
+ }
424
+ return { ...facts, lifecycleWritten: true };
425
+ }
426
+ /**
427
+ * rid `2026-09-13-compact-event-settle` (repair R1): supply the measurement a
428
+ * harness-settled run was left owing.
429
+ *
430
+ * The function above deliberately refuses to launder a post-compact reading
431
+ * that has not dropped — and right after a compaction that refusal is the
432
+ * NORMAL case, because the statusline still holds the pre-compact value. The
433
+ * run is then closed at `completed` with no `afterRatio`, so the dispatch's
434
+ * calibration pair never closes and "intent vs observed" stays blank for
435
+ * exactly the compactions this slice exists to witness. This function is what
436
+ * makes the function above's promise payable.
437
+ *
438
+ * WHY NOT WIDEN `settleOpenLifecycleRun`. That one re-emits `verifying` before
439
+ * `completed`, which on an already-`completed` record is a backwards stage
440
+ * transition with no observer to serve. This is not a settlement — the run IS
441
+ * settled; only the number is owed. So no stage is rewritten here.
442
+ *
443
+ * WRITING `afterRatio` ONTO THE RECORD IS THE IDEMPOTENCE TOKEN: every later
444
+ * probe finds it present and returns `null`, so however many probes follow, one
445
+ * compaction yields exactly one late measurement.
446
+ *
447
+ * THE DROP GATE IS THE EVENT PATH'S OWN (`measuredRatio < triggerRatio`), not
448
+ * the probe path's `autoFireThreshold`. `afterRatio` has to mean "below the
449
+ * ratio this run was dispatched at" — the rule the event path already enforces
450
+ * — or a run dispatched under the threshold (a forced or banded dispatch) would
451
+ * let a NON-drop through the one path that can still write a `completed` record.
452
+ * `conservative-fallback` is refused for the probe path's reason: its `0` is
453
+ * the absence of a measurement, not an empty context.
454
+ *
455
+ * Returns the filled record, or `null` when no run is owed a measurement.
456
+ */
457
+ export function fillEventSettledMeasurement(input) {
458
+ if (input.source === 'conservative-fallback')
459
+ return null;
460
+ const openRead = readOpenCompactLifecycle({
461
+ projectRoot: input.projectRoot,
462
+ sessionId: input.sessionId
463
+ });
464
+ // Same collapse as the two settle paths above — `null` here admits nothing.
465
+ if (openRead.kind !== 'found')
466
+ return null;
467
+ const prior = openRead.record;
468
+ // Exactly one shape is owed a number: the one the EVENT path leaves behind.
469
+ // `settleOpenLifecycleRun` never writes it (it always carries `afterRatio`),
470
+ // and a `failed` run never dispatched, so it has no row to pair with.
471
+ if (prior.stage !== 'completed' || prior.afterRatio !== undefined)
472
+ return null;
473
+ if (input.measuredRatio >= prior.triggerRatio)
474
+ return null;
475
+ const record = {
476
+ schemaVersion: 1,
477
+ runId: prior.runId,
478
+ stage: 'completed',
479
+ updatedAt: new Date().toISOString(),
480
+ triggerRatio: prior.triggerRatio,
481
+ redLine: prior.redLine,
482
+ afterRatio: input.measuredRatio
483
+ };
484
+ try {
485
+ writeCompactLifecycle({
486
+ projectRoot: input.projectRoot,
487
+ sessionId: input.sessionId,
488
+ record
489
+ });
490
+ }
491
+ catch {
492
+ return null;
493
+ }
276
494
  return { runId: prior.runId, triggerRatio: prior.triggerRatio, afterRatio: input.measuredRatio };
277
495
  }
@@ -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, 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,14 +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
400
+ onLifecycleStage: input.onLifecycleStage,
401
+ failLifecycleWrite: input.testHooks?.failLifecycleWrite
392
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
+ }));
393
425
  // Slice 2026-09-13-auto-compact-trigger-ownership (T4): a settle means a
394
426
  // dispatched compact demonstrably landed. Append an `observed` row
395
427
  // carrying the measured ratio, so `peaks compact history` can show
@@ -449,6 +481,85 @@ export async function runAutoCompact(input) {
449
481
  }
450
482
  const isRedLine = decision.reason === 'red-line';
451
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
+ }
452
563
  // Slice 2026-08-01-compact-lifecycle (Task 5): the decision has now
453
564
  // committed to compacting, so the run is `queued`. One runId per
454
565
  // attempt; every later transition carries it forward.
@@ -654,7 +765,7 @@ export async function runAutoCompact(input) {
654
765
  };
655
766
  }
656
767
  export function appendCompactHistoryEvent(input) {
657
- const dir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId);
768
+ const dir = getSessionDir(input.projectRoot, input.sessionId);
658
769
  if (!existsSync(dir))
659
770
  mkdirSync(dir, { recursive: true });
660
771
  const path = join(dir, 'compact-history.jsonl');
@@ -0,0 +1,134 @@
1
+ /** The harness's own report of what caused a compaction. */
2
+ export type CompactTrigger = 'manual' | 'auto';
3
+ /**
4
+ * Which layer settled the run. `post-compact-hook` = the harness's event;
5
+ * `post-compact-probe` (written by the orchestrator) = a later probe's
6
+ * measurement. The two are deliberately distinct strings so a reader of
7
+ * `compact-history.jsonl` can tell a NOTIFICATION from an INFERENCE — which is
8
+ * the entire instrument this slice exists to build.
9
+ */
10
+ export declare const COMPACT_EVENT_PATHWAY = "post-compact-hook";
11
+ /**
12
+ * One post-compact reading: the ratio, the window it was divided by, and the
13
+ * adapter that produced it — all from a SINGLE probe call, so the three cannot
14
+ * disagree with each other.
15
+ */
16
+ export type PostCompactMeasurement = {
17
+ /** `null` = nothing could be measured. Never `0`, which means "unknown". */
18
+ readonly ratio: number | null;
19
+ readonly ide: string;
20
+ readonly windowTokens: number | null;
21
+ readonly windowSource: string | null;
22
+ };
23
+ export type CompactSettleResult = {
24
+ readonly settled: true;
25
+ readonly runId: string;
26
+ readonly beforeRatio: number;
27
+ /** The measured post-compact ratio, or `null` when none was trustworthy. */
28
+ readonly afterRatio: number | null;
29
+ /** Present only when the harness reported one. */
30
+ readonly trigger?: CompactTrigger;
31
+ /**
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`).
40
+ */
41
+ readonly historyWritten: boolean;
42
+ /**
43
+ * False when the lifecycle record could not be written. The run is then
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.
56
+ */
57
+ readonly lifecycleWritten: boolean;
58
+ } | {
59
+ readonly settled: false;
60
+ /**
61
+ * `nothing-to-settle` — there was no open run. `different-session` — the
62
+ * payload named a harness session that is not this project's, so the open
63
+ * run was left alone. The second is a refusal of ATTRIBUTION, not an
64
+ * absence of it, and a reader told "nothing was open" would be told
65
+ * something false.
66
+ */
67
+ readonly reason: 'nothing-to-settle' | 'different-session';
68
+ };
69
+ /**
70
+ * Read `trigger` out of an already-parsed `PostCompact` payload.
71
+ *
72
+ * Anything that is not exactly `'manual'` or `'auto'` yields `undefined` — a
73
+ * missing field, a `null`, a number, another string, a payload that is not an
74
+ * object at all. This function never throws and never guesses (see this file's
75
+ * header, refusal 1).
76
+ */
77
+ export declare function readTriggerFromHookPayload(payload: unknown): CompactTrigger | undefined;
78
+ /**
79
+ * Read the harness session id out of an already-parsed `PostCompact` payload.
80
+ *
81
+ * Same contract as `readTriggerFromHookPayload`: a missing field, an empty
82
+ * string, a number, a non-object payload — every one of those is `undefined`,
83
+ * and "absent" is an expected input rather than an error (see this file's
84
+ * header, refusal 4, for what the caller does with it and what it does with
85
+ * `undefined`).
86
+ */
87
+ export declare function readSessionIdFromHookPayload(payload: unknown): string | undefined;
88
+ /**
89
+ * The post-compact ruler: the same probe the orchestrator uses, so this row
90
+ * divides by the same denominator the dispatch did.
91
+ *
92
+ * `ratio` is `null` when nothing could be measured — a `conservative-fallback`
93
+ * probe reports `0`, but that `0` means "unknown", and recording it here would
94
+ * publish a real, infinitely-deep drop. The rest of the reading (adapter, the
95
+ * window the ratio divides by) is still carried, because knowing WHICH adapter
96
+ * could not measure is diagnostic.
97
+ */
98
+ export declare function measurePostCompact(input: {
99
+ readonly projectRoot: string;
100
+ readonly sessionId: string;
101
+ readonly env: NodeJS.ProcessEnv;
102
+ }): PostCompactMeasurement;
103
+ /**
104
+ * Settle whatever compact run is open in `sessionId`, because the harness just
105
+ * reported one.
106
+ *
107
+ * `measure` is the ruler, injected rather than called directly for one concrete
108
+ * reason: the production ruler prefers `~/.claude/statusline-state.json`, a real
109
+ * file whose contents differ on every machine that runs the test suite. A test
110
+ * asserting this row's `afterRatio` against the developer's own statusline would
111
+ * pass or fail by accident. It is called at most once — the IDE tag and the
112
+ * window come from the same reading, so they cannot disagree with each other.
113
+ *
114
+ * Returns `{ settled: false }` when there is no open run — the honest answer,
115
+ * not an error — and when the payload names another harness session. The two
116
+ * say different things in `reason`, because they are different facts.
117
+ */
118
+ export declare function settleCompactFromHarnessEvent(input: {
119
+ readonly projectRoot: string;
120
+ readonly sessionId: string;
121
+ readonly trigger?: CompactTrigger | undefined;
122
+ /**
123
+ * The harness session id the payload named, when it named one (see
124
+ * `readSessionIdFromHookPayload`). Refused when it is present AND this
125
+ * project's own harness session id resolves AND the two differ. Absent, or
126
+ * unresolvable on this side, is accepted: a guard that cannot see what it is
127
+ * checking against must not become a hook that never settles anything.
128
+ */
129
+ readonly hookSessionId?: string | undefined;
130
+ readonly env?: NodeJS.ProcessEnv | undefined;
131
+ readonly measure?: (() => PostCompactMeasurement | null) | undefined;
132
+ /** Failure injection for the lifecycle write — the seam `CompactLifecyclePublisher` already takes. */
133
+ readonly failLifecycleWrite?: boolean | undefined;
134
+ }): CompactSettleResult;