peaks-loop 4.0.29 → 4.0.31

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 (75) hide show
  1. package/CHANGELOG.md +27 -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 +35 -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/request-commands.js +2 -96
  10. package/dist/cli/commands/request-format-helpers.d.ts +19 -0
  11. package/dist/cli/commands/request-format-helpers.js +102 -0
  12. package/dist/cli/commands/workspace/init-command.js +27 -14
  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.js +44 -6
  22. package/dist/services/codegraph/codegraph-preflight-service.d.ts +5 -2
  23. package/dist/services/codegraph/codegraph-preflight-service.js +13 -6
  24. package/dist/services/codegraph/codegraph-service.d.ts +17 -2
  25. package/dist/services/codegraph/codegraph-service.js +27 -2
  26. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  27. package/dist/services/context/build-dispatch-system-prompt.js +38 -3
  28. package/dist/services/dispatch/dispatch-record-types.d.ts +278 -0
  29. package/dist/services/dispatch/dispatch-record-types.js +15 -0
  30. package/dist/services/dispatch/dispatch-record-upgrade.d.ts +5 -0
  31. package/dist/services/dispatch/dispatch-record-upgrade.js +230 -0
  32. package/dist/services/dispatch/dispatch-record-writer.d.ts +3 -278
  33. package/dist/services/dispatch/dispatch-record-writer.js +3 -245
  34. package/dist/services/dispatch/dispatch-sub-agent.d.ts +43 -0
  35. package/dist/services/dispatch/dispatch-sub-agent.js +55 -0
  36. package/dist/services/dispatch/isolation-lease.d.ts +43 -0
  37. package/dist/services/dispatch/isolation-lease.js +129 -0
  38. package/dist/services/evidence/evidence-generator.d.ts +26 -0
  39. package/dist/services/evidence/evidence-generator.js +349 -0
  40. package/dist/services/fresh-context/config.d.ts +1 -0
  41. package/dist/services/fresh-context/config.js +13 -0
  42. package/dist/services/fresh-context/fresh-context-block.d.ts +12 -0
  43. package/dist/services/fresh-context/fresh-context-block.js +40 -0
  44. package/dist/services/fresh-context/trigger-scan.d.ts +30 -0
  45. package/dist/services/fresh-context/trigger-scan.js +29 -0
  46. package/dist/services/skills/hooks-codegate-superpowers.d.ts +65 -0
  47. package/dist/services/skills/hooks-codegate-superpowers.js +204 -0
  48. package/dist/services/skills/hooks-settings-service.d.ts +2 -64
  49. package/dist/services/skills/hooks-settings-service.js +2 -215
  50. package/dist/services/skills/skill-statusline-renderer.d.ts +2 -34
  51. package/dist/services/skills/skill-statusline-renderer.js +1 -184
  52. package/dist/services/skills/statusline-palette.d.ts +62 -0
  53. package/dist/services/skills/statusline-palette.js +190 -0
  54. package/dist/services/slice/slice-decompose-import-edges.d.ts +8 -0
  55. package/dist/services/slice/slice-decompose-import-edges.js +102 -0
  56. package/dist/services/slice/slice-decompose-service.js +3 -198
  57. package/dist/services/slice/slice-decompose-tarjan.d.ts +8 -0
  58. package/dist/services/slice/slice-decompose-tarjan.js +108 -0
  59. package/dist/services/standards/project-standards-service.d.ts +1 -9
  60. package/dist/services/standards/project-standards-service.js +3 -232
  61. package/dist/services/standards/standards-render.d.ts +23 -0
  62. package/dist/services/standards/standards-render.js +238 -0
  63. package/dist/services/standards/ui-library-dispatch-block.d.ts +27 -0
  64. package/dist/services/standards/ui-library-dispatch-block.js +48 -0
  65. package/dist/services/workspace/reconcile-migrate.d.ts +77 -0
  66. package/dist/services/workspace/reconcile-migrate.js +230 -0
  67. package/dist/services/workspace/reconcile-service.d.ts +0 -72
  68. package/dist/services/workspace/reconcile-service.js +3 -220
  69. package/dist/services/workspace/workspace-service.js +13 -2
  70. package/dist/shared/incrementing-number.d.ts +11 -0
  71. package/dist/shared/incrementing-number.js +16 -3
  72. package/package.json +5 -5
  73. package/skills/peaks-code/SKILL.md +6 -2
  74. package/skills/peaks-code/references/fresh-context-preflight.md +81 -0
  75. package/skills/peaks-code/references/sub-agent-dispatch.md +27 -8
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Format / parse helpers extracted from `request-commands.ts`
3
+ * (slice 2026-09-06-split-batch-b) so the command file stays under the
4
+ * 800-line cap. Behaviour-preserving verbatim move.
5
+ */
6
+ import { InvalidArgumentError } from 'commander';
7
+ import { allowedStatesForRole, isRequestType, VALID_REQUEST_TYPES, } from '../../services/artifacts/request-artifact-service.js';
8
+ import { formatMdCompact } from '../../shared/format-md-compact.js';
9
+ /**
10
+ * Per-artifact default format. Only PRD and tech-doc are user-review
11
+ * surfaces; all other RD/QA/TXT artifacts default to compact. The
12
+ * `--pretty` / `--compact` flags override uniformly. See tech-doc
13
+ * §1.3.
14
+ */
15
+ const DEFAULT_FORMAT_BY_ARTIFACT = {
16
+ prd: 'pretty',
17
+ 'tech-doc': 'pretty',
18
+ 'code-review': 'compact',
19
+ 'security-review': 'compact',
20
+ 'perf-baseline': 'compact',
21
+ 'bug-analysis': 'compact',
22
+ 'test-cases': 'compact',
23
+ 'test-reports': 'compact',
24
+ 'security-findings': 'compact',
25
+ 'performance-findings': 'compact',
26
+ handoff: 'compact'
27
+ };
28
+ export function resolveDefaultFormat(artifactName) {
29
+ return DEFAULT_FORMAT_BY_ARTIFACT[artifactName] ?? 'compact';
30
+ }
31
+ export function applyPerArtifactFormat(envelope, override) {
32
+ if (override === null || envelope === null || typeof envelope !== 'object')
33
+ return envelope;
34
+ // The service returns `{ id, sessionId, role, body, ... }` for a
35
+ // single-artifact show. The per-artifact `body` field is the only
36
+ // thing that changes; we attach a `format` field to surface the
37
+ // choice to the caller. Slice 023 (R3) AC6 / AC7.
38
+ const obj = envelope;
39
+ if (typeof obj.body === 'string') {
40
+ return {
41
+ ...obj,
42
+ body: override === 'compact' ? formatMdCompact(obj.body) : obj.body,
43
+ format: override
44
+ };
45
+ }
46
+ return envelope;
47
+ }
48
+ /**
49
+ * Map a request-artifact envelope to a `DEFAULT_FORMAT_BY_ARTIFACT` key.
50
+ * The service returns the path of the artifact in `path`; the role +
51
+ * filename stem gives us the artifact name. Falls back to the role
52
+ * itself when no `path` field is present.
53
+ */
54
+ export function inferArtifactName(envelope, role) {
55
+ if (envelope === null || typeof envelope !== 'object')
56
+ return role;
57
+ const obj = envelope;
58
+ const pathField = typeof obj.path === 'string' ? obj.path : '';
59
+ // Path looks like `.peaks/_runtime/<sid>/<role>/requests/<file>.md`.
60
+ // The role is the second-to-last directory; the file stem is the
61
+ // last segment. For RD role, the artifact is one of {code-review,
62
+ // security-review, perf-baseline, bug-analysis, tech-doc}; the file
63
+ // stem usually carries the artifact name (e.g. `tech-doc.md`,
64
+ // `code-review-002.md`). We strip the trailing `-<digits>` suffix
65
+ // when present.
66
+ const stem = pathField.split(/[\\/]/).pop()?.replace(/\.md$/, '') ?? '';
67
+ if (stem.length > 0) {
68
+ // Try the stem verbatim first.
69
+ if (DEFAULT_FORMAT_BY_ARTIFACT[stem] !== undefined)
70
+ return stem;
71
+ // Strip a trailing -<digits> (e.g. code-review-002 -> code-review).
72
+ const trimmed = stem.replace(/-\d+$/, '');
73
+ if (DEFAULT_FORMAT_BY_ARTIFACT[trimmed] !== undefined)
74
+ return trimmed;
75
+ // Last-ditch: match by prefix.
76
+ for (const key of Object.keys(DEFAULT_FORMAT_BY_ARTIFACT)) {
77
+ if (stem.startsWith(key))
78
+ return key;
79
+ }
80
+ }
81
+ return role;
82
+ }
83
+ export const VALID_ROLES = ['prd', 'ui', 'rd', 'qa', 'sc'];
84
+ export function parseRole(value) {
85
+ if (!VALID_ROLES.includes(value)) {
86
+ throw new InvalidArgumentError(`must be one of ${VALID_ROLES.join(', ')}`);
87
+ }
88
+ return value;
89
+ }
90
+ export function parseStateForRole(role, value) {
91
+ const allowed = allowedStatesForRole(role);
92
+ if (!allowed.includes(value)) {
93
+ throw new InvalidArgumentError(`must be one of ${allowed.join(', ')} for role ${role}`);
94
+ }
95
+ return value;
96
+ }
97
+ export function parseRequestType(value) {
98
+ if (!isRequestType(value)) {
99
+ throw new InvalidArgumentError(`must be one of ${VALID_REQUEST_TYPES.join(', ')}`);
100
+ }
101
+ return value;
102
+ }
@@ -26,7 +26,7 @@ import { resolveCanonicalProjectRoot } from '../../../services/config/config-ser
26
26
  import { applyHookInstall, readHookStatus } from '../../../services/skills/hooks-settings-service.js';
27
27
  import { clearStalePresenceOnRotation } from '../../../services/skills/skill-presence-service.js';
28
28
  import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
29
- import { defaultCodegraphInitGuard, writeCodegraphMarker } from '../../../services/codegraph/codegraph-service.js';
29
+ import { createCodegraphInvocation, defaultCodegraphInitGuard, executeCodegraphInvocation, writeCodegraphMarker } from '../../../services/codegraph/codegraph-service.js';
30
30
  import { mkdirSync } from 'node:fs';
31
31
  import { fail, ok } from 'peaks-loop-shared/result';
32
32
  import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.js';
@@ -339,23 +339,36 @@ export function registerWorkspaceInitCommand(workspace, io) {
339
339
  if (report.standardsMissing.missing) {
340
340
  markStandardsChecked(projectRoot, sessionId);
341
341
  }
342
- // rid-CG-001: auto-stake a peaks-loop-managed `.codegraph/`
343
- // directory after a successful workspace init. Calls the
344
- // conflict guard (rid-CG-006) BEFORE writing so a foreign
345
- // schema is never silently overwritten. We deliberately do
346
- // NOT spawn the upstream codegraph binary here — `peaks
347
- // workspace init` must stay fast and offline-safe; the user
348
- // runs `peaks codegraph index` next if they want the actual
349
- // SQLite-backed index.
342
+ // rid-CG-001 (revised): auto-stake a peaks-loop-managed `.codegraph/`
343
+ // directory after a successful workspace init. Calls the conflict
344
+ // guard (rid-CG-006) BEFORE writing so a foreign schema is never
345
+ // silently overwritten. We now run a REAL upstream `codegraph init`
346
+ // (not just mkdir + marker) so the schema actually has a
347
+ // `codegraph.db`, which is what post-slice auto-refresh and the
348
+ // pre-dispatch preflight gate on. `codegraph init` (WITHOUT
349
+ // `--index`) is fast (~1 s) and offline-safe — it only `Parser.init()`s
350
+ // WASM grammars that resolve from node_modules; the slow/offline
351
+ // concern applies to `index` (full build), not `init`.
350
352
  let codegraphAutoOutcome = null;
351
353
  try {
352
354
  const guard = defaultCodegraphInitGuard(projectRoot);
353
355
  if (guard.status === 'fresh') {
354
- mkdirSync(guard.codegraphDir, { recursive: true });
355
- writeCodegraphMarker(guard.codegraphDir);
356
- codegraphAutoOutcome = { status: 'fresh' };
357
- nextActions.push(`Auto-created empty ${guard.codegraphDir}/ and stamped the peaks-loop marker. ` +
358
- 'Run `peaks codegraph index --project <path>` next to build the SQLite-backed index.');
356
+ // 'fresh' here also covers the dangling state (marker present but
357
+ // no codegraph.db) — running init self-heals both.
358
+ const initResult = await executeCodegraphInvocation(createCodegraphInvocation({ subcommand: 'init', project: projectRoot }));
359
+ if (initResult.exitCode !== 0) {
360
+ warningsForEnvelope.push(`codegraph auto-stake init failed (exit ${String(initResult.exitCode)}). ` +
361
+ 'Run `peaks codegraph init --project <path>` manually to enable codegraph.');
362
+ }
363
+ else {
364
+ // Upstream init creates `.codegraph/`; mkdir is a no-op in the
365
+ // real path and lets the marker write succeed. Best-effort.
366
+ mkdirSync(guard.codegraphDir, { recursive: true });
367
+ writeCodegraphMarker(guard.codegraphDir);
368
+ codegraphAutoOutcome = { status: 'fresh' };
369
+ nextActions.push(`Auto-staked a peaks-loop-managed ${guard.codegraphDir}/ (upstream init + marker). ` +
370
+ 'Run `peaks codegraph index --project <path>` next to build the SQLite-backed index.');
371
+ }
359
372
  }
360
373
  else if (guard.status === 'noop-already-peaks-loop') {
361
374
  codegraphAutoOutcome = { status: 'noop' };
@@ -26,15 +26,9 @@
26
26
  */
27
27
  import { fail, ok } from 'peaks-loop-shared/result';
28
28
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
29
- import { findProjectRoot } from '../../services/config/config-safety.js';
30
- import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
31
29
  import { clearAllGrants, readAuthorization, writeAuthorization, } from '../../services/hooks/worktree-authorization-gate.js';
32
- import { atomicWriteJson } from '../../services/ide/shared/atomic-json.js';
33
- import { emitLeaseEvent } from '../../services/observability/observability-service.js';
34
- import { deserializeLease, finalizeLease, generateLeaseId, isLeaseActive, isLeaseGcEligible, leaseFilePath, leaseStoreDir, listLeasesSync, markExpired, markGc, markReleased, renewLease, ttlForRole, worktreePath, } from '../../services/worktree/worktree-lease.js';
35
- import { execSync } from 'node:child_process';
36
30
  import { reconcileHostWorktrees } from '../../services/worktree/host-worktree-reconciler.js';
37
- import { existsSync, readdirSync, readFileSync as readFileSyncNode, statSync } from 'node:fs';
31
+ import { registerWorktreeLeaseCommands, resolveProjectRoot, resolveSessionId } from './worktree-lease-commands.js';
38
32
  const DEFAULT_TTL_MS = 5 * 60 * 1_000;
39
33
  const ALLOWED_OPERATIONS = [
40
34
  'git-worktree',
@@ -45,15 +39,6 @@ const ALLOWED_OPERATIONS = [
45
39
  function parseOperation(raw) {
46
40
  return ALLOWED_OPERATIONS.includes(raw) ? raw : null;
47
41
  }
48
- function resolveSessionId(options, projectRoot) {
49
- if (typeof options.session === 'string' && options.session.length > 0)
50
- return options.session;
51
- // Reuse the same precedence as the rest of peaks: explicit --session > PEAKS_SESSION_ID > active session.json
52
- return process.env.PEAKS_SESSION_ID ?? getCurrentSessionId(projectRoot) ?? 'unknown-sid';
53
- }
54
- function resolveProjectRoot(options) {
55
- return options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
56
- }
57
42
  export function registerWorktreeAuthCommand(program, io) {
58
43
  const auth = program
59
44
  .command('worktree')
@@ -220,460 +205,5 @@ export function registerWorktreeAuthCommand(program, io) {
220
205
  process.exitCode = 1;
221
206
  }
222
207
  });
223
- function resolveTtlMs(raw, role) {
224
- if (typeof raw !== 'string' || raw.length === 0)
225
- return ttlForRole(role);
226
- const parsed = Number(raw);
227
- if (!Number.isFinite(parsed) || parsed <= 0)
228
- return ttlForRole(role);
229
- return parsed;
230
- }
231
- function deriveBranch(rid) {
232
- // Branch names must be safe for git ref-format. Strip leading
233
- // `rid-` if present and replace any non-safe chars with `-`.
234
- return rid.replace(/[^A-Za-z0-9._/-]/g, '-').slice(0, 80);
235
- }
236
- addJsonOption(auth
237
- .command('spawn')
238
- .description(`Spawn a worktree under .peaks/_runtime/<sid>/worktrees/<leaseId>/ with a managed lease. ` +
239
- `The lease is the source of truth for the L2 hook gate (Part 2 of this slice); ` +
240
- `until the hook integration ships, sub-agents may still invoke raw \`git worktree add\` ` +
241
- `with a current \`peaks worktree auth grant\` token. Default TTL is role-aware ` +
242
- `(rd=30m / qa=15m / ui=1h); pass --ttl <ms> to override.`)
243
- .requiredOption('--rid <rid>', 'peaks request id the lease is associated with')
244
- .requiredOption('--role <role>', 'sub-agent role (rd | qa | ui | sc | prd | general-purpose)')
245
- .requiredOption('--purpose <text>', 'why this worktree was spawned (audit log)')
246
- .option('--ttl <ms>', `time-to-live in ms (default role-aware; override with positive number)`)
247
- .option('--branch <name>', 'git branch name (default: derived from rid)')
248
- .option('--session <sid>', 'override session id')
249
- .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
250
- const projectRoot = resolveProjectRoot(options);
251
- const sessionId = resolveSessionId(options, projectRoot);
252
- try {
253
- const leaseId = generateLeaseId();
254
- const now = Date.now();
255
- const ttlMs = resolveTtlMs(options.ttl, options.role);
256
- const branch = options.branch ?? deriveBranch(options.rid);
257
- const wtPath = worktreePath(joinPathSession(projectRoot, sessionId), leaseId);
258
- const lease = finalizeLease({
259
- leaseId,
260
- rid: options.rid,
261
- role: options.role,
262
- path: wtPath,
263
- branch,
264
- createdAt: now,
265
- expiresAt: now + ttlMs,
266
- purpose: options.purpose
267
- });
268
- // Run `git worktree add` from the project root. The PreToolUse hook
269
- // (Part 2 of this slice) will consult the lease file we just wrote
270
- // and authorize this very `git worktree add`; for Part 1 we still
271
- // require a current `peaks worktree auth grant` token to remain
272
- // consistent with the slice-027 hard gate contract.
273
- execSync(`git worktree add "${wtPath}" -b "${branch}"`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
274
- atomicWriteJson(leaseFilePath(joinPathSession(projectRoot, sessionId), leaseId), lease);
275
- // Part 4.A: emit spawn metric (fire-and-forget; never blocks).
276
- emitLeaseEvent({
277
- sessionId,
278
- projectRoot,
279
- kind: 'spawn',
280
- leaseId,
281
- rid: options.rid,
282
- role: options.role
283
- });
284
- printResult(io, ok('worktree.spawn', {
285
- lease,
286
- sessionId,
287
- projectRoot,
288
- ttlMs,
289
- nextActions: [
290
- `Worktree path: ${wtPath}`,
291
- `Branch: ${branch}`,
292
- `Lease expires at: ${new Date(lease.expiresAt).toISOString()}`,
293
- 'Run `peaks worktree release --lease-id <leaseId>` when done',
294
- 'Hook integration (lease-aware gate) ships in Part 2'
295
- ]
296
- }, [], []), options.json);
297
- }
298
- catch (error) {
299
- printResult(io, fail('worktree.spawn', 'SPAWN_FAILED', getErrorMessage(error), { rid: options.rid, role: options.role, sessionId }, [
300
- 'Verify `git worktree add` succeeded (output above).',
301
- 'If the lease file was NOT written, retry; the lease directory is .peaks/_runtime/<sid>/worktree-leases/.',
302
- 'For an existing branch, pass --branch <name> explicitly (the spawn refuses to overwrite an active branch).'
303
- ]), options.json);
304
- process.exitCode = 1;
305
- }
306
- });
307
- addJsonOption(auth
308
- .command('release')
309
- .description('Transition a lease to released and run `git worktree remove`. Idempotent on already-released leases.')
310
- .requiredOption('--lease-id <id>', 'lease id returned by `peaks worktree spawn`')
311
- .option('--session <sid>', 'override session id')
312
- .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
313
- const projectRoot = resolveProjectRoot(options);
314
- const sessionId = resolveSessionId(options, projectRoot);
315
- try {
316
- const file = leaseFilePath(joinPathSession(projectRoot, sessionId), options.leaseId);
317
- let lease;
318
- try {
319
- if (!existsSync(file)) {
320
- printResult(io, fail('worktree.release', 'LEASE_NOT_FOUND', `no lease on disk at ${file}`, { leaseId: options.leaseId, file }, [
321
- 'Run `peaks worktree list` to inspect active leases.',
322
- 'For a never-spawned lease, this is a no-op — no further action needed.'
323
- ]), options.json);
324
- process.exitCode = 1;
325
- return;
326
- }
327
- const raw = readFileSyncNode(file, 'utf8');
328
- lease = deserializeLease(raw);
329
- }
330
- catch (error) {
331
- printResult(io, fail('worktree.release', 'LEASE_FILE_INVALID', getErrorMessage(error), { leaseId: options.leaseId, file }, [
332
- 'Delete the malformed lease file manually and re-issue spawn.',
333
- 'For security, release never fails open on a malformed lease.'
334
- ]), options.json);
335
- process.exitCode = 1;
336
- return;
337
- }
338
- if (lease.status === 'released') {
339
- // Idempotent: already released, nothing to do.
340
- printResult(io, ok('worktree.release', { lease, sessionId, projectRoot, alreadyReleased: true }, [], [`Lease ${lease.leaseId} already released; nothing to do.`]), options.json);
341
- return;
342
- }
343
- // Run `git worktree remove` from the project root. If the path is
344
- // missing on disk (e.g. manually pruned), this fails; we still
345
- // update the lease state so the L2 hook (Part 2) treats it as
346
- // released.
347
- let gitWorktreeRemoveFailed = false;
348
- try {
349
- execSync(`git worktree remove --force "${lease.path}"`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
350
- }
351
- catch {
352
- gitWorktreeRemoveFailed = true;
353
- }
354
- const released = markReleased(lease);
355
- atomicWriteJson(file, released);
356
- // Part 4.A: emit release metric (manual path; auto-release is
357
- // recorded in dispatch-record-writer's tryAutoReleaseLease).
358
- emitLeaseEvent({
359
- sessionId,
360
- projectRoot,
361
- kind: 'release',
362
- leaseId: released.leaseId,
363
- rid: released.rid,
364
- role: released.role
365
- });
366
- printResult(io, ok('worktree.release', { lease: released, sessionId, projectRoot, gitWorktreeRemoveFailed }, gitWorktreeRemoveFailed ? ['git worktree remove failed (likely the path was already pruned); lease marked released.'] : [], [
367
- `Lease ${lease.leaseId} marked released.`,
368
- gitWorktreeRemoveFailed
369
- ? 'Manual `git worktree prune` may be needed.'
370
- : `Worktree ${lease.path} removed.`
371
- ]), options.json);
372
- }
373
- catch (error) {
374
- printResult(io, fail('worktree.release', 'RELEASE_FAILED', getErrorMessage(error), { leaseId: options.leaseId, sessionId }, [
375
- 'Verify the lease id and re-run.',
376
- 'If the lease was never spawned, no-op.'
377
- ]), options.json);
378
- process.exitCode = 1;
379
- }
380
- });
381
- addJsonOption(auth
382
- .command('renew')
383
- .description('Extend an active lease\'s `expiresAt` and persist it. Idempotent for already-active leases. ' +
384
- 'Default TTL uses `DEFAULT_TTL_BY_ROLE[<lease.role>]`; pass --ttl <ms> to override.')
385
- .requiredOption('--lease-id <id>', 'lease id returned by `peaks worktree spawn`')
386
- .option('--ttl <ms>', 'time-to-live in ms from now (default: role-default)')
387
- .option('--session <sid>', 'override session id')
388
- .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
389
- const projectRoot = resolveProjectRoot(options);
390
- const sessionId = resolveSessionId(options, projectRoot);
391
- try {
392
- const file = leaseFilePath(joinPathSession(projectRoot, sessionId), options.leaseId);
393
- if (!existsSync(file)) {
394
- printResult(io, fail('worktree.renew', 'LEASE_NOT_FOUND', `no lease on disk at ${file}`, { leaseId: options.leaseId, file }, [
395
- 'Run `peaks worktree list` to inspect active leases.',
396
- 'For a never-spawned lease, this is a no-op.'
397
- ]), options.json);
398
- process.exitCode = 1;
399
- return;
400
- }
401
- let lease;
402
- try {
403
- lease = deserializeLease(readFileSyncNode(file, 'utf8'));
404
- }
405
- catch (err) {
406
- printResult(io, fail('worktree.renew', 'LEASE_FILE_INVALID', getErrorMessage(err), { leaseId: options.leaseId, file }, [
407
- 'Delete the malformed lease file manually and re-spawn.',
408
- 'For security, renew never fails open on a malformed lease.'
409
- ]), options.json);
410
- process.exitCode = 1;
411
- return;
412
- }
413
- if (lease.status === 'released' || lease.status === 'gc') {
414
- 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 }, [
415
- 'Released leases cannot be renewed. Run `peaks worktree spawn` to start a new lease.',
416
- 'Gc-marked leases are terminal — re-spawn.'
417
- ]), options.json);
418
- process.exitCode = 1;
419
- return;
420
- }
421
- const now = Date.now();
422
- const ttlMs = options.ttl === undefined ? ttlForRole(lease.role) : Number.parseInt(options.ttl, 10);
423
- if (!Number.isInteger(ttlMs) || ttlMs <= 0) {
424
- printResult(io, fail('worktree.renew', 'INVALID_TTL', '--ttl must be a positive integer (ms)', { ttl: options.ttl }, [
425
- 'Re-run with --ttl 1800000 (30 min) or omit to use role default.'
426
- ]), options.json);
427
- process.exitCode = 1;
428
- return;
429
- }
430
- const renewed = renewLease(lease, now + ttlMs);
431
- atomicWriteJson(file, renewed);
432
- // Part 4.A: emit renew metric.
433
- emitLeaseEvent({
434
- sessionId,
435
- projectRoot,
436
- kind: 'renew',
437
- leaseId: renewed.leaseId,
438
- rid: renewed.rid,
439
- role: renewed.role
440
- });
441
- printResult(io, ok('worktree.renew', { lease: renewed, sessionId, projectRoot, ttlMs, previousExpiresAt: lease.expiresAt }, [], [
442
- `Lease ${renewed.leaseId} renewed; new expiresAt: ${new Date(renewed.expiresAt).toISOString()}.`,
443
- `Branch ${renewed.branch} at ${renewed.path} remains intact (no ` +
444
- '`git worktree` operation was performed — the worktree was always there).'
445
- ]), options.json);
446
- }
447
- catch (err) {
448
- printResult(io, fail('worktree.renew', 'RENEW_FAILED', getErrorMessage(err), { leaseId: options.leaseId, sessionId }, [
449
- 'Re-run after fixing the failure (see cause in the error message).'
450
- ]), options.json);
451
- process.exitCode = 1;
452
- }
453
- });
454
- addJsonOption(auth
455
- .command('list')
456
- .description('List every lease under the current session\'s lease store. ' +
457
- 'Optionally filter by --status (active|released|expired|gc) and/or --expired-only. ' +
458
- 'Leases past their expiresAt with status=active are still listed under "active" by default ' +
459
- '(`isLeaseActive` returns false for them, but the on-disk status only flips to "expired" ' +
460
- 'after `peaks worktree gc` runs).')
461
- .option('--status <state>', 'filter by lease status: active | released | expired | gc')
462
- .option('--expired-only', 'only show leases whose expiresAt is in the past')
463
- .option('--session <sid>', 'override session id')
464
- .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
465
- const projectRoot = resolveProjectRoot(options);
466
- const sessionId = resolveSessionId(options, projectRoot);
467
- try {
468
- const storeDir = leaseStoreDir(joinPathSession(projectRoot, sessionId));
469
- const result = listLeasesSync(storeDir, {
470
- readdir: (p) => readdirSync(p),
471
- readFile: (p) => readFileSyncNode(p, 'utf8'),
472
- existsSync: (p) => existsSync(p)
473
- });
474
- if (result.kind === 'store-missing') {
475
- printResult(io, ok('worktree.list', { sessionId, projectRoot, leases: [], errors: [], storeMissing: true }, [], [
476
- `No lease store at ${storeDir}. Spawn a worktree first (\`peaks worktree spawn ...\`).`
477
- ]), options.json);
478
- return;
479
- }
480
- const now = Date.now();
481
- const annotated = result.leases.map((l) => ({
482
- ...l,
483
- live: isLeaseActive(l, now),
484
- elapsedMs: now - l.createdAt,
485
- remainingMs: l.expiresAt - now
486
- }));
487
- let filtered = annotated;
488
- if (options.status) {
489
- filtered = filtered.filter((l) => l.status === options.status);
490
- }
491
- if (options.expiredOnly === true) {
492
- filtered = filtered.filter((l) => !l.live);
493
- }
494
- // Sort by createdAt desc — most-recent first. Stable for diffs.
495
- filtered = [...filtered].sort((a, b) => b.createdAt - a.createdAt);
496
- printResult(io, ok('worktree.list', {
497
- sessionId,
498
- projectRoot,
499
- storeDir,
500
- totalOnDisk: result.leases.length,
501
- returned: filtered.length,
502
- errors: result.errors,
503
- leases: filtered
504
- }, result.errors.map((e) => `Malformed lease: ${e.file} (${e.error})`), [
505
- `${filtered.length} lease(s) matched (${result.leases.length} on disk).`,
506
- result.errors.length > 0 ? 'Some lease files were malformed — see errors[]; they were skipped.' : ''
507
- ].filter(Boolean)), options.json);
508
- }
509
- catch (err) {
510
- printResult(io, fail('worktree.list', 'LIST_FAILED', getErrorMessage(err), { sessionId }, [
511
- 'Re-run after fixing the failure (see cause in the error message).'
512
- ]), options.json);
513
- process.exitCode = 1;
514
- }
515
- });
516
- addJsonOption(auth
517
- .command('gc')
518
- .description('Sweep released/expired leases: remove their git worktree (if still attached), prune git\'s ' +
519
- 'worktree references, and mark the lease as "gc". With --lease-id <id>, only that lease is ' +
520
- 'considered. With --dry-run, report what would be gc\'d without mutating. Expired-active ' +
521
- 'leases (status=active but past expiresAt) are eligible — they are first marked "expired" ' +
522
- 'then their worktree is removed.')
523
- .option('--lease-id <id>', 'only consider this specific lease')
524
- .option('--dry-run', 'report what would be gc\'d without mutating')
525
- .option('--session <sid>', 'override session id')
526
- .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
527
- const projectRoot = resolveProjectRoot(options);
528
- const sessionId = resolveSessionId(options, projectRoot);
529
- try {
530
- const storeDir = leaseStoreDir(joinPathSession(projectRoot, sessionId));
531
- const result = listLeasesSync(storeDir, {
532
- readdir: (p) => readdirSync(p),
533
- readFile: (p) => readFileSyncNode(p, 'utf8'),
534
- existsSync: (p) => existsSync(p)
535
- });
536
- if (result.kind === 'store-missing') {
537
- printResult(io, ok('worktree.gc', { sessionId, projectRoot, swept: 0, storeMissing: true }, [], [
538
- `No lease store at ${storeDir}; nothing to gc.`
539
- ]), options.json);
540
- return;
541
- }
542
- const now = Date.now();
543
- const candidates = result.leases
544
- .filter((l) => (options.leaseId ? l.leaseId === options.leaseId : true))
545
- .filter((l) => isLeaseGcEligible(l, now));
546
- const dryRun = options.dryRun === true;
547
- const swept = [];
548
- for (const lease of candidates) {
549
- let prevStatus = lease.status;
550
- let updated = lease;
551
- // If the lease is still marked active but past expiresAt, transition to expired first.
552
- if (lease.status === 'active' && lease.expiresAt <= now) {
553
- updated = markExpired(lease);
554
- prevStatus = 'active';
555
- }
556
- if (!dryRun) {
557
- // `git worktree remove --force` is best-effort — if the path is
558
- // already gone we still mark the lease gc.
559
- let gitWorktreeRemoveFailed = false;
560
- try {
561
- execSync(`git worktree remove --force "${updated.path}"`, { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
562
- }
563
- catch {
564
- gitWorktreeRemoveFailed = true;
565
- }
566
- // `git worktree prune` clears any stale admin entries. Best-effort.
567
- try {
568
- execSync('git worktree prune', { cwd: projectRoot, stdio: 'pipe', encoding: 'utf8' });
569
- }
570
- catch {
571
- // ignore — prune is idempotent
572
- }
573
- const finalLease = markGc(updated);
574
- atomicWriteJson(leaseFilePath(joinPathSession(projectRoot, sessionId), lease.leaseId), finalLease);
575
- // Part 4.A: emit gc metric (per swept lease).
576
- emitLeaseEvent({
577
- sessionId,
578
- projectRoot,
579
- kind: 'gc',
580
- leaseId: lease.leaseId,
581
- rid: lease.rid,
582
- role: lease.role
583
- });
584
- swept.push({ leaseId: lease.leaseId, path: lease.path, prevStatus, gitWorktreeRemoveFailed });
585
- }
586
- else {
587
- swept.push({ leaseId: lease.leaseId, path: lease.path, prevStatus, gitWorktreeRemoveFailed: false });
588
- }
589
- }
590
- 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})`), [
591
- dryRun
592
- ? `[dry-run] Would gc ${swept.length} lease(s); no filesystem changes were made.`
593
- : `Gc'd ${swept.length} lease(s).`,
594
- 'For per-lease detail, run `peaks worktree status --lease-id <id>`.'
595
- ]), options.json);
596
- }
597
- catch (err) {
598
- printResult(io, fail('worktree.gc', 'GC_FAILED', getErrorMessage(err), { sessionId }, [
599
- 'Re-run after fixing the failure (see cause in the error message).'
600
- ]), options.json);
601
- process.exitCode = 1;
602
- }
603
- });
604
- addJsonOption(auth
605
- .command('lease-status')
606
- .description('Show one lease in detail: full lease record + computed `live` flag (active AND not past ' +
607
- 'expiry) + path/branch/path-exists-on-disk diagnostics. Use this when triaging why a ' +
608
- 'sub-agent cannot write to a worktree.')
609
- .requiredOption('--lease-id <id>', 'lease id to inspect')
610
- .option('--session <sid>', 'override session id')
611
- .option('--project <path>', 'project root (default: findProjectRoot(cwd))')).action((options) => {
612
- const projectRoot = resolveProjectRoot(options);
613
- const sessionId = resolveSessionId(options, projectRoot);
614
- try {
615
- const file = leaseFilePath(joinPathSession(projectRoot, sessionId), options.leaseId);
616
- if (!existsSync(file)) {
617
- printResult(io, fail('worktree.lease-status', 'LEASE_NOT_FOUND', `no lease on disk at ${file}`, { leaseId: options.leaseId, file }, [
618
- 'Run `peaks worktree list` to inspect available leases.'
619
- ]), options.json);
620
- process.exitCode = 1;
621
- return;
622
- }
623
- let lease;
624
- try {
625
- lease = deserializeLease(readFileSyncNode(file, 'utf8'));
626
- }
627
- catch (err) {
628
- printResult(io, fail('worktree.lease-status', 'LEASE_FILE_INVALID', getErrorMessage(err), { leaseId: options.leaseId, file }, [
629
- 'Delete the malformed lease file manually and re-spawn.'
630
- ]), options.json);
631
- process.exitCode = 1;
632
- return;
633
- }
634
- const now = Date.now();
635
- const pathExists = existsSync(lease.path);
636
- let pathIsDirectory = false;
637
- if (pathExists) {
638
- try {
639
- pathIsDirectory = statSync(lease.path).isDirectory();
640
- }
641
- catch {
642
- pathIsDirectory = false;
643
- }
644
- }
645
- printResult(io, ok('worktree.lease-status', {
646
- sessionId,
647
- projectRoot,
648
- file: '.peaks/_runtime/' + sessionId + '/worktree-leases/' + lease.leaseId + '.json',
649
- lease,
650
- live: isLeaseActive(lease, now),
651
- diagnostics: {
652
- now,
653
- remainingMs: lease.expiresAt - now,
654
- pathExists,
655
- pathIsDirectory
656
- }
657
- }, [], [
658
- `Lease ${lease.leaseId} is ${isLeaseActive(lease, now) ? 'LIVE' : 'NOT LIVE'} ` +
659
- `(status=${lease.status}, remaining=${lease.expiresAt - now}ms).`,
660
- `Worktree path ${lease.path} ${pathExists ? (pathIsDirectory ? 'exists (dir)' : 'exists (NOT a dir)') : 'MISSING'}.`
661
- ]), options.json);
662
- }
663
- catch (err) {
664
- printResult(io, fail('worktree.lease-status', 'STATUS_FAILED', getErrorMessage(err), { leaseId: options.leaseId, sessionId }, [
665
- 'Re-run after fixing the failure (see cause in the error message).'
666
- ]), options.json);
667
- process.exitCode = 1;
668
- }
669
- });
670
- }
671
- /**
672
- * Resolve the per-session runtime directory: `<projectRoot>/.peaks/_runtime/<sessionId>`.
673
- * The runtime tree is gitignored; the spawn CLI writes the lease file
674
- * under `<this>/worktree-leases/<leaseId>.json` and the worktree itself
675
- * under `<this>/worktrees/<leaseId>/`.
676
- */
677
- function joinPathSession(projectRoot, sessionId) {
678
- return `${projectRoot.replace(/[\\/]+$/, '')}/.peaks/_runtime/${sessionId}`;
208
+ registerWorktreeLeaseCommands(auth, io);
679
209
  }