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
@@ -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
  }
@@ -3,6 +3,16 @@ export interface ReadContextPercentInput {
3
3
  readonly projectRoot: string;
4
4
  readonly sessionId: string;
5
5
  readonly env?: NodeJS.ProcessEnv | undefined;
6
+ /**
7
+ * Slice 2026-07-31-rid-002: explicit byte count from `--prompt-size <bytes>`.
8
+ * When set to a finite non-negative number, short-circuits the entire
9
+ * env / statusline / transcript chain with `source: 'user-overridden'`.
10
+ * Mac escape hatch — Claude Code Mac does NOT inject
11
+ * `CLAUDE_CONTEXT_USAGE_PERCENT` into PreToolUse sub-shells, so the
12
+ * user (or a hook wrapper) can inject the bytes they observed themselves.
13
+ * Priority P0 — above everything else.
14
+ */
15
+ readonly promptSizeBytes?: number | undefined;
6
16
  }
7
17
  /**
8
18
  * Read env-var-based context percentage. Returns `null` when the env
@@ -19,12 +29,30 @@ declare function readEnvPercent(env: NodeJS.ProcessEnv, varName: string): number
19
29
  */
20
30
  declare function readClaudeStatuslinePercent(): number | null;
21
31
  /**
22
- * Conservative transcript-size fallback. Walks
23
- * `~/.claude/projects/<hash>/<sid>.jsonl` and estimates
32
+ * Recursive search for `<sessionId>.jsonl` under `projectsDir`. Used by
33
+ * `readClaudeTranscriptFallback` and exported via `_internal` so unit
34
+ * tests can drive it without monkey-patching `os.homedir` (which is
35
+ * non-configurable in ESM module namespaces).
36
+ *
37
+ * The Mac layout encodes the cwd as a single hash directory; on Mac
38
+ * Claude Code nests the transcript under that hash with an extra level
39
+ * of subdirectory we cannot predict ahead of time. A flat readdir misses
40
+ * that branch and returns null — the silent-failure mode that this fix
41
+ * closes.
42
+ */
43
+ declare function findTranscriptJsonl(projectsDir: string, sessionId: string): {
44
+ path: string;
45
+ bytes: number;
46
+ } | null;
47
+ /**
48
+ * Conservative transcript-size fallback. Recursively searches
49
+ * `~/.claude/projects/<hash>/<sid-or-nested>.jsonl` (Mac may nest
50
+ * the jsonl under an
51
+ * extra directory we cannot predict ahead of time) and estimates
24
52
  * `ratio = bytesUsed / 256K`. Returns the bytes seen so the
25
53
  * orchestrator can show "estimated from 124KB of 256KB transcript"
26
- * in the envelope. Tagged `'conservative-fallback'` so callers
27
- * know NOT to treat this as a hard gate.
54
+ * in the envelope. Tagged `'transcript-estimate'` (v2.14.0) so callers
55
+ * know it is a real signal, NOT a hard gate.
28
56
  */
29
57
  declare function readClaudeTranscriptFallback(sessionId: string): {
30
58
  ratio: number;
@@ -47,5 +75,6 @@ export declare const _internal: {
47
75
  readEnvPercent: typeof readEnvPercent;
48
76
  readClaudeStatuslinePercent: typeof readClaudeStatuslinePercent;
49
77
  readClaudeTranscriptFallback: typeof readClaudeTranscriptFallback;
78
+ findTranscriptJsonl: typeof findTranscriptJsonl;
50
79
  };
51
80
  export {};