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
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Type envelope for the `peaks workspace migrate` CLI command.
3
+ *
4
+ * The migrate command is the downstream-project counterpart to the
5
+ * one-time Phase 5 migration script that ran on the peaks-cli self-host
6
+ * for slice 2026-06-05-change-id-as-unit-of-work. Where
7
+ * `peaks workspace reconcile` only handles the top-level runtime state
8
+ * files (`.peaks/.session.json`, `.peaks/.active-skill.json`,
9
+ * `.peaks/sop-state/` → `.peaks/_runtime/`), `peaks workspace migrate`
10
+ * handles the much bigger case: legacy reviewable content under
11
+ * `.peaks/<session-id>/<role>/<file>` → `.peaks/retrospective/<change-id>/<role>/<file>`.
12
+ *
13
+ * Each legacy session dir typically contains multiple change-ids worth
14
+ * of work (the old layout allowed one session to host several slices).
15
+ * Change-id resolution uses a 4-tier per-file heuristic (filename
16
+ * regex → content H1 → body frontmatter → per-session fallback to the
17
+ * most-recent `<change-id>` in `rd/requests/`).
18
+ *
19
+ * Types are hand-rolled, no new top-level dependencies.
20
+ */
21
+ export type MigrateFilePlan = {
22
+ /** Absolute source path (under `.peaks/<session-id>/...`). */
23
+ from: string;
24
+ /** Absolute target path (under `.peaks/retrospective/<change-id>/...`). */
25
+ to: string;
26
+ /** The session dir this file came from. */
27
+ sessionId: string;
28
+ /** The change-id the file is being routed to. */
29
+ changeId: string;
30
+ /** The role inferred from the file's path (rd/qa/prd/ui/sc). */
31
+ role: 'prd' | 'ui' | 'rd' | 'qa' | 'sc' | 'system' | 'unknown';
32
+ /** Path of the file relative to the session dir, e.g. `rd/tech-doc.md`. */
33
+ relativePath: string;
34
+ /**
35
+ * Which tier of the 4-tier heuristic produced the change-id. Null
36
+ * when the file is not a per-slice artifact (e.g. cross-cutting
37
+ * `rd/project-scan.md` or `qa/.initiated`).
38
+ */
39
+ source: 'filename-regex' | 'content-h1' | 'content-frontmatter' | 'session-fallback' | 'cross-cutting' | null;
40
+ /** True if the file was skipped (e.g. `session.json`, cross-cutting, conflict). */
41
+ skipped?: boolean;
42
+ /** Why the file was skipped (only set when `skipped === true`). */
43
+ skipReason?: 'transient-runtime' | 'conflict' | 'no-change-id' | 'unsupported-role';
44
+ };
45
+ export type MigrateSessionPlan = {
46
+ sessionId: string;
47
+ /** Absolute path to the session dir. */
48
+ path: string;
49
+ /** True if the dir is empty / only has `session.json`. */
50
+ empty: boolean;
51
+ /** All files in the dir, planned. */
52
+ files: MigrateFilePlan[];
53
+ /** The fallback change-id derived from the session's most recent `rd/requests/` entry. Null if no requests exist. */
54
+ fallbackChangeId: string | null;
55
+ };
56
+ export type MigrateOptions = {
57
+ projectRoot: string;
58
+ /** When true, actually `git mv` the files + `rm -rf` the emptied session dirs. */
59
+ apply: boolean;
60
+ /**
61
+ * Slice 003 (2026-06-06-session-layout-canonicalize): when true, the
62
+ * command performs the **session-dir consolidation** — moves every
63
+ * top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/`. Idempotent;
64
+ * conflicts (target exists with different content) are logged but
65
+ * never overwrite. With `apply: false` (the default), the response
66
+ * lists what WOULD move + the conflicts.
67
+ *
68
+ * Mutually exclusive with the reviewable-content migration: the
69
+ * `--to-runtime` step is the data side of slice 003, while the
70
+ * default `migrate` step is the cross-cutting content side
71
+ * (reviewable files → retrospective). Both run when both flags are
72
+ * set; the order is `--to-runtime` first (so the cross-cutting
73
+ * step sees the canonical tree) and then the reviewable-content
74
+ * step.
75
+ */
76
+ toRuntime?: boolean;
77
+ };
78
+ export type MigrateToRuntimeFilePlan = {
79
+ /** Absolute source path (top-level `.peaks/<sid>/`). */
80
+ from: string;
81
+ /** Absolute target path (`.peaks/_runtime/<sid>/`). */
82
+ to: string;
83
+ /** The session id the dir belongs to. */
84
+ sessionId: string;
85
+ /** 'moved' or 'skipped-already-canonical' or 'conflict'. */
86
+ action: 'moved' | 'skipped-already-canonical' | 'conflict-target-exists-with-different-content' | 'f15-conflict-project-scan';
87
+ /** Human-readable reason for the action (for the conflicts list). */
88
+ reason: string;
89
+ };
90
+ export type MigrateResult = {
91
+ /** Absolute project root the command operated on. */
92
+ projectRoot: string;
93
+ /** All discovered legacy session dirs, sorted by name. */
94
+ sessions: MigrateSessionPlan[];
95
+ /** All moves the apply step WOULD perform (only populated when `apply: false` as well, for symmetry). */
96
+ wouldMove: MigrateFilePlan[];
97
+ /** All moves actually performed (only populated when `apply: true`). */
98
+ moved: MigrateFilePlan[];
99
+ /** Sessions that became empty after the move and were/will be removed. */
100
+ deletedSessions: string[];
101
+ /** Sessions that WOULD become empty (dry-run only). */
102
+ wouldDeleteSessions: string[];
103
+ /** Files that already exist at the target (collision). Dry-run reports; apply skips + warns. */
104
+ conflicts: Array<{
105
+ from: string;
106
+ to: string;
107
+ reason: string;
108
+ }>;
109
+ /** Whether `--apply` was set. */
110
+ apply: boolean;
111
+ /** Total files moved or scheduled to move. */
112
+ totalFilesMoved: number;
113
+ /**
114
+ * Slice 003: per-session-dir move plans for the `--to-runtime` step.
115
+ * Empty when `toRuntime` was not set. Conflicts include both the
116
+ * top-level/<sid>/ → _runtime/<sid>/ collisions AND the F15 carve-out
117
+ * for `rd/project-scan.md`.
118
+ */
119
+ toRuntimePlans?: MigrateToRuntimeFilePlan[];
120
+ toRuntimeMoved?: string[];
121
+ toRuntimeSkipped?: string[];
122
+ toRuntimeConflicts?: Array<{
123
+ from: string;
124
+ to: string;
125
+ reason: string;
126
+ }>;
127
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Type envelope for the `peaks workspace migrate` CLI command.
3
+ *
4
+ * The migrate command is the downstream-project counterpart to the
5
+ * one-time Phase 5 migration script that ran on the peaks-cli self-host
6
+ * for slice 2026-06-05-change-id-as-unit-of-work. Where
7
+ * `peaks workspace reconcile` only handles the top-level runtime state
8
+ * files (`.peaks/.session.json`, `.peaks/.active-skill.json`,
9
+ * `.peaks/sop-state/` → `.peaks/_runtime/`), `peaks workspace migrate`
10
+ * handles the much bigger case: legacy reviewable content under
11
+ * `.peaks/<session-id>/<role>/<file>` → `.peaks/retrospective/<change-id>/<role>/<file>`.
12
+ *
13
+ * Each legacy session dir typically contains multiple change-ids worth
14
+ * of work (the old layout allowed one session to host several slices).
15
+ * Change-id resolution uses a 4-tier per-file heuristic (filename
16
+ * regex → content H1 → body frontmatter → per-session fallback to the
17
+ * most-recent `<change-id>` in `rd/requests/`).
18
+ *
19
+ * Types are hand-rolled, no new top-level dependencies.
20
+ */
21
+ export {};
@@ -75,6 +75,39 @@ export declare function applyDeletions(candidates: SessionEntry[], apply: boolea
75
75
  message: string;
76
76
  }>;
77
77
  };
