peaks-cli 1.3.0 → 1.3.1

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 (42) hide show
  1. package/README.md +62 -46
  2. package/dist/src/cli/commands/hooks-commands.js +24 -9
  3. package/dist/src/cli/commands/progress-commands.js +26 -2
  4. package/dist/src/cli/commands/request-commands.js +5 -0
  5. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  6. package/dist/src/cli/commands/slice-commands.js +42 -0
  7. package/dist/src/cli/commands/workflow-commands.js +3 -3
  8. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  9. package/dist/src/cli/commands/workspace-commands.js +288 -4
  10. package/dist/src/cli/program.js +4 -0
  11. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  12. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  13. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  14. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  15. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  16. package/dist/src/services/doctor/doctor-service.js +20 -2
  17. package/dist/src/services/progress/progress-service.d.ts +26 -0
  18. package/dist/src/services/progress/progress-service.js +25 -0
  19. package/dist/src/services/sc/sc-service.js +71 -13
  20. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  21. package/dist/src/services/session/session-manager.js +12 -2
  22. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  23. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  24. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  25. package/dist/src/services/slice/slice-check-service.js +248 -0
  26. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  27. package/dist/src/services/slice/slice-check-types.js +18 -0
  28. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  29. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  30. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  31. package/dist/src/services/workspace/migrate-service.js +484 -0
  32. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  33. package/dist/src/services/workspace/migrate-types.js +21 -0
  34. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  35. package/dist/src/services/workspace/workspace-service.js +87 -7
  36. package/dist/src/shared/change-id.d.ts +59 -0
  37. package/dist/src/shared/change-id.js +194 -16
  38. package/dist/src/shared/version.d.ts +1 -1
  39. package/dist/src/shared/version.js +1 -1
  40. package/package.json +10 -2
  41. package/skills/peaks-solo/SKILL.md +11 -1
  42. package/skills/peaks-solo/references/micro-cycle.md +155 -0
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
4
4
  import { isDirectory, listDirectories } from '../../shared/fs.js';
5
5
  import { checkPrerequisites, DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES } from './artifact-prerequisites.js';
6
6
  import { ensureSession } from '../session/session-manager.js';
7
+ import { getCurrentChangeId, getChangeArtifactRoot } from '../../shared/change-id.js';
7
8
  import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
8
9
  import { lintRequestArtifact } from './artifact-lint-service.js';
