peaks-loop 4.0.28 → 4.0.30

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 (74) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli/commands/_register.js +4 -0
  3. package/dist/cli/commands/dispatch-commands.d.ts +5 -41
  4. package/dist/cli/commands/dispatch-commands.js +59 -185
  5. package/dist/cli/commands/evidence-commands.d.ts +12 -0
  6. package/dist/cli/commands/evidence-commands.js +43 -0
  7. package/dist/cli/commands/fresh-context-commands.d.ts +15 -0
  8. package/dist/cli/commands/fresh-context-commands.js +24 -0
  9. package/dist/cli/commands/job-commands.js +14 -1
  10. package/dist/cli/commands/request-commands.js +15 -97
  11. package/dist/cli/commands/request-format-helpers.d.ts +19 -0
  12. package/dist/cli/commands/request-format-helpers.js +102 -0
  13. package/dist/cli/commands/worktree-auth-commands.js +2 -472
  14. package/dist/cli/commands/worktree-lease-commands.d.ts +21 -0
  15. package/dist/cli/commands/worktree-lease-commands.js +500 -0
  16. package/dist/services/artifacts/request-artifact-service.js +13 -3
  17. package/dist/services/code/auto-compact-lifecycle.d.ts +102 -0
  18. package/dist/services/code/auto-compact-lifecycle.js +235 -0
  19. package/dist/services/code/auto-compact-orchestrator.d.ts +1 -1
  20. package/dist/services/code/auto-compact-orchestrator.js +1 -222
  21. package/dist/services/codegraph/codegraph-autorefresh.d.ts +22 -0
  22. package/dist/services/codegraph/codegraph-autorefresh.js +91 -0
  23. package/dist/services/codegraph/codegraph-preflight-service.d.ts +53 -0
  24. package/dist/services/codegraph/codegraph-preflight-service.js +226 -0
  25. package/dist/services/context/build-dispatch-system-prompt.d.ts +52 -0
  26. package/dist/services/context/build-dispatch-system-prompt.js +70 -3
  27. package/dist/services/dispatch/dispatch-record-types.d.ts +278 -0
  28. package/dist/services/dispatch/dispatch-record-types.js +15 -0
  29. package/dist/services/dispatch/dispatch-record-upgrade.d.ts +5 -0
  30. package/dist/services/dispatch/dispatch-record-upgrade.js +230 -0
  31. package/dist/services/dispatch/dispatch-record-writer.d.ts +3 -278
  32. package/dist/services/dispatch/dispatch-record-writer.js +3 -245
  33. package/dist/services/dispatch/dispatch-sub-agent.d.ts +43 -0
  34. package/dist/services/dispatch/dispatch-sub-agent.js +55 -0
  35. package/dist/services/dispatch/isolation-lease.d.ts +43 -0
  36. package/dist/services/dispatch/isolation-lease.js +129 -0
  37. package/dist/services/evidence/evidence-generator.d.ts +26 -0
  38. package/dist/services/evidence/evidence-generator.js +349 -0
  39. package/dist/services/fresh-context/config.d.ts +1 -0
  40. package/dist/services/fresh-context/config.js +13 -0
  41. package/dist/services/fresh-context/fresh-context-block.d.ts +12 -0
  42. package/dist/services/fresh-context/fresh-context-block.js +40 -0
  43. package/dist/services/fresh-context/trigger-scan.d.ts +30 -0
  44. package/dist/services/fresh-context/trigger-scan.js +29 -0
  45. package/dist/services/skills/hooks-codegate-superpowers.d.ts +65 -0
  46. package/dist/services/skills/hooks-codegate-superpowers.js +204 -0
  47. package/dist/services/skills/hooks-settings-service.d.ts +2 -64
  48. package/dist/services/skills/hooks-settings-service.js +2 -215
  49. package/dist/services/skills/skill-statusline-renderer.d.ts +2 -34
  50. package/dist/services/skills/skill-statusline-renderer.js +1 -184
  51. package/dist/services/skills/statusline-palette.d.ts +62 -0
  52. package/dist/services/skills/statusline-palette.js +190 -0
  53. package/dist/services/slice/slice-decompose-import-edges.d.ts +8 -0
  54. package/dist/services/slice/slice-decompose-import-edges.js +102 -0
  55. package/dist/services/slice/slice-decompose-service.js +3 -198
  56. package/dist/services/slice/slice-decompose-tarjan.d.ts +8 -0
  57. package/dist/services/slice/slice-decompose-tarjan.js +108 -0
  58. package/dist/services/standards/project-standards-service.d.ts +1 -9
  59. package/dist/services/standards/project-standards-service.js +3 -232
  60. package/dist/services/standards/standards-render.d.ts +23 -0
  61. package/dist/services/standards/standards-render.js +238 -0
  62. package/dist/services/standards/ui-library-dispatch-block.d.ts +27 -0
  63. package/dist/services/standards/ui-library-dispatch-block.js +48 -0
  64. package/dist/services/workspace/reconcile-migrate.d.ts +77 -0
  65. package/dist/services/workspace/reconcile-migrate.js +230 -0
  66. package/dist/services/workspace/reconcile-service.d.ts +0 -72
  67. package/dist/services/workspace/reconcile-service.js +3 -220
  68. package/dist/services/workspace/workspace-service.js +13 -2
  69. package/dist/shared/incrementing-number.d.ts +11 -0
  70. package/dist/shared/incrementing-number.js +16 -3
  71. package/package.json +5 -5
  72. package/skills/peaks-code/SKILL.md +6 -2
  73. package/skills/peaks-code/references/fresh-context-preflight.md +81 -0
  74. package/skills/peaks-code/references/sub-agent-dispatch.md +27 -8
@@ -0,0 +1,500 @@
1
+ /**
2
+ * `peaks worktree <spawn|release|renew|list|gc|lease-status>` — lease
3
+ * lifecycle sub-commands.
4
+ *
5
+ * Extracted from `worktree-auth-commands.ts` to keep that file under the
6
+ * 800 LOC cap (mechanical verbatim move). These commands own the lease
7
+ * lifecycle: spawn writes a lease + runs `git worktree add`; release runs
8
+ * `git worktree remove` + transitions the lease to 'released'; renew /
9
+ * list / gc / lease-status read and mutate the same on-disk lease store.
10
+ * They coexist with `peaks worktree auth grant|revoke|status` (the L2 hook
11
+ * gate surface) on the shared `peaks worktree` parent command.
12
+ */
13
+ import { fail, ok } from 'peaks-loop-shared/result';
14
+ import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
15
+ import { findProjectRoot } from '../../services/config/config-safety.js';
16
+ import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
17
+ import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
18
+ import { emitLeaseEvent } from '../../services/observability/observability-service.js';
19
+ import { deserializeLease, finalizeLease, generateLeaseId, isLeaseActive, isLeaseGcEligible, leaseFilePath, leaseStoreDir, listLeasesSync, markExpired, markGc, markReleased, renewLease, ttlForRole, worktreePath, } from '../../services/worktree/worktree-lease.js';
20
+ import { execSync } from 'node:child_process';
21
+ import { existsSync, readdirSync, readFileSync as readFileSyncNode, statSync } from 'node:fs';
22
+ export function resolveSessionId(options, projectRoot) {
23
+ if (typeof options.session === 'string' && options.session.length > 0)
24
+ return options.session;
25
+ // Reuse the same precedence as the rest of peaks: explicit --session > PEAKS_SESSION_ID > active session.json
26
+ return process.env.PEAKS_SESSION_ID ?? getCurrentSessionId(projectRoot) ?? 'unknown-sid';
27
+ }
28
+ export function resolveProjectRoot(options) {
29
+ return options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
30
+ }
31
+ export function registerWorktreeLeaseCommands(auth, io) {
32
+ // Slice 2026-07-29-worktree-l2-extended Part 1 — `peaks worktree spawn`
33
+ // and `peaks worktree release`. These commands own the lease lifecycle:
34
+ // spawn writes a lease + runs `git worktree add`; release runs `git
35
+ // worktree remove` + transitions the lease to 'released'. The remaining
36
+ // CLI surface (renew / list / gc / status) ships in Part 2 along with
37
+ // the hook integration that consults the lease.
38
+ //
39
+ // Coexistence with `peaks worktree auth`: this slice does NOT delete
40
+ // `peaks worktree auth grant|revoke|status` — those are the L2 hook
41
+ // gate's existing surface and remain valid for sub-agents that have
42
+ // NOT adopted the lease contract yet. New code uses lease; old code
43
+ // uses grant; both live on the `peaks worktree` parent command.
44
+ function resolveTtlMs(raw, role) {
45
+ if (typeof raw !== 'string' || raw.length === 0)
46
+ return ttlForRole(role);
47
+ const parsed = Number(raw);
48
+ if (!Number.isFinite(parsed) || parsed <= 0)
49
+ return ttlForRole(role);
50
+ return parsed;
51
+ }
52
+ function deriveBranch(rid) {
53
+ // Branch names must be safe for git ref-format. Strip leading
54
+ // `rid-` if present and replace any non-safe chars with `-`.
55
+ return rid.replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 80);
56
+ }
57
+ addJsonOption(auth
58
+ .command('spawn')
59
+ .description(`Spawn a worktree under .peaks/_runtime/<sid>/worktrees/<leaseId>/ with a managed lease. ` +
60
+ `The lease is the source of truth for the L2 hook gate (Part 2 of this slice); ` +
61
+ `until the hook integration ships, sub-agents may still invoke raw \`git worktree add\` ` +
62
+ `with a current \`peaks worktree auth grant\` token. Default TTL is role-aware ` +
63
+ `(rd=30m / qa=15m / ui=1h); pass --ttl <ms> to override.`)
64
+ .requiredOption('--rid <rid>', 'peaks request id the lease is associated with')
65
+ .requiredOption('--role <role>', 'sub-agent role (rd | qa | ui | sc | prd | general-purpose)')
66
+ .requiredOption('--purpose <text>', 'why this worktree was spawned (audit log)')
67
+ .option('--ttl <ms>', `time-to-live in ms (default role-aware; override with positive number)`)
68
+ .option('--branch <name>', 'git branch name (default: derived from rid)')
69
+ .option('--session <sid>', 'override session id')
70
+ .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
71
+ const projectRoot = resolveProjectRoot(options);
72
+ const sessionId = resolveSessionId(options, projectRoot);
73
+ try {
74
+ const leaseId = generateLeaseId();
75
+ const now = Date.now();
76
+ const ttlMs = resolveTtlMs(options.ttl, options.role);
77
+ const branch = options.branch ?? deriveBranch(options.rid);
78
+ const wtPath = worktreePath(joinPathSession(projectRoot, sessionId), leaseId);
79
+ const lease = finalizeLease({
80
+ leaseId,
81
+ rid: options.rid,
82
+ role: options.role,
83
+ path: wtPath,
84
+ branch,
85
+ createdAt: now,
86
+ expiresAt: now + ttlMs,
87
+ purpose: options.purpose
88
+ });
89
+ // Run `git worktree add` from the project root. The PreToolUse hook
90
+ // (Part 2 of this slice) will consult the lease file we just wrote
91
+ // and authorize this very `git worktree add`; for Part 1 we still
92
+ // require a current `peaks worktree auth grant` token to remain
93
+ // consistent with the slice-027 hard gate contract.
94
+ execSync(`git worktree add "${wtPath}" -b "${branch}"`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
95
+ atomicWriteJson(leaseFilePath(joinPathSession(projectRoot, sessionId), leaseId), lease);
96
+ // Part 4.A: emit spawn metric (fire-and-forget; never blocks).
97
+ emitLeaseEvent({
98
+ sessionId,
99
+ projectRoot,
100
+ kind: 'spawn',
101
+ leaseId,
102
+ rid: options.rid,
103
+ role: options.role
104
+ });
105
+ printResult(io, ok('worktree.spawn', {
106
+ lease,
107
+ sessionId,
108
+ projectRoot,
109
+ ttlMs,
110
+ nextActions: [
111
+ `Worktree path: ${wtPath}`,
112
+ `Branch: ${branch}`,
113
+ `Lease expires at: ${new Date(lease.expiresAt).toISOString()}`,
114
+ 'Run `peaks worktree release --lease-id <leaseId>` when done',
115
+ 'Hook integration (lease-aware gate) ships in Part 2'
116
+ ]
117
+ }, [], []), options.json);
118
+ }
119
+ catch (error) {
120
+ printResult(io, fail('worktree.spawn', 'SPAWN_FAILED', getErrorMessage(error), { rid: options.rid, role: options.role, sessionId }, [
121
+ 'Verify `git worktree add` succeeded (output above).',
122
+ 'If the lease file was NOT written, retry; the lease directory is .peaks/_runtime/<sid>/worktree-leases/.',
123
+ 'For an existing branch, pass --branch <name> explicitly (the spawn refuses to overwrite an active branch).'
124
+ ]), options.json);
125
+ process.exitCode = 1;
126
+ }
127
+ });
128
+ addJsonOption(auth
129
+ .command('release')
130
+ .description('Transition a lease to released and run `git worktree remove`. Idempotent on already-released leases.')
131
+ .requiredOption('--lease-id <id>', 'lease id returned by `peaks worktree spawn`')
132
+ .option('--session <sid>', 'override session id')
133
+ .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
134
+ const projectRoot = resolveProjectRoot(options);
135
+ const sessionId = resolveSessionId(options, projectRoot);
136
+ try {
137
+ const file = leaseFilePath(joinPathSession(projectRoot, sessionId), options.leaseId);
138
+ let lease;
139
+ try {
140
+ if (!existsSync(file)) {
141
+ printResult(io, fail('worktree.release', 'LEASE_NOT_FOUND', `no lease on disk at ${file}`, { leaseId: options.leaseId, file }, [
142
+ 'Run `peaks worktree list` to inspect active leases.',
143
+ 'For a never-spawned lease, this is a no-op — no further action needed.'
144
+ ]), options.json);
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+ const raw = readFileSyncNode(file, 'utf8');
149
+ lease = deserializeLease(raw);
150
+ }
151
+ catch (error) {
152
+ printResult(io, fail('worktree.release', 'LEASE_FILE_INVALID', getErrorMessage(error), { leaseId: options.leaseId, file }, [
153
+ 'Delete the malformed lease file manually and re-issue spawn.',
154
+ 'For security, release never fails open on a malformed lease.'
155
+ ]), options.json);
156
+ process.exitCode = 1;
157
+ return;
158
+ }
159
+ if (lease.status === 'released') {
160
+ // Idempotent: already released, nothing to do.
161
+ printResult(io, ok('worktree.release', { lease, sessionId, projectRoot, alreadyReleased: true }, [], [`Lease ${lease.leaseId} already released; nothing to do.`]), options.json);
162
+ return;
163
+ }
164
+ // Run `git worktree remove` from the project root. If the path is
165
+ // missing on disk (e.g. manually pruned), this fails; we still
166
+ // update the lease state so the L2 hook (Part 2) treats it as
167
+ // released.
168
+ let gitWorktreeRemoveFailed = false;
169
+ try {
170
+ execSync(`git worktree remove --force "${lease.path}"`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
171
+ }
172
+ catch {
173
+ gitWorktreeRemoveFailed = true;
174
+ }
175
+ const released = markReleased(lease);
176
+ atomicWriteJson(file, released);
177
+ // Part 4.A: emit release metric (manual path; auto-release is
178
+ // recorded in dispatch-record-writer's tryAutoReleaseLease).
179
+ emitLeaseEvent({
180
+ sessionId,
181
+ projectRoot,
182
+ kind: 'release',
183
+ leaseId: released.leaseId,
184
+ rid: released.rid,
185
+ role: released.role
186
+ });
187
+ printResult(io, ok('worktree.release', { lease: released, sessionId, projectRoot, gitWorktreeRemoveFailed }, gitWorktreeRemoveFailed ? ['git worktree remove failed (likely the path was already pruned); lease marked released.'] : [], [
188
+ `Lease ${lease.leaseId} marked released.`,
189
+ gitWorktreeRemoveFailed
190
+ ? 'Manual `git worktree prune` may be needed.'
191
+ : `Worktree ${lease.path} removed.`
192
+ ]), options.json);
193
+ }
194
+ catch (error) {
195
+ printResult(io, fail('worktree.release', 'RELEASE_FAILED', getErrorMessage(error), { leaseId: options.leaseId, sessionId }, [
196
+ 'Verify the lease id and re-run.',
197
+ 'If the lease was never spawned, no-op.'
198
+ ]), options.json);
199
+ process.exitCode = 1;
200
+ }
201
+ });
202
+ addJsonOption(auth
203
+ .command('renew')
204
+ .description('Extend an active lease\'s `expiresAt` and persist it. Idempotent for already-active leases. ' +
205
+ 'Default TTL uses `DEFAULT_TTL_BY_ROLE[<lease.role>]`; pass --ttl <ms> to override.')
206
+ .requiredOption('--lease-id <id>', 'lease id returned by `peaks worktree spawn`')
207
+ .option('--ttl <ms>', 'time-to-live in ms from now (default: role-default)')
208
+ .option('--session <sid>', 'override session id')
209
+ .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
210
+ const projectRoot = resolveProjectRoot(options);
211
+ const sessionId = resolveSessionId(options, projectRoot);
212
+ try {
213
+ const file = leaseFilePath(joinPathSession(projectRoot, sessionId), options.leaseId);
214
+ if (!existsSync(file)) {
215
+ printResult(io, fail('worktree.renew', 'LEASE_NOT_FOUND', `no lease on disk at ${file}`, { leaseId: options.leaseId, file }, [
216
+ 'Run `peaks worktree list` to inspect active leases.',
217
+ 'For a never-spawned lease, this is a no-op.'
218
+ ]), options.json);
219
+ process.exitCode = 1;
220
+ return;
221
+ }
222
+ let lease;
223
+ try {
224
+ lease = deserializeLease(readFileSyncNode(file, 'utf8'));
225
+ }
226
+ catch (err) {
227
+ printResult(io, fail('worktree.renew', 'LEASE_FILE_INVALID', getErrorMessage(err), { leaseId: options.leaseId, file }, [
228
+ 'Delete the malformed lease file manually and re-spawn.',
229
+ 'For security, renew never fails open on a malformed lease.'
230
+ ]), options.json);
231
+ process.exitCode = 1;
232
+ return;
233
+ }
234
+ if (lease.status === 'released' || lease.status === 'gc') {
235
+ printResult(io, fail('worktree.renew', 'LEASE_NOT_RENEWABLE', `lease is in status '${lease.status}'; only active/expired leases may be renewed`, { leaseId: lease.leaseId, status: lease.status }, [
236
+ 'Released leases cannot be renewed. Run `peaks worktree spawn` to start a new lease.',
237
+ 'Gc-marked leases are terminal — re-spawn.'
238
+ ]), options.json);
239
+ process.exitCode = 1;
240
+ return;
241
+ }
242
+ const now = Date.now();
243
+ const ttlMs = options.ttl === undefined ? ttlForRole(lease.role) : Number.parseInt(options.ttl, 10);
244
+ if (!Number.isInteger(ttlMs) || ttlMs <= 0) {
245
+ printResult(io, fail('worktree.renew', 'INVALID_TTL', '--ttl must be a positive integer (ms)', { ttl: options.ttl }, [
246
+ 'Re-run with --ttl 1800000 (30 min) or omit to use role default.'
247
+ ]), options.json);
248
+ process.exitCode = 1;
249
+ return;
250
+ }
251
+ const renewed = renewLease(lease, now + ttlMs);
252
+ atomicWriteJson(file, renewed);
253
+ // Part 4.A: emit renew metric.
254
+ emitLeaseEvent({
255
+ sessionId,
256
+ projectRoot,
257
+ kind: 'renew',
258
+ leaseId: renewed.leaseId,
259
+ rid: renewed.rid,
260
+ role: renewed.role
261
+ });
262
+ printResult(io, ok('worktree.renew', { lease: renewed, sessionId, projectRoot, ttlMs, previousExpiresAt: lease.expiresAt }, [], [
263
+ `Lease ${renewed.leaseId} renewed; new expiresAt: ${new Date(renewed.expiresAt).toISOString()}.`,
264
+ `Branch ${renewed.branch} at ${renewed.path} remains intact (no ` +
265
+ '`git worktree` operation was performed — the worktree was always there).'
266
+ ]), options.json);
267
+ }
268
+ catch (err) {
269
+ printResult(io, fail('worktree.renew', 'RENEW_FAILED', getErrorMessage(err), { leaseId: options.leaseId, sessionId }, [
270
+ 'Re-run after fixing the failure (see cause in the error message).'
271
+ ]), options.json);
272
+ process.exitCode = 1;
273
+ }
274
+ });
275
+ addJsonOption(auth
276
+ .command('list')
277
+ .description('List every lease under the current session\'s lease store. ' +
278
+ 'Optionally filter by --status (active|released|expired|gc) and/or --expired-only. ' +
279
+ 'Leases past their expiresAt with status=active are still listed under "active" by default ' +
280
+ '(`isLeaseActive` returns false for them, but the on-disk status only flips to "expired" ' +
281
+ 'after `peaks worktree gc` runs).')
282
+ .option('--status <state>', 'filter by lease status: active | released | expired | gc')
283
+ .option('--expired-only', 'only show leases whose expiresAt is in the past')
284
+ .option('--session <sid>', 'override session id')
285
+ .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
286
+ const projectRoot = resolveProjectRoot(options);
287
+ const sessionId = resolveSessionId(options, projectRoot);
288
+ try {
289
+ const storeDir = leaseStoreDir(joinPathSession(projectRoot, sessionId));
290
+ const result = listLeasesSync(storeDir, {
291
+ readdir: (p) => readdirSync(p),
292
+ readFile: (p) => readFileSyncNode(p, 'utf8'),
293
+ existsSync: (p) => existsSync(p)
294
+ });
295
+ if (result.kind === 'store-missing') {
296
+ printResult(io, ok('worktree.list', { sessionId, projectRoot, leases: [], errors: [], storeMissing: true }, [], [
297
+ `No lease store at ${storeDir}. Spawn a worktree first (\`peaks worktree spawn ...\`).`
298
+ ]), options.json);
299
+ return;
300
+ }
301
+ const now = Date.now();
302
+ const annotated = result.leases.map((l) => ({
303
+ ...l,
304
+ live: isLeaseActive(l, now),
305
+ elapsedMs: now - l.createdAt,
306
+ remainingMs: l.expiresAt - now
307
+ }));
308
+ let filtered = annotated;
309
+ if (options.status) {
310
+ filtered = filtered.filter((l) => l.status === options.status);
311
+ }
312
+ if (options.expiredOnly === true) {
313
+ filtered = filtered.filter((l) => !l.live);
314
+ }
315
+ // Sort by createdAt desc — most-recent first. Stable for diffs.
316
+ filtered = [...filtered].sort((a, b) => b.createdAt - a.createdAt);
317
+ printResult(io, ok('worktree.list', {
318
+ sessionId,
319
+ projectRoot,
320
+ storeDir,
321
+ totalOnDisk: result.leases.length,
322
+ returned: filtered.length,
323
+ errors: result.errors,
324
+ leases: filtered
325
+ }, result.errors.map((e) => `Malformed lease: ${e.file} (${e.error})`), [
326
+ `${filtered.length} lease(s) matched (${result.leases.length} on disk).`,
327
+ result.errors.length > 0 ? 'Some lease files were malformed — see errors[]; they were skipped.' : ''
328
+ ].filter(Boolean)), options.json);
329
+ }
330
+ catch (err) {
331
+ printResult(io, fail('worktree.list', 'LIST_FAILED', getErrorMessage(err), { sessionId }, [
332
+ 'Re-run after fixing the failure (see cause in the error message).'
333
+ ]), options.json);
334
+ process.exitCode = 1;
335
+ }
336
+ });
337
+ addJsonOption(auth
338
+ .command('gc')
339
+ .description('Sweep released/expired leases: remove their git worktree (if still attached), prune git\'s ' +
340
+ 'worktree references, and mark the lease as "gc". With --lease-id <id>, only that lease is ' +
341
+ 'considered. With --dry-run, report what would be gc\'d without mutating. Expired-active ' +
342
+ 'leases (status=active but past expiresAt) are eligible — they are first marked "expired" ' +
343
+ 'then their worktree is removed.')
344
+ .option('--lease-id <id>', 'only consider this specific lease')
345
+ .option('--dry-run', 'report what would be gc\'d without mutating')
346
+ .option('--session <sid>', 'override session id')
347
+ .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
348
+ const projectRoot = resolveProjectRoot(options);
349
+ const sessionId = resolveSessionId(options, projectRoot);
350
+ try {
351
+ const storeDir = leaseStoreDir(joinPathSession(projectRoot, sessionId));
352
+ const result = listLeasesSync(storeDir, {
353
+ readdir: (p) => readdirSync(p),
354
+ readFile: (p) => readFileSyncNode(p, 'utf8'),
355
+ existsSync: (p) => existsSync(p)
356
+ });
357
+ if (result.kind === 'store-missing') {
358
+ printResult(io, ok('worktree.gc', { sessionId, projectRoot, swept: 0, storeMissing: true }, [], [
359
+ `No lease store at ${storeDir}; nothing to gc.`
360
+ ]), options.json);
361
+ return;
362
+ }
363
+ const now = Date.now();
364
+ const candidates = result.leases
365
+ .filter((l) => (options.leaseId ? l.leaseId === options.leaseId : true))
366
+ .filter((l) => isLeaseGcEligible(l, now));
367
+ const dryRun = options.dryRun === true;
368
+ const swept = [];
369
+ for (const lease of candidates) {
370
+ let prevStatus = lease.status;
371
+ let updated = lease;
372
+ // If the lease is still marked active but past expiresAt, transition to expired first.
373
+ if (lease.status === 'active' && lease.expiresAt <= now) {
374
+ updated = markExpired(lease);
375
+ prevStatus = 'active';
376
+ }
377
+ if (!dryRun) {
378
+ // `git worktree remove --force` is best-effort — if the path is
379
+ // already gone we still mark the lease gc.
380
+ let gitWorktreeRemoveFailed = false;
381
+ try {
382
+ execSync(`git worktree remove --force "${updated.path}"`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
383
+ }
384
+ catch {
385
+ gitWorktreeRemoveFailed = true;
386
+ }
387
+ // `git worktree prune` clears any stale admin entries. Best-effort.
388
+ try {
389
+ execSync('git worktree prune', { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
390
+ }
391
+ catch {
392
+ // ignore — prune is idempotent
393
+ }
394
+ const finalLease = markGc(updated);
395
+ atomicWriteJson(leaseFilePath(joinPathSession(projectRoot, sessionId), lease.leaseId), finalLease);
396
+ // Part 4.A: emit gc metric (per swept lease).
397
+ emitLeaseEvent({
398
+ sessionId,
399
+ projectRoot,
400
+ kind: 'gc',
401
+ leaseId: lease.leaseId,
402
+ rid: lease.rid,
403
+ role: lease.role
404
+ });
405
+ swept.push({ leaseId: lease.leaseId, path: lease.path, prevStatus, gitWorktreeRemoveFailed });
406
+ }
407
+ else {
408
+ swept.push({ leaseId: lease.leaseId, path: lease.path, prevStatus, gitWorktreeRemoveFailed: false });
409
+ }
410
+ }
411
+ printResult(io, ok('worktree.gc', { sessionId, projectRoot, dryRun, candidates: candidates.length, swept, errors: result.errors }, result.errors.map((e) => `Malformed lease: ${e.file} (${e.error})`), [
412
+ dryRun
413
+ ? `[dry-run] Would gc ${swept.length} lease(s); no filesystem changes were made.`
414
+ : `Gc'd ${swept.length} lease(s).`,
415
+ 'For per-lease detail, run `peaks worktree status --lease-id <id>`.'
416
+ ]), options.json);
417
+ }
418
+ catch (err) {
419
+ printResult(io, fail('worktree.gc', 'GC_FAILED', getErrorMessage(err), { sessionId }, [
420
+ 'Re-run after fixing the failure (see cause in the error message).'
421
+ ]), options.json);
422
+ process.exitCode = 1;
423
+ }
424
+ });
425
+ addJsonOption(auth
426
+ .command('lease-status')
427
+ .description('Show one lease in detail: full lease record + computed `live` flag (active AND not past ' +
428
+ 'expiry) + path/branch/path-exists-on-disk diagnostics. Use this when triaging why a ' +
429
+ 'sub-agent cannot write to a worktree.')
430
+ .requiredOption('--lease-id <id>', 'lease id to inspect')
431
+ .option('--session <sid>', 'override session id')
432
+ .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
433
+ const projectRoot = resolveProjectRoot(options);
434
+ const sessionId = resolveSessionId(options, projectRoot);
435
+ try {
436
+ const file = leaseFilePath(joinPathSession(projectRoot, sessionId), options.leaseId);
437
+ if (!existsSync(file)) {
438
+ printResult(io, fail('worktree.lease-status', 'LEASE_NOT_FOUND', `no lease on disk at ${file}`, { leaseId: options.leaseId, file }, [
439
+ 'Run `peaks worktree list` to inspect available leases.'
440
+ ]), options.json);
441
+ process.exitCode = 1;
442
+ return;
443
+ }
444
+ let lease;
445
+ try {
446
+ lease = deserializeLease(readFileSyncNode(file, 'utf8'));
447
+ }
448
+ catch (err) {
449
+ printResult(io, fail('worktree.lease-status', 'LEASE_FILE_INVALID', getErrorMessage(err), { leaseId: options.leaseId, file }, [
450
+ 'Delete the malformed lease file manually and re-spawn.'
451
+ ]), options.json);
452
+ process.exitCode = 1;
453
+ return;
454
+ }
455
+ const now = Date.now();
456
+ const pathExists = existsSync(lease.path);
457
+ let pathIsDirectory = false;
458
+ if (pathExists) {
459
+ try {
460
+ pathIsDirectory = statSync(lease.path).isDirectory();
461
+ }
462
+ catch {
463
+ pathIsDirectory = false;
464
+ }
465
+ }
466
+ printResult(io, ok('worktree.lease-status', {
467
+ sessionId,
468
+ projectRoot,
469
+ file: '.peaks/_runtime/' + sessionId + '/worktree-leases/' + lease.leaseId + '.json',
470
+ lease,
471
+ live: isLeaseActive(lease, now),
472
+ diagnostics: {
473
+ now,
474
+ remainingMs: lease.expiresAt - now,
475
+ pathExists,
476
+ pathIsDirectory
477
+ }
478
+ }, [], [
479
+ `Lease ${lease.leaseId} is ${isLeaseActive(lease, now) ? 'LIVE' : 'NOT LIVE'} ` +
480
+ `(status=${lease.status}, remaining=${lease.expiresAt - now}ms).`,
481
+ `Worktree path ${lease.path} ${pathExists ? (pathIsDirectory ? 'exists (dir)' : 'exists (NOT a dir)') : 'MISSING'}.`
482
+ ]), options.json);
483
+ }
484
+ catch (err) {
485
+ printResult(io, fail('worktree.lease-status', 'STATUS_FAILED', getErrorMessage(err), { leaseId: options.leaseId, sessionId }, [
486
+ 'Re-run after fixing the failure (see cause in the error message).'
487
+ ]), options.json);
488
+ process.exitCode = 1;
489
+ }
490
+ });
491
+ }
492
+ /**
493
+ * Resolve the per-session runtime directory: `<projectRoot>/.peaks/_runtime/<sessionId>`.
494
+ * The runtime tree is gitignored; the spawn CLI writes the lease file
495
+ * under `<this>/worktree-leases/<leaseId>.json` and the worktree itself
496
+ * under `<this>/worktrees/<leaseId>/`.
497
+ */
498
+ function joinPathSession(projectRoot, sessionId) {
499
+ return `${projectRoot.replace(/[\\/]+$/, '')}/.peaks/_runtime/${sessionId}`;
500
+ }
@@ -9,7 +9,7 @@ import { ensureSession, getSessionIdCanonical } from '../session/session-manager
9
9
  // gone; request-artifact callers pass `options.sessionId` explicitly
10
10
  // or accept the requestId as the default. Path-safety helpers now
11
11
  // live at `shared/path-safety.ts` if this module ever needs them.
12
- import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
12
+ import { getNextNumber, buildNumberedFilename, slugifyDescription } from '../../shared/incrementing-number.js';
13
13
  import { lintRequestArtifact } from './artifact-lint-service.js';
14
14
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
15
15
  import { requireUserConfirmation } from '../mode/mode-enforcement.js';
@@ -94,10 +94,15 @@ export async function createRequestArtifact(options) {
94
94
  // Check if a file with this requestId already exists (regardless of number prefix)
95
95
  if (await isDirectory(requestsDir)) {
96
96
  const existingFiles = await listMarkdownFiles(requestsDir);
97
+ // The writer lowercases the request id into a kebab slug
98
+ // (`buildNumberedFilename`), so the duplicate-detection endsWith must
99
+ // compare against the SAME slug — a mixed-case id like
100
+ // `2026-09-06-split-batchA` is stored as `...-split-batcha.md`.
101
+ const slug = slugifyDescription(options.requestId);
97
102
  const alreadyExists = existingFiles.some((file) => {
98
103
  if (file === `${options.requestId}.md`)
99
104
  return true;
100
- if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`))
105
+ if (/^\d+-/.test(file) && file.endsWith(`-${slug}.md`))
101
106
  return true;
102
107
  return false;
103
108
  });
@@ -272,6 +277,11 @@ export async function showRequestArtifact(options) {
272
277
  throw new Error(`Invalid request id: ${options.requestId} (expected letters, digits, dots, underscores, or dashes)`);
273
278
  }
274
279
  // Search for files matching the requestId (supports both legacy and numbered formats)
280
+ // The numbered format lowercases the request id into a kebab slug
281
+ // (`buildNumberedFilename`), so the endsWith must compare against the SAME
282
+ // slug — a mixed-case id like `2026-09-06-split-batchA` is stored as
283
+ // `...-split-batcha.md` and must still resolve here.
284
+ const slug = slugifyDescription(options.requestId);
275
285
  const findFileInDir = async (dir) => {
276
286
  const files = await listMarkdownFiles(dir);
277
287
  for (const file of files) {
@@ -280,7 +290,7 @@ export async function showRequestArtifact(options) {
280
290
  return { fileName: file, path: join(dir, file) };
281
291
  }
282
292
  // Match numbered format: ${number}-${requestId}.md
283
- if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`)) {
293
+ if (/^\d+-/.test(file) && file.endsWith(`-${slug}.md`)) {
284
294
  return { fileName: file, path: join(dir, file) };
285
295
  }
286
296
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Auto-compact lifecycle telemetry (v2.13.0 AC-2 + AC-3 + AC-4,
3
+ * slice 2026-08-01-compact-lifecycle Task 5).
4
+ *
5
+ * Extracted from `auto-compact-orchestrator.ts` to keep that file under
6
+ * the 800 LOC cap (mechanical verbatim move). Publishes the stages a
7
+ * compact attempt actually PROVED in-process (`queued` / `preparing` /
8
+ * `compacting`) plus the terminal `failed` stage, and settles an open
9
+ * `compacting` run using a real post-compact measurement. Telemetry is
10
+ * strictly subordinate to the compact itself: every write is best-effort
11
+ * and never changes the threshold decision, the dispatch, or the
12
+ * returned envelope.
13
+ */
14
+ import { type CompactLifecycleRecord, type CompactLifecycleStage } from '../compact-statusline/compact-lifecycle-store.js';
15
+ /**
16
+ * Stages a compact *attempt* can prove from inside the dispatching
17
+ * process. `verifying` / `completed` are deliberately absent — see
18
+ * `CompactLifecyclePublisher` and `settleOpenLifecycleRun` for why.
19
+ */
20
+ type ObservableDispatchStage = Extract<CompactLifecycleStage, 'queued' | 'preparing' | 'compacting'>;
21
+ /**
22
+ * One id per compact attempt. Timestamp-prefixed so a human reading
23
+ * the raw record can order runs by eye; the random suffix keeps two
24
+ * attempts inside the same millisecond distinct.
25
+ */
26
+ export declare function newCompactRunId(now: Date): string;
27
+ /**
28
+ * Slice 2026-08-01-compact-lifecycle (Task 5): the local transition
29
+ * builder. Carries `runId`, `triggerRatio` and `redLine` forward from
30
+ * the run that opened, and remembers the prior stage so a failure can
31
+ * name the stage it died in.
32
+ *
33
+ * TRUTHFULNESS: this publisher only ever emits a stage the calling
34
+ * process has actually PROVED. It never emits `verifying` or
35
+ * `completed` off the back of a successful dispatch — see
36
+ * `runAutoCompact` and `settleOpenLifecycleRun` for the reason.
37
+ *
38
+ * Telemetry is strictly subordinate to the compact itself: every write
39
+ * is best-effort, and a store failure must not change the threshold
40
+ * decision, the dispatch, or the returned envelope.
41
+ */
42
+ export declare class CompactLifecyclePublisher {
43
+ private readonly ctx;
44
+ private lastStage;
45
+ constructor(ctx: {
46
+ readonly projectRoot: string;
47
+ readonly sessionId: string;
48
+ readonly runId: string;
49
+ readonly triggerRatio: number;
50
+ readonly redLine: boolean;
51
+ readonly onLifecycleStage?: ((stage: CompactLifecycleStage, record: CompactLifecycleRecord) => void) | undefined;
52
+ readonly failLifecycleWrite?: boolean | undefined;
53
+ });
54
+ /** Publish an active stage the process has proved. */
55
+ advance(stage: ObservableDispatchStage): void;
56
+ /** Publish the terminal failure, attributed to the last stage reached. */
57
+ fail(error: unknown): void;
58
+ private write;
59
+ }
60
+ /**
61
+ * Reduce an arbitrary thrown value to a single-line, bounded summary
62
+ * fit for a statusline. Stack frames are dropped (the record is a
63
+ * human-facing indicator, not a crash dump); the store clamps the
64
+ * result to its own 160-character cap as a second line of defence.
65
+ *
66
+ * `null` / `undefined` thrown values — a real possibility from
67
+ * `Promise.reject(null)` or a thrown `undefined` — must not collapse
68
+ * to the empty string, which the store would then reject as missing.
69
+ * They map to a fixed "unknown error" sentinel so the record always
70
+ * carries some diagnostic text.
71
+ */
72
+ export declare function summarizeLifecycleError(error: unknown): string;
73
+ /**
74
+ * Slice 2026-08-01-compact-lifecycle (Task 5, Step 4): close out an
75
+ * open compact run using a REAL measurement.
76
+ *
77
+ * This is the integration with the actual post-compact detection path.
78
+ * The claude-code adapter's `postCompactDetectCommand` is
79
+ * `peaks compact auto --json` — i.e. the next probe through this very
80
+ * function. So when a probe finds a run still sitting at `compacting`
81
+ * and MEASURES a ratio that has dropped below the auto-fire threshold,
82
+ * that measurement is the proof the compact landed. Only then do we
83
+ * emit `verifying` (we have a measurement in hand) followed by
84
+ * `completed` (it confirms the drop), carrying the measured
85
+ * `afterRatio`.
86
+ *
87
+ * We refuse to complete when:
88
+ * - the probe could not measure anything (`conservative-fallback`
89
+ * returns `ratio: 0`, which means "unknown", NOT "empty"). Writing
90
+ * `afterRatio: 0` there would publish a fabricated number;
91
+ * - the ratio is still at or above the auto-fire threshold — the
92
+ * compact has not landed, so the run stays open.
93
+ */
94
+ export declare function settleOpenLifecycleRun(input: {
95
+ readonly projectRoot: string;
96
+ readonly sessionId: string;
97
+ readonly measuredRatio: number;
98
+ readonly source: string;
99
+ readonly autoFireThreshold: number;
100
+ readonly onLifecycleStage?: ((stage: CompactLifecycleStage, record: CompactLifecycleRecord) => void) | undefined;
101
+ }): void;
102
+ export {};