peaks-cli 1.3.0 → 1.3.2

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 (53) hide show
  1. package/README.md +62 -46
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/core-artifact-commands.js +49 -11
  4. package/dist/src/cli/commands/hooks-commands.js +24 -9
  5. package/dist/src/cli/commands/progress-commands.js +26 -2
  6. package/dist/src/cli/commands/request-commands.js +5 -0
  7. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  8. package/dist/src/cli/commands/slice-commands.js +44 -0
  9. package/dist/src/cli/commands/workflow-commands.js +3 -3
  10. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  11. package/dist/src/cli/commands/workspace-commands.js +349 -12
  12. package/dist/src/cli/program.js +4 -0
  13. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
  14. package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
  15. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  16. package/dist/src/services/artifacts/request-artifact-service.js +214 -56
  17. package/dist/src/services/doctor/doctor-service.d.ts +69 -0
  18. package/dist/src/services/doctor/doctor-service.js +296 -3
  19. package/dist/src/services/progress/progress-service.d.ts +26 -0
  20. package/dist/src/services/progress/progress-service.js +25 -0
  21. package/dist/src/services/sc/sc-service.js +71 -13
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +22 -1
  24. package/dist/src/services/session/session-manager.js +149 -30
  25. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  26. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  27. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  28. package/dist/src/services/slice/slice-check-service.js +267 -0
  29. package/dist/src/services/slice/slice-check-types.d.ts +70 -0
  30. package/dist/src/services/slice/slice-check-types.js +18 -0
  31. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  32. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  33. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  34. package/dist/src/services/workspace/migrate-service.js +606 -0
  35. package/dist/src/services/workspace/migrate-types.d.ts +127 -0
  36. package/dist/src/services/workspace/migrate-types.js +21 -0
  37. package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
  38. package/dist/src/services/workspace/reconcile-service.js +160 -42
  39. package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
  40. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  41. package/dist/src/services/workspace/workspace-service.js +71 -24
  42. package/dist/src/shared/change-id.d.ts +59 -0
  43. package/dist/src/shared/change-id.js +194 -16
  44. package/dist/src/shared/version.d.ts +1 -1
  45. package/dist/src/shared/version.js +1 -1
  46. package/package.json +10 -2
  47. package/schemas/doctor-report.schema.json +2 -2
  48. package/skills/peaks-qa/SKILL.md +1 -0
  49. package/skills/peaks-rd/SKILL.md +2 -1
  50. package/skills/peaks-solo/SKILL.md +17 -1
  51. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  52. package/skills/peaks-txt/SKILL.md +2 -0
  53. package/skills/peaks-ui/SKILL.md +1 -0
@@ -3,7 +3,8 @@ import { existsSync } from 'node:fs';
3
3
  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
- import { ensureSession } from '../session/session-manager.js';
6
+ import { ensureSession, getSessionIdCanonical } from '../session/session-manager.js';
7
+ import { getCurrentChangeId } 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,49 @@ 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 the binding for the artifact file's location.
317
+ //
318
+ // Slice 006 collapses the per-change-id top-level dirs. The artifact
319
+ // file is now written under the SESSION dir
320
+ // (`.peaks/_runtime/<sid>/<role>/requests/`) instead of the
321
+ // change-id dir. The 2-tier fallback (canonical session → legacy
322
+ // session) replaces the F3 3-tier fallback (per-change-id →
323
+ // canonical session → legacy session). The change-id is preserved
324
+ // in the artifact body's frontmatter (under `- change-id:`) for
325
+ // human navigation; it is no longer a filesystem path key.
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 body metadata):
329
+ // 1. Explicit `options.changeId` (CLI `--change-id`).
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
+ // Slice 008 (F21 fix): fail fast when the resolved session id
334
+ // looks like a real session id (matches the date+session prefix)
335
+ // but does NOT correspond to an actual session dir under
336
+ // `.peaks/_runtime/`. Pre-F21 a sub-agent with a typo or stale
337
+ // binding (e.g. `2025-01-01-session-deadbe`) silently planned
338
+ // to write to a non-existent path. The check is intentionally
339
+ // scoped to "looks like a real session id" — a sid like
340
+ // `test-session` or `s` (no date prefix) is allowed through so
341
+ // the existing F3 / slice-007 back-compat flows (e.g. the
342
+ // `peaks request init --session-id <arbitrary-scope>` tests)
343
+ // can still create the dir on demand via the writer's
344
+ // `mkdir(..., { recursive: true })`.
345
+ const LOOKS_LIKE_SESSION_ID = /^\d{4}-\d{2}-\d{2}-session-/;
346
+ if (LOOKS_LIKE_SESSION_ID.test(sessionId)) {
347
+ const sessionDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
348
+ if (!(await isDirectory(sessionDir))) {
349
+ const canonicalSid = getSessionIdCanonical(options.projectRoot);
350
+ const hint = canonicalSid !== null
351
+ ? `Use --session-id ${canonicalSid} or run 'peaks workspace init' to create a new session.`
352
+ : `Run 'peaks workspace init' to create a new session.`;
353
+ throw new Error(`session id '${sessionId}' does not exist in _runtime/. Current canonical binding is '${canonicalSid ?? '<none>'}'. ${hint}`);
354
+ }
355
+ }
356
+ // Build numbered path under the session dir (canonical post-F3 home).
357
+ const requestsDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, options.role, 'requests');
313
358
  // Check if a file with this requestId already exists (regardless of number prefix)
314
359
  if (await isDirectory(requestsDir)) {
315
360
  const existingFiles = await listMarkdownFiles(requestsDir);
@@ -327,15 +372,18 @@ export async function createRequestArtifact(options) {
327
372
  const number = getNextNumber(requestsDir);
328
373
  const filename = buildNumberedFilename(number, options.requestId);
329
374
  const path = join(requestsDir, filename);
330
- const content = renderTemplate(options.role, options.requestId, sessionId, timestamp, requestType);
375
+ const content = renderTemplate(options.role, options.requestId, changeId, sessionId, timestamp, requestType);
331
376
  if (options.apply !== true) {
332
377
  return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: false };
333
378
  }
334
379
  await mkdir(dirname(path), { recursive: true });
335
380
  await writeFile(path, content, 'utf8');
336
- // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked
381
+ // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
382
+ // Slice 006: the marker lives under the SESSION dir (canonical post-F3
383
+ // home), not the change-id dir. The gate's prereq scan finds it at
384
+ // `.peaks/_runtime/<sid>/qa/.initiated`.
337
385
  if (options.role === 'qa') {
338
- const qaDir = join(options.projectRoot, '.peaks', sessionId, 'qa');
386
+ const qaDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, 'qa');
339
387
  const initiatedPath = join(qaDir, '.initiated');
340
388
  if (!existsSync(initiatedPath)) {
341
389
  await mkdir(qaDir, { recursive: true });
@@ -348,6 +396,7 @@ function extractMetadata(markdown) {
348
396
  let state = 'unknown';
349
397
  let createdAt;
350
398
  let requestType = DEFAULT_REQUEST_TYPE;
399
+ let sessionId;
351
400
  for (const rawLine of markdown.split(/\r?\n/)) {
352
401
  const line = rawLine.trim();
353
402
  const stateMatch = /^-\s*state:\s*(.+?)\s*$/.exec(line);
@@ -367,21 +416,41 @@ function extractMetadata(markdown) {
367
416
  requestType = candidate;
368
417
  }
369
418
  // Placeholder values (e.g. "feature | bug | refactor | clarification") fall back to default.
419
+ continue;
420
+ }
421
+ const sessionMatch = /^-\s*session:\s*(.+?)\s*$/.exec(line);
422
+ if (sessionMatch !== null && sessionMatch[1] !== undefined) {
423
+ sessionId = sessionMatch[1];
424
+ continue;
370
425
  }
371
426
  }
372
427
  const base = { state, requestType };
373
428
  if (createdAt !== undefined)
374
429
  base.createdAt = createdAt;
430
+ if (sessionId !== undefined)
431
+ base.sessionId = sessionId;
375
432
  return base;
376
433
  }
377
- async function readSummary(projectRoot, sessionId, role, fileName) {
378
- const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
434
+ async function readSummary(projectRoot, changeId, role, fileName) {
435
+ const path = join(projectRoot, '.peaks', changeId, role, 'requests', fileName);
379
436
  const body = await readFile(path, 'utf8');
380
- const { state, createdAt, requestType } = extractMetadata(body);
437
+ const { state, createdAt, requestType, sessionId: bodySessionId } = extractMetadata(body);
381
438
  // Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
382
439
  // Only strip 3-digit zero-padded prefixes (our incrementing number format)
383
440
  const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
384
- const summary = { role, sessionId, requestId, path, state, requestType };
441
+ // `changeId` is the durable scope (the directory the file lives in).
442
+ // `sessionId` is metadata (the session that wrote the file, parsed from
443
+ // the `- session:` body line). They may differ when a request is read
444
+ // across sessions (back-compat) or after a session re-bind.
445
+ const summary = {
446
+ role,
447
+ changeId,
448
+ sessionId: bodySessionId ?? changeId,
449
+ requestId,
450
+ path,
451
+ state,
452
+ requestType
453
+ };
385
454
  if (createdAt !== undefined) {
386
455
  summary.createdAt = createdAt;
387
456
  }
@@ -402,15 +471,53 @@ export async function listRequestArtifacts(options) {
402
471
  if (!(await isDirectory(peaksRoot))) {
403
472
  return [];
404
473
  }
405
- const sessions = options.sessionId !== undefined ? [options.sessionId] : await listDirectories(peaksRoot);
474
+ // Slice 006 collapsed the per-change-id top-level dirs. The 2-tier
475
+ // resolution model is:
476
+ // 1. `.peaks/_runtime/<sid>/<role>/requests/` (post-F3 canonical
477
+ // session home; slice 006's primary home for request artifacts).
478
+ // 2. `.peaks/<sid>/<role>/requests/` (pre-F3 legacy home; back-compat
479
+ // for users who have not yet migrated).
480
+ //
481
+ // When `sessionId` is pinned, the function scans that one session's
482
+ // two tiers (canonical + legacy). When `sessionId` is NOT pinned,
483
+ // the function scans every session dir under `.peaks/_runtime/`
484
+ // (canonical) AND every legacy session dir under `.peaks/`
485
+ // (top-level). Per-change-id dirs (the old `.peaks/<changeId>/<role>/`
486
+ // layout) are NOT scanned — slice 008 will migrate the 5
487
+ // already-shipped slices' artifacts to the new layout; new request
488
+ // artifacts are written to the session dir directly.
489
+ const scopes = [];
490
+ if (options.sessionId !== undefined) {
491
+ scopes.push(join('_runtime', options.sessionId));
492
+ scopes.push(options.sessionId);
493
+ }
494
+ else {
495
+ const runtimeRoot = join(peaksRoot, '_runtime');
496
+ if (await isDirectory(runtimeRoot)) {
497
+ for (const sid of await listDirectories(runtimeRoot)) {
498
+ scopes.push(join('_runtime', sid));
499
+ }
500
+ }
501
+ // Legacy top-level session dirs: scan every non-`._peaks` top-level
502
+ // dir as a potential legacy scope. Slice 006 dropped per-change-id
503
+ // dirs, so any top-level dir name under `.peaks/` that is NOT
504
+ // `_runtime` (and not a well-known umbrella like retrospective,
505
+ // _dogfood, memory, etc. — those have no `<role>/requests/` tree)
506
+ // is treated as a candidate legacy session dir.
507
+ for (const dir of await listDirectories(peaksRoot)) {
508
+ if (dir === '_runtime')
509
+ continue;
510
+ scopes.push(dir);
511
+ }
512
+ }
406
513
  const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
407
514
  const summaries = [];
408
- for (const sessionId of sessions) {
515
+ for (const scope of scopes) {
409
516
  for (const role of roles) {
410
- const dir = join(peaksRoot, sessionId, role, 'requests');
517
+ const dir = join(peaksRoot, scope, role, 'requests');
411
518
  const fileNames = await listMarkdownFiles(dir);
412
519
  for (const fileName of fileNames) {
413
- summaries.push(await readSummary(options.projectRoot, sessionId, role, fileName));
520
+ summaries.push(await readSummary(options.projectRoot, scope, role, fileName));
414
521
  }
415
522
  }
416
523
  }
@@ -438,32 +545,76 @@ export async function showRequestArtifact(options) {
438
545
  }
439
546
  return null;
440
547
  };
548
+ // As of slice 2026-06-05-change-id-as-unit-of-work, the dir key is the
549
+ // change-id (not the session-id). When the caller pins `sessionId` we
550
+ // use it as the scope anyway (legacy callers, and tests that pass
551
+ // `STABLE_SESSION` as a stand-in).
552
+ //
553
+ // As of slice 2026-06-06-session-layout-canonicalize (F3), the
554
+ // canonical home for session dirs is `.peaks/_runtime/<sid>/`.
555
+ // The pre-F3 layout `.peaks/<sid>/` is preserved as a one-minor
556
+ // back-compat fallback (the new path wins when both exist). We
557
+ // resolve the dir to use UP FRONT (not lazily after a miss) so the
558
+ // prerequisite gate's "request artifact present" check observes
559
+ // the same path the rest of the canonical layout uses.
441
560
  if (options.sessionId !== undefined) {
442
- const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
561
+ const canonicalDir = join(options.projectRoot, '.peaks', '_runtime', options.sessionId, options.role, 'requests');
562
+ const legacyDir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
563
+ // Try the canonical (post-F3) path first; fall back to the legacy
564
+ // path only if the canonical path is absent. The legacy path is
565
+ // expected to be empty after a `peaks workspace migrate --to-runtime`
566
+ // run; this fallback exists for users who have not yet migrated.
567
+ const dir = (await isDirectory(canonicalDir)) ? canonicalDir : legacyDir;
568
+ const scope = dir === canonicalDir
569
+ ? join('_runtime', options.sessionId)
570
+ : options.sessionId;
443
571
  const found = await findFileInDir(dir);
444
572
  if (found === null) {
445
573
  return null;
446
574
  }
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 };
575
+ return await readRequestArtifact(options.projectRoot, scope, options.role, found);
450
576
  }
451
577
  const peaksRoot = join(options.projectRoot, '.peaks');
452
578
  if (!(await isDirectory(peaksRoot))) {
453
579
  return null;
454
580
  }
455
- const sessions = await listDirectories(peaksRoot);
456
- for (const sessionId of sessions) {
457
- const dir = join(peaksRoot, sessionId, options.role, 'requests');
458
- const found = await findFileInDir(dir);
581
+ // Slice 006: scan only session-scoped dirs (canonical + legacy)
582
+ // for the artifact. The per-change-id top-level dirs are no longer
583
+ // scanned they are frozen until slice 008 migrates them.
584
+ const runtimeRoot = join(peaksRoot, '_runtime');
585
+ if (await isDirectory(runtimeRoot)) {
586
+ for (const sid of await listDirectories(runtimeRoot)) {
587
+ const dir = join(runtimeRoot, sid, options.role, 'requests');
588
+ const found = await findFileInDir(dir);
589
+ if (found !== null) {
590
+ return await readRequestArtifact(options.projectRoot, join('_runtime', sid), options.role, found);
591
+ }
592
+ }
593
+ }
594
+ for (const dir of await listDirectories(peaksRoot)) {
595
+ if (dir === '_runtime')
596
+ continue;
597
+ const target = join(peaksRoot, dir, options.role, 'requests');
598
+ const found = await findFileInDir(target);
459
599
  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 };
600
+ return await readRequestArtifact(options.projectRoot, dir, options.role, found);
463
601
  }
464
602
  }
465
603
  return null;
466
604
  }
605
+ /** Read the summary + content for a found request file; treat a read
606
+ * error on the content as "not found" so the caller can fall through
607
+ * to the next candidate (the on-disk file may be partially written). */
608
+ async function readRequestArtifact(projectRoot, scope, role, found) {
609
+ const summary = await readSummary(projectRoot, scope, role, found.fileName);
610
+ try {
611
+ const content = await readFile(found.path, 'utf8');
612
+ return { ...summary, content };
613
+ }
614
+ catch {
615
+ return null;
616
+ }
617
+ }
467
618
  const ALLOWED_STATES_PER_ROLE = {
468
619
  prd: ['draft', 'confirmed-by-user', 'handed-off', 'blocked'],
469
620
  ui: ['draft', 'direction-locked', 'handed-off', 'blocked'],
@@ -601,6 +752,12 @@ export async function transitionRequestArtifact(options) {
601
752
  });
602
753
  const prerequisiteResult = await checkPrerequisites({
603
754
  projectRoot: options.projectRoot,
755
+ changeId: existing.changeId,
756
+ // F3 repair cycle 1: pass the session binding so the gate can fall
757
+ // back to `.peaks/_runtime/<sid>/<role>/` (and the legacy
758
+ // `.peaks/<sid>/<role>/`) for prerequisite artifacts that still
759
+ // live under the session dir rather than the change-id dir. This
760
+ // mirrors the F1/F2 back-compat pattern.
604
761
  sessionId: existing.sessionId,
605
762
  role: options.role,
606
763
  newState: options.newState,
@@ -653,6 +810,7 @@ export async function transitionRequestArtifact(options) {
653
810
  await writeFile(existing.path, updated, 'utf8');
654
811
  const result = {
655
812
  role: options.role,
813
+ changeId: existing.changeId,
656
814
  sessionId: existing.sessionId,
657
815
  requestId: options.requestId,
658
816
  path: existing.path,
@@ -18,6 +18,32 @@ export type CodegraphCapabilityProbe = {
18
18
  binaryPath: string;
19
19
  binaryExists: boolean;
20
20
  };
21
+ export type DistVersionComparison = {
22
+ dist: string | null;
23
+ source: string;
24
+ match: boolean;
25
+ distReadable: boolean;
26
+ };
27
+ export type DistVersionProbe = () => DistVersionComparison;
28
+ export type WorkspaceLayoutInspection = {
29
+ topLevelSessionDirs: string[];
30
+ legacyDotfiles: string[];
31
+ /**
32
+ * Slice 007 — per-change-id top-level dirs (e.g. `.peaks/001-2026-06-06-.../`).
33
+ * The pre-F3 canonical layout put reviewable artifacts under a
34
+ * per-change-id top-level dir; the post-F3 canonical layout
35
+ * consolidates them under `.peaks/_runtime/<sid>/<role>/`. Any
36
+ * leftover per-change-id top-level dir is a regression to flag.
37
+ * Slice 008's migration will consolidate these; until then, the
38
+ * check reports them as `ok: false`.
39
+ *
40
+ * Optional in the type for back-compat with test probes that
41
+ * pre-date the slice 007 broadening; the check itself falls back
42
+ * to an empty array when the field is missing.
43
+ */
44
+ perChangeIdDirs?: string[];
45
+ };
46
+ export type WorkspaceLayoutProbe = () => WorkspaceLayoutInspection;
21
47
  export type DoctorOptions = {
22
48
  schemasBaseDir?: string;
23
49
  skillsBaseDir?: string;
@@ -29,5 +55,48 @@ export type DoctorOptions = {
29
55
  workspaceInitializedProbe?: () => boolean;
30
56
  /** Platform string (defaults to process.platform); injectable for tests. */
31
57
  platform?: NodeJS.Platform;
58
+ /** Injected for the build:dist-version-matches-source check (defaults to compareDistVersion on disk). */
59
+ distVersionProbe?: DistVersionProbe;
60
+ /** Injected for the build:workspace-layout-canonical check (defaults to inspectWorkspaceLayout on disk). */
61
+ workspaceLayoutProbe?: WorkspaceLayoutProbe;
32
62
  };
63
+ /**
64
+ * Pure helper extracted from `defaultWorkspaceInitializedProbe` so tests can
65
+ * drive the filesystem check without monkey-patching `process.cwd()` or
66
+ * `findProjectRoot`. Returns `true` when EITHER the canonical
67
+ * `.peaks/_runtime/session.json` OR the legacy `.peaks/.session.json` exists.
68
+ */
69
+ export declare function isWorkspaceInitializedAt(projectRoot: string): boolean;
70
+ /**
71
+ * Pure helper that compares the published dist `CLI_VERSION` against the
72
+ * source-of-truth `package.json#version`. Default readers fail-soft to `null`
73
+ * on missing/unreadable/malformed input. Exported so tests can drive the
74
+ * filesystem reads without monkey-patching `process.cwd()`.
75
+ */
76
+ export declare function compareDistVersion(opts: {
77
+ projectRoot: string;
78
+ distVersionReader?: (root: string) => string | null;
79
+ sourceVersionReader?: (root: string) => string | null;
80
+ }): DistVersionComparison;
81
+ /**
82
+ * Pure helper that inspects the on-disk workspace layout for
83
+ * post-F3-canonical violations. The post-F3 canonical layout puts
84
+ * session dirs under `.peaks/_runtime/<sid>/` and the runtime
85
+ * binding at `.peaks/_runtime/session.json`; the legacy paths
86
+ * (top-level `<YYYY-MM-DD-session-<hex>>/` dirs and the legacy
87
+ * top-level `.peaks/.session.json` / `.peaks/.active-skill.json`
88
+ * dotfiles) must be absent. This helper is exported so tests can
89
+ * drive the filesystem walk without monkey-patching `process.cwd()`
90
+ * or `findProjectRoot`.
91
+ *
92
+ * Both scanners fail-soft (return `[]` on read errors) so a flaky
93
+ * filesystem read on a non-fatal probe path never escalates into a
94
+ * doctor failure.
95
+ */
96
+ export declare function inspectWorkspaceLayout(opts: {
97
+ projectRoot: string;
98
+ topLevelScanner?: (root: string) => string[];
99
+ dotfileScanner?: (root: string) => string[];
100
+ perChangeIdScanner?: (root: string) => string[];
101
+ }): WorkspaceLayoutInspection;
33
102
  export declare function runDoctor(options?: DoctorOptions): Promise<DoctorReport>;