9
10
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
@@ -21,10 +22,11 @@ function dateSlugFromIso(iso) {
21
22
  function defaultSessionId(iso) {
22
23
  return `${dateSlugFromIso(iso)}-session`;
23
24
  }
24
- function renderPrdTemplate(requestId, sessionId, timestamp, requestType) {
25
+ function renderPrdTemplate(requestId, changeId, sessionId, timestamp, requestType) {
25
26
  return `# PRD Request ${requestId}
26
27
 
27
28
  - session: ${sessionId}
29
+ - change-id: ${changeId}
28
30
  - type: ${requestType}
29
31
  - source: <ticket, message URL, or "verbal" with a short sanitized quote>
30
32
  - raw input (sanitized): <one-paragraph restatement of what the user actually asked for>
@@ -57,9 +59,9 @@ function renderPrdTemplate(requestId, sessionId, timestamp, requestType) {
57
59
 
58
60
  ## Handoff
59
61
 
60
- - to peaks-rd: .peaks/${sessionId}/rd/requests/${requestId}.md
61
- - to peaks-qa: .peaks/${sessionId}/qa/requests/${requestId}.md
62
- - to peaks-ui: .peaks/${sessionId}/ui/requests/${requestId}.md (when UI involved)
62
+ - to peaks-rd: .peaks/${changeId}/rd/requests/${requestId}.md
63
+ - to peaks-qa: .peaks/${changeId}/qa/requests/${requestId}.md
64
+ - to peaks-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
63
65
 
64
66
  ## Status
65
67
 
@@ -68,11 +70,12 @@ function renderPrdTemplate(requestId, sessionId, timestamp, requestType) {
68
70
  - state: draft
69
71
  `;
70
72
  }
71
- function renderUiTemplate(requestId, sessionId, timestamp, requestType) {
73
+ function renderUiTemplate(requestId, changeId, sessionId, timestamp, requestType) {
72
74
  return `# UI Request ${requestId}
73
75
 
74
76
  - session: ${sessionId}
75
- - linked-prd: .peaks/${sessionId}/prd/requests/${requestId}.md
77
+ - change-id: ${changeId}
78
+ - linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
76
79
  - type: ${requestType}
77
80
  - scope: full new surface | iteration on existing surface | regression fix | visual refresh
78
81
  - design direction: editorial | bento | Swiss | luxury | retro-futurist | glass | product-system | other-explicit-name
@@ -108,8 +111,8 @@ function renderUiTemplate(requestId, sessionId, timestamp, requestType) {
108
111
 
109
112
  ## Handoff
110
113
 
111
- - to peaks-rd: .peaks/${sessionId}/rd/requests/${requestId}.md
112
- - to peaks-qa: .peaks/${sessionId}/qa/requests/${requestId}.md
114
+ - to peaks-rd: .peaks/${changeId}/rd/requests/${requestId}.md
115
+ - to peaks-qa: .peaks/${changeId}/qa/requests/${requestId}.md
113
116
 
114
117
  ## Status
115
118
 
@@ -118,12 +121,13 @@ function renderUiTemplate(requestId, sessionId, timestamp, requestType) {
118
121
  - state: draft
119
122
  `;
120
123
  }
121
- function renderRdTemplate(requestId, sessionId, timestamp, requestType) {
124
+ function renderRdTemplate(requestId, changeId, sessionId, timestamp, requestType) {
122
125
  return `# RD Request ${requestId}
123
126
 
124
127
  - session: ${sessionId}
125
- - linked-prd: .peaks/${sessionId}/prd/requests/${requestId}.md
126
- - linked-ui: .peaks/${sessionId}/ui/requests/${requestId}.md (when UI involved)
128
+ - change-id: ${changeId}
129
+ - linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
130
+ - linked-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
127
131
  - type: ${requestType}
128
132
 
129
133
  ## Red-line scope
@@ -165,8 +169,8 @@ function renderRdTemplate(requestId, sessionId, timestamp, requestType) {
165
169
 
166
170
  ## Handoff
167
171
 
168
- - to peaks-qa: .peaks/${sessionId}/qa/requests/${requestId}.md
169
- - to peaks-sc: .peaks/${sessionId}/sc/commit-boundaries/${requestId}.md
172
+ - to peaks-qa: .peaks/${changeId}/qa/requests/${requestId}.md
173
+ - to peaks-sc: .peaks/${changeId}/sc/commit-boundaries/${requestId}.md
170
174
 
171
175
  ## Status
172
176
 
@@ -175,13 +179,14 @@ function renderRdTemplate(requestId, sessionId, timestamp, requestType) {
175
179
  - state: draft
176
180
  `;
177
181
  }
178
- function renderQaTemplate(requestId, sessionId, timestamp, requestType) {
182
+ function renderQaTemplate(requestId, changeId, sessionId, timestamp, requestType) {
179
183
  return `# QA Request ${requestId}
180
184
 
181
185
  - session: ${sessionId}
182
- - linked-prd: .peaks/${sessionId}/prd/requests/${requestId}.md
183
- - linked-rd: .peaks/${sessionId}/rd/requests/${requestId}.md
184
- - linked-ui: .peaks/${sessionId}/ui/requests/${requestId}.md (when UI involved)
186
+ - change-id: ${changeId}
187
+ - linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
188
+ - linked-rd: .peaks/${changeId}/rd/requests/${requestId}.md
189
+ - linked-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
185
190
  - type: ${requestType}
186
191
 
187
192
  ## Red-line boundary check
@@ -230,14 +235,15 @@ function renderQaTemplate(requestId, sessionId, timestamp, requestType) {
230
235
  - state: draft
231
236
  `;
232
237
  }
233
- function renderScTemplate(requestId, sessionId, timestamp, requestType) {
238
+ function renderScTemplate(requestId, changeId, sessionId, timestamp, requestType) {
234
239
  return `# SC Request ${requestId}
235
240
 
236
241
  - session: ${sessionId}
237
- - linked-prd: .peaks/${sessionId}/prd/requests/${requestId}.md
238
- - linked-rd: .peaks/${sessionId}/rd/requests/${requestId}.md
239
- - linked-qa: .peaks/${sessionId}/qa/requests/${requestId}.md
240
- - linked-ui: .peaks/${sessionId}/ui/requests/${requestId}.md (when UI involved)
242
+ - change-id: ${changeId}
243
+ - linked-prd: .peaks/${changeId}/prd/requests/${requestId}.md
244
+ - linked-rd: .peaks/${changeId}/rd/requests/${requestId}.md
245
+ - linked-qa: .peaks/${changeId}/qa/requests/${requestId}.md
246
+ - linked-ui: .peaks/${changeId}/ui/requests/${requestId}.md (when UI involved)
241
247
  - type: ${requestType}
242
248
 
243
249
  ## Change impact
@@ -262,7 +268,7 @@ function renderScTemplate(requestId, sessionId, timestamp, requestType) {
262
268
 
263
269
  ## Sync / authorization
264
270
 
265
- - artifact workspace path: .peaks/${sessionId}/
271
+ - artifact workspace path: .peaks/${changeId}/
266
272
  - memory sync authorized: yes | no
267
273
  - artifact sync authorized: yes | no
268
274
  - rationale if not authorized: keep local
@@ -273,7 +279,7 @@ function renderScTemplate(requestId, sessionId, timestamp, requestType) {
273
279
 
274
280
  ## Handoff
275
281
 
276
- - to peaks-txt: .peaks/${sessionId}/txt/skill-usage-lessons.md (when reusable lesson exists)
282
+ - to peaks-txt: .peaks/${changeId}/txt/skill-usage-lessons.md (when reusable lesson exists)
277
283
 
278
284
  ## Status
279
285
 
@@ -282,18 +288,18 @@ function renderScTemplate(requestId, sessionId, timestamp, requestType) {
282
288
  - state: draft
283
289
  `;
284
290
  }
285
- function renderTemplate(role, requestId, sessionId, timestamp, requestType) {
291
+ function renderTemplate(role, requestId, changeId, sessionId, timestamp, requestType) {
286
292
  switch (role) {
287
293
  case 'prd':
288
- return renderPrdTemplate(requestId, sessionId, timestamp, requestType);
294
+ return renderPrdTemplate(requestId, changeId, sessionId, timestamp, requestType);
289
295
  case 'ui':
290
- return renderUiTemplate(requestId, sessionId, timestamp, requestType);
296
+ return renderUiTemplate(requestId, changeId, sessionId, timestamp, requestType);
291
297
  case 'rd':
292
- return renderRdTemplate(requestId, sessionId, timestamp, requestType);
298
+ return renderRdTemplate(requestId, changeId, sessionId, timestamp, requestType);
293
299
  case 'qa':
294
- return renderQaTemplate(requestId, sessionId, timestamp, requestType);
300
+ return renderQaTemplate(requestId, changeId, sessionId, timestamp, requestType);
295
301
  case 'sc':
296
- return renderScTemplate(requestId, sessionId, timestamp, requestType);
302
+ return renderScTemplate(requestId, changeId, sessionId, timestamp, requestType);
297
303
  }
298
304
  }
299
305
  export async function createRequestArtifact(options) {
@@ -306,10 +312,26 @@ export async function createRequestArtifact(options) {
306
312
  const requestType = options.requestType ?? DEFAULT_REQUEST_TYPE;
307
313
  const clock = options.clock ?? defaultClock;
308
314
  const timestamp = clock();
309
- // Use provided session ID or get/create current session
315
+ // Use provided session ID or get/create current session. The session
316
+ // id is kept as the in-memory binding (so the artifact body can record
317
+ // which session wrote it), but the artifact file is now written
318
+ // under the change-id dir, NOT the session dir.
319
+ //
320
+ // The change-id is the file's durable scope. As of slice
321
+ // 2026-06-05-change-id-as-unit-of-work, the requestId IS the
322
+ // change-id (per the legacy `001-<change-id>.md` filename convention);
323
+ // a request that lives in a session dir under a different change-id
324
+ // is no longer the model. We honor a `current-change` binding if
325
+ // one is set, and otherwise fall back to the requestId itself.
310
326
  const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
311
- // Build numbered path in session directory
312
- const requestsDir = join(options.projectRoot, '.peaks', sessionId, options.role, 'requests');
327
+ const boundChangeId = getCurrentChangeId(options.projectRoot);
328
+ // Resolution order for the change-id (file path key):
329
+ // 1. Explicit `options.changeId` (CLI `--session-id` pre-1.3.0 set this).
330
+ // 2. `current-change` binding (live developer working context).
331
+ // 3. The requestId itself (every request is its own scope by default).
332
+ const changeId = options.changeId ?? boundChangeId ?? options.requestId;
333
+ // Build numbered path under the change-id dir
334
+ const requestsDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/' + options.role + '/requests';
313
335
  // Check if a file with this requestId already exists (regardless of number prefix)
314
336
  if (await isDirectory(requestsDir)) {
315
337
  const existingFiles = await listMarkdownFiles(requestsDir);
@@ -327,15 +349,18 @@ export async function createRequestArtifact(options) {
327
349
  const number = getNextNumber(requestsDir);
328
350
  const filename = buildNumberedFilename(number, options.requestId);
329
351
  const path = join(requestsDir, filename);
330
- const content = renderTemplate(options.role, options.requestId, sessionId, timestamp, requestType);
352
+ const content = renderTemplate(options.role, options.requestId, changeId, sessionId, timestamp, requestType);
331
353
  if (options.apply !== true) {
332
354
  return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: false };
333
355
  }
334
356
  await mkdir(dirname(path), { recursive: true });
335
357
  await writeFile(path, content, 'utf8');
336
- // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked
358
+ // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
359
+ // As of slice 2026-06-05-change-id-as-unit-of-work, the marker lives under
360
+ // the change-id dir (not the session dir), so the gate's prereq scan
361
+ // (which reads from `.peaks/<change-id>/<role>/...`) finds it.
337
362
  if (options.role === 'qa') {
338
- const qaDir = join(options.projectRoot, '.peaks', sessionId, 'qa');
363
+ const qaDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/qa';
339
364
  const initiatedPath = join(qaDir, '.initiated');
340
365
  if (!existsSync(initiatedPath)) {
341
366
  await mkdir(qaDir, { recursive: true });
@@ -348,6 +373,7 @@ function extractMetadata(markdown) {
348
373
  let state = 'unknown';
349
374
  let createdAt;
350
375
  let requestType = DEFAULT_REQUEST_TYPE;
376
+ let sessionId;
351
377
  for (const rawLine of markdown.split(/\r?\n/)) {
352
378
  const line = rawLine.trim();
353
379
  const stateMatch = /^-\s*state:\s*(.+?)\s*$/.exec(line);
@@ -367,21 +393,41 @@ function extractMetadata(markdown) {
367
393
  requestType = candidate;
368
394
  }
369
395
  // Placeholder values (e.g. "feature | bug | refactor | clarification") fall back to default.
396
+ continue;
397
+ }
398
+ const sessionMatch = /^-\s*session:\s*(.+?)\s*$/.exec(line);
399
+ if (sessionMatch !== null && sessionMatch[1] !== undefined) {
400
+ sessionId = sessionMatch[1];
401
+ continue;
370
402
  }
371
403
  }
372
404
  const base = { state, requestType };
373
405
  if (createdAt !== undefined)
374
406
  base.createdAt = createdAt;
407
+ if (sessionId !== undefined)
408
+ base.sessionId = sessionId;
375
409
  return base;
376
410
  }
377
- async function readSummary(projectRoot, sessionId, role, fileName) {
378
- const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
411
+ async function readSummary(projectRoot, changeId, role, fileName) {
412
+ const path = join(projectRoot, '.peaks', changeId, role, 'requests', fileName);
379
413
  const body = await readFile(path, 'utf8');
380
- const { state, createdAt, requestType } = extractMetadata(body);
414
+ const { state, createdAt, requestType, sessionId: bodySessionId } = extractMetadata(body);
381
415
  // Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
382
416
  // Only strip 3-digit zero-padded prefixes (our incrementing number format)
383
417
  const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
384
- const summary = { role, sessionId, requestId, path, state, requestType };
418
+ // `changeId` is the durable scope (the directory the file lives in).
419
+ // `sessionId` is metadata (the session that wrote the file, parsed from
420
+ // the `- session:` body line). They may differ when a request is read
421
+ // across sessions (back-compat) or after a session re-bind.
422
+ const summary = {
423
+ role,
424
+ changeId,
425
+ sessionId: bodySessionId ?? changeId,
426
+ requestId,
427
+ path,
428
+ state,
429
+ requestType
430
+ };
385
431
  if (createdAt !== undefined) {
386
432
  summary.createdAt = createdAt;
387
433
  }
@@ -402,15 +448,53 @@ export async function listRequestArtifacts(options) {
402
448
  if (!(await isDirectory(peaksRoot))) {
403
449
  return [];
404
450
  }
405
- const sessions = options.sessionId !== undefined ? [options.sessionId] : await listDirectories(peaksRoot);
451
+ // As of slice 2026-06-05-change-id-as-unit-of-work, artifact files live
452
+ // in `.peaks/<change-id>/<role>/requests/`. The top-level `.peaks/<dir>/`
453
+ // entries we scan here are change-id dirs (new layout) AND legacy
454
+ // session-id dirs (pre-1.3.0 layout). Both have the same
455
+ // `<role>/requests/<file>.md` shape, so we read them uniformly.
456
+ //
457
+ // Additionally, shipped slices are archived under
458
+ // `.peaks/retrospective/<change-id>/<role>/requests/` and dogfood
459
+ // evidence lives under `.peaks/_dogfood/<change-id>/<role>/requests/`.
460
+ // When `sessionId` is NOT pinned, we scan ALL three umbrella dirs
461
+ // (`<top>`, `retrospective/`, `_dogfood/`) so a `peaks request show
462
+ // <rid>` resolves shipped slices too — which is what the slice check's
463
+ // gate-verify-pipeline stage needs to find evidence for the retrospective
464
+ // slice being verified.
465
+ //
466
+ // Skip well-known non-artifact dirs: `_runtime/` holds ephemeral state
467
+ // (no `requests/` subdirs anyway, but skip explicitly to avoid noise).
468
+ const allDirs = await listDirectories(peaksRoot);
469
+ const candidateDirs = allDirs.filter((dir) => dir !== '_runtime');
470
+ // Expand scopes to include the nested umbrellas that host change-id dirs
471
+ // (retrospective/, _dogfood/). For each, list its sub-dirs and treat
472
+ // them as additional scopes. This makes the lookup span the entire
473
+ // .peaks tree.
474
+ const expandedScopes = [];
475
+ if (options.sessionId !== undefined) {
476
+ expandedScopes.push(options.sessionId);
477
+ }
478
+ else {
479
+ for (const dir of candidateDirs) {
480
+ expandedScopes.push(dir);
481
+ if (dir === 'retrospective' || dir === '_dogfood') {
482
+ const nested = await listDirectories(join(peaksRoot, dir));
483
+ for (const n of nested) {
484
+ expandedScopes.push(join(dir, n));
485
+ }
486
+ }
487
+ }
488
+ }
489
+ const scopes = expandedScopes;
406
490
  const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
407
491
  const summaries = [];
408
- for (const sessionId of sessions) {
492
+ for (const scope of scopes) {
409
493
  for (const role of roles) {
410
- const dir = join(peaksRoot, sessionId, role, 'requests');
494
+ const dir = join(peaksRoot, scope, role, 'requests');
411
495
  const fileNames = await listMarkdownFiles(dir);
412
496
  for (const fileName of fileNames) {
413
- summaries.push(await readSummary(options.projectRoot, sessionId, role, fileName));
497
+ summaries.push(await readSummary(options.projectRoot, scope, role, fileName));
414
498
  }
415
499
  }
416
500
  }
@@ -438,32 +522,65 @@ export async function showRequestArtifact(options) {
438
522
  }
439
523
  return null;
440
524
  };
525
+ // As of slice 2026-06-05-change-id-as-unit-of-work, the dir key is the
526
+ // change-id (not the session-id). When the caller pins `sessionId` we
527
+ // use it as the scope anyway (legacy callers, and tests that pass
528
+ // `STABLE_SESSION` as a stand-in). The directory layout is identical
529
+ // for both old session dirs and new change-id dirs, so a single
530
+ // read path works for both.
441
531
  if (options.sessionId !== undefined) {
442
532
  const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
443
533
  const found = await findFileInDir(dir);
444
534
  if (found === null) {
445
535
  return null;
446
536
  }
447
- const summary = await readSummary(options.projectRoot, options.sessionId, options.role, found.fileName);
448
- const content = await readFile(found.path, 'utf8');
449
- return { ...summary, content };
537
+ return await readRequestArtifact(options.projectRoot, options.sessionId, options.role, found);
450
538
  }
451
539
  const peaksRoot = join(options.projectRoot, '.peaks');
452
540
  if (!(await isDirectory(peaksRoot))) {
453
541
  return null;
454
542
  }
455
- const sessions = await listDirectories(peaksRoot);
456
- for (const sessionId of sessions) {
457
- const dir = join(peaksRoot, sessionId, options.role, 'requests');
543
+ // Scan all top-level dirs in `.peaks/` AND nested change-id dirs
544
+ // under `retrospective/` and `_dogfood/`. The expanded scope list
545
+ // lets us find request artifacts that live one or two levels deep
546
+ // (shipped slices, dogfood evidence). Without this expansion,
547
+ // verify-pipeline can't find the RD/QA request files for any
548
+ // retrospective slice.
549
+ const allDirs = await listDirectories(peaksRoot);
550
+ const scopes = [];
551
+ for (const dir of allDirs) {
552
+ if (dir === '_runtime')
553
+ continue;
554
+ scopes.push(dir);
555
+ if (dir === 'retrospective' || dir === '_dogfood') {
556
+ const nested = await listDirectories(join(peaksRoot, dir));
557
+ for (const n of nested) {
558
+ scopes.push(join(dir, n));
559
+ }
560
+ }
561
+ }
562
+ for (const scope of scopes) {
563
+ const dir = join(peaksRoot, scope, options.role, 'requests');
458
564
  const found = await findFileInDir(dir);
459
565
  if (found !== null) {
460
- const summary = await readSummary(options.projectRoot, sessionId, options.role, found.fileName);
461
- const content = await readFile(found.path, 'utf8');
462
- return { ...summary, content };
566
+ return await readRequestArtifact(options.projectRoot, scope, options.role, found);
463
567
  }
464
568
  }
465
569
  return null;
466
570
  }
571
+ /** Read the summary + content for a found request file; treat a read
572
+ * error on the content as "not found" so the caller can fall through
573
+ * to the next candidate (the on-disk file may be partially written). */
574
+ async function readRequestArtifact(projectRoot, scope, role, found) {
575
+ const summary = await readSummary(projectRoot, scope, role, found.fileName);
576
+ try {
577
+ const content = await readFile(found.path, 'utf8');
578
+ return { ...summary, content };
579
+ }
580
+ catch {
581
+ return null;
582
+ }
583
+ }
467
584
  const ALLOWED_STATES_PER_ROLE = {
468
585
  prd: ['draft', 'confirmed-by-user', 'handed-off', 'blocked'],
469
586
  ui: ['draft', 'direction-locked', 'handed-off', 'blocked'],
@@ -601,7 +718,7 @@ export async function transitionRequestArtifact(options) {
601
718
  });
602
719
  const prerequisiteResult = await checkPrerequisites({
603
720
  projectRoot: options.projectRoot,
604
- sessionId: existing.sessionId,
721
+ changeId: existing.changeId,
605
722
  role: options.role,
606
723
  newState: options.newState,
607
724
  requestId: options.requestId,
@@ -653,6 +770,7 @@ export async function transitionRequestArtifact(options) {
653
770
  await writeFile(existing.path, updated, 'utf8');
654
771
  const result = {
655
772
  role: options.role,
773
+ changeId: existing.changeId,
656
774
  sessionId: existing.sessionId,
657
775
  requestId: options.requestId,
658
776
  path: existing.path,
@@ -30,4 +30,11 @@ export type DoctorOptions = {
30
30
  /** Platform string (defaults to process.platform); injectable for tests. */
31
31
  platform?: NodeJS.Platform;
32
32
  };
33
+ /**
34
+ * Pure helper extracted from `defaultWorkspaceInitializedProbe` so tests can
35
+ * drive the filesystem check without monkey-patching `process.cwd()` or
36
+ * `findProjectRoot`. Returns `true` when EITHER the canonical
37
+ * `.peaks/_runtime/session.json` OR the legacy `.peaks/.session.json` exists.
38
+ */
39
+ export declare function isWorkspaceInitializedAt(projectRoot: string): boolean;
33
40
  export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;
@@ -48,7 +48,25 @@ function defaultWorkspaceInitializedProbe() {
48
48
  const projectRoot = findProjectRoot(process.cwd());
49
49
  if (projectRoot === null)
50
50
  return false;
51
- return existsSync(join(projectRoot, '.peaks', '.session.json'));
51
+ // Workspace is "initialized" when EITHER the canonical runtime-layer session
52
+ // binding (`.peaks/_runtime/session.json`, the home since slice
53
+ // 2026-06-05-peaks-runtime-layer) OR the legacy top-level binding
54
+ // (`.peaks/.session.json`, kept as read-only back-compat for one minor
55
+ // release) is present. The legacy check is what catches projects that ran
56
+ // `peaks workspace init` before the runtime-layer migration and have not yet
57
+ // been reconciled; both paths must continue to satisfy the doctor until the
58
+ // legacy location is removed.
59
+ return isWorkspaceInitializedAt(projectRoot);
60
+ }
61
+ /**
62
+ * Pure helper extracted from `defaultWorkspaceInitializedProbe` so tests can
63
+ * drive the filesystem check without monkey-patching `process.cwd()` or
64
+ * `findProjectRoot`. Returns `true` when EITHER the canonical
65
+ * `.peaks/_runtime/session.json` OR the legacy `.peaks/.session.json` exists.
66
+ */
67
+ export function isWorkspaceInitializedAt(projectRoot) {
68
+ return (existsSync(join(projectRoot, '.peaks', '_runtime', 'session.json')) ||
69
+ existsSync(join(projectRoot, '.peaks', '.session.json')));
52
70
  }
53
71
  const DESTRUCTIVE_APPLY_PATTERNS = [
54
72
  /peaks\s+memory\s+sync[^\n]*--apply/,
@@ -234,7 +252,7 @@ export async function runDoctor(options = {}) {
234
252
  checks.push({
235
253
  id: 'skill-presence:workspace',
236
254
  ok: false,
237
- message: `Skill ${presence.skill} is active but no workspace session exists (.peaks/.session.json missing); run \`peaks workspace init --project <repo>\` — peaks-solo Step 0 must anchor the workspace before any work`
255
+ message: `Skill ${presence.skill} is active but no workspace session exists (.peaks/_runtime/session.json missing); run \`peaks workspace init --project <repo>\` — peaks-solo Step 0 must anchor the workspace before any work`
238
256
  });
239
257
  }
240
258
  else {
@@ -164,6 +164,32 @@ export type ReadSpawnRecordResult = {
164
164
  reason: 'no-binding' | 'no-spawn-record' | 'invalid-json';
165
165
  };
166
166
  export declare function readSpawnRecord(projectRoot: string): ReadSpawnRecordResult;
167
+ /**
168
+ * Idempotency check for the `peaks progress start` CLI (and the Task-tool
169
+ * PreToolUse hook that fires it on every Task call). Returns `true` when a
170
+ * recent spawn record exists for this project's session, meaning a watch
171
+ * window was opened within the last `ttlMs` milliseconds. The LLM-side
172
+ * caller treats `true` as "no-op — a watch is already running somewhere".
173
+ *
174
+ * Why TTL instead of pid liveness: the spawned terminal's pid is the
175
+ * osascript/gnome-terminal process, which exits as soon as it spawns the
176
+ * nested shell. Checking pid liveness gives false negatives (the process
177
+ * is gone by design). The spawn record is the canonical "watch was opened
178
+ * for this session" marker. After `ttlMs` the record is treated as stale
179
+ * (e.g. the user closed the window long ago) and a fresh start proceeds.
180
+ *
181
+ * `ttlMs` defaults to 5 minutes — long enough to cover a typical sub-agent
182
+ * slice (RD parallel fan-out + QA verdict), short enough that a user who
183
+ * closed the window gets a fresh one on the next Task call.
184
+ */
185
+ export type RecentSpawnCheck = {
186
+ recent: boolean;
187
+ reason: 'recent-spawn' | 'no-spawn-record' | 'no-binding' | 'invalid-json' | 'stale-spawn' | 'process-dead';
188
+ /** When reason is 'recent-spawn' or 'stale-spawn', the spawn record. */
189
+ record?: ProgressSpawnRecord;
190
+ ageMs?: number;
191
+ };
192
+ export declare function isRecentSpawn(projectRoot: string, now?: () => number, ttlMs?: number): RecentSpawnCheck;
167
193
  export declare function clearSpawnRecord(projectRoot: string): boolean;
168
194
  export type PhaseClosingTrigger = 'finished' | 'failed';
169
195
  /**
@@ -245,6 +245,31 @@ export function readSpawnRecord(projectRoot) {
245
245
  return { ok: false, reason: 'invalid-json' };
246
246
  }
247
247
  }
248
+ export function isRecentSpawn(projectRoot, now = Date.now, ttlMs = 5 * 60 * 1000) {
249
+ const result = readSpawnRecord(projectRoot);
250
+ if (!result.ok) {
251
+ return { recent: false, reason: result.reason };
252
+ }
253
+ const record = result.data;
254
+ const spawnedMs = Date.parse(record.spawnedAt);
255
+ if (Number.isNaN(spawnedMs)) {
256
+ // Treat unparseable timestamps as a stale record so the next start
257
+ // proceeds normally. This is the conservative choice — the alternative
258
+ // (treating unparseable as "always recent") would block legitimate
259
+ // re-spawns forever.
260
+ return { recent: false, reason: 'stale-spawn', record };
261
+ }
262
+ const ageMs = now() - spawnedMs;
263
+ if (ageMs < 0) {
264
+ // Clock skew or future-dated record: trust the record as "recent" so
265
+ // we do not double-spawn. Same conservative default as a 0-age record.
266
+ return { recent: true, reason: 'recent-spawn', record, ageMs: 0 };
267
+ }
268
+ if (ageMs >= ttlMs) {
269
+ return { recent: false, reason: 'stale-spawn', record, ageMs };
270
+ }
271
+ return { recent: true, reason: 'recent-spawn', record, ageMs };
272
+ }
248
273
  export function clearSpawnRecord(projectRoot) {
249
274
  const path = spawnRecordPath(projectRoot);
250
275
  if (!existsSync(path))
@@ -197,12 +197,24 @@ function readSessionJsonBinding(projectRoot) {
197
197
  * session does not own the slice.
198
198
  */
199
199
  function sessionOwnsSlice(projectRoot, sessionId, sliceId) {
200
- const sessionDir = join(projectRoot, '.peaks', sessionId);
201
- if (!existsSync(sessionDir))
202
- return false;
203
- for (const marker of [`qa/test-cases/${sliceId}.md`, `qa/test-reports/${sliceId}.md`]) {
204
- if (existsSync(join(sessionDir, marker)))
205
- return true;
200
+ // As of slice 2026-06-05-change-id-as-unit-of-work, the same
201
+ // session id can live at multiple umbrella locations:
202
+ // - `.peaks/<sessionId>/` (legacy or top-level active)
203
+ // - `.peaks/retrospective/<sessionId>/` (shipped slice)
204
+ // - `.peaks/_dogfood/<sessionId>/` (dogfood evidence)
205
+ // Try each in turn; the first match wins.
206
+ const candidateDirs = [
207
+ join(projectRoot, '.peaks', sessionId),
208
+ join(projectRoot, '.peaks', 'retrospective', sessionId),
209
+ join(projectRoot, '.peaks', '_dogfood', sessionId)
210
+ ];
211
+ for (const sessionDir of candidateDirs) {
212
+ if (!existsSync(sessionDir))
213
+ continue;
214
+ for (const marker of [`qa/test-cases/${sliceId}.md`, `qa/test-reports/${sliceId}.md`]) {
215
+ if (existsSync(join(sessionDir, marker)))
216
+ return true;
217
+ }
206
218
  }
207
219
  return false;
208
220
  }
@@ -215,23 +227,69 @@ function findSessionOwningSlice(projectRoot, sliceId) {
215
227
  const peaksRoot = join(projectRoot, '.peaks');
216
228
  if (!existsSync(peaksRoot))
217
229
  return null;
218
- let names;
230
+ let topLevel;
219
231
  try {
220
- names = readdirSync(peaksRoot);
232
+ topLevel = readdirSync(peaksRoot);
221
233
  }
222
234
  catch {
223
235
  return null;
224
236
  }
225
- names.sort();
226
- for (const name of names) {
227
- if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
237
+ topLevel.sort();
238
+ // As of slice 2026-06-05-change-id-as-unit-of-work, shipped slices
239
+ // are archived under `.peaks/retrospective/<dir>/` and dogfood
240
+ // evidence lives under `.peaks/_dogfood/<dir>/`. Both umbrellas host
241
+ // scopes at one level deeper than the top-level `.peaks/<dir>/`
242
+ // shape. Expand the candidate list so the find-fallback tier can
243
+ // locate these. Accept both legacy session-id format
244
+ // (`YYYY-MM-DD-session-<6hex>`) and new change-id format
245
+ // (`YYYY-MM-DD-<slug>`) as valid scope dir names.
246
+ const candidateSessionIds = [];
247
+ for (const entry of topLevel) {
248
+ if (entry === 'retrospective' || entry === '_dogfood') {
249
+ const nested = readdirSyncSafe(join(peaksRoot, entry));
250
+ for (const n of nested) {
251
+ candidateSessionIds.push(n);
252
+ }
253
+ }
254
+ else {
255
+ candidateSessionIds.push(entry);
256
+ }
257
+ }
258
+ candidateSessionIds.sort();
259
+ for (const id of candidateSessionIds) {
260
+ // Legacy session-id format OR new change-id format. Reject files
261
+ // and other non-scope entries (e.g. .peaks-init-hooks-decision.json,
262
+ // PROJECT.md, _runtime, memory, sops, issues, perf-baseline, etc.)
263
+ if (!isScopeDirName(id))
228
264
  continue;
229
- if (sessionOwnsSlice(projectRoot, name, sliceId)) {
230
- return name;
265
+ if (sessionOwnsSlice(projectRoot, id, sliceId)) {
266
+ return id;
231
267
  }
232
268
  }
233
269
  return null;
234
270
  }
271
+ /** Is this a valid legacy session-id OR new change-id dir name? */
272
+ function isScopeDirName(name) {
273
+ // Legacy: 2026-MM-DD-session-<6hex>
274
+ if (/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
275
+ return true;
276
+ // New: 2026-MM-DD-<slug> where slug is letters / digits / hyphens / dots
277
+ // (the slice-migration kept the 4-digit year prefix, so 3-digit
278
+ // numbered filenames from request artifacts stay out of this path —
279
+ // they live in retrospective/ nested with the year-prefixed change-id
280
+ // as the dir, not at top level).
281
+ if (/^\d{4}-\d{2}-\d{2}-[\w.\-]+$/.test(name))
282
+ return true;
283
+ return false;
284
+ }
285
+ function readdirSyncSafe(dir) {
286
+ try {
287
+ return readdirSync(dir);
288
+ }
289
+ catch {
290
+ return [];
291
+ }
292
+ }
235
293
  /**
236
294
  * Resolve the session id that owns the slice's artifacts using a 3-tier
237
295
  * precedence: