peaks-loop 4.0.4 → 4.0.6

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 (64) hide show
  1. package/CHANGELOG.md +64 -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/core/skill-command.js +2 -0
  6. package/dist/cli/commands/dispatch-commands.js +41 -2
  7. package/dist/cli/commands/e2e-verify.d.ts +47 -0
  8. package/dist/cli/commands/e2e-verify.js +211 -0
  9. package/dist/cli/commands/gate-commands.js +17 -4
  10. package/dist/cli/commands/statusline-commands.d.ts +6 -0
  11. package/dist/cli/commands/statusline-commands.js +48 -11
  12. package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
  13. package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
  14. package/dist/cli/commands/worktree-auth-commands.js +27 -0
  15. package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
  16. package/dist/services/code/auto-compact-orchestrator.js +358 -41
  17. package/dist/services/code/post-compact-detector.js +12 -4
  18. package/dist/services/code/step-08-gate.js +6 -2
  19. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
  20. package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
  21. package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
  22. package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
  23. package/dist/services/context/auto-compact-reader.js +12 -4
  24. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  25. package/dist/services/context/build-dispatch-system-prompt.js +33 -2
  26. package/dist/services/dispatch/conflict-replay.d.ts +32 -0
  27. package/dist/services/dispatch/conflict-replay.js +44 -0
  28. package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
  29. package/dist/services/dispatch/dispatch-record-writer.js +27 -7
  30. package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
  31. package/dist/services/dispatch/e2e-fixtures.js +72 -0
  32. package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
  33. package/dist/services/dispatch/merge-back-runner.js +83 -0
  34. package/dist/services/dispatch/post-merge.d.ts +21 -0
  35. package/dist/services/dispatch/post-merge.js +13 -0
  36. package/dist/services/dispatch/service-shutdown.d.ts +22 -0
  37. package/dist/services/dispatch/service-shutdown.js +51 -0
  38. package/dist/services/hooks/presence-marker-detector.js +12 -4
  39. package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
  40. package/dist/services/hooks/worktree-authorization-gate.js +22 -0
  41. package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
  42. package/dist/services/skills/skill-statusline-renderer.js +374 -29
  43. package/dist/services/skills/skill-statusline-service.d.ts +2 -0
  44. package/dist/services/skills/skill-statusline-service.js +34 -5
  45. package/dist/services/skills/sync-service.d.ts +3 -0
  46. package/dist/services/skills/sync-service.js +1 -0
  47. package/dist/services/standards/project-context.d.ts +2 -1
  48. package/dist/services/standards/project-context.js +17 -2
  49. package/dist/services/standards/project-standards-service.d.ts +9 -0
  50. package/dist/services/standards/project-standards-service.js +26 -0
  51. package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
  52. package/dist/services/worktree/dispatch-provenance.js +47 -0
  53. package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
  54. package/dist/services/worktree/git-worktree-parser.js +45 -0
  55. package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
  56. package/dist/services/worktree/host-worktree-reconciler.js +39 -0
  57. package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
  58. package/dist/services/worktree/long-path-cleanup.js +38 -0
  59. package/dist/services/worktree/playwright-profile.d.ts +8 -0
  60. package/dist/services/worktree/playwright-profile.js +23 -0
  61. package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
  62. package/dist/services/worktree/worktree-gc-guard.js +38 -0
  63. package/package.json +4 -4
  64. package/scripts/install-skills.mjs +182 -2
@@ -0,0 +1,222 @@
1
+ // src/services/compact-statusline/compact-lifecycle-store.ts
2
+ //
3
+ // Slice 2026-08-01-compact-lifecycle (Task 1/5). Pure persistence
4
+ // for the runtime *compact lifecycle* record that the auto-compact
5
+ // orchestrator writes on every state transition. The record is the
6
+ // single source of truth that downstream statusline / dashboard
7
+ // consumers (Tasks 2-5) read to decide what to display.
8
+ //
9
+ // Storage:
10
+ // .peaks/_runtime/<sessionId>/compact-lifecycle.json
11
+ //
12
+ // Writes go through a temp-file-then-rename in the same directory,
13
+ // so a crash mid-write never leaves a half-written record. Reads
14
+ // validate parsed values explicitly (numeric ranges, stage enum,
15
+ // terminal-stage semantics) and return a tagged union so callers
16
+ // can distinguish `missing` / `valid` / `invalid` / `stalled`.
17
+ //
18
+ // Stalled detection: an **active** stage (queued / preparing /
19
+ // compacting / verifying) is `stalled` when (nowMs - updatedAt) >
20
+ // staleAfterMs. Terminal stages (`completed`, `failed`) NEVER go
21
+ // stalled — the record is the historical answer, not a heartbeat.
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
23
+ import { dirname, join } from 'node:path';
24
+ import { getSessionDir } from '../session/getSessionDir.js';
25
+ const LIFECYCLE_FILENAME = 'compact-lifecycle.json';
26
+ const ERROR_SUMMARY_MAX = 160;
27
+ const ACTIVE_STAGES = [
28
+ 'queued',
29
+ 'preparing',
30
+ 'compacting',
31
+ 'verifying',
32
+ ];
33
+ const ALL_STAGES = [
34
+ 'queued',
35
+ 'preparing',
36
+ 'compacting',
37
+ 'verifying',
38
+ 'completed',
39
+ 'failed',
40
+ ];
41
+ function lifecyclePath(projectRoot, sessionId) {
42
+ return join(getSessionDir(projectRoot, sessionId), LIFECYCLE_FILENAME);
43
+ }
44
+ function isPlainObject(v) {
45
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
46
+ }
47
+ function isStage(v) {
48
+ return typeof v === 'string' && ALL_STAGES.includes(v);
49
+ }
50
+ function isActiveStage(stage) {
51
+ return ACTIVE_STAGES.includes(stage);
52
+ }
53
+ function isFiniteRatio(v) {
54
+ return typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
55
+ }
56
+ function coerceRecord(raw) {
57
+ if (!isPlainObject(raw)) {
58
+ throw new Error('compact-lifecycle: root must be a JSON object');
59
+ }
60
+ if (raw['schemaVersion'] !== 1) {
61
+ throw new Error(`compact-lifecycle: schemaVersion must be 1 (got ${JSON.stringify(raw['schemaVersion'])})`);
62
+ }
63
+ if (typeof raw['runId'] !== 'string' || raw['runId'].length === 0) {
64
+ throw new Error('compact-lifecycle: runId must be a non-empty string');
65
+ }
66
+ if (!isStage(raw['stage'])) {
67
+ throw new Error(`compact-lifecycle: stage must be one of ${ALL_STAGES.join('|')} (got ${JSON.stringify(raw['stage'])})`);
68
+ }
69
+ if (typeof raw['updatedAt'] !== 'string' || Number.isNaN(Date.parse(raw['updatedAt']))) {
70
+ throw new Error('compact-lifecycle: updatedAt must be an ISO 8601 string');
71
+ }
72
+ if (!isFiniteRatio(raw['triggerRatio'])) {
73
+ throw new Error(`compact-lifecycle: triggerRatio must be a finite number in [0,1] (got ${JSON.stringify(raw['triggerRatio'])})`);
74
+ }
75
+ if (raw['afterRatio'] !== undefined && !isFiniteRatio(raw['afterRatio'])) {
76
+ throw new Error(`compact-lifecycle: afterRatio must be a finite number in [0,1] (got ${JSON.stringify(raw['afterRatio'])})`);
77
+ }
78
+ if (typeof raw['redLine'] !== 'boolean') {
79
+ throw new Error(`compact-lifecycle: redLine must be a boolean (got ${JSON.stringify(raw['redLine'])})`);
80
+ }
81
+ const stage = raw['stage'];
82
+ if (stage === 'failed') {
83
+ const failedAt = raw['failedAt'];
84
+ if (!isStage(failedAt) || failedAt === 'failed' || failedAt === 'completed') {
85
+ throw new Error(`compact-lifecycle: failedAt must be one of the active stages (got ${JSON.stringify(failedAt)})`);
86
+ }
87
+ const errorSummary = raw['errorSummary'];
88
+ if (errorSummary !== undefined && typeof errorSummary !== 'string') {
89
+ throw new Error(`compact-lifecycle: errorSummary must be a string when provided (got ${JSON.stringify(errorSummary)})`);
90
+ }
91
+ return {
92
+ schemaVersion: 1,
93
+ runId: raw['runId'],
94
+ stage,
95
+ updatedAt: raw['updatedAt'],
96
+ triggerRatio: raw['triggerRatio'],
97
+ redLine: raw['redLine'],
98
+ failedAt: failedAt,
99
+ ...(raw['afterRatio'] !== undefined ? { afterRatio: raw['afterRatio'] } : {}),
100
+ ...(errorSummary !== undefined ? { errorSummary: errorSummary } : {}),
101
+ };
102
+ }
103
+ // Non-failed records must NOT carry a failedAt or errorSummary.
104
+ if (raw['failedAt'] !== undefined) {
105
+ throw new Error('compact-lifecycle: failedAt is only allowed when stage="failed"');
106
+ }
107
+ if (raw['errorSummary'] !== undefined) {
108
+ throw new Error('compact-lifecycle: errorSummary is only allowed when stage="failed"');
109
+ }
110
+ return {
111
+ schemaVersion: 1,
112
+ runId: raw['runId'],
113
+ stage,
114
+ updatedAt: raw['updatedAt'],
115
+ triggerRatio: raw['triggerRatio'],
116
+ redLine: raw['redLine'],
117
+ ...(raw['afterRatio'] !== undefined ? { afterRatio: raw['afterRatio'] } : {}),
118
+ };
119
+ }
120
+ function clampErrorSummary(value) {
121
+ if (value === undefined)
122
+ return undefined;
123
+ return value.length > ERROR_SUMMARY_MAX ? value.slice(0, ERROR_SUMMARY_MAX) : value;
124
+ }
125
+ /**
126
+ * Atomically write a compact lifecycle record. Uses a temp file in
127
+ * the same directory plus `renameSync`, so a crash mid-write never
128
+ * leaves a half-written record (atomic on POSIX, near-atomic on
129
+ * Windows — same contract `24h-mode/store.ts` and
130
+ * `session-checkpoint-service.ts` use).
131
+ *
132
+ * Side effects:
133
+ * - creates `<sessionDir>/` if missing
134
+ * - clamps `errorSummary` to 160 characters (longer strings are
135
+ * truncated at the head, not padded)
136
+ * - removes the temp file on rename failure (best effort)
137
+ *
138
+ * Throws on I/O failure or validation rejection; the caller decides
139
+ * whether to retry / log / surface to the user.
140
+ */
141
+ export function writeCompactLifecycle(input) {
142
+ const path = lifecyclePath(input.projectRoot, input.sessionId);
143
+ mkdirSync(dirname(path), { recursive: true });
144
+ // Validate the record the caller passed BEFORE opening a tmp file,
145
+ // so a malformed caller doesn't leave a tmp file dangling.
146
+ // (Re-coercing also catches cases where the caller bypassed TypeScript.)
147
+ const validated = coerceRecord({
148
+ ...input.record,
149
+ errorSummary: clampErrorSummary(input.record.errorSummary),
150
+ });
151
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
152
+ try {
153
+ writeFileSync(tmp, JSON.stringify(validated, null, 2) + '\n', 'utf8');
154
+ renameSync(tmp, path);
155
+ }
156
+ catch (error) {
157
+ try {
158
+ unlinkSync(tmp);
159
+ }
160
+ catch {
161
+ // best effort: the OS will reap the tmp file eventually
162
+ }
163
+ throw error;
164
+ }
165
+ }
166
+ /**
167
+ * Read a compact lifecycle record and classify it.
168
+ *
169
+ * Return kinds:
170
+ * - `missing` — no record has ever been written for this session
171
+ * - `valid` — record parsed and validated; stage is terminal
172
+ * (completed/failed) OR is active but still fresh
173
+ * - `invalid` — file exists but is malformed / wrong schema / out
174
+ * of range. `reason` is a single-line English message
175
+ * (no CLI verbs, no stack traces). The caller MUST
176
+ * surface this to a human — we never silently turn
177
+ * a corrupted record into `missing`.
178
+ * - `stalled` — record is valid and is currently an ACTIVE stage,
179
+ * but `updatedAt` is older than `staleAfterMs` (a
180
+ * heartbeat that never arrived). Terminal stages
181
+ * never go stalled.
182
+ */
183
+ export function readCompactLifecycle(input) {
184
+ const path = lifecyclePath(input.projectRoot, input.sessionId);
185
+ if (!existsSync(path)) {
186
+ return { kind: 'missing' };
187
+ }
188
+ let raw;
189
+ try {
190
+ raw = readFileSync(path, 'utf8');
191
+ }
192
+ catch (error) {
193
+ return {
194
+ kind: 'invalid',
195
+ reason: `compact-lifecycle: unable to read record file: ${error.message}`,
196
+ };
197
+ }
198
+ let parsed;
199
+ try {
200
+ parsed = JSON.parse(raw);
201
+ }
202
+ catch (error) {
203
+ return {
204
+ kind: 'invalid',
205
+ reason: `compact-lifecycle: malformed JSON (${error.message})`,
206
+ };
207
+ }
208
+ let record;
209
+ try {
210
+ record = coerceRecord(parsed);
211
+ }
212
+ catch (error) {
213
+ return { kind: 'invalid', reason: error.message };
214
+ }
215
+ if (isActiveStage(record.stage)) {
216
+ const updatedAtMs = Date.parse(record.updatedAt);
217
+ if (!Number.isNaN(updatedAtMs) && input.nowMs - updatedAtMs > input.staleAfterMs) {
218
+ return { kind: 'stalled', record };
219
+ }
220
+ }
221
+ return { kind: 'valid', record };
222
+ }
@@ -1,16 +1,36 @@
1
+ import { type CompactLifecycleStage } from './compact-lifecycle-store.js';
2
+ export type CompactDisplayKind = 'none' | 'queued' | 'preparing' | 'compacting' | 'verifying' | 'completed' | 'failed' | 'stalled' | 'invalid';
1
3
  export interface CompactStatuslineState {
2
- readonly kind: 'missing' | 'idle' | 'pending' | 'red-line' | 'just-compacted';
3
- readonly label: string;
4
+ readonly kind: CompactDisplayKind;
5
+ readonly filledCells: 0 | 2 | 4 | 6 | 8;
6
+ readonly triggerRatio?: number;
7
+ readonly afterRatio?: number;
8
+ readonly redLine?: boolean;
9
+ readonly failedAt?: CompactLifecycleStage;
4
10
  readonly detail?: string;
5
11
  }
12
+ /**
13
+ * 10-second completed-window expiry. The brief (Task 6 design requirement)
14
+ * calls out: once a compact lifecycle reaches `completed`, the primary
15
+ * statusline should surface the success indicator for at most 10 seconds,
16
+ * then fall back to the C1 baseline so the consumer (the IDE) does not
17
+ * keep a green ✓ pinned on the status bar indefinitely. The narrow window
18
+ * is sufficient for the human to see "we just compacted" and long enough
19
+ * to not flap on subsequent reads. Adjustable after real timing feedback.
20
+ */
21
+ export declare const COMPLETED_EXPIRY_MS = 10000;
6
22
  export declare function decideCompactStatusline(input: {
7
23
  readonly projectRoot: string;
8
24
  readonly sessionId: string | null;
9
25
  readonly now: number;
26
+ readonly staleAfterMs?: number;
27
+ readonly completedExpiryMs?: number;
10
28
  }): CompactStatuslineState;
11
29
  /**
12
- * Plain-text render: just the label. The LLM embeds this in
13
- * the Claude Code statusline. Pure: no side effects, no I/O
14
- * beyond the two file reads.
30
+ * Plain-text render: the cell bar + a small annotation. The bar is
31
+ * always the fixed-width 8-cell shape `[████░░░░]`. After-ratio is
32
+ * only rendered when the lifecycle record carries a real one.
33
+ *
34
+ * The output never contains `?` characters — we never guess a ratio.
15
35
  */
16
36
  export declare function renderCompactStatusline(state: CompactStatuslineState): string;
@@ -1,83 +1,273 @@
1
1
  // src/services/compact-statusline/compact-statusline-service.ts
2
2
  //
3
- // Slice 2026-07-30-compact-visibility (slice 3/4). Pure helper
4
- // for the 'peaks statusline compact' indicator. Reads
5
- // .peaks/_runtime/<sessionId>/auto-compact-pending.json +
6
- // compact-history.jsonl and decides the single-line text
7
- // the LLM should embed in Claude Code's statusline.
3
+ // Slice 2026-08-01-compact-lifecycle (Task 3/5). Pure semantic
4
+ // decision + render helper for the 'peaks statusline compact'
5
+ // indicator. Reads .peaks/_runtime/<sessionId>/compact-lifecycle.json
6
+ // first (the canonical source of truth) and falls back to the legacy
7
+ // auto-compact-pending.json + compact-history.jsonl only when the
8
+ // lifecycle record is missing.
8
9
  //
9
- // State machine:
10
- // - pending: a 'block' intent is sitting in
11
- // auto-compact-pending.json → 'compact pending (<ratio>)'
12
- // - redLine: pending.redLine === true → 'REDLINE 0.95'
13
- // - just-compacted: history has an event within the last
14
- // 30 seconds → 'just compacted (<from>→<to>)'
15
- // - idle: nothing recent → '--'
16
- // - missing: no session binding or no orchestrator run yet
17
- // → '' (empty)
10
+ // Decision priority (explicit, no implicit fall-through):
11
+ // 1. lifecycle missing → fall back to legacy (pending → queued,
12
+ // recent history → completed WITHOUT an invented after-ratio,
13
+ // else none)
14
+ // 2. lifecycle invalid → 'invalid' (NEVER fall back to legacy
15
+ // — a corrupted lifecycle is not a green progress bar)
16
+ // 3. lifecycle valid → map stage to filledCells via the
17
+ // documented cell table
18
+ // 4. lifecycle stalled → 'stalled' kind, retains the cell that
19
+ // the active stage was holding
20
+ //
21
+ // Cell mapping (frozen first-version contract):
22
+ // queued → 0 cells
23
+ // preparing → 2 cells
24
+ // compacting → 4 cells
25
+ // verifying → 6 cells
26
+ // completed → 8 cells
27
+ // failed → keep the failedAt cell (default to compacting = 4)
28
+ // none → 0 cells
29
+ // invalid → 0 cells (no false reassurance)
30
+ // stalled → keep the active stage's cell
31
+ //
32
+ // Render contract: the rendered label is a fixed-width 8-cell bar
33
+ // (`[████░░░░]` filled from the left). NO `?` characters. NO guessed
34
+ // ratios. After-ratio is only rendered when the lifecycle record
35
+ // carries a real one; otherwise the bar shows a stable "no
36
+ // measurement" hint.
18
37
  import { existsSync, readFileSync, statSync } from 'node:fs';
19
- import { join } from 'node:path';
20
- const JUST_COMPACTED_WINDOW_MS = 30_000;
38
+ import { getSessionDir } from '../session/getSessionDir.js';
39
+ import { readCompactLifecycle, } from './compact-lifecycle-store.js';
40
+ /**
41
+ * Concrete first-version stale timeout. Adjustable after real timing
42
+ * evidence from the auto-compact orchestrator (it currently writes a
43
+ * heartbeat on every state transition; 120 s is the longest realistic
44
+ * gap between a heartbeat and an actual stall).
45
+ */
46
+ const DEFAULT_STALE_AFTER_MS = 120_000;
47
+ /**
48
+ * 10-second completed-window expiry. The brief (Task 6 design requirement)
49
+ * calls out: once a compact lifecycle reaches `completed`, the primary
50
+ * statusline should surface the success indicator for at most 10 seconds,
51
+ * then fall back to the C1 baseline so the consumer (the IDE) does not
52
+ * keep a green ✓ pinned on the status bar indefinitely. The narrow window
53
+ * is sufficient for the human to see "we just compacted" and long enough
54
+ * to not flap on subsequent reads. Adjustable after real timing feedback.
55
+ */
56
+ export const COMPLETED_EXPIRY_MS = 10_000;
57
+ /** Legacy mtime window for the "just compacted" indicator. */
58
+ const LEGACY_JUST_COMPACTED_WINDOW_MS = 30_000;
59
+ const CELL_BY_STAGE = new Map([
60
+ ['queued', 0],
61
+ ['preparing', 2],
62
+ ['compacting', 4],
63
+ ['verifying', 6],
64
+ ['completed', 8],
65
+ ['failed', 4],
66
+ ]);
67
+ const FILLED = '█';
68
+ const EMPTY = '░';
69
+ const BAR_WIDTH = 8;
70
+ const NO_AFTER_RATIO_HINT = 'after-ratio not recorded';
71
+ function renderBar(filledCells) {
72
+ return `[${FILLED.repeat(filledCells)}${EMPTY.repeat(BAR_WIDTH - filledCells)}]`;
73
+ }
74
+ function renderLegacyBar(filledCells) {
75
+ return renderBar(filledCells);
76
+ }
21
77
  export function decideCompactStatusline(input) {
78
+ const staleAfterMs = input.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;
79
+ const completedExpiryMs = input.completedExpiryMs ?? COMPLETED_EXPIRY_MS;
22
80
  if (input.sessionId === null) {
23
- return { kind: 'missing', label: '' };
81
+ return { kind: 'none', filledCells: 0 };
82
+ }
83
+ const sessionDir = getSessionDir(input.projectRoot, input.sessionId);
84
+ // Priority 1: lifecycle reads. Lifecycle is the canonical source of
85
+ // truth; invalid is non-recoverable in this decision layer.
86
+ const lifecycle = readCompactLifecycle({
87
+ projectRoot: input.projectRoot,
88
+ sessionId: input.sessionId,
89
+ nowMs: input.now,
90
+ staleAfterMs,
91
+ });
92
+ if (lifecycle.kind === 'valid') {
93
+ // 10-second completed-expiry: once a compact lifecycle reaches
94
+ // `completed`, the primary statusline should surface the success
95
+ // indicator for at most COMPLETED_EXPIRY_MS (10s), then fall back to
96
+ // the C1 baseline so the IDE does not keep a green ✓ pinned on the
97
+ // status bar indefinitely. The narrow window is sufficient for the
98
+ // human to see "we just compacted" and long enough to not flap on
99
+ // subsequent reads. The narrow read of `updatedAt` is intentional: we
100
+ // surface the truth ("the run completed") and immediately expire
101
+ // rather than carrying forward a stale green check.
102
+ //
103
+ // Note: the expiry applies ONLY to `kind: 'completed'`. The `failed`
104
+ // stage is deliberately PERSISTENT — the orchestrator writes a single
105
+ // terminal `failed` record and the user (or the next slice's QA gate)
106
+ // needs to see it on the statusline until the next lifecycle write
107
+ // clears it. Expiring a failed record would silently hide a real
108
+ // failure from the human and is a NO-GO. The `stalled` failure mode
109
+ // is distinct and is computed by the lifecycle store (not here).
110
+ if (lifecycle.record.stage === 'completed') {
111
+ const updatedAtMs = Date.parse(lifecycle.record.updatedAt);
112
+ if (!Number.isNaN(updatedAtMs) && input.now - updatedAtMs > completedExpiryMs) {
113
+ return { kind: 'none', filledCells: 0 };
114
+ }
115
+ }
116
+ return stateFromLifecycle(lifecycle.record);
117
+ }
118
+ if (lifecycle.kind === 'stalled') {
119
+ return stateFromStalled(lifecycle.record);
120
+ }
121
+ if (lifecycle.kind === 'invalid') {
122
+ return {
123
+ kind: 'invalid',
124
+ filledCells: 0,
125
+ detail: lifecycle.reason,
126
+ };
127
+ }
128
+ // lifecycle.kind === 'missing' → fall back to legacy files.
129
+ return decideLegacyFallback({
130
+ projectRoot: input.projectRoot,
131
+ sessionDir,
132
+ now: input.now,
133
+ });
134
+ }
135
+ function stateFromLifecycle(record) {
136
+ if (record.stage === 'failed') {
137
+ const failedAt = record.failedAt ?? 'compacting';
138
+ const state = {
139
+ kind: 'failed',
140
+ filledCells: CELL_BY_STAGE.get(failedAt) ?? 4,
141
+ triggerRatio: record.triggerRatio,
142
+ redLine: record.redLine,
143
+ failedAt,
144
+ };
145
+ if (record.errorSummary !== undefined) {
146
+ return { ...state, detail: record.errorSummary };
147
+ }
148
+ return state;
149
+ }
150
+ const filledCells = CELL_BY_STAGE.get(record.stage) ?? 0;
151
+ const base = {
152
+ kind: record.stage,
153
+ filledCells,
154
+ triggerRatio: record.triggerRatio,
155
+ redLine: record.redLine,
156
+ };
157
+ if (record.stage === 'completed' && typeof record.afterRatio === 'number') {
158
+ return { ...base, afterRatio: record.afterRatio };
159
+ }
160
+ return base;
161
+ }
162
+ function stateFromStalled(record) {
163
+ const filledCells = CELL_BY_STAGE.get(record.stage) ?? 4;
164
+ const detailText = record.stage === 'failed'
165
+ ? record.errorSummary
166
+ : `no heartbeat for ${record.stage} stage`;
167
+ const state = {
168
+ kind: 'stalled',
169
+ filledCells,
170
+ triggerRatio: record.triggerRatio,
171
+ redLine: record.redLine,
172
+ };
173
+ if (detailText !== undefined) {
174
+ return { ...state, detail: detailText };
24
175
  }
25
- const runtimeDir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId);
26
- const pendingPath = join(runtimeDir, 'txt', 'auto-compact-pending.json');
27
- const historyPath = join(runtimeDir, 'compact-history.jsonl');
28
- // Priority 1: a pending compact intent.
176
+ return state;
177
+ }
178
+ function decideLegacyFallback(input) {
179
+ const { sessionDir, now } = input;
180
+ const pendingPath = `${sessionDir}/txt/auto-compact-pending.json`;
181
+ const historyPath = `${sessionDir}/compact-history.jsonl`;
182
+ // Priority 1 within legacy: a pending intent.
29
183
  if (existsSync(pendingPath)) {
30
184
  try {
31
185
  const raw = readFileSync(pendingPath, 'utf8');
32
186
  const parsed = JSON.parse(raw);
33
187
  if (parsed.pending === true) {
34
- if (parsed.redLine === true) {
35
- return {
36
- kind: 'red-line',
37
- label: `REDLINE ${(parsed.ratio ?? 0).toFixed(2)}`,
38
- detail: pendingPath,
39
- };
40
- }
41
188
  return {
42
- kind: 'pending',
43
- label: `compact pending (${(parsed.ratio ?? 0).toFixed(2)})`,
189
+ kind: 'queued',
190
+ filledCells: 0,
191
+ ...(typeof parsed.ratio === 'number' ? { triggerRatio: parsed.ratio } : {}),
192
+ ...(parsed.redLine === true ? { redLine: true } : {}),
44
193
  detail: pendingPath,
45
194
  };
46
195
  }
47
196
  }
48
- catch { /* fall through to history check */ }
197
+ catch {
198
+ // fall through to history check
199
+ }
49
200
  }
50
- // Priority 2: the most recent history event is within 30s.
201
+ // Priority 2 within legacy: a recent history event within 30s.
51
202
  if (existsSync(historyPath)) {
52
203
  try {
53
204
  const mtimeMs = statSync(historyPath).mtimeMs;
54
- if (input.now - mtimeMs <= JUST_COMPACTED_WINDOW_MS) {
55
- const raw = readFileSync(historyPath, 'utf8');
56
- const lines = raw.split(/\r?\n/).filter((l) => l.length > 0);
57
- const last = lines[lines.length - 1];
58
- if (last !== undefined) {
59
- const parsed = JSON.parse(last);
60
- const from = (parsed.beforeRatio ?? 0).toFixed(2);
61
- const to = (parsed.ok === false) ? 'failed' : '0.0?';
62
- return {
63
- kind: 'just-compacted',
64
- label: parsed.redLine === true
65
- ? `just compacted (REDLINE ${from}→?)`
66
- : `just compacted (${from}→${to})`,
67
- detail: historyPath,
68
- };
69
- }
205
+ if (now - mtimeMs <= LEGACY_JUST_COMPACTED_WINDOW_MS) {
206
+ // CRITICAL: no invented after-ratio. The history event may
207
+ // carry a beforeRatio, but never a measured after-ratio;
208
+ // this is the legacy path and we honour the "no measurement"
209
+ // default.
210
+ return {
211
+ kind: 'completed',
212
+ filledCells: 8,
213
+ detail: historyPath,
214
+ };
70
215
  }
71
216
  }
72
- catch { /* fall through to idle */ }
217
+ catch {
218
+ // fall through to idle
219
+ }
73
220
  }
74
- return { kind: 'idle', label: '--' };
221
+ return { kind: 'none', filledCells: 0 };
75
222
  }
76
223
  /**
77
- * Plain-text render: just the label. The LLM embeds this in
78
- * the Claude Code statusline. Pure: no side effects, no I/O
79
- * beyond the two file reads.
224
+ * Plain-text render: the cell bar + a small annotation. The bar is
225
+ * always the fixed-width 8-cell shape `[████░░░░]`. After-ratio is
226
+ * only rendered when the lifecycle record carries a real one.
227
+ *
228
+ * The output never contains `?` characters — we never guess a ratio.
80
229
  */
81
230
  export function renderCompactStatusline(state) {
82
- return state.label;
231
+ switch (state.kind) {
232
+ case 'none':
233
+ return `compact ${renderLegacyBar(0)}`;
234
+ case 'queued':
235
+ return `compact ${renderLegacyBar(0)}${state.redLine === true ? ' (redLine)' : ''}`;
236
+ case 'preparing':
237
+ return `compact ${renderBar(2)}`;
238
+ case 'compacting':
239
+ return `compact ${renderBar(4)}`;
240
+ case 'verifying':
241
+ return `compact ${renderBar(6)}`;
242
+ case 'completed':
243
+ return formatCompleted(state);
244
+ case 'failed':
245
+ return formatFailed(state);
246
+ case 'stalled':
247
+ return formatStalled(state);
248
+ case 'invalid':
249
+ return formatInvalid(state);
250
+ }
251
+ }
252
+ function formatCompleted(state) {
253
+ const bar = renderBar(8);
254
+ if (typeof state.afterRatio === 'number') {
255
+ return `compact ${bar} → ${state.afterRatio.toFixed(2)}`;
256
+ }
257
+ return `compact ${bar} (${NO_AFTER_RATIO_HINT})`;
258
+ }
259
+ function formatFailed(state) {
260
+ const filledAt = state.failedAt ?? 'compacting';
261
+ const cells = CELL_BY_STAGE.get(filledAt) ?? 4;
262
+ const bar = renderBar(cells);
263
+ const detail = state.detail ? ` — ${state.detail}` : '';
264
+ return `compact ${bar} failed at ${filledAt}${detail}`;
265
+ }
266
+ function formatStalled(state) {
267
+ const bar = renderBar(state.filledCells);
268
+ const detail = state.detail ? ` — ${state.detail}` : '';
269
+ return `compact ${bar} stalled${detail}`;
270
+ }
271
+ function formatInvalid(state) {
272
+ return `compact status unreadable: ${state.detail ?? 'lifecycle record malformed'}`;
83
273
  }
@@ -64,8 +64,12 @@ function readClaudeStatuslinePercent() {
64
64
  }
65
65
  }
66
66
  }
67
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
68
- return null;
67
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
68
+ if (err instanceof ReferenceError)
69
+ throw err; // surface module-load bugs
70
+ if (err instanceof SyntaxError)
71
+ throw err; // surface parse bugs (e.g. broken statusline JSON)
72
+ return null; // only swallow IO errors
69
73
  }
70
74
  return null;
71
75
  }
@@ -103,8 +107,12 @@ function findTranscriptJsonl(projectsDir, sessionId) {
103
107
  }
104
108
  }
105
109
  }
106
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
107
- return null;
110
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
111
+ if (err instanceof ReferenceError)
112
+ throw err; // surface module-load bugs
113
+ if (err instanceof SyntaxError)
114
+ throw err; // surface parse bugs
115
+ return null; // only swallow IO errors
108
116
  }
109
117
  return null;
110
118
  }
@@ -52,6 +52,32 @@ export interface DispatchPromptInput {
52
52
  * --ttl <5m>` (already shipped). Update the prose once `spawn` lands.
53
53
  */
54
54
  export declare const L1_WORKTREE_GOVERNANCE_BLOCK = "## Superpowers chain refusal (L1 worktree governance \u2014 locked 2026-07-29)\n\nYou MUST NOT follow the superpowers chain for worktree decisions:\n\n- superpowers:brainstorming \u2192 superpowers:writing-plans \u2192\n superpowers:subagent-driven-development \u2192 superpowers:using-git-worktrees\n\nThis chain ends with `git worktree add` (superpowers:using-git-worktrees SKILL.md line 96). It bypasses peaks-loop's L2 hook gate and L3 IDE `permissions.deny`. Even when L3 denies the terminal Skill, the chain has already taught you to use raw `git worktree add`, so L3 is not sufficient.\n\nFor worktree operations, use ONLY:\n\n- `peaks worktree spawn --rid <rid> --ttl <duration> --purpose <text>` (after rid-L2-extended ships)\n- until then: `peaks worktree auth grant --rid <id> --reason <text> --ttl <5m>`\n\nThe superpowers skills remain available as REFERENCE material for brainstorming / planning techniques, but MUST NOT be used as a workflow. peaks-rd's own PRD/RD artifacts (`.peaks/_runtime/<sessionId>/rd/requests/<rid>.md`) are authoritative.\n\nIf the upstream superpowers chain suggests raw `git worktree add`:\n\n1. STOP\n2. Re-author the plan as a peaks-rd artifact\n3. Continue with `peaks worktree spawn` (or the auth-grant fallback)\n";
55
+ /**
56
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 8): the dispatch
57
+ * system prompt gains three lifecycle rules. The sub-agent must:
58
+ *
59
+ * 1. Register any long-lived local process it starts (vite dev,
60
+ * mock API, docker proxy, etc.) with
61
+ * `peaks sub-agent shutdown register --pid <pid> --name <label>`
62
+ * before it exits, so the parent session can best-effort-kill
63
+ * the process before merge-back (Task 3 + Task 6).
64
+ *
65
+ * 2. NOT run E2E. The parent session runs Playwright verification
66
+ * once after merge-back (Task 10). Sub-agent E2E runs are
67
+ * duplicates that the parent's single E2E pass will catch.
68
+ *
69
+ * 3. NOT call `git merge / pull / rebase` or
70
+ * `peaks worktree release`. The parent session owns the
71
+ * merge-back step (Task 9). Sub-agent merges double-write the
72
+ * index, race the worktree release, and can corrupt the
73
+ * caller's working branch.
74
+ *
75
+ * These rules are appended to the system prompt on top of the L1
76
+ * worktree governance block so the sub-agent sees them last (i.e.
77
+ * most-recently-read), which is the strongest prompt position in
78
+ * transformer attention.
79
+ */
80
+ export declare const LIFECYCLE_RULES = "## Sub-agent lifecycle rules (locked 2026-08-01)\n\n- If you start a long-lived local service (vite dev, mock API, docker container, etc.), register it with `peaks sub-agent shutdown register --pid <pid> --name <label>` before you exit. The parent session will best-effort-kill it before merge-back.\n- Do NOT run E2E. The parent session runs Playwright verification once after merge-back (Task 10). Your E2E work is duplicate effort.\n- Do NOT call `git merge`, `git pull`, `git rebase`, or `peaks worktree release`. The parent session owns the merge-back step.\n";
55
81
  /**
56
82
  * Compose the system-prompt body that the dispatch site prepends to
57
83
  * `formatTestToolDetection()\n\n`.