peaks-loop 4.0.3 → 4.0.5

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 (67) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README-en.md +26 -0
  3. package/README.md +26 -0
  4. package/dist/cli/commands/_register.js +3 -0
  5. package/dist/cli/commands/code-runtime-commands.js +15 -2
  6. package/dist/cli/commands/core/skill-command.js +2 -0
  7. package/dist/cli/commands/dispatch-commands.js +41 -2
  8. package/dist/cli/commands/e2e-verify.d.ts +47 -0
  9. package/dist/cli/commands/e2e-verify.js +211 -0
  10. package/dist/cli/commands/gate-commands.js +17 -4
  11. package/dist/cli/commands/statusline-commands.d.ts +6 -0
  12. package/dist/cli/commands/statusline-commands.js +48 -11
  13. package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
  14. package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
  15. package/dist/cli/commands/worktree-auth-commands.js +27 -0
  16. package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
  17. package/dist/services/code/auto-compact-orchestrator.js +358 -41
  18. package/dist/services/code/post-compact-detector.js +12 -4
  19. package/dist/services/code/step-08-gate.js +6 -2
  20. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
  21. package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
  22. package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
  23. package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
  24. package/dist/services/context/auto-compact-reader.d.ts +33 -4
  25. package/dist/services/context/auto-compact-reader.js +77 -22
  26. package/dist/services/context/auto-compact-types.d.ts +10 -2
  27. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  28. package/dist/services/context/build-dispatch-system-prompt.js +33 -2
  29. package/dist/services/dispatch/conflict-replay.d.ts +32 -0
  30. package/dist/services/dispatch/conflict-replay.js +44 -0
  31. package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
  32. package/dist/services/dispatch/dispatch-record-writer.js +27 -7
  33. package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
  34. package/dist/services/dispatch/e2e-fixtures.js +72 -0
  35. package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
  36. package/dist/services/dispatch/merge-back-runner.js +83 -0
  37. package/dist/services/dispatch/post-merge.d.ts +21 -0
  38. package/dist/services/dispatch/post-merge.js +13 -0
  39. package/dist/services/dispatch/service-shutdown.d.ts +22 -0
  40. package/dist/services/dispatch/service-shutdown.js +51 -0
  41. package/dist/services/hooks/presence-marker-detector.js +12 -4
  42. package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
  43. package/dist/services/hooks/worktree-authorization-gate.js +22 -0
  44. package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
  45. package/dist/services/skills/skill-statusline-renderer.js +374 -29
  46. package/dist/services/skills/skill-statusline-service.d.ts +2 -0
  47. package/dist/services/skills/skill-statusline-service.js +34 -5
  48. package/dist/services/skills/sync-service.d.ts +3 -0
  49. package/dist/services/skills/sync-service.js +1 -0
  50. package/dist/services/standards/project-context.d.ts +2 -1
  51. package/dist/services/standards/project-context.js +17 -2
  52. package/dist/services/standards/project-standards-service.d.ts +9 -0
  53. package/dist/services/standards/project-standards-service.js +26 -0
  54. package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
  55. package/dist/services/worktree/dispatch-provenance.js +47 -0
  56. package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
  57. package/dist/services/worktree/git-worktree-parser.js +45 -0
  58. package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
  59. package/dist/services/worktree/host-worktree-reconciler.js +39 -0
  60. package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
  61. package/dist/services/worktree/long-path-cleanup.js +38 -0
  62. package/dist/services/worktree/playwright-profile.d.ts +8 -0
  63. package/dist/services/worktree/playwright-profile.js +23 -0
  64. package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
  65. package/dist/services/worktree/worktree-gc-guard.js +38 -0
  66. package/package.json +4 -4
  67. package/scripts/install-skills.mjs +11 -2
@@ -31,9 +31,10 @@
31
31
  import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
32
32
  import { dirname, join } from 'node:path';
33
33
  import { getSessionIdCanonical } from '../session/session-manager.js';
34
- import { AUTO_COMPACT_THRESHOLD_RATIO } from '../context/auto-compact-types.js';
34
+ import { AUTO_COMPACT_PRE_COMPACT_RATIO, AUTO_COMPACT_THRESHOLD_RATIO } from '../context/auto-compact-types.js';
35
35
  import { describeMode, thresholdFor } from './auto-compact-modes.js';
36
36
  import { read24hState } from '../24h-mode/store.js';
37
+ import { readCompactLifecycle, writeCompactLifecycle } from '../compact-statusline/compact-lifecycle-store.js';
37
38
  const PRE_COMPACT_REASON = 'pre-compact-auto';
38
39
  /**
39
40
  * Map a context ratio to a `CompactTrigger` action. Pure; the side
@@ -121,6 +122,16 @@ export function evaluateAutoCompactDecision(input) {
121
122
  if (input.force) {
122
123
  return { shouldCompact: true, reason: 'pre-compact', trigger };
123
124
  }
125
+ // Slice 2026-07-31-rid-mac-transcript-estimate-trigger: transcript-estimate
126
+ // is the ONLY signal available on Mac Claude Code (no env-var, no statusline
127
+ // poll). The gate above already returns shouldCompact: true at ratio ≥ 0.85,
128
+ // but this forward-compat carve-out makes the source-aware rule explicit so
129
+ // any future source-aware downgrading cannot silently re-introduce the
130
+ // Mac auto-compact silent-failure mode without an audit. No higher-priority
131
+ // source is present (`claude-code-env` would have been P1, `statusline-poll`
132
+ // P2, `user-overridden` P4) — Mac's only signal is `transcript-estimate`.
133
+ if (input.source === 'transcript-estimate' && input.ratio >= AUTO_COMPACT_PRE_COMPACT_RATIO)
134
+ return { shouldCompact: true, reason: 'pre-compact', trigger };
124
135
  // Default: peaks-loop drives pre-compact autonomously.
125
136
  return { shouldCompact: true, reason: 'pre-compact', trigger };
126
137
  }
@@ -248,6 +259,219 @@ function resolveAutoCompactMode(projectRoot, sessionId) {
248
259
  return 'standard';
249
260
  }
250
261
  }
262
+ /**
263
+ * One id per compact attempt. Timestamp-prefixed so a human reading
264
+ * the raw record can order runs by eye; the random suffix keeps two
265
+ * attempts inside the same millisecond distinct.
266
+ */
267
+ function newCompactRunId(now) {
268
+ const stamp = now.toISOString().replace(/[:.]/g, '-');
269
+ const suffix = Math.random().toString(16).slice(2, 8);
270
+ return `compact-${stamp}-${suffix}`;
271
+ }
272
+ /**
273
+ * Slice 2026-08-01-compact-lifecycle (Task 5): read an open compact
274
+ * record ignoring staleness.
275
+ *
276
+ * The lifecycle store classifies an active record as `stalled` after a
277
+ * caller-supplied `staleAfterMs` window. That classification exists for
278
+ * the statusline renderer (Task 2/3) — it tells the user "this run
279
+ * hasn't heartbeated for a while".
280
+ *
281
+ * For settling a run by measurement, staleness is irrelevant: if a
282
+ * record exists at `compacting`, a post-compact probe that measures a
283
+ * drop should still complete it, even if the probe itself ran hours
284
+ * later. Wrapping that unbounded read here keeps the magic number out
285
+ * of the settle path and makes the intent self-documenting.
286
+ *
287
+ * Returns `null` for any non-`valid` kind (missing / invalid /
288
+ * stalled). Errors from the underlying read are swallowed — settling
289
+ * is best-effort telemetry and must never bubble.
290
+ */
291
+ function readOpenCompactLifecycle(input) {
292
+ let out;
293
+ try {
294
+ out = readCompactLifecycle({
295
+ projectRoot: input.projectRoot,
296
+ sessionId: input.sessionId,
297
+ nowMs: Date.now(),
298
+ // Number.MAX_SAFE_INTEGER disables the store's staleness gate
299
+ // for this call — see the doc comment above for why settling
300
+ // intentionally ignores staleness.
301
+ staleAfterMs: Number.MAX_SAFE_INTEGER
302
+ });
303
+ }
304
+ catch {
305
+ return null;
306
+ }
307
+ return out.kind === 'valid' ? out.record : null;
308
+ }
309
+ /**
310
+ * Slice 2026-08-01-compact-lifecycle (Task 5): the local transition
311
+ * builder. Carries `runId`, `triggerRatio` and `redLine` forward from
312
+ * the run that opened, and remembers the prior stage so a failure can
313
+ * name the stage it died in.
314
+ *
315
+ * TRUTHFULNESS: this publisher only ever emits a stage the calling
316
+ * process has actually PROVED. It never emits `verifying` or
317
+ * `completed` off the back of a successful dispatch — see
318
+ * `runAutoCompact` and `settleOpenLifecycleRun` for the reason.
319
+ *
320
+ * Telemetry is strictly subordinate to the compact itself: every write
321
+ * is best-effort, and a store failure must not change the threshold
322
+ * decision, the dispatch, or the returned envelope.
323
+ */
324
+ class CompactLifecyclePublisher {
325
+ ctx;
326
+ lastStage = 'queued';
327
+ constructor(ctx) {
328
+ this.ctx = ctx;
329
+ }
330
+ /** Publish an active stage the process has proved. */
331
+ advance(stage) {
332
+ this.lastStage = stage;
333
+ this.write({
334
+ schemaVersion: 1,
335
+ runId: this.ctx.runId,
336
+ stage,
337
+ updatedAt: new Date().toISOString(),
338
+ triggerRatio: this.ctx.triggerRatio,
339
+ redLine: this.ctx.redLine
340
+ });
341
+ }
342
+ /** Publish the terminal failure, attributed to the last stage reached. */
343
+ fail(error) {
344
+ this.write({
345
+ schemaVersion: 1,
346
+ runId: this.ctx.runId,
347
+ stage: 'failed',
348
+ updatedAt: new Date().toISOString(),
349
+ triggerRatio: this.ctx.triggerRatio,
350
+ redLine: this.ctx.redLine,
351
+ failedAt: this.lastStage,
352
+ errorSummary: summarizeLifecycleError(error)
353
+ });
354
+ }
355
+ write(record) {
356
+ try {
357
+ if (this.ctx.failLifecycleWrite)
358
+ throw new Error('lifecycle store unavailable');
359
+ writeCompactLifecycle({
360
+ projectRoot: this.ctx.projectRoot,
361
+ sessionId: this.ctx.sessionId,
362
+ record
363
+ });
364
+ }
365
+ catch {
366
+ // Best-effort telemetry: a lifecycle write failure must never
367
+ // change the compact decision, the dispatch, or the envelope.
368
+ return;
369
+ }
370
+ try {
371
+ this.ctx.onLifecycleStage?.(record.stage, record);
372
+ }
373
+ catch {
374
+ // An observer is a passive listener; its failure is not ours.
375
+ }
376
+ }
377
+ }
378
+ /**
379
+ * Reduce an arbitrary thrown value to a single-line, bounded summary
380
+ * fit for a statusline. Stack frames are dropped (the record is a
381
+ * human-facing indicator, not a crash dump); the store clamps the
382
+ * result to its own 160-character cap as a second line of defence.
383
+ *
384
+ * `null` / `undefined` thrown values — a real possibility from
385
+ * `Promise.reject(null)` or a thrown `undefined` — must not collapse
386
+ * to the empty string, which the store would then reject as missing.
387
+ * They map to a fixed "unknown error" sentinel so the record always
388
+ * carries some diagnostic text.
389
+ */
390
+ function summarizeLifecycleError(error) {
391
+ let raw;
392
+ if (error instanceof Error)
393
+ raw = error.message;
394
+ else if (error === null || error === undefined)
395
+ raw = 'unknown error';
396
+ else
397
+ raw = String(error);
398
+ if (raw.length === 0)
399
+ raw = 'unknown error';
400
+ const firstLine = raw.split('\n')[0] ?? '';
401
+ const collapsed = firstLine.replace(/\s+/g, ' ').trim();
402
+ return collapsed.length === 0 ? 'unknown error' : (collapsed.length > 160 ? collapsed.slice(0, 160) : collapsed);
403
+ }
404
+ /**
405
+ * Slice 2026-08-01-compact-lifecycle (Task 5, Step 4): close out an
406
+ * open compact run using a REAL measurement.
407
+ *
408
+ * This is the integration with the actual post-compact detection path.
409
+ * The claude-code adapter's `postCompactDetectCommand` is
410
+ * `peaks compact auto --json` — i.e. the next probe through this very
411
+ * function. So when a probe finds a run still sitting at `compacting`
412
+ * and MEASURES a ratio that has dropped below the auto-fire threshold,
413
+ * that measurement is the proof the compact landed. Only then do we
414
+ * emit `verifying` (we have a measurement in hand) followed by
415
+ * `completed` (it confirms the drop), carrying the measured
416
+ * `afterRatio`.
417
+ *
418
+ * We refuse to complete when:
419
+ * - the probe could not measure anything (`conservative-fallback`
420
+ * returns `ratio: 0`, which means "unknown", NOT "empty"). Writing
421
+ * `afterRatio: 0` there would publish a fabricated number;
422
+ * - the ratio is still at or above the auto-fire threshold — the
423
+ * compact has not landed, so the run stays open.
424
+ */
425
+ function settleOpenLifecycleRun(input) {
426
+ // A `conservative-fallback` probe means no signal was available at
427
+ // all. Its `ratio: 0` is the absence of a measurement, so it can
428
+ // never be evidence that the context shrank.
429
+ if (input.source === 'conservative-fallback')
430
+ return;
431
+ if (input.measuredRatio >= input.autoFireThreshold)
432
+ return;
433
+ const prior = readOpenCompactLifecycle({
434
+ projectRoot: input.projectRoot,
435
+ sessionId: input.sessionId
436
+ });
437
+ if (prior === null)
438
+ return;
439
+ // Only a run that was actually dispatched (`compacting`) can be
440
+ // completed by a post-compact measurement.
441
+ if (prior.stage !== 'compacting')
442
+ return;
443
+ const emit = (stage, withAfterRatio) => {
444
+ const record = {
445
+ schemaVersion: 1,
446
+ runId: prior.runId,
447
+ stage,
448
+ updatedAt: new Date().toISOString(),
449
+ triggerRatio: prior.triggerRatio,
450
+ redLine: prior.redLine,
451
+ ...(withAfterRatio ? { afterRatio: input.measuredRatio } : {})
452
+ };
453
+ try {
454
+ writeCompactLifecycle({
455
+ projectRoot: input.projectRoot,
456
+ sessionId: input.sessionId,
457
+ record
458
+ });
459
+ }
460
+ catch {
461
+ return;
462
+ }
463
+ try {
464
+ input.onLifecycleStage?.(stage, record);
465
+ }
466
+ catch {
467
+ // Observer failures are not ours to propagate.
468
+ }
469
+ };
470
+ // `verifying` = we hold a measurement and are checking it.
471
+ emit('verifying', false);
472
+ // `completed` = the measurement confirms the drop; publish it.
473
+ emit('completed', true);
474
+ }
251
475
  /**
252
476
  * Execute the auto-compact flow.
253
477
  *
@@ -293,9 +517,28 @@ export async function runAutoCompact(input) {
293
517
  inFlightBatch: input.inFlightBatch,
294
518
  force: input.force,
295
519
  bypassRedLine: input.bypassRedLine,
296
- mode
520
+ mode,
521
+ // Slice 2026-07-31-rid-mac-transcript-estimate-trigger: pipe the source
522
+ // tag through so `evaluateAutoCompactDecision` can apply the
523
+ // source-aware carve-out for Mac's `transcript-estimate` signal.
524
+ source: probe.source
297
525
  });
298
526
  if (!decision.shouldCompact) {
527
+ // Slice 2026-08-01-compact-lifecycle (Task 5): this probe IS the
528
+ // adapter's `postCompactDetectCommand`. A ratio that has fallen
529
+ // back below the auto-fire threshold is the real, measured proof
530
+ // that a previously-dispatched compact landed — so settle any run
531
+ // still open at `compacting`. Nothing is written when there is no
532
+ // open run, when the ratio is still high, or when the probe could
533
+ // not measure at all.
534
+ settleOpenLifecycleRun({
535
+ projectRoot: input.projectRoot,
536
+ sessionId,
537
+ measuredRatio: probe.ratio,
538
+ source: probe.source,
539
+ autoFireThreshold: thresholdFor(mode, 'autoFire'),
540
+ onLifecycleStage: input.onLifecycleStage
541
+ });
299
542
  return {
300
543
  ok: true,
301
544
  code: decision.reason === 'in-flight-batch' ? 'AUTO_COMPACT_WAIT' : 'AUTO_COMPACT_SKIP',
@@ -314,55 +557,129 @@ export async function runAutoCompact(input) {
314
557
  }
315
558
  const isRedLine = decision.reason === 'red-line';
316
559
  const now = input.now ?? new Date();
317
- const checkpointPath = writePreCompactCheckpoint({
560
+ // Slice 2026-08-01-compact-lifecycle (Task 5): the decision has now
561
+ // committed to compacting, so the run is `queued`. One runId per
562
+ // attempt; every later transition carries it forward.
563
+ const lifecycle = new CompactLifecyclePublisher({
318
564
  projectRoot: input.projectRoot,
319
565
  sessionId,
320
- now,
321
- redLine: isRedLine
566
+ runId: newCompactRunId(now),
567
+ triggerRatio: probe.ratio,
568
+ redLine: isRedLine,
569
+ onLifecycleStage: input.onLifecycleStage,
570
+ failLifecycleWrite: input.testHooks?.failLifecycleWrite
322
571
  });
323
- const nextActions = isRedLine
324
- ? [
325
- 'RED-LINE compact dispatched — further sub-agent dispatch BLOCKED until ratio < 0.85',
326
- 'Post-compact resume picks up the convergence plan from auto-decisions.md',
327
- 'Next `peaks compact auto` probe will confirm ratio dropped below 0.85'
328
- ]
329
- : [
330
- 'Pre-compact dispatched — IDE compact in progress (async)',
331
- 'Post-compact resume picks up the convergence plan from auto-decisions.md',
332
- 'Next `peaks compact auto` probe will confirm ratio dropped below 0.85'
333
- ];
334
- const plan = buildConvergencePlan({
335
- sessionId,
336
- projectRoot: input.projectRoot,
337
- ratio: probe.ratio,
338
- checkpointPath,
339
- nextActions,
340
- redLine: isRedLine
341
- });
342
- appendAutoDecisionLog({ projectRoot: input.projectRoot, sessionId, plan });
572
+ lifecycle.advance('queued');
573
+ let checkpointPath;
574
+ let plan;
575
+ let nextActions;
576
+ try {
577
+ // `preparing` covers checkpoint + convergence-plan + recovery writes.
578
+ lifecycle.advance('preparing');
579
+ if (input.testHooks?.failPreparing)
580
+ throw new Error('disk full while writing checkpoint');
581
+ checkpointPath = writePreCompactCheckpoint({
582
+ projectRoot: input.projectRoot,
583
+ sessionId,
584
+ now,
585
+ redLine: isRedLine
586
+ });
587
+ nextActions = isRedLine
588
+ ? [
589
+ 'RED-LINE compact dispatched — further sub-agent dispatch BLOCKED until ratio < 0.85',
590
+ 'Post-compact resume picks up the convergence plan from auto-decisions.md',
591
+ 'Next `peaks compact auto` probe will confirm ratio dropped below 0.85'
592
+ ]
593
+ : [
594
+ 'Pre-compact dispatched — IDE compact in progress (async)',
595
+ 'Post-compact resume picks up the convergence plan from auto-decisions.md',
596
+ 'Next `peaks compact auto` probe will confirm ratio dropped below 0.85'
597
+ ];
598
+ plan = buildConvergencePlan({
599
+ sessionId,
600
+ projectRoot: input.projectRoot,
601
+ ratio: probe.ratio,
602
+ checkpointPath,
603
+ nextActions,
604
+ redLine: isRedLine
605
+ });
606
+ appendAutoDecisionLog({ projectRoot: input.projectRoot, sessionId, plan });
607
+ }
608
+ catch (error) {
609
+ lifecycle.fail(error);
610
+ // Preserve the original error contract: the caller gets the same
611
+ // `AUTO_COMPACT_DISPATCH_FAILED` envelope shape it already handles,
612
+ // not a thrown exception and not a widened type.
613
+ return {
614
+ ok: false,
615
+ code: 'AUTO_COMPACT_DISPATCH_FAILED',
616
+ message: `Auto-compact preparation failed before IDE dispatch: ${summarizeLifecycleError(error)}`,
617
+ data: {
618
+ sessionId,
619
+ ratio: probe.ratio,
620
+ source: probe.source,
621
+ target: input.target ?? 'main',
622
+ mode,
623
+ redLineGated: isRedLine
624
+ }
625
+ };
626
+ }
343
627
  // Lazy import to keep AC-3 (IDE dispatch) pluggable; tests mock this module.
344
628
  const { dispatchIdeCompact } = await import('../context/auto-compact-dispatcher.js');
345
629
  const target = input.target ?? 'main';
346
- // Slice 2026-06-28: when targeting the main session, write an
347
- // intent record so the next main-session LLM turn fires `/compact`
348
- // in-band. Without this record the LLM has no signal that the
349
- // orchestrator asked for compact; the dispatcher alone would have
350
- // been a no-op against the main Claude Code window.
351
- if (target === 'main') {
352
- writeMainSessionCompactIntent({
630
+ let dispatch;
631
+ try {
632
+ // `compacting` is the last stage this process can prove: the IDE
633
+ // performs the actual compaction out-of-band, so a successful
634
+ // dispatch return is NOT evidence the context shrank.
635
+ lifecycle.advance('compacting');
636
+ if (input.testHooks?.failCompacting)
637
+ throw new Error('IDE dispatch exploded');
638
+ // Slice 2026-06-28: when targeting the main session, write an
639
+ // intent record so the next main-session LLM turn fires `/compact`
640
+ // in-band. Without this record the LLM has no signal that the
641
+ // orchestrator asked for compact; the dispatcher alone would have
642
+ // been a no-op against the main Claude Code window.
643
+ if (target === 'main') {
644
+ writeMainSessionCompactIntent({
645
+ projectRoot: input.projectRoot,
646
+ sessionId,
647
+ ratio: probe.ratio,
648
+ redLine: isRedLine,
649
+ now
650
+ });
651
+ }
652
+ dispatch = await dispatchIdeCompact({
353
653
  projectRoot: input.projectRoot,
354
654
  sessionId,
355
- ratio: probe.ratio,
356
- redLine: isRedLine,
357
- now
655
+ env: input.env,
656
+ target
358
657
  });
359
658
  }
360
- const dispatch = await dispatchIdeCompact({
361
- projectRoot: input.projectRoot,
362
- sessionId,
363
- env: input.env,
364
- target
365
- });
659
+ catch (error) {
660
+ lifecycle.fail(error);
661
+ return {
662
+ ok: false,
663
+ code: 'AUTO_COMPACT_DISPATCH_FAILED',
664
+ message: `Auto-compact checkpoint written but IDE dispatch threw: ${summarizeLifecycleError(error)}`,
665
+ data: {
666
+ sessionId,
667
+ ratio: probe.ratio,
668
+ source: probe.source,
669
+ checkpointPath,
670
+ convergencePlan: plan,
671
+ target,
672
+ mode,
673
+ redLineGated: isRedLine
674
+ }
675
+ };
676
+ }
677
+ // A dispatcher that returns `ok: false` did not compact anything —
678
+ // record that as a failure at `compacting` rather than leaving the
679
+ // run looking like it is still in progress.
680
+ if (!dispatch.ok) {
681
+ lifecycle.fail(new Error(dispatch.message));
682
+ }
366
683
  // Slice 2026-07-30-compact-visibility: append a compact-history
367
684
  // event so the new 'peaks compact history' CLI and the
368
685
  // 'peaks statusline compact' indicator have a record. The
@@ -65,8 +65,12 @@ function safeReadCheckpoint(absPath) {
65
65
  const content = mutable;
66
66
  return { path: absPath, mtime: stat.mtime, content };
67
67
  }
68
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
69
- return null;
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
71
+ if (err instanceof SyntaxError)
72
+ throw err; // surface parse bugs
73
+ return null; // only swallow IO errors
70
74
  }
71
75
  }
72
76
  function readLatestTodayCheckpoint(runtimeDir, now) {
@@ -223,8 +227,12 @@ function readActiveSkillName(projectRoot) {
223
227
  const presence = getSkillPresence(projectRoot);
224
228
  return presence?.skill;
225
229
  }
226
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
227
- return undefined;
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
228
236
  }
229
237
  }
230
238
  /**
@@ -85,8 +85,12 @@ function readProgressIfAny(projectRoot, sessionId, jid) {
85
85
  }
86
86
  return null;
87
87
  }
88
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
89
- return null;
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
91
+ if (err instanceof SyntaxError)
92
+ throw err; // surface parse bugs (corrupt progress.json)
93
+ return null; // only swallow IO errors (ENOENT, EACCES, …)
90
94
  }
91
95
  }
92
96
  function readPromptFromLastPromptFile(projectRoot, sessionId) {
@@ -0,0 +1,68 @@
1
+ export type CompactLifecycleStage = 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed';
2
+ export interface CompactLifecycleRecord {
3
+ readonly schemaVersion: 1;
4
+ readonly runId: string;
5
+ readonly stage: CompactLifecycleStage;
6
+ readonly updatedAt: string;
7
+ readonly triggerRatio: number;
8
+ readonly afterRatio?: number;
9
+ readonly redLine: boolean;
10
+ readonly failedAt?: Exclude<CompactLifecycleStage, 'failed' | 'completed'>;
11
+ readonly errorSummary?: string;
12
+ }
13
+ export type CompactLifecycleRead = {
14
+ readonly kind: 'missing';
15
+ } | {
16
+ readonly kind: 'valid';
17
+ readonly record: CompactLifecycleRecord;
18
+ } | {
19
+ readonly kind: 'invalid';
20
+ readonly reason: string;
21
+ } | {
22
+ readonly kind: 'stalled';
23
+ readonly record: CompactLifecycleRecord;
24
+ };
25
+ /**
26
+ * Atomically write a compact lifecycle record. Uses a temp file in
27
+ * the same directory plus `renameSync`, so a crash mid-write never
28
+ * leaves a half-written record (atomic on POSIX, near-atomic on
29
+ * Windows — same contract `24h-mode/store.ts` and
30
+ * `session-checkpoint-service.ts` use).
31
+ *
32
+ * Side effects:
33
+ * - creates `<sessionDir>/` if missing
34
+ * - clamps `errorSummary` to 160 characters (longer strings are
35
+ * truncated at the head, not padded)
36
+ * - removes the temp file on rename failure (best effort)
37
+ *
38
+ * Throws on I/O failure or validation rejection; the caller decides
39
+ * whether to retry / log / surface to the user.
40
+ */
41
+ export declare function writeCompactLifecycle(input: {
42
+ readonly projectRoot: string;
43
+ readonly sessionId: string;
44
+ readonly record: CompactLifecycleRecord;
45
+ }): void;
46
+ /**
47
+ * Read a compact lifecycle record and classify it.
48
+ *
49
+ * Return kinds:
50
+ * - `missing` — no record has ever been written for this session
51
+ * - `valid` — record parsed and validated; stage is terminal
52
+ * (completed/failed) OR is active but still fresh
53
+ * - `invalid` — file exists but is malformed / wrong schema / out
54
+ * of range. `reason` is a single-line English message
55
+ * (no CLI verbs, no stack traces). The caller MUST
56
+ * surface this to a human — we never silently turn
57
+ * a corrupted record into `missing`.
58
+ * - `stalled` — record is valid and is currently an ACTIVE stage,
59
+ * but `updatedAt` is older than `staleAfterMs` (a
60
+ * heartbeat that never arrived). Terminal stages
61
+ * never go stalled.
62
+ */
63
+ export declare function readCompactLifecycle(input: {
64
+ readonly projectRoot: string;
65
+ readonly sessionId: string;
66
+ readonly nowMs: number;
67
+ readonly staleAfterMs: number;
68
+ }): CompactLifecycleRead;