78
+ /**
79
+ * Sync the single `change/<canonicalSessionId>/` live marker under
80
+ * `.peaks/_runtime/change/`. The marker is an EMPTY directory (no
81
+ * symlinks, no manifest, no content). Slice 006 collapses the F3
82
+ * per-change-id symlink layer to a single live marker so the
83
+ * `change/` layer is a single sentinel — easy for the LLM to
84
+ * navigate, easy for tests to assert on.
85
+ *
86
+ * Steps:
87
+ * 1. Ensure `.peaks/_runtime/change/` exists.
88
+ * 2. List its entries.
89
+ * 3. Remove every entry whose name is NOT `<canonicalSessionId>/`
90
+ * (no-op for those whose name matches).
91
+ * 4. If `<canonicalSessionId>/` is missing, create it (empty).
92
+ * 5. Return the diff for telemetry.
93
+ *
94
+ * Path-traversal guards: the canonical session id is validated by
95
+ * the caller (`SESSION_ID_PATTERN` in the session-manager helper).
96
+ * The function only ever operates under the project-root-resolved
97
+ * `.peaks/_runtime/change/` dir, so the resolved paths cannot
98
+ * escape the project tree.
99
+ *
100
+ * @returns `{ removed, created, error }`:
101
+ * - `removed`: list of entry names that were deleted (e.g. `['<oldSid>/']`).
102
+ * - `created`: name of the new marker (e.g. `'<newSid>/'`) or null
103
+ * when the canonical marker already existed (no-op).
104
+ * - `error`: error message string or null.
105
+ */
106
+ export declare function syncChangeMarker(projectRoot: string, canonicalSessionId: string): {
107
+ removed: string[];
108
+ created: string | null;
109
+ error: string | null;
110
+ };
78
111
  /**
79
112
  * One-time migration step (added in slice 2026-06-05-peaks-runtime-layer).
80
113
  *
@@ -55,59 +55,75 @@ function runtimeNewBasename(oldBasename) {
55
55
  * of files under the dir excluding `session.json` itself.
56
56
  */
