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.
- package/CHANGELOG.md +64 -0
- package/README-en.md +26 -0
- package/README.md +26 -0
- package/dist/cli/commands/_register.js +3 -0
- 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.js +12 -4
- 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 +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:
|
|
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
|
}
|
|
@@ -64,8 +64,12 @@ function readClaudeStatuslinePercent() {
|
|
|
64
64
|
}
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
|
-
catch { // TODO(g2): legacy silent catch — grace: 1 minor release
|
|
68
|
-
|
|
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
|
|
107
|
-
|
|
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`.
|