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.
- package/CHANGELOG.md +55 -0
- package/README-en.md +26 -0
- package/README.md +26 -0
- package/dist/cli/commands/_register.js +3 -0
- package/dist/cli/commands/code-runtime-commands.js +15 -2
- package/dist/cli/commands/core/skill-command.js +2 -0
- package/dist/cli/commands/dispatch-commands.js +41 -2
- package/dist/cli/commands/e2e-verify.d.ts +47 -0
- package/dist/cli/commands/e2e-verify.js +211 -0
- package/dist/cli/commands/gate-commands.js +17 -4
- package/dist/cli/commands/statusline-commands.d.ts +6 -0
- package/dist/cli/commands/statusline-commands.js +48 -11
- package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
- package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
- package/dist/cli/commands/worktree-auth-commands.js +27 -0
- package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
- package/dist/services/code/auto-compact-orchestrator.js +358 -41
- package/dist/services/code/post-compact-detector.js +12 -4
- package/dist/services/code/step-08-gate.js +6 -2
- package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
- package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
- package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
- package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
- package/dist/services/context/auto-compact-reader.d.ts +33 -4
- package/dist/services/context/auto-compact-reader.js +77 -22
- package/dist/services/context/auto-compact-types.d.ts +10 -2
- package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
- package/dist/services/context/build-dispatch-system-prompt.js +33 -2
- package/dist/services/dispatch/conflict-replay.d.ts +32 -0
- package/dist/services/dispatch/conflict-replay.js +44 -0
- package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
- package/dist/services/dispatch/dispatch-record-writer.js +27 -7
- package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
- package/dist/services/dispatch/e2e-fixtures.js +72 -0
- package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
- package/dist/services/dispatch/merge-back-runner.js +83 -0
- package/dist/services/dispatch/post-merge.d.ts +21 -0
- package/dist/services/dispatch/post-merge.js +13 -0
- package/dist/services/dispatch/service-shutdown.d.ts +22 -0
- package/dist/services/dispatch/service-shutdown.js +51 -0
- package/dist/services/hooks/presence-marker-detector.js +12 -4
- package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
- package/dist/services/hooks/worktree-authorization-gate.js +22 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
- package/dist/services/skills/skill-statusline-renderer.js +374 -29
- package/dist/services/skills/skill-statusline-service.d.ts +2 -0
- package/dist/services/skills/skill-statusline-service.js +34 -5
- package/dist/services/skills/sync-service.d.ts +3 -0
- package/dist/services/skills/sync-service.js +1 -0
- package/dist/services/standards/project-context.d.ts +2 -1
- package/dist/services/standards/project-context.js +17 -2
- package/dist/services/standards/project-standards-service.d.ts +9 -0
- package/dist/services/standards/project-standards-service.js +26 -0
- package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
- package/dist/services/worktree/dispatch-provenance.js +47 -0
- package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
- package/dist/services/worktree/git-worktree-parser.js +45 -0
- package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
- package/dist/services/worktree/host-worktree-reconciler.js +39 -0
- package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
- package/dist/services/worktree/long-path-cleanup.js +38 -0
- package/dist/services/worktree/playwright-profile.d.ts +8 -0
- package/dist/services/worktree/playwright-profile.js +23 -0
- package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
- package/dist/services/worktree/worktree-gc-guard.js +38 -0
- package/package.json +4 -4
- 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:
|
|
3
|
-
readonly
|
|
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:
|
|
13
|
-
* the
|
|
14
|
-
*
|
|
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-
|
|
4
|
-
// for the 'peaks statusline compact'
|
|
5
|
-
// .peaks/_runtime/<sessionId>/
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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 {
|
|
20
|
-
|
|
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: '
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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: '
|
|
43
|
-
|
|
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 {
|
|
197
|
+
catch {
|
|
198
|
+
// fall through to history check
|
|
199
|
+
}
|
|
49
200
|
}
|
|
50
|
-
// Priority 2:
|
|
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 (
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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 {
|
|
217
|
+
catch {
|
|
218
|
+
// fall through to idle
|
|
219
|
+
}
|
|
73
220
|
}
|
|
74
|
-
return { kind: '
|
|
221
|
+
return { kind: 'none', filledCells: 0 };
|
|
75
222
|
}
|
|
76
223
|
/**
|
|
77
|
-
* Plain-text render:
|
|
78
|
-
* the
|
|
79
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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 `'
|
|
27
|
-
* know
|
|
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 {};
|