57
57
  export function discoverSessions(projectRoot) {
58
+ const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
58
59
  const peaksRoot = join(projectRoot, '.peaks');
59
- if (!existsSync(peaksRoot))
60
- return [];
61
- let names;
62
- try {
63
- names = readdirSync(peaksRoot);
64
- }
65
- catch {
66
- return [];
67
- }
60
+ // As of slice 003, the canonical home for session dirs is
61
+ // `.peaks/_runtime/<sid>/`. The legacy top-level layout is
62
+ // read for back-compat (one minor release) so pre-migration
63
+ // trees keep working. Both are scanned; duplicates (same sid
64
+ // in both homes) are de-duplicated with the canonical home
65
+ // winning.
66
+ const seen = new Set();
68
67
  const entries = [];
69
- for (const name of names) {
70
- if (!SESSION_ID_PATTERN.test(name))
71
- continue;
72
- const dir = join(peaksRoot, name);
73
- let stat;
68
+ const collect = (root) => {
69
+ if (!existsSync(root))
70
+ return;
71
+ let names;
74
72
  try {
75
- // lstatSync: false for symlinks (prevents rm -rf from following a
76
- // malicious symlink that points outside the project root).
77
- stat = lstatSync(dir);
73
+ names = readdirSync(root);
78
74
  }
79
75
  catch {
80
- continue;
76
+ return;
81
77
  }
82
- if (!stat.isDirectory())
83
- continue;
84
- const metaPath = join(dir, META_FILE);
85
- let lastActivity = null;
86
- if (existsSync(metaPath)) {
87
- try {
88
- lastActivity = statSync(metaPath).mtimeMs;
89
- }
90
- catch {
91
- lastActivity = null;
92
- }
78
+ for (const name of names) {
79
+ if (!SESSION_ID_PATTERN.test(name))
80
+ continue;
81
+ if (seen.has(name))
82
+ continue;
83
+ seen.add(name);
84
+ scanSessionDir(root, name, entries);
93
85
  }
94
- let childNames;
86
+ };
87
+ collect(runtimeRoot);
88
+ collect(peaksRoot);
89
+ entries.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
90
+ return entries;
91
+ }
92
+ function scanSessionDir(peaksRoot, name, entries) {
93
+ const dir = join(peaksRoot, name);
94
+ let stat;
95
+ try {
96
+ stat = lstatSync(dir);
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ if (!stat.isDirectory())
102
+ return;
103
+ const metaPath = join(dir, META_FILE);
104
+ let lastActivity = null;
105
+ if (existsSync(metaPath)) {
95
106
  try {
96
- childNames = readdirSync(dir);
107
+ lastActivity = statSync(metaPath).mtimeMs;
97
108
  }
98
109
  catch {
99
- childNames = [];
110
+ lastActivity = null;
100
111
  }
101
- let artifactCount = 0;
102
- for (const child of childNames) {
103
- if (child === META_FILE)
104
- continue;
105
- artifactCount += 1;
106
- }
107
- entries.push({ sessionId: name, path: dir, lastActivity, artifactCount });
108
112
  }
109
- entries.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
110
- return entries;
113
+ let childNames;
114
+ try {
115
+ childNames = readdirSync(dir);
116
+ }
117
+ catch {
118
+ childNames = [];
119
+ }
120
+ let artifactCount = 0;
121
+ for (const child of childNames) {
122
+ if (child === META_FILE)
123
+ continue;
124
+ artifactCount += 1;
125
+ }
126
+ entries.push({ sessionId: name, path: dir, lastActivity, artifactCount });
111
127
  }
112
128
  /**
113
129
  * 4-tier canonical selection. Tiers evaluated in order; first one that
@@ -299,6 +315,76 @@ function readActiveSkillSessionId(projectRoot) {
299
315
  }
300
316
  return null;
301
317
  }
318
+ /**
319
+ * Sync the single `change/<canonicalSessionId>/` live marker under
320
+ * `.peaks/_runtime/change/`. The marker is an EMPTY directory (no
321
+ * symlinks, no manifest, no content). Slice 006 collapses the F3
322
+ * per-change-id symlink layer to a single live marker so the
323
+ * `change/` layer is a single sentinel — easy for the LLM to
324
+ * navigate, easy for tests to assert on.
325
+ *
326
+ * Steps:
327
+ * 1. Ensure `.peaks/_runtime/change/` exists.
328
+ * 2. List its entries.
329
+ * 3. Remove every entry whose name is NOT `<canonicalSessionId>/`
330
+ * (no-op for those whose name matches).
331
+ * 4. If `<canonicalSessionId>/` is missing, create it (empty).
332
+ * 5. Return the diff for telemetry.
333
+ *
334
+ * Path-traversal guards: the canonical session id is validated by
335
+ * the caller (`SESSION_ID_PATTERN` in the session-manager helper).
336
+ * The function only ever operates under the project-root-resolved
337
+ * `.peaks/_runtime/change/` dir, so the resolved paths cannot
338
+ * escape the project tree.
339
+ *
340
+ * @returns `{ removed, created, error }`:
341
+ * - `removed`: list of entry names that were deleted (e.g. `['<oldSid>/']`).
342
+ * - `created`: name of the new marker (e.g. `'<newSid>/'`) or null
343
+ * when the canonical marker already existed (no-op).
344
+ * - `error`: error message string or null.
345
+ */
346
+ export function syncChangeMarker(projectRoot, canonicalSessionId) {
347
+ const root = resolve(projectRoot);
348
+ const changeDir = join(root, '.peaks', '_runtime', 'change');
349
+ const removed = [];
350
+ let created = null;
351
+ let error = null;
352
+ try {
353
+ if (!existsSync(changeDir)) {
354
+ mkdirSync(changeDir, { recursive: true });
355
+ }
356
+ const markerPath = join(changeDir, canonicalSessionId);
357
+ let existingNames = [];
358
+ try {
359
+ existingNames = readdirSync(changeDir);
360
+ }
361
+ catch {
362
+ existingNames = [];
363
+ }
364
+ for (const name of existingNames) {
365
+ if (name === canonicalSessionId)
366
+ continue;
367
+ const stale = join(changeDir, name);
368
+ try {
369
+ rmSync(stale, { recursive: true, force: true });
370
+ removed.push(name);
371
+ }
372
+ catch (e) {
373
+ // Best-effort: a single un-deletable entry must not abort the
374
+ // sync (the caller gets the rest of the diff).
375
+ error = e instanceof Error ? e.message : String(e);
376
+ }
377
+ }
378
+ if (!existsSync(markerPath)) {
379
+ mkdirSync(markerPath, { recursive: true });
380
+ created = canonicalSessionId;
381
+ }
382
+ }
383
+ catch (e) {
384
+ error = e instanceof Error ? e.message : String(e);
385
+ }
386
+ return { removed, created, error };
387
+ }
302
388
  /**
303
389
  * One-time migration step (added in slice 2026-06-05-peaks-runtime-layer).
304
390
  *
@@ -445,6 +531,36 @@ export function reconcileWorkspace(options) {
445
531
  }
446
532
  const deletionCandidates = findDeletionCandidates(sessions, ageThresholdMs);
447
533
  const deletionResult = applyDeletions(deletionCandidates, apply);
534
+ // Slice 006: sync the single `change/<canonicalSessionId>/` live
535
+ // marker (replaces the F3 per-change-id symlink layer). The marker
536
+ // is an empty dir; the function removes every other entry under
537
+ // `.peaks/_runtime/change/`. Idempotent. This step is independent
538
+ // of the apply flag — syncing the marker is a derived-state write,
539
+ // not a destructive side effect.
540
+ const changeMarker = canonical === null
541
+ ? { removed: [], created: null, error: 'no canonical session' }
542
+ : syncChangeMarker(projectRoot, canonical.sessionId);
543
+ // Slice 006: clean up the F3-introduced `.peaks/_runtime/<sid>/system/`
544
+ // subdir under EVERY session dir (not just the canonical one). The
545
+ // subdir was created eagerly by `initWorkspace` (F3) but was never
546
+ // used. The cleanup is idempotent: re-running on a tree without the
547
+ // subdir is a no-op. We walk every discovered session, not just the
548
+ // canonical one, because the user has 6 F3 sessions with the cruft
549
+ // and the spec says all of them must be removed. Logged in the
550
+ // systemCleaned array.
551
+ const systemCleaned = [];
552
+ for (const session of sessions) {
553
+ const systemDir = join(session.path, 'system');
554
+ if (existsSync(systemDir)) {
555
+ try {
556
+ rmSync(systemDir, { recursive: true, force: true });
557
+ systemCleaned.push(systemDir);
558
+ }
559
+ catch {
560
+ // Best-effort: a locked subdir does not block the rest of reconcile.
561
+ }
562
+ }
563
+ }
448
564
  return {
449
565
  projectRoot,
450
566
  sessions,
@@ -459,6 +575,8 @@ export function reconcileWorkspace(options) {
459
575
  apply,
460
576
  repointed,
461
577
  migratedFiles: migration.migratedFiles,
462
- errors: [...migrateErrors, ...deletionResult.errors]
578
+ errors: [...migrateErrors, ...deletionResult.errors],
579
+ changeMarker,
580
+ systemCleaned
463
581
  };
464
582
  }
@@ -78,6 +78,31 @@ export type ReconcileResult = {
78
78
  path: string;
79
79
  message: string;
80
80
  }>;
81
+ /**
82
+ * Slice 006 (2026-06-06-change-folder-simplify-and-lazy-role-subdirs):
83
+ * result of syncing the single `change/<canonicalSessionId>/` live
84
+ * marker under `.peaks/_runtime/change/`. The marker is an empty
85
+ * directory; every other entry under `change/` is removed. The
86
+ * `removed` array lists entry names that were deleted (e.g.
87
+ * `['<oldSid>/']`); `created` is the name of the new marker (or
88
+ * `null` when the canonical marker already existed — no-op);
89
+ * `error` is the first error message (if any) encountered during
90
+ * the sync. Additive — older consumers can ignore this field.
91
+ * Replaces the F3 `changeLinks` field.
92
+ */
93
+ changeMarker: {
94
+ removed: string[];
95
+ created: string | null;
96
+ error: string | null;
97
+ };
98
+ /**
99
+ * Slice 006: list of absolute paths to `.peaks/_runtime/<sid>/system/`
100
+ * subdirs that were removed by this reconcile run. The F3
101
+ * `initWorkspace` eagerly created the `system/` subdir; slice 006
102
+ * deletes it during reconcile. Empty when the canonical session
103
+ * had no `system/` subdir. Additive.
104
+ */
105
+ systemCleaned: string[];
81
106
  };
82
107
  export type ReconcileOptions = {
83
108
  projectRoot: string;
@@ -8,6 +8,15 @@ export type WorkspaceInitOptions = {
8
8
  * — the CLI surfaces this as `--allow-session-rebind`.
9
9
  */
10
10
  allowSessionRebind?: boolean;
11
+ /**
12
+ * Optional change-id to bind as the active unit of work. When set,
13
+ * `peaks workspace init` also writes a `.peaks/_runtime/current-change`
14
+ * symlink pointing at `.peaks/<changeId>/`, so RD/QA/PRD services
15
+ * know which `<change-id>` directory to write reviewable artifacts
16
+ * into. The session id is still the binding for ephemeral state
17
+ * (live sub-agent progress, spawn records).
18
+ */
19
+ changeId?: string;
11
20
  };
12
21
  export type WorkspaceInitReport = {
13
22
  sessionId: string;
@@ -16,6 +25,8 @@ export type WorkspaceInitReport = {
16
25
  alreadyExisted: string[];
17
26
  bound: boolean;
18
27
  previousSessionId: string | null;
28
+ changeId: string | null;
29
+ changeIdAction: 'bound' | 'preserved' | 'none';
19
30
  };
20
31
  export declare class InvalidSessionIdError extends Error {
21
32
  readonly code = "INVALID_SESSION_ID";
@@ -1,20 +1,8 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { isDirectory } from '../../shared/fs.js';
4
- import { getSessionId, setCurrentSessionBinding } from '../session/session-manager.js';
5
- const SUBDIRECTORIES = [
6
- 'prd/source',
7
- 'prd/requests',
8
- 'ui/requests',
9
- 'rd/requests',
10
- 'qa/test-cases',
11
- 'qa/test-reports',
12
- 'qa/requests',
13
- 'qa/screenshots',
14
- 'sc',
15
- 'txt',
16
- 'system'
17
- ];
4
+ import { getSessionId, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
5
+ import { setCurrentChangeId } from '../../shared/change-id.js';
18
6
  const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
19
7
  const PROHIBITED_SUFFIXES = ['session', 'work', 'task', 'test', 'temp', 'tmp'];
20
8
  // Auto-generated session ID pattern: YYYY-MM-DD-session-<6位hex>
@@ -61,9 +49,33 @@ export function validateSessionId(sessionId) {
61
49
  }
62
50
  export async function initWorkspace(options) {
63
51
  validateSessionId(options.sessionId);
64
- const sessionRoot = join(options.projectRoot, '.peaks', options.sessionId);
52
+ // Phase 6 refactor (slice 2026-06-05-change-id-as-unit-of-work) +
53
+ // slice 006 (2026-06-06-change-folder-simplify-and-lazy-role-subdirs):
54
+ // - Reviewable artifacts (rd/, qa/, prd/, txt/) live at
55
+ // `.peaks/<change-id>/<role>/` (tracked in git) when a change-id
56
+ // is given. The role subdirs are NOT pre-created — the writer
57
+ // (e.g. `peaks request init`, `peaks rd`) creates the parent
58
+ // dirs on demand via `mkdirSync(..., { recursive: true })`.
59
+ // - The session dir `.peaks/_runtime/<session-id>/` (gitignored)
60
+ // now holds ONLY the canonical `session.json` metadata. The
61
+ // F3-introduced `system/` subdir is gone — slice 006 removes
62
+ // it via `peaks workspace reconcile`; new init calls do not
63
+ // pre-create it.
64
+ // - The change-id dir is created when `--change-id` is given,
65
+ // but its role subdirs (prd/, qa/, rd/, sc/, txt/) are NOT
66
+ // pre-created either — same lazy-mkdir rule.
67
+ //
68
+ // The CLI accepts `--change-id <id>` to bind the change. The legacy
69
+ // session-scoped layout (`.peaks/<session-id>/<role>/<file>`) is
70
+ // no longer used by writes; pre-1.3.1 trees get their session
71
+ // files migrated to the change-id dir by `peaks workspace reconcile`.
72
+ const runtimeRoot = join(options.projectRoot, '.peaks', '_runtime');
73
+ const sessionRoot = join(runtimeRoot, options.sessionId);
65
74
  const created = [];
66
75
  const alreadyExisted = [];
76
+ // 1. Create the session dir (canonical location `.peaks/_runtime/<sid>/`)
77
+ // with NO subdirs. The session dir is gitignored; the role
78
+ // subdirs and the `system/` subdir are gone entirely (slice 006).
67
79
  if (await isDirectory(sessionRoot)) {
68
80
  alreadyExisted.push('.');
69
81
  }
@@ -71,17 +83,43 @@ export async function initWorkspace(options) {
71
83
  await mkdir(sessionRoot, { recursive: true });
72
84
  created.push('.');
73
85
  }
74
- for (const sub of SUBDIRECTORIES) {
75
- const full = join(sessionRoot, sub);
76
- if (await isDirectory(full)) {
77
- alreadyExisted.push(sub);
86
+ // 1a. Write the per-session metadata file. Slice 006 makes
87
+ // `.peaks/_runtime/<sid>/session.json` the durable session
88
+ // metadata (the body's source of truth, the `peaks workspace
89
+ // reconcile` discovery source). The file is created on first
90
+ // init and refreshed on every subsequent init. Idempotent.
91
+ setSessionMeta(options.projectRoot, options.sessionId, {});
92
+ // 2. If a change-id is given, also create the change-id dir at
93
+ // `.peaks/<change-id>/` (tracked) with NO role subdirs. The
94
+ // role subdirs (prd/, qa/, rd/, sc/, txt/) are created on demand
95
+ // by the writer at the write site. When the caller did NOT
96
+ // specify a change-id, this step is skipped — reviewable writes
97
+ // for this session are then blocked until a change-id is bound
98
+ // (or the user re-runs init with `--change-id`). Surfaces in
99
+ // `changeIdAction: 'none'`.
100
+ let resolvedChangeId = null;
101
+ let changeIdAction = 'none';
102
+ if (options.changeId !== undefined && options.changeId.length > 0) {
103
+ resolvedChangeId = options.changeId;
104
+ const changeDir = join(options.projectRoot, '.peaks', resolvedChangeId);
105
+ if (await isDirectory(changeDir)) {
106
+ alreadyExisted.push(resolvedChangeId);
78
107
  }
79
108
  else {
80
- await mkdir(full, { recursive: true });
81
- created.push(sub);
109
+ await mkdir(changeDir, { recursive: true });
110
+ created.push(resolvedChangeId);
82
111
  }
112
+ // 3. Bind the change-id so RD/QA/PRD services know where to write
113
+ // reviewable artifacts. The binding is a symlink at
114
+ // `.peaks/_runtime/current-change` pointing at the change-id dir.
115
+ setCurrentChangeId(options.projectRoot, resolvedChangeId);
116
+ changeIdAction = 'bound';
117
+ }
118
+ else if (options.changeId !== undefined && options.changeId.length === 0) {
119
+ // Empty string — same as undefined; treat as no change-id.
120
+ changeIdAction = 'none';
83
121
  }
84
- // Bind this session as the project's current one.
122
+ // 4. Bind this session as the project's current one.
85
123
  //
86
124
  // Single source of truth: `peaks workspace init` is the only CLI entry point
87
125
  // that takes an explicit --session-id, so it owns the binding to .session.json.
@@ -112,7 +150,7 @@ export async function initWorkspace(options) {
112
150
  // etc.) regardless of whether the per-session metadata file is present.
113
151
  // Refuse to rebind without explicit authorization.
114
152
  previousSessionId = existingSessionId;
115
- const existingSessionDir = join(options.projectRoot, '.peaks', existingSessionId);
153
+ const existingSessionDir = join(runtimeRoot, existingSessionId);
116
154
  if (await isDirectory(existingSessionDir) && !options.allowSessionRebind) {
117
155
  const { readdirSync } = await import('node:fs');
118
156
  const entries = readdirSync(existingSessionDir);
@@ -127,5 +165,14 @@ export async function initWorkspace(options) {
127
165
  setCurrentSessionBinding(options.projectRoot, options.sessionId);
128
166
  bound = true;
129
167
  }
130
- return { sessionId: options.sessionId, sessionRoot, created, alreadyExisted, bound, previousSessionId };
168
+ return {
169
+ sessionId: options.sessionId,
170
+ sessionRoot,
171
+ created,
172
+ alreadyExisted,
173
+ bound,
174
+ previousSessionId,
175
+ changeId: resolvedChangeId,
176
+ changeIdAction
177
+ };
131
178
  }