peaks-loop 4.0.48 → 4.0.50

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 (156) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/audit-commands.js +1 -0
  5. package/dist/cli/commands/baseline-commands.js +163 -25
  6. package/dist/cli/commands/compact-command.js +1 -3
  7. package/dist/cli/commands/core/skill-command.js +53 -4
  8. package/dist/cli/commands/core/standards-command.d.ts +24 -0
  9. package/dist/cli/commands/core/standards-command.js +74 -0
  10. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  11. package/dist/cli/commands/feedback-commands.js +49 -17
  12. package/dist/cli/commands/final-review-commands.js +12 -0
  13. package/dist/cli/commands/hooks-commands.js +55 -38
  14. package/dist/cli/commands/loop-eval-commands.js +22 -6
  15. package/dist/cli/commands/share-commands.js +37 -11
  16. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  17. package/dist/cli/commands/web-commands.js +8 -1
  18. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
  19. package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
  20. package/dist/services/adapter/adapter.d.ts +30 -0
  21. package/dist/services/adapter/auto-adapter.d.ts +13 -0
  22. package/dist/services/adapter/claude-adapter.js +12 -0
  23. package/dist/services/adapter/codex-adapter.d.ts +12 -0
  24. package/dist/services/adapter/codex-adapter.js +12 -0
  25. package/dist/services/adapter/copilot-adapter.d.ts +12 -0
  26. package/dist/services/adapter/copilot-adapter.js +12 -0
  27. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  28. package/dist/services/artifacts/request-artifact-service.js +59 -38
  29. package/dist/services/audit/backing-detector.d.ts +25 -7
  30. package/dist/services/audit/backing-detector.js +33 -17
  31. package/dist/services/audit/enforcer-liveness.d.ts +12 -0
  32. package/dist/services/audit/enforcer-liveness.js +100 -0
  33. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  34. package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
  35. package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
  36. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
  37. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
  38. package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
  39. package/dist/services/audit/enforcers/lint-style.js +38 -2
  40. package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
  41. package/dist/services/audit/prose-ratio-calculator.js +25 -18
  42. package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
  43. package/dist/services/audit/red-lines-service.js +51 -7
  44. package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
  45. package/dist/services/capability-audit-service/independent-checker.js +140 -0
  46. package/dist/services/capability-audit-service/index.d.ts +3 -1
  47. package/dist/services/capability-audit-service/index.js +1 -0
  48. package/dist/services/capability-audit-service/runner.d.ts +17 -13
  49. package/dist/services/capability-audit-service/runner.js +76 -15
  50. package/dist/services/capability-audit-service/types.d.ts +48 -0
  51. package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
  52. package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
  53. package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
  54. package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
  55. package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
  56. package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
  57. package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
  58. package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
  59. package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
  60. package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
  61. package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
  62. package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
  63. package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
  64. package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
  65. package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
  66. package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
  67. package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
  68. package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
  69. package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
  70. package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
  71. package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
  72. package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
  73. package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
  74. package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
  75. package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
  76. package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
  77. package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
  78. package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
  79. package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
  80. package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
  81. package/dist/services/capability-guard-runner/registry.d.ts +5 -0
  82. package/dist/services/capability-guard-runner/registry.js +140 -0
  83. package/dist/services/capability-guard-runner/runner.d.ts +26 -0
  84. package/dist/services/capability-guard-runner/runner.js +63 -6
  85. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  86. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  87. package/dist/services/code/auto-compact-modes.d.ts +13 -2
  88. package/dist/services/code/auto-compact-modes.js +20 -4
  89. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  90. package/dist/services/code/compact-event-settle.d.ts +20 -8
  91. package/dist/services/code/compact-event-settle.js +21 -0
  92. package/dist/services/code/post-compact-detector.js +20 -11
  93. package/dist/services/code/step-08-gate.js +21 -6
  94. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  95. package/dist/services/config/config-safety.js +11 -9
  96. package/dist/services/context/auto-compact-types.d.ts +20 -2
  97. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  98. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  99. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  100. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  101. package/dist/services/final-review/pre-post-diff.js +10 -2
  102. package/dist/services/job/job-progress-store.js +18 -3
  103. package/dist/services/observability/jsonl-store.d.ts +19 -0
  104. package/dist/services/observability/jsonl-store.js +27 -2
  105. package/dist/services/observability/observability-service.d.ts +11 -4
  106. package/dist/services/observability/observability-service.js +16 -3
  107. package/dist/services/prd/handoff-service.js +43 -0
  108. package/dist/services/qa/qa-business-review-state.js +19 -5
  109. package/dist/services/sc/sc-service.d.ts +8 -0
  110. package/dist/services/sc/sc-service.js +8 -1
  111. package/dist/services/scan/api-diff-types.js +20 -2
  112. package/dist/services/security/safe-settings-path.js +19 -1
  113. package/dist/services/session/getSessionDir.d.ts +33 -0
  114. package/dist/services/session/getSessionDir.js +60 -0
  115. package/dist/services/skill/skill-search-service.d.ts +3 -3
  116. package/dist/services/slice/slice-review-state.js +19 -4
  117. package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
  118. package/dist/services/standards/loop-engineering-lint.js +6 -0
  119. package/dist/services/web/daemon-registry.js +27 -2
  120. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  121. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  122. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  123. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  124. package/dist/services/workspace/claude-settings-template.d.ts +53 -37
  125. package/dist/services/workspace/claude-settings-template.js +105 -83
  126. package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
  127. package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
  128. package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
  129. package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
  130. package/dist/services/workspace/workspace-service.js +11 -1
  131. package/dist/shared/fs-utils.d.ts +26 -0
  132. package/dist/shared/fs-utils.js +35 -0
  133. package/dist/shared/runtime-root.d.ts +73 -0
  134. package/dist/shared/runtime-root.js +77 -0
  135. package/package.json +9 -7
  136. package/scripts/copy-templates.mjs +0 -12
  137. package/scripts/install-skills.mjs +154 -53
  138. package/skills/bee/peaks-qa/SKILL.md +0 -1
  139. package/skills/bee/peaks-rd/SKILL.md +0 -1
  140. package/skills/peaks-code/SKILL.md +12 -10
  141. package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
  142. package/skills/peaks-code/references/runbook.md +3 -0
  143. package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
  144. package/skills/peaks-code/references/startup-sequence.md +2 -2
  145. package/skills/peaks-code/references/step-0-8-gate.md +1 -1
  146. package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
  147. package/dist/cli/commands/context-builder-commands.d.ts +0 -11
  148. package/dist/cli/commands/context-builder-commands.js +0 -85
  149. package/dist/services/hooks/write-gate.js +0 -111
  150. package/skills/bee/peaks-prd/references/command-migration.md +0 -3
  151. package/skills/bee/peaks-qa/references/command-migration.md +0 -3
  152. package/skills/bee/peaks-rd/references/command-migration.md +0 -3
  153. package/skills/bee/peaks-sc/references/command-migration.md +0 -3
  154. package/skills/bee/peaks-txt/references/command-migration.md +0 -3
  155. package/skills/bee/peaks-ui/references/command-migration.md +0 -3
  156. package/skills/peaks-code/references/command-migration.md +0 -3
@@ -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',
@@ -24,6 +24,7 @@
24
24
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
25
25
  import { join } from 'node:path';
26
26
  import { getSkillPresence } from '../skills/skill-presence-service.js';
27
+ import { isExpectedFsMiss } from '../../shared/fs-utils.js';
27
28
  import { emitObservabilityEvent } from '../observability/observability-service.js';
28
29
  import { isCodeMode } from './mode-gate.js';
29
30
  export const POST_COMPACT_RESUME_REASONS = [
@@ -65,12 +66,19 @@ function safeReadCheckpoint(absPath) {
65
66
  const content = mutable;
66
67
  return { path: absPath, mtime: stat.mtime, content };
67
68
  }
68
- catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
69
- if (err instanceof ReferenceError)
70
- throw err; // surface module-load bugs
69
+ catch (err) {
70
+ // P1 site (S6, 2026-09-15). The TODO(g2) marker this replaces said the
71
+ // catch "narrows to IO errors only" — it did not. It rethrew the two JS
72
+ // error classes it happened to name and swallowed everything else,
73
+ // including the ReferenceError an ESM `require()` bug produces on the one
74
+ // platform this file's whole defence exists for. Corrupt JSON stays loud
75
+ // (we cannot read a checkpoint we cannot parse); every non-fs-miss error
76
+ // now propagates. See `isExpectedFsMiss`.
71
77
  if (err instanceof SyntaxError)
72
- throw err; // surface parse bugs
73
- return null; // only swallow IO errors
78
+ throw err;
79
+ if (!isExpectedFsMiss(err))
80
+ throw err;
81
+ return null;
74
82
  }
75
83
  }
76
84
  function readLatestTodayCheckpoint(runtimeDir, now) {
@@ -227,12 +235,13 @@ function readActiveSkillName(projectRoot) {
227
235
  const presence = getSkillPresence(projectRoot);
228
236
  return presence?.skill;
229
237
  }
230
- catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
231
- if (err instanceof ReferenceError)
232
- throw err; // surface module-load bugs
233
- if (err instanceof SyntaxError)
234
- throw err; // surface parse bugs
235
- return undefined; // only swallow IO errors
238
+ catch (err) {
239
+ // P1 site (S6, 2026-09-15) — same correction as `safeReadCheckpoint`
240
+ // above: a non-fs-miss failure reading the active skill is a bug, not an
241
+ // absence, and no longer resolves to `undefined` (i.e. to "no skill").
242
+ if (!isExpectedFsMiss(err))
243
+ throw err;
244
+ return undefined;
236
245
  }
237
246
  }
238
247
  /**
@@ -35,6 +35,7 @@
35
35
  import { existsSync, readFileSync } from 'node:fs';
36
36
  import { join } from 'node:path';
37
37
  import { readJobShapeDecision, JobShapeDecisionError, JOB_SHAPE_NOT_DECIDED } from './job-shape-decision.js';
38
+ import { isExpectedFsMiss } from '../../shared/fs-utils.js';
38
39
  export const STEP_08_GATE_FILE_NAME = 'job-shape.json';
39
40
  export const STEP_08_PROGRESS_FILE_NAME = 'progress.json';
40
41
  /**
@@ -85,12 +86,16 @@ function readProgressIfAny(projectRoot, sessionId, jid) {
85
86
  }
86
87
  return null;
87
88
  }
88
- catch (err) { // narrows to IO errors only — surface ReferenceError / SyntaxError so future module-load or parse bugs fail loudly (instead of silently masking progress.json corruption)
89
- if (err instanceof ReferenceError)
90
- throw err; // surface module-load bugs
89
+ catch (err) {
90
+ // P1 site (S6, 2026-09-15) — same correction as `readPromptFromLastPromptFile`
91
+ // below: corrupt progress.json stays loud, and a failure that is not an
92
+ // fs miss (module-load ReferenceError, TypeError from a bad field, …)
93
+ // propagates instead of reading as "no progress yet".
91
94
  if (err instanceof SyntaxError)
92
- throw err; // surface parse bugs (corrupt progress.json)
93
- return null; // only swallow IO errors (ENOENT, EACCES, …)
95
+ throw err;
96
+ if (!isExpectedFsMiss(err))
97
+ throw err;
98
+ return null;
94
99
  }
95
100
  }
96
101
  function readPromptFromLastPromptFile(projectRoot, sessionId) {
@@ -100,7 +105,17 @@ function readPromptFromLastPromptFile(projectRoot, sessionId) {
100
105
  try {
101
106
  return readFileSync(path, 'utf8');
102
107
  }
103
- catch {
108
+ catch (err) {
109
+ // P1 site (S6, 2026-09-15). This catch was bare — it bound nothing and
110
+ // swallowed every error class. The sibling reads in this file had been
111
+ // narrowed; this one had not, so the ONE path that feeds the step-08
112
+ // backup regex was also the one that could not tell "no prompt file" from
113
+ // "the module is broken". On the platform this gate exists for, that is
114
+ // the difference between blocking a disavowal prompt and silently
115
+ // allowing it. Missing/unreadable text is an empty prompt; anything else
116
+ // propagates.
117
+ if (!isExpectedFsMiss(err))
118
+ throw err;
104
119
  return '';
105
120
  }
106
121
  }
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // Decision priority (explicit, no implicit fall-through):
11
11
  // 1. lifecycle missing → fall back to legacy (pending → queued,
12
- // recent history → completed WITHOUT an invented after-ratio,
13
- // else none)
12
+ // a recent `observed` history row → completed WITHOUT an invented
13
+ // after-ratio, else none)
14
14
  // 2. lifecycle invalid → 'invalid' (NEVER fall back to legacy
15
15
  // — a corrupted lifecycle is not a green progress bar)
16
16
  // 3. lifecycle valid → map stage to filledCells via the
@@ -37,9 +37,10 @@
37
37
  // ratios. After-ratio is only rendered when the lifecycle record
38
38
  // carries a real one; otherwise the bar shows a stable "no
39
39
  // measurement" hint.
40
- import { existsSync, readFileSync, statSync } from 'node:fs';
40
+ import { existsSync, readFileSync } from 'node:fs';
41
41
  import { getSessionDir } from '../session/getSessionDir.js';
42
42
  import { AUTO_COMPACT_RED_LINE_RATIO } from '../context/auto-compact-types.js';
43
+ import { readCompactHistory } from '../compact-history/compact-history-service.js';
43
44
  import { readCompactLifecycle, } from './compact-lifecycle-store.js';
44
45
  /**
45
46
  * Concrete first-version stale timeout. Adjustable after real timing
@@ -58,7 +59,12 @@ const DEFAULT_STALE_AFTER_MS = 120_000;
58
59
  * to not flap on subsequent reads. Adjustable after real timing feedback.
59
60
  */
60
61
  export const COMPLETED_EXPIRY_MS = 10_000;
61
- /** Legacy mtime window for the "just compacted" indicator. */
62
+ /**
63
+ * How recently the last history row must itself have been written for the
64
+ * legacy path to report it as "just compacted". Measured against the row's own
65
+ * `ts` — the fact — rather than the file's mtime, which is only a proxy for it
66
+ * and is also moved by rows that record no compaction (repair R9).
67
+ */
62
68
  const LEGACY_JUST_COMPACTED_WINDOW_MS = 30_000;
63
69
  // PRD-002b slice 2 — extract cell-table magic numbers (4/6/8) into named
64
70
  // consts so the no-magic-numbers lint rule stops flagging the typed
@@ -142,6 +148,7 @@ export function decideCompactStatusline(input) {
142
148
  // lifecycle.kind === 'missing' → fall back to legacy files.
143
149
  return decideLegacyFallback({
144
150
  projectRoot: input.projectRoot,
151
+ sessionId: input.sessionId,
145
152
  sessionDir,
146
153
  now: input.now,
147
154
  });
@@ -212,26 +219,53 @@ function decideLegacyFallback(input) {
212
219
  // fall through to history check
213
220
  }
214
221
  }
215
- // Priority 2 within legacy: a recent history event within 30s.
216
- if (existsSync(historyPath)) {
217
- try {
218
- const mtimeMs = statSync(historyPath).mtimeMs;
219
- if (now - mtimeMs <= LEGACY_JUST_COMPACTED_WINDOW_MS) {
220
- // CRITICAL: no invented after-ratio. The history event may
221
- // carry a beforeRatio, but never a measured after-ratio;
222
- // this is the legacy path and we honour the "no measurement"
223
- // default.
224
- return {
225
- kind: 'completed',
226
- filledCells: 8,
227
- detail: historyPath,
228
- };
229
- }
230
- }
231
- catch {
232
- // fall through to idle
222
+ // Priority 2 within legacy: a history row TESTIFIES that a compaction was
223
+ // witnessed, recently enough to still be the one being reported.
224
+ //
225
+ // Repair R9 (AC4). This used to read the file's mtime — freshness taken as
226
+ // evidence that "a compact just landed". Freshness is evidence of neither:
227
+ // the same file takes a `dispatch` row every time peaks-loop ASKS for a
228
+ // compact, and an ask is an intent, not an outcome. One real session
229
+ // (2026-09-13, ~15.5 h) holds 1075 dispatch rows and ZERO compactions
230
+ // (`auto-compact-orchestrator.ts`), so a fresh file was the NORMAL state of a
231
+ // session in which nothing had compacted at all — and each of those asks
232
+ // painted this 8-cell "completed" bar.
233
+ //
234
+ // So the row's own testimony is read instead, through the same reader the CLI
235
+ // uses. Only `kind: 'observed'` means a compaction was witnessed (a row with
236
+ // no `kind` is a dispatch — every pre-`kind` row is one), and the row's `ts`
237
+ // is the moment compared, not the filesystem's.
238
+ //
239
+ // Repair R11. The testimony is looked for in EVERY row inside the window, not
240
+ // only in the last one. Reading the last row alone made the indicator depend
241
+ // on the ASK: a `dispatch` row lands on every probe, so an `observed` row
242
+ // stopped counting the moment peaks-loop asked again — `[observed, dispatch]`
243
+ // one second apart answered `none` for a compaction that had just been
244
+ // witnessed — i.e. it deleted the very indicator the mtime read had shown,
245
+ // the one the replacement was meant to keep honest. A `dispatch` row alone
246
+ // still testifies
247
+ // to nothing (the direction R9 closed): what is required is an `observed` row
248
+ // inside the window, wherever in the file it sits.
249
+ try {
250
+ // The reader answers `file-missing` / `empty` itself; the `catch` is for the
251
+ // read itself, which it does not guard.
252
+ const read = readCompactHistory({ projectRoot: input.projectRoot, sessionId: input.sessionId });
253
+ const witnessed = read.kind === 'ok' &&
254
+ read.events.some((row) => row.kind === 'observed' && now - Date.parse(row.ts) <= LEGACY_JUST_COMPACTED_WINDOW_MS);
255
+ if (witnessed) {
256
+ // CRITICAL: no invented after-ratio. The row may carry a measured
257
+ // afterRatio, but this is the legacy path and we honour the
258
+ // "no measurement" default.
259
+ return {
260
+ kind: 'completed',
261
+ filledCells: 8,
262
+ detail: historyPath,
263
+ };
233
264
  }
234
265
  }
266
+ catch {
267
+ // fall through to idle
268
+ }
235
269
  return { kind: 'none', filledCells: 0 };
236
270
  }
237
271
  /**
@@ -173,15 +173,17 @@ export function resolveCanonicalProjectRootStrict(startPath) {
173
173
  catch {
174
174
  throw new InvalidProjectRootError('non-existent', startPath);
175
175
  }
176
- if (realStart !== start) {
177
- // User passed a path through a symlink; reject as non-canonical
178
- // for trust-boundary entry points.
179
- throw new InvalidProjectRootError('non-canonical', startPath);
180
- }
181
- // Delegate to the existing canonicalization (git root → heuristic)
182
- // AFTER the strict pre-checks pass.
183
- const canonical = resolveCanonicalProjectRoot(startPath);
184
- if (canonical === startPath || canonical === realStart) {
176
+ // Use the realpath form as the canonical input. macOS exposes
177
+ // `/var/folders/...` as a symlink to `/private/var/folders/...`, so any
178
+ // path returned by `os.tmpdir()` (and therefore any `mkdtempSync(...)`
179
+ // result) has `realStart !== start`. Rejecting that case as
180
+ // "non-canonical" would block the legitimate SessionStart hook path
181
+ // and the test fixtures under `tests/unit/hooks/`. The security
182
+ // property is preserved: `realpathSync` collapses any attacker-
183
+ // controlled symlink chain, so `realStart` IS the canonical form
184
+ // regardless of which prefix the caller used.
185
+ const canonical = resolveCanonicalProjectRoot(realStart);
186
+ if (canonical === realStart) {
185
187
  return canonical;
186
188
  }
187
189
  // Canonicalization moved the path (e.g. to a git root) — accept it
@@ -128,13 +128,31 @@ export type AutoCompactResult = {
128
128
  readonly nextActions: readonly string[];
129
129
  } | {
130
130
  readonly ok: true;
131
- readonly code: 'AUTO_COMPACT_SKIP' | 'AUTO_COMPACT_WAIT';
131
+ readonly code: 'AUTO_COMPACT_SKIP' | 'AUTO_COMPACT_WAIT' | 'AUTO_COMPACT_ALREADY_ARMED' | 'AUTO_COMPACT_UNRESOLVED_SESSION';
132
132
  readonly message: string;
133
133
  readonly data: {
134
134
  readonly sessionId: string;
135
135
  readonly ratio: number;
136
136
  readonly source: string;
137
- readonly decision: 'below-threshold' | 'in-flight-batch';
137
+ /**
138
+ * `unresolved-session` (repair R6): the session id named no session
139
+ * directory, so the compact backoff's question — "is an attempt already
140
+ * outstanding?" — could not be asked. It is NOT `already-armed` (no run
141
+ * was found; the record could not be read at all) and NOT an admit: the
142
+ * dispatch is left undone because an unanswerable question is not a
143
+ * "no", and admitting on one is how a gate ends up reading a string
144
+ * instead of the artifact the string names.
145
+ */
146
+ readonly decision: 'below-threshold' | 'in-flight-batch' | 'already-armed' | 'unresolved-session';
147
+ /**
148
+ * rid `2026-09-14-compact-dispatch-backoff`, `already-armed` only: the
149
+ * ratio the open run was dispatched at, and its id. `ratio` above is the
150
+ * LIVE reading — the pair is what keeps "it is still high" legible after
151
+ * the backoff drops the per-probe rows: the ask is at `armedAtRatio`, and
152
+ * the context is now at `ratio`, above it and not yet compacted.
153
+ */
154
+ readonly armedAtRatio?: number;
155
+ readonly armedRunId?: string;
138
156
  /**
139
157
  * Slice 2026-09-13-auto-compact-trigger-ownership: what syncing the
140
158
  * harness auto-compact window did on this probe. `null` = the active