peaks-loop 4.0.47 → 4.0.48

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 (104) hide show
  1. package/CHANGELOG.md +24 -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 +112 -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/hooks-commands.js +4 -4
  15. package/dist/cli/commands/job-commands.js +8 -0
  16. package/dist/cli/commands/loop-eval-commands.js +15 -0
  17. package/dist/cli/commands/perf-audit-commands.js +2 -0
  18. package/dist/cli/commands/playwright-commands.js +12 -0
  19. package/dist/cli/commands/prd-commands.js +1 -1
  20. package/dist/cli/commands/qa-commands.js +22 -0
  21. package/dist/cli/commands/request-commands.js +8 -0
  22. package/dist/cli/commands/scan-commands.js +1 -1
  23. package/dist/cli/commands/security-audit-commands.js +2 -0
  24. package/dist/cli/commands/slice-integrate-commands.js +5 -0
  25. package/dist/cli/commands/statusline-commands.js +44 -4
  26. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  27. package/dist/cli/commands/sub-agent/detached.js +47 -22
  28. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  29. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  30. package/dist/cli/commands/workflow-commands.js +1 -1
  31. package/dist/cli/index.js +5 -45
  32. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  33. package/dist/services/artifacts/artifact-prerequisites.js +130 -65
  34. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  35. package/dist/services/artifacts/request-artifact-service.js +18 -8
  36. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  37. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  38. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  39. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  40. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  41. package/dist/services/audit-independent/security-audit-service.js +28 -6
  42. package/dist/services/code/auto-compact-lifecycle.d.ts +119 -0
  43. package/dist/services/code/auto-compact-lifecycle.js +169 -0
  44. package/dist/services/code/auto-compact-orchestrator.js +13 -2
  45. package/dist/services/code/compact-event-settle.d.ts +122 -0
  46. package/dist/services/code/compact-event-settle.js +219 -0
  47. package/dist/services/compact-history/compact-history-service.d.ts +14 -0
  48. package/dist/services/config/config-restore.d.ts +12 -1
  49. package/dist/services/config/config-restore.js +35 -4
  50. package/dist/services/config/config-rollback.js +6 -1
  51. package/dist/services/context/harness-context-witness.d.ts +310 -0
  52. package/dist/services/context/harness-context-witness.js +606 -0
  53. package/dist/services/evidence/evidence-generator.js +86 -49
  54. package/dist/services/final-review/final-review-service.d.ts +9 -0
  55. package/dist/services/final-review/final-review-service.js +36 -12
  56. package/dist/services/ide/ide-registry.d.ts +19 -0
  57. package/dist/services/ide/ide-registry.js +21 -0
  58. package/dist/services/job/job-state-store.js +7 -0
  59. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  60. package/dist/services/prd/handoff-auto-regen.js +31 -27
  61. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  62. package/dist/services/prd/handoff-frontmatter.js +75 -0
  63. package/dist/services/prd/handoff-service.d.ts +41 -2
  64. package/dist/services/prd/handoff-service.js +81 -8
  65. package/dist/services/prd/handoff-types.d.ts +3 -2
  66. package/dist/services/prd/handoff-types.js +3 -2
  67. package/dist/services/qa/qa-business-review-state.js +9 -0
  68. package/dist/services/scan/karpathy-service.js +2 -2
  69. package/dist/services/session/session-checkpoint-service.js +8 -0
  70. package/dist/services/skill/resume-detector.js +29 -11
  71. package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
  72. package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
  73. package/dist/services/skills/hooks-settings-service.js +14 -4
  74. package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
  75. package/dist/services/skills/session-start-hook-constants.js +45 -0
  76. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  77. package/dist/services/slice/slice-check-service.js +29 -11
  78. package/dist/services/slice/slice-review-state.js +8 -0
  79. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  80. package/dist/services/workflow/pipeline-verify-gate-support.js +212 -93
  81. package/dist/services/workflow/pipeline-verify-service.js +24 -23
  82. package/dist/services/workflow/pipeline-verify-types.d.ts +10 -3
  83. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  84. package/dist/services/workspace/claude-settings-template.js +98 -20
  85. package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
  86. package/package.json +6 -6
  87. package/skills/bee/peaks-prd/SKILL.md +7 -5
  88. package/skills/bee/peaks-qa/SKILL.md +5 -5
  89. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  90. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  91. package/skills/bee/peaks-rd/SKILL.md +8 -6
  92. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  93. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  94. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  95. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  96. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  97. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  98. package/skills/peaks-code/SKILL.md +1 -1
  99. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  100. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  101. package/skills/peaks-code/references/resume-detection.md +13 -7
  102. package/skills/peaks-code/references/runbook.md +3 -2
  103. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  104. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
@@ -275,3 +275,172 @@ export function settleOpenLifecycleRun(input) {
275
275
  emit('completed', true);
276
276
  return { runId: prior.runId, triggerRatio: prior.triggerRatio, afterRatio: input.measuredRatio };
277
277
  }
278
+ /**
279
+ * rid `2026-09-13-compact-event-settle`: close out an open compact run because
280
+ * the HARNESS said one completed — `PostCompact` — rather than because a later
281
+ * probe noticed the ratio had fallen.
282
+ *
283
+ * WHY THIS IS A SECOND FUNCTION AND NOT A FLAG ON THE ONE ABOVE. The function
284
+ * above is defined by two MEASUREMENT gates: it refuses when nothing could be
285
+ * measured, and refuses when the number it got has not dropped far enough. Both
286
+ * are correct for a probe, whose ratio is an INFERENCE about whether something
287
+ * happened. Handed a harness event, both are wrong in the same direction —
288
+ * the harness has already stated that the compaction happened, so a probe that
289
+ * could not measure, or measured something larger, contradicts nothing. The
290
+ * event is the evidence; the ratio is a consequence.
291
+ *
292
+ * What survives from the probe path is the ATTRIBUTION gate, and only that:
293
+ * there must be an open run (`compacting` / `armed`) for this event to be
294
+ * about. A `PostCompact` on a session where peaks-loop never dispatched has
295
+ * nothing to settle — objectively, the run the event would complete does not
296
+ * exist. (`queued` / `preparing` are excluded for the probe path's reason: a
297
+ * run that died before dispatch never had a compaction to complete.)
298
+ *
299
+ * `afterRatio` is recorded ONLY when it is a genuine DROP below the ratio the
300
+ * dispatch was made at. Immediately after a compaction, `readContextPercent`
301
+ * prefers the statusline file, which may still hold the PRE-compact value; the
302
+ * one thing this row must not do is launder that stale reading into an
303
+ * `afterRatio` and publish "the context did not shrink" as a measurement. A
304
+ * `null` here means "no honest post-compact number was available at the moment
305
+ * the event fired" — and that is NOT self-healing: the record is left at
306
+ * `completed` with no number, `computeWindowCalibration` skips `observed` rows
307
+ * that carry none, and the probe path refuses a run that is no longer open. The
308
+ * pair is then closed by `fillEventSettledMeasurement` below — but only on the
309
+ * probes that reach it, which is not all of them: that call sits in the
310
+ * BELOW-THRESHOLD branch of `runAutoCompact` (`auto-compact-orchestrator.ts:567`
311
+ * guards it, `:589` calls it). A probe that instead commits to compacting does
312
+ * not merely defer the measurement: `advance('queued')` writes a fresh run to
313
+ * the same one-record-per-session store (`auto-compact-orchestrator.ts:668`), so
314
+ * the `completed`-without-`afterRatio` record this pair was owed is gone and the
315
+ * pair stays unmeasured. That loss is inherent rather than an oversight — once a
316
+ * second compaction has happened, no later ratio can be attributed to the first,
317
+ * so there is nothing honest left to fill — and it is visible as `unmeasured` in
318
+ * `peaks compact history` (QA residual R9). A fabricated number is not
319
+ * recoverable at all, which is why the stale reading is dropped rather than
320
+ * corrected.
321
+ *
322
+ * `verifying` is deliberately NOT emitted: its documented meaning is "we hold a
323
+ * measurement and are checking it", and on this path there may be no
324
+ * measurement at all. Emitting it would move the same untruth from the history
325
+ * row into the lifecycle record.
326
+ *
327
+ * Returns the settled facts, or `null` when there was nothing to settle. `null`
328
+ * means exactly ONE thing here — there was no OPEN run for this event to be
329
+ * about. A run that was open but whose record could not be written is not
330
+ * `null`: it returns the facts read off that run with `lifecycleWritten: false`,
331
+ * because a failed write is not an absent run, and a caller that cannot tell the
332
+ * two apart ends up telling the user a falsehood (see `compact-event-settle.ts`).
333
+ */
334
+ export function settleOpenLifecycleRunOnCompactEvent(input) {
335
+ const prior = readOpenCompactLifecycle({
336
+ projectRoot: input.projectRoot,
337
+ sessionId: input.sessionId
338
+ });
339
+ if (prior === null)
340
+ return null;
341
+ if (prior.stage !== 'compacting' && prior.stage !== 'armed')
342
+ return null;
343
+ const afterRatio = input.measuredRatio !== null && input.measuredRatio < prior.triggerRatio ? input.measuredRatio : null;
344
+ const record = {
345
+ schemaVersion: 1,
346
+ runId: prior.runId,
347
+ stage: 'completed',
348
+ updatedAt: new Date().toISOString(),
349
+ triggerRatio: prior.triggerRatio,
350
+ redLine: prior.redLine,
351
+ ...(afterRatio !== null ? { afterRatio } : {})
352
+ };
353
+ const facts = { runId: prior.runId, triggerRatio: prior.triggerRatio, afterRatio };
354
+ try {
355
+ if (input.failLifecycleWrite)
356
+ throw new Error('lifecycle store unavailable');
357
+ writeCompactLifecycle({
358
+ projectRoot: input.projectRoot,
359
+ sessionId: input.sessionId,
360
+ record
361
+ });
362
+ }
363
+ catch {
364
+ // Deliberately NOT `null`. The run WAS open and the write FAILED; returning
365
+ // the same value as "no run is open" is the conflation the repo's own lint
366
+ // names at this exact line (`catch-return-null — caller cannot distinguish
367
+ // failure from success`), and it reached the user as "No compact run was
368
+ // open", which is false.
369
+ return { ...facts, lifecycleWritten: false };
370
+ }
371
+ try {
372
+ input.onLifecycleStage?.('completed', record);
373
+ }
374
+ catch {
375
+ // Observer failures are not ours to propagate.
376
+ }
377
+ return { ...facts, lifecycleWritten: true };
378
+ }
379
+ /**
380
+ * rid `2026-09-13-compact-event-settle` (repair R1): supply the measurement a
381
+ * harness-settled run was left owing.
382
+ *
383
+ * The function above deliberately refuses to launder a post-compact reading
384
+ * that has not dropped — and right after a compaction that refusal is the
385
+ * NORMAL case, because the statusline still holds the pre-compact value. The
386
+ * run is then closed at `completed` with no `afterRatio`, so the dispatch's
387
+ * calibration pair never closes and "intent vs observed" stays blank for
388
+ * exactly the compactions this slice exists to witness. This function is what
389
+ * makes the function above's promise payable.
390
+ *
391
+ * WHY NOT WIDEN `settleOpenLifecycleRun`. That one re-emits `verifying` before
392
+ * `completed`, which on an already-`completed` record is a backwards stage
393
+ * transition with no observer to serve. This is not a settlement — the run IS
394
+ * settled; only the number is owed. So no stage is rewritten here.
395
+ *
396
+ * WRITING `afterRatio` ONTO THE RECORD IS THE IDEMPOTENCE TOKEN: every later
397
+ * probe finds it present and returns `null`, so however many probes follow, one
398
+ * compaction yields exactly one late measurement.
399
+ *
400
+ * THE DROP GATE IS THE EVENT PATH'S OWN (`measuredRatio < triggerRatio`), not
401
+ * the probe path's `autoFireThreshold`. `afterRatio` has to mean "below the
402
+ * ratio this run was dispatched at" — the rule the event path already enforces
403
+ * — or a run dispatched under the threshold (a forced or banded dispatch) would
404
+ * let a NON-drop through the one path that can still write a `completed` record.
405
+ * `conservative-fallback` is refused for the probe path's reason: its `0` is
406
+ * the absence of a measurement, not an empty context.
407
+ *
408
+ * Returns the filled record, or `null` when no run is owed a measurement.
409
+ */
410
+ export function fillEventSettledMeasurement(input) {
411
+ if (input.source === 'conservative-fallback')
412
+ return null;
413
+ const prior = readOpenCompactLifecycle({
414
+ projectRoot: input.projectRoot,
415
+ sessionId: input.sessionId
416
+ });
417
+ if (prior === null)
418
+ return null;
419
+ // Exactly one shape is owed a number: the one the EVENT path leaves behind.
420
+ // `settleOpenLifecycleRun` never writes it (it always carries `afterRatio`),
421
+ // and a `failed` run never dispatched, so it has no row to pair with.
422
+ if (prior.stage !== 'completed' || prior.afterRatio !== undefined)
423
+ return null;
424
+ if (input.measuredRatio >= prior.triggerRatio)
425
+ return null;
426
+ const record = {
427
+ schemaVersion: 1,
428
+ runId: prior.runId,
429
+ stage: 'completed',
430
+ updatedAt: new Date().toISOString(),
431
+ triggerRatio: prior.triggerRatio,
432
+ redLine: prior.redLine,
433
+ afterRatio: input.measuredRatio
434
+ };
435
+ try {
436
+ writeCompactLifecycle({
437
+ projectRoot: input.projectRoot,
438
+ sessionId: input.sessionId,
439
+ record
440
+ });
441
+ }
442
+ catch {
443
+ return null;
444
+ }
445
+ return { runId: prior.runId, triggerRatio: prior.triggerRatio, afterRatio: input.measuredRatio };
446
+ }
@@ -42,7 +42,7 @@ import { describeHarnessWindowSync, harnessWindowSyncWarning } from '../context/
42
42
  import { AUTO_COMPACT_PRE_COMPACT_RATIO, DEPRECATED_ENVELOPE_FIELDS } from '../context/auto-compact-types.js';
43
43
  import { describeMode, thresholdFor } from './auto-compact-modes.js';
44
44
  import { resolveAutoCompactProfile } from '../mode/mode-status-service.js';
45
- import { CompactLifecyclePublisher, newCompactRunId, resolveDispatchedStage, settleOpenLifecycleRun, summarizeLifecycleError } from './auto-compact-lifecycle.js';
45
+ import { CompactLifecyclePublisher, fillEventSettledMeasurement, newCompactRunId, resolveDispatchedStage, settleOpenLifecycleRun, summarizeLifecycleError } from './auto-compact-lifecycle.js';
46
46
  const PRE_COMPACT_REASON = 'pre-compact-auto';
47
47
  /**
48
48
  * Map a context ratio to a `CompactTrigger` action. Pure; the side
@@ -389,7 +389,18 @@ export async function runAutoCompact(input) {
389
389
  source: probe.source,
390
390
  autoFireThreshold: thresholdFor(mode, 'autoFire'),
391
391
  onLifecycleStage: input.onLifecycleStage
392
- });
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
+ });
393
404
  // Slice 2026-09-13-auto-compact-trigger-ownership (T4): a settle means a
394
405
  // dispatched compact demonstrably landed. Append an `observed` row
395
406
  // carrying the measured ratio, so `peaks compact history` can show
@@ -0,0 +1,122 @@
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
+ * 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.
35
+ */
36
+ readonly historyWritten: boolean;
37
+ /**
38
+ * 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
+ */
45
+ readonly lifecycleWritten: boolean;
46
+ } | {
47
+ readonly settled: false;
48
+ /**
49
+ * `nothing-to-settle` — there was no open run. `different-session` — the
50
+ * payload named a harness session that is not this project's, so the open
51
+ * run was left alone. The second is a refusal of ATTRIBUTION, not an
52
+ * absence of it, and a reader told "nothing was open" would be told
53
+ * something false.
54
+ */
55
+ readonly reason: 'nothing-to-settle' | 'different-session';
56
+ };
57
+ /**
58
+ * Read `trigger` out of an already-parsed `PostCompact` payload.
59
+ *
60
+ * Anything that is not exactly `'manual'` or `'auto'` yields `undefined` — a
61
+ * missing field, a `null`, a number, another string, a payload that is not an
62
+ * object at all. This function never throws and never guesses (see this file's
63
+ * header, refusal 1).
64
+ */
65
+ export declare function readTriggerFromHookPayload(payload: unknown): CompactTrigger | undefined;
66
+ /**
67
+ * Read the harness session id out of an already-parsed `PostCompact` payload.
68
+ *
69
+ * Same contract as `readTriggerFromHookPayload`: a missing field, an empty
70
+ * string, a number, a non-object payload — every one of those is `undefined`,
71
+ * and "absent" is an expected input rather than an error (see this file's
72
+ * header, refusal 4, for what the caller does with it and what it does with
73
+ * `undefined`).
74
+ */
75
+ export declare function readSessionIdFromHookPayload(payload: unknown): string | undefined;
76
+ /**
77
+ * The post-compact ruler: the same probe the orchestrator uses, so this row
78
+ * divides by the same denominator the dispatch did.
79
+ *
80
+ * `ratio` is `null` when nothing could be measured — a `conservative-fallback`
81
+ * probe reports `0`, but that `0` means "unknown", and recording it here would
82
+ * publish a real, infinitely-deep drop. The rest of the reading (adapter, the
83
+ * window the ratio divides by) is still carried, because knowing WHICH adapter
84
+ * could not measure is diagnostic.
85
+ */
86
+ export declare function measurePostCompact(input: {
87
+ readonly projectRoot: string;
88
+ readonly sessionId: string;
89
+ readonly env: NodeJS.ProcessEnv;
90
+ }): PostCompactMeasurement;
91
+ /**
92
+ * Settle whatever compact run is open in `sessionId`, because the harness just
93
+ * reported one.
94
+ *
95
+ * `measure` is the ruler, injected rather than called directly for one concrete
96
+ * reason: the production ruler prefers `~/.claude/statusline-state.json`, a real
97
+ * file whose contents differ on every machine that runs the test suite. A test
98
+ * asserting this row's `afterRatio` against the developer's own statusline would
99
+ * pass or fail by accident. It is called at most once — the IDE tag and the
100
+ * window come from the same reading, so they cannot disagree with each other.
101
+ *
102
+ * Returns `{ settled: false }` when there is no open run — the honest answer,
103
+ * not an error — and when the payload names another harness session. The two
104
+ * say different things in `reason`, because they are different facts.
105
+ */
106
+ export declare function settleCompactFromHarnessEvent(input: {
107
+ readonly projectRoot: string;
108
+ readonly sessionId: string;
109
+ readonly trigger?: CompactTrigger | undefined;
110
+ /**
111
+ * The harness session id the payload named, when it named one (see
112
+ * `readSessionIdFromHookPayload`). Refused when it is present AND this
113
+ * project's own harness session id resolves AND the two differ. Absent, or
114
+ * unresolvable on this side, is accepted: a guard that cannot see what it is
115
+ * checking against must not become a hook that never settles anything.
116
+ */
117
+ readonly hookSessionId?: string | undefined;
118
+ readonly env?: NodeJS.ProcessEnv | undefined;
119
+ readonly measure?: (() => PostCompactMeasurement | null) | undefined;
120
+ /** Failure injection for the lifecycle write — the seam `CompactLifecyclePublisher` already takes. */
121
+ readonly failLifecycleWrite?: boolean | undefined;
122
+ }): CompactSettleResult;
@@ -0,0 +1,219 @@
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
+ const event = {
165
+ schemaVersion: 1,
166
+ kind: 'observed',
167
+ ts: new Date().toISOString(),
168
+ // The guard above refused every payload that named another session, so
169
+ // reaching here means the event named this one or named none. The row is
170
+ // therefore attributable to the main session, which is the only session
171
+ // this command settles.
172
+ target: 'main',
173
+ mode: resolveAutoCompactProfile(input.projectRoot),
174
+ ide: measurement?.ide ?? 'claude-code',
175
+ pathway: COMPACT_EVENT_PATHWAY,
176
+ beforeRatio: settled.triggerRatio,
177
+ ...(settled.afterRatio !== null ? { afterRatio: settled.afterRatio } : {}),
178
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {}),
179
+ redLine: false,
180
+ ok: true,
181
+ checkpointPath: '',
182
+ dispatchMessage: `PostCompact fired: the harness reported a ${input.trigger ?? 'unreported-trigger'} compaction, ` +
183
+ `settling the run dispatched at ${(settled.triggerRatio * 100).toFixed(1)}%` +
184
+ (settled.afterRatio !== null
185
+ ? ` (measured now at ${(settled.afterRatio * 100).toFixed(1)}%)`
186
+ : ' (no post-compact measurement available)'),
187
+ ...(settled.afterRatio !== null
188
+ ? { windowTokens: measurement?.windowTokens ?? null, windowSource: measurement?.windowSource ?? null }
189
+ : {})
190
+ };
191
+ try {
192
+ appendCompactHistoryEvent({ projectRoot: input.projectRoot, sessionId: input.sessionId, event });
193
+ }
194
+ catch {
195
+ // The lifecycle run is already settled; losing the history row is the
196
+ // smaller loss, and a throwing hook is the larger one. Reported rather
197
+ // than silenced: "the run settled but the row is missing" and "the row is
198
+ // on disk" are different facts, and a caller that cannot tell them apart
199
+ // cannot diagnose a hook that has stopped recording anything.
200
+ return {
201
+ settled: true,
202
+ runId: settled.runId,
203
+ beforeRatio: settled.triggerRatio,
204
+ afterRatio: settled.afterRatio,
205
+ historyWritten: false,
206
+ lifecycleWritten: settled.lifecycleWritten,
207
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {})
208
+ };
209
+ }
210
+ return {
211
+ settled: true,
212
+ runId: settled.runId,
213
+ beforeRatio: settled.triggerRatio,
214
+ afterRatio: settled.afterRatio,
215
+ historyWritten: true,
216
+ lifecycleWritten: settled.lifecycleWritten,
217
+ ...(input.trigger !== undefined ? { trigger: input.trigger } : {})
218
+ };
219
+ }
@@ -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
@@ -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
  }