peaks-cli 1.2.9 → 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 (53) hide show
  1. package/README.md +62 -46
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/hooks-commands.js +24 -9
  4. package/dist/src/cli/commands/progress-commands.js +26 -2
  5. package/dist/src/cli/commands/request-commands.js +5 -0
  6. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  7. package/dist/src/cli/commands/slice-commands.js +42 -0
  8. package/dist/src/cli/commands/workflow-commands.js +3 -3
  9. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  10. package/dist/src/cli/commands/workspace-commands.js +347 -5
  11. package/dist/src/cli/program.js +4 -0
  12. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  13. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  14. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  15. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  16. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  17. package/dist/src/services/doctor/doctor-service.js +20 -2
  18. package/dist/src/services/progress/progress-service.d.ts +26 -0
  19. package/dist/src/services/progress/progress-service.js +25 -0
  20. package/dist/src/services/sc/sc-service.d.ts +52 -1
  21. package/dist/src/services/sc/sc-service.js +324 -17
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +7 -5
  24. package/dist/src/services/session/session-manager.js +60 -16
  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/skills/skill-presence-service.js +102 -68
  28. package/dist/src/services/skills/skill-runbook-service.js +2 -1
  29. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  30. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  31. package/dist/src/services/slice/slice-check-service.js +248 -0
  32. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  33. package/dist/src/services/slice/slice-check-types.js +18 -0
  34. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  35. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  36. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  37. package/dist/src/services/workspace/migrate-service.js +484 -0
  38. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  39. package/dist/src/services/workspace/migrate-types.js +21 -0
  40. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  41. package/dist/src/services/workspace/reconcile-service.js +464 -0
  42. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  43. package/dist/src/services/workspace/reconcile-types.js +13 -0
  44. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  45. package/dist/src/services/workspace/workspace-service.js +87 -7
  46. package/dist/src/shared/change-id.d.ts +59 -0
  47. package/dist/src/shared/change-id.js +194 -16
  48. package/dist/src/shared/version.d.ts +1 -1
  49. package/dist/src/shared/version.js +1 -1
  50. package/package.json +13 -2
  51. package/skills/peaks-solo/SKILL.md +28 -4
  52. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  53. package/skills/peaks-solo/references/runbook.md +2 -0
@@ -1,6 +1,6 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { basename, relative, resolve } from 'node:path';
3
+ import { basename, join, resolve } from 'node:path';
4
4
  import { isInsidePath } from '../../shared/path-utils.js';
5
5
  import { getWorkspaceConfigForPath } from '../config/config-service.js';
6
6
  import { getArtifactRemoteRepo, getArtifactWorkspaceStatus, getLocalArtifactPath } from '../artifacts/workspace-service.js';
@@ -21,6 +21,27 @@ const RETENTION_REQUIREMENTS = [
21
21
  ['sc', 'retention-boundary.md'],
22
22
  ['txt', 'context-capsule.md']
23
23
  ];
24
+ /**
25
+ * "Modern" retention requirements for the current peaks-cli artifact
26
+ * naming convention. The legacy `RETENTION_REQUIREMENTS` above assume
27
+ * older `refactor-goal.md` / `slice-spec.md` / `coverage-report.md` /
28
+ * `validation-report.md` / `change-impact.json` / `retention-boundary.md`
29
+ * / `context-capsule.md` filenames that predate the W4 session resolver.
30
+ *
31
+ * When the resolver finds a session that owns the slice, validate
32
+ * against the modern set: the actual files the current workflow emits
33
+ * (per-slice `prd/requests/<rid>.md`, per-slice `rd/requests/<rid>.md`,
34
+ * per-session `rd/tech-doc.md`, per-slice `qa/test-cases/<rid>.md`,
35
+ * per-slice `qa/test-reports/<rid>.md`, per-session `txt/handoff.md`).
36
+ * The legacy set is preserved for the workspace-artifact path so
37
+ * existing repos on the old convention keep working.
38
+ */
39
+ const MODERN_RETENTION_REQUIREMENTS = [
40
+ 'rd/tech-doc.md',
41
+ 'qa/test-cases/{sliceId}.md',
42
+ 'qa/test-reports/{sliceId}.md',
43
+ 'txt/handoff.md'
44
+ ];
24
45
  const SLICE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
25
46
  function getPeaksPath(workspaceRoot) {
26
47
  return resolve(workspaceRoot, '.peaks');
@@ -108,6 +129,208 @@ function isRetainedArtifactFile(filePath, artifactWorkspacePath, changesRoot, ch
108
129
  return false;
109
130
  }
110
131
  }
132
+ /**
133
+ * Read the orchestrator's active-skill marker and return its
134
+ * `sessionId`, or null when the file is missing / malformed.
135
+ *
136
+ * As of slice 2026-06-05-peaks-runtime-layer the canonical home is
137
+ * `<projectRoot>/.peaks/_runtime/active-skill.json`. The legacy
138
+ * `<projectRoot>/.peaks/.active-skill.json` is consulted as a
139
+ * one-minor-release back-compat fallback: if the new path is
140
+ * absent but the legacy path is present and valid, we use the
141
+ * legacy value. The new path always wins when both exist.
142
+ */
143
+ function readActiveSkillSessionId(projectRoot) {
144
+ const newPath = join(projectRoot, '.peaks', '_runtime', 'active-skill.json');
145
+ const legacyPath = join(projectRoot, '.peaks', '.active-skill.json');
146
+ const pathToRead = existsSync(newPath) ? newPath : legacyPath;
147
+ if (!existsSync(pathToRead))
148
+ return null;
149
+ try {
150
+ const raw = readFileSync(pathToRead, 'utf8');
151
+ const parsed = JSON.parse(raw);
152
+ if (typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0) {
153
+ return parsed.sessionId;
154
+ }
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * Read the workspace session binding and return its `sessionId`,
163
+ * or null when the file is missing / malformed.
164
+ *
165
+ * As of slice 2026-06-05-peaks-runtime-layer the canonical home is
166
+ * `<projectRoot>/.peaks/_runtime/session.json`. The legacy
167
+ * `<projectRoot>/.peaks/.session.json` is consulted as a
168
+ * one-minor-release back-compat fallback: if the new path is
169
+ * absent but the legacy path is present and valid, we use the
170
+ * legacy value. The new path always wins when both exist.
171
+ */
172
+ function readSessionJsonBinding(projectRoot) {
173
+ const newPath = join(projectRoot, '.peaks', '_runtime', 'session.json');
174
+ const legacyPath = join(projectRoot, '.peaks', '.session.json');
175
+ const pathToRead = existsSync(newPath) ? newPath : legacyPath;
176
+ if (!existsSync(pathToRead))
177
+ return null;
178
+ try {
179
+ const raw = readFileSync(pathToRead, 'utf8');
180
+ const parsed = JSON.parse(raw);
181
+ if (typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0) {
182
+ return parsed.sessionId;
183
+ }
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ return null;
189
+ }
190
+ /**
191
+ * The "marker" artifact whose existence under a session dir is the signal
192
+ * that the session owns the slice. We look for `qa/test-cases/<sliceId>.md`
193
+ * first (a present test plan is the most decisive signal of session
194
+ * ownership for a slice id). If the test plan is absent we also accept
195
+ * `qa/test-reports/<sliceId>.md` (a finished QA report is also a decisive
196
+ * ownership signal). When neither exists for a candidate session, that
197
+ * session does not own the slice.
198
+ */
199
+ function sessionOwnsSlice(projectRoot, sessionId, sliceId) {
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
+ }
218
+ }
219
+ return false;
220
+ }
221
+ /**
222
+ * Find a session dir under `<projectRoot>/.peaks/` that owns the slice
223
+ * (see `sessionOwnsSlice`). Returns the first match in lexicographic
224
+ * order, or null when no session owns the slice.
225
+ */
226
+ function findSessionOwningSlice(projectRoot, sliceId) {
227
+ const peaksRoot = join(projectRoot, '.peaks');
228
+ if (!existsSync(peaksRoot))
229
+ return null;
230
+ let topLevel;
231
+ try {
232
+ topLevel = readdirSync(peaksRoot);
233
+ }
234
+ catch {
235
+ return null;
236
+ }
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))
264
+ continue;
265
+ if (sessionOwnsSlice(projectRoot, id, sliceId)) {
266
+ return id;
267
+ }
268
+ }
269
+ return null;
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
+ }
293
+ /**
294
+ * Resolve the session id that owns the slice's artifacts using a 3-tier
295
+ * precedence:
296
+ *
297
+ * 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
298
+ * fallback to `.peaks/.active-skill.json`) if it points to a real
299
+ * session that owns the slice.
300
+ * 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
301
+ * fallback to `.peaks/.session.json`) if it points to a real
302
+ * session that owns the slice.
303
+ * 3. `find .peaks/ -name '<marker>'` — the first session dir under
304
+ * `.peaks/` that owns the slice.
305
+ * 4. else `{ resolvedSessionId: null, candidateSources: [] }`.
306
+ *
307
+ * The back-compat fallbacks are tolerated for one minor release so
308
+ * users with pre-migration trees (or running an older CLI version)
309
+ * still get a clean resolution. After the migration (or after v1.3.0
310
+ * is installed and `peaks workspace reconcile` has been run), only
311
+ * the new paths exist and the fallbacks never fire.
312
+ *
313
+ * `candidateSources` reports which sources were checked before the
314
+ * resolver found (or did not find) a winner; the list is in the order
315
+ * the resolver consulted them. This makes the precedence observable in
316
+ * the JSON envelope so a human reviewer can see "active-skill was empty
317
+ * AND session-json was empty, so find-fallback won".
318
+ */
319
+ export function resolveArtifactSession(projectRoot, sliceId) {
320
+ const activeSkill = readActiveSkillSessionId(projectRoot);
321
+ if (activeSkill !== null && sessionOwnsSlice(projectRoot, activeSkill, sliceId)) {
322
+ return { resolvedSessionId: activeSkill, candidateSources: ['active-skill'] };
323
+ }
324
+ const sessionJson = readSessionJsonBinding(projectRoot);
325
+ if (sessionJson !== null && sessionOwnsSlice(projectRoot, sessionJson, sliceId)) {
326
+ return { resolvedSessionId: sessionJson, candidateSources: ['active-skill', 'session-json'] };
327
+ }
328
+ const findHit = findSessionOwningSlice(projectRoot, sliceId);
329
+ if (findHit !== null) {
330
+ return { resolvedSessionId: findHit, candidateSources: ['active-skill', 'session-json', 'find-fallback'] };
331
+ }
332
+ return { resolvedSessionId: null, candidateSources: [] };
333
+ }
111
334
  export function getChangeTraceabilityStatus() {
112
335
  const workspace = getWorkspaceConfigForPath(process.cwd());
113
336
  const artifactStatus = getArtifactWorkspaceStatus(workspace?.workspaceId);
@@ -196,6 +419,8 @@ export function recordCommitBoundary(options) {
196
419
  const workspace = getWorkspaceConfigForPath(process.cwd());
197
420
  const artifactStatus = getArtifactWorkspaceStatus(workspace?.workspaceId);
198
421
  const commitHash = getCurrentCommitHash(workspace?.rootPath);
422
+ const projectRoot = workspace?.rootPath ?? process.cwd();
423
+ const resolution = resolveArtifactSession(projectRoot, options.sliceId);
199
424
  return {
200
425
  sliceId: options.sliceId,
201
426
  commitHash,
@@ -203,38 +428,120 @@ export function recordCommitBoundary(options) {
203
428
  artifacts: options.artifacts ?? [],
204
429
  codeFiles: options.codeFiles ?? [],
205
430
  syncState: mapSyncState(artifactStatus.syncStatus),
206
- rollbackPoint: commitHash
431
+ rollbackPoint: commitHash,
432
+ resolvedSessionId: resolution.resolvedSessionId,
433
+ candidateSources: resolution.candidateSources
207
434
  };
208
435
  }
209
436
  export function validateArtifactRetention(sliceId) {
210
437
  const workspace = getWorkspaceConfigForPath(process.cwd());
211
- if (!workspace) {
438
+ // Resolve from `process.cwd()` even when no workspace is configured, so
439
+ // the W4 session resolver can still find the slice's owning session.
440
+ // The legacy "no workspace" check still surfaces as a missing artifact,
441
+ // but the resolution happens first so the JSON envelope's additive
442
+ // `resolvedSessionId` is populated regardless of workspace state.
443
+ const projectRoot = workspace?.rootPath ?? process.cwd();
444
+ if (!SLICE_ID_PATTERN.test(sliceId)) {
212
445
  return {
213
446
  valid: false,
214
- missingArtifacts: ['No workspace configured'],
215
- warnings: ['Cannot validate without a configured workspace']
447
+ missingArtifacts: ['Invalid slice id'],
448
+ warnings: ['Slice id must stay inside .peaks/<session-id> and only contain letters, numbers, dots, underscores, or hyphens'],
449
+ resolvedSessionId: null,
450
+ candidateSources: []
216
451
  };
217
452
  }
218
- if (!SLICE_ID_PATTERN.test(sliceId)) {
453
+ const resolution = resolveArtifactSession(projectRoot, sliceId);
454
+ const effectiveSliceId = resolution.resolvedSessionId ?? sliceId;
455
+ // W4: if the resolver found a session, ALSO accept artifacts under
456
+ // `<projectRoot>/.peaks/<resolvedSessionId>/` (the canonical per-slice
457
+ // dir). The project-root peaks is where the orchestrator's skills
458
+ // actually write (see `initWorkspace` in `workspace-service.ts`), so
459
+ // when the resolution chain lands on a real session the artifacts are
460
+ // usually there. We accept either location — workspace artifact path
461
+ // OR project-root peaks — so the additive behavior does not regress
462
+ // existing workspaces.
463
+ const resolvedPeaksSessionDir = resolution.resolvedSessionId !== null
464
+ ? join(projectRoot, '.peaks', resolution.resolvedSessionId)
465
+ : null;
466
+ // Collect present files: legacy workspace-artifact-path check, OR the
467
+ // resolved session's project-root peaks dir.
468
+ const legacyPresent = (folder, file) => {
469
+ if (!workspace)
470
+ return false;
471
+ const artifactWorkspacePath = getLocalArtifactPath(workspace);
472
+ const { peaksPath, changeDir } = getRetentionChangeDir(artifactWorkspacePath, effectiveSliceId);
473
+ const filePath = resolve(changeDir, folder, file);
474
+ return isRetainedArtifactFile(filePath, artifactWorkspacePath, peaksPath, changeDir);
475
+ };
476
+ const resolvedPresent = (folder, file) => {
477
+ if (resolvedPeaksSessionDir === null)
478
+ return false;
479
+ return existsSync(join(resolvedPeaksSessionDir, folder, file));
480
+ };
481
+ if (!workspace) {
482
+ // No workspace: validate against the resolved session dir directly
483
+ // (this is the common peaks-solo / peaks-rd invocation: the slice
484
+ // lives under the project-root `.peaks/<sessionId>/`, and the
485
+ // workspace artifact path is irrelevant). When the resolution also
486
+ // fails, fall back to the legacy "No workspace configured" failure
487
+ // mode so the existing CLI contract is preserved.
488
+ if (resolvedPeaksSessionDir === null) {
489
+ return {
490
+ valid: false,
491
+ missingArtifacts: ['No workspace configured'],
492
+ warnings: ['Cannot validate without a configured workspace'],
493
+ resolvedSessionId: resolution.resolvedSessionId,
494
+ candidateSources: resolution.candidateSources
495
+ };
496
+ }
497
+ const missingArtifacts = modernRequirementRelativePaths(sliceId).filter((rel) => !existsSync(join(resolvedPeaksSessionDir, rel)));
219
498
  return {
220
- valid: false,
221
- missingArtifacts: ['Invalid slice id'],
222
- warnings: ['Slice id must stay inside .peaks/<session-id> and only contain letters, numbers, dots, underscores, or hyphens']
499
+ valid: missingArtifacts.length === 0,
500
+ missingArtifacts,
501
+ warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing'],
502
+ resolvedSessionId: resolution.resolvedSessionId,
503
+ candidateSources: resolution.candidateSources
223
504
  };
224
505
  }
225
- const artifactWorkspacePath = getLocalArtifactPath(workspace);
226
- const { peaksPath, changeDir } = getRetentionChangeDir(artifactWorkspacePath, sliceId);
227
- const changesRoot = peaksPath;
228
506
  const missingArtifacts = RETENTION_REQUIREMENTS
229
- .map(([folder, file]) => resolve(changeDir, folder, file))
230
- .filter((filePath) => !isRetainedArtifactFile(filePath, artifactWorkspacePath, changesRoot, changeDir))
231
- .map((filePath) => relative(changeDir, filePath).replace(/\\/g, '/'));
507
+ .map(([folder, file]) => `${folder}/${file}`)
508
+ .filter((rel) => !legacyPresent(...rel.split('/')) && !resolvedPresent(...rel.split('/')));
509
+ // If the legacy check is short (i.e. we're missing a lot of legacy-named
510
+ // files) but the resolver landed on a real session, ALSO accept the
511
+ // modern set. The legacy set was designed for an older workflow naming
512
+ // and a freshly-minted session in the current peaks-cli flow will not
513
+ // have the legacy names. This keeps `peaks sc validate --slice-id <rid>`
514
+ // returning `valid: true` for slices that completed under the current
515
+ // peaks-cli convention.
516
+ if (missingArtifacts.length > 0 && resolvedPeaksSessionDir !== null) {
517
+ const modernMissing = modernRequirementRelativePaths(sliceId).filter((rel) => !resolvedPresent(...rel.split('/')));
518
+ if (modernMissing.length === 0) {
519
+ return {
520
+ valid: true,
521
+ missingArtifacts: [],
522
+ warnings: [],
523
+ resolvedSessionId: resolution.resolvedSessionId,
524
+ candidateSources: resolution.candidateSources
525
+ };
526
+ }
527
+ }
232
528
  return {
233
529
  valid: missingArtifacts.length === 0,
234
530
  missingArtifacts,
235
- warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing']
531
+ warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing'],
532
+ resolvedSessionId: resolution.resolvedSessionId,
533
+ candidateSources: resolution.candidateSources
236
534
  };
237
535
  }
536
+ /**
537
+ * Render the modern retention requirements as relative paths keyed
538
+ * against the slice id. The `{sliceId}` placeholder in the template
539
+ * is replaced with the actual slice id; per-session files (no
540
+ * placeholder) keep their literal name.
541
+ */
542
+ function modernRequirementRelativePaths(sliceId) {
543
+ return MODERN_RETENTION_REQUIREMENTS.map((template) => template.replace('{sliceId}', sliceId));
544
+ }
238
545
  export function getScHelpText() {
239
546
  return [
240
547
  'peaks sc status Show change traceability status',
@@ -104,8 +104,12 @@ export async function getAcceptanceCoverage(options) {
104
104
  if (prdArtifact === null) {
105
105
  return { kind: 'prd-not-found' };
106
106
  }
107
- const sessionId = prdArtifact.sessionId;
108
- const testCasesPath = join(options.projectRoot, '.peaks', sessionId, 'qa', 'test-cases', `${options.requestId}.md`);
107
+ // As of slice 2026-06-05-change-id-as-unit-of-work, test-cases live
108
+ // under the same change-id dir as the PRD itself (the on-disk scope),
109
+ // not under the body's `- session:` line. The `prdArtifact.changeId`
110
+ // is the dir the PRD was found in.
111
+ const changeId = prdArtifact.changeId;
112
+ const testCasesPath = join(options.projectRoot, '.peaks', changeId, 'qa', 'test-cases', `${options.requestId}.md`);
109
113
  if (!(await pathExists(testCasesPath))) {
110
114
  return { kind: 'test-cases-not-found', expectedPath: testCasesPath };
111
115
  }
@@ -33,11 +33,13 @@ export type SessionMeta = {
33
33
  outerSessionId?: string;
34
34
  };
35
35
  /**
36
- * Drop the project-level session binding (`.peaks/.session.json`)
37
- * so the next `ensureSession()` call auto-generates a fresh
38
- * session id. The on-disk session directory is left intact —
39
- * rotating does NOT delete the user's data, it just unbinds the
40
- * project from that session.
36
+ * Drop the project-level session binding at the canonical
37
+ * `.peaks/_runtime/session.json` so the next `ensureSession()` call
38
+ * auto-generates a fresh session id. The on-disk session directory
39
+ * is left intact — rotating does NOT delete the user's data, it
40
+ * just unbinds the project from that session. Also drops the legacy
41
+ * `.peaks/.session.json` if present so a stale read from another
42
+ * tool cannot re-bind the project after rotation.
41
43
  *
42
44
  * Returns the id of the session that was unbound, or `null` if
43
45
  * no binding was present. The caller is expected to do something
@@ -6,11 +6,21 @@
6
6
  * Each session gets a unique directory under .peaks/ with incrementing numbered files.
7
7
  */
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
9
- import { join, resolve } from 'node:path';
9
+ import { mkdir as mkdirAsync } from 'node:fs/promises';
10
+ import { dirname, join, resolve } from 'node:path';
10
11
  import { randomBytes } from 'node:crypto';
11
12
  import { initWorkspace } from '../workspace/workspace-service.js';
12
- const SESSION_FILE = '.session.json';
13
+ // As of slice 2026-06-05-peaks-runtime-layer the project-level session
14
+ // binding lives under `.peaks/_runtime/session.json`. The legacy
15
+ // `.peaks/.session.json` path is preserved as a read-only fallback for one
16
+ // minor release so older CLI versions (or trees that have not been migrated
17
+ // by `peaks workspace reconcile`) keep working without a forced re-init.
18
+ const SESSION_FILE = join('_runtime', 'session.json');
19
+ const LEGACY_SESSION_FILE = '.session.json';
13
20
  const META_FILE = 'session.json';
21
+ function getLegacySessionFilePath(projectRoot) {
22
+ return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
23
+ }
14
24
  /**
15
25
  * Canonicalize a project root path. Returns the realpath
16
26
  * (resolving all symlinks — important on macOS where `/var`
@@ -68,7 +78,9 @@ function generateSessionId() {
68
78
  return `${date}-session-${random}`;
69
79
  }
70
80
  /**
71
- * Get the path to the session file for a project.
81
+ * Get the path to the session file for a project. The canonical home is
82
+ * `.peaks/_runtime/session.json`; the legacy `.peaks/.session.json` is
83
+ * read-only fallback (see `readSessionFile`).
72
84
  */
73
85
  function getSessionFilePath(projectRoot) {
74
86
  return join(projectRoot, '.peaks', SESSION_FILE);
@@ -92,10 +104,15 @@ function getSessionFilePath(projectRoot) {
92
104
  */
93
105
  function readSessionFile(projectRoot) {
94
106
  const sessionFile = getSessionFilePath(projectRoot);
95
- if (!existsSync(sessionFile))
107
+ const legacyFile = getLegacySessionFilePath(projectRoot);
108
+ // Back-compat window: prefer the new canonical path; fall back to the
109
+ // legacy `.peaks/.session.json` so older CLI versions or pre-migration
110
+ // trees keep working. When both exist, the new path wins.
111
+ const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
112
+ if (!existsSync(pathToRead))
96
113
  return null;
97
114
  try {
98
- const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
115
+ const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
99
116
  if (data.sessionId && data.projectRoot === projectRoot) {
100
117
  return data;
101
118
  }
@@ -116,10 +133,14 @@ function readSessionFile(projectRoot) {
116
133
  */
117
134
  function readSessionFileCanonical(projectRoot) {
118
135
  const sessionFile = getSessionFilePath(projectRoot);
119
- if (!existsSync(sessionFile))
136
+ const legacyFile = getLegacySessionFilePath(projectRoot);
137
+ // Back-compat window: prefer the new canonical path; fall back to the
138
+ // legacy `.peaks/.session.json` for one minor release.
139
+ const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
140
+ if (!existsSync(pathToRead))
120
141
  return null;
121
142
  try {
122
- const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
143
+ const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
123
144
  const storedRaw = typeof data.projectRoot === 'string' ? data.projectRoot : null;
124
145
  if (data.sessionId &&
125
146
  storedRaw !== null &&
@@ -133,22 +154,27 @@ function readSessionFileCanonical(projectRoot) {
133
154
  }
134
155
  }
135
156
  /**
136
- * Write session info to disk.
157
+ * Write session info to disk at the canonical new path
158
+ * `.peaks/_runtime/session.json`. The `.peaks/_runtime/` directory is
159
+ * created on demand. The legacy `.peaks/.session.json` is NOT written by
160
+ * this slice; it is only read for back-compat.
137
161
  */
138
162
  function writeSessionFile(projectRoot, info) {
139
163
  const sessionFile = getSessionFilePath(projectRoot);
140
- const dir = join(projectRoot, '.peaks');
164
+ const dir = dirname(sessionFile);
141
165
  if (!existsSync(dir)) {
142
166
  mkdirSync(dir, { recursive: true });
143
167
  }
144
168
  writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
145
169
  }
146
170
  /**
147
- * Drop the project-level session binding (`.peaks/.session.json`)
148
- * so the next `ensureSession()` call auto-generates a fresh
149
- * session id. The on-disk session directory is left intact —
150
- * rotating does NOT delete the user's data, it just unbinds the
151
- * project from that session.
171
+ * Drop the project-level session binding at the canonical
172
+ * `.peaks/_runtime/session.json` so the next `ensureSession()` call
173
+ * auto-generates a fresh session id. The on-disk session directory
174
+ * is left intact — rotating does NOT delete the user's data, it
175
+ * just unbinds the project from that session. Also drops the legacy
176
+ * `.peaks/.session.json` if present so a stale read from another
177
+ * tool cannot re-bind the project after rotation.
152
178
  *
153
179
  * Returns the id of the session that was unbound, or `null` if
154
180
  * no binding was present. The caller is expected to do something
@@ -164,6 +190,15 @@ export function rotateSessionBinding(projectRoot) {
164
190
  if (existsSync(sessionFile)) {
165
191
  unlinkSync(sessionFile);
166
192
  }
193
+ const legacyFile = getLegacySessionFilePath(projectRoot);
194
+ if (existsSync(legacyFile)) {
195
+ try {
196
+ unlinkSync(legacyFile);
197
+ }
198
+ catch {
199
+ // best-effort: a stale legacy binding is not blocking
200
+ }
201
+ }
167
202
  return previous.sessionId;
168
203
  }
169
204
  /**
@@ -387,7 +422,15 @@ export function listSessions(projectRoot) {
387
422
  */
388
423
  export async function getProjectScanPath(projectRoot) {
389
424
  const sessionId = await ensureSession(projectRoot);
390
- return join(projectRoot, '.peaks', sessionId, 'rd', 'project-scan.md');
425
+ // As of slice 2026-06-05-change-id-as-unit-of-work the session dir
426
+ // is at the canonical runtime location (gitignored). The scan is a
427
+ // session-local artifact; it lives alongside the rest of the
428
+ // ephemeral state under `_runtime/`. The parent `rd/` subdir is
429
+ // created on demand so the first scanner call has a place to land
430
+ // (consistent with the legacy behavior pre-1.3.1).
431
+ const scanPath = join(projectRoot, '.peaks', '_runtime', sessionId, 'rd', 'project-scan.md');
432
+ await mkdirAsync(dirname(scanPath), { recursive: true });
433
+ return scanPath;
391
434
  }
392
435
  /**
393
436
  * Check if project-scan.md exists for the current session.
@@ -399,6 +442,7 @@ export function hasProjectScan(projectRoot) {
399
442
  const info = readSessionFile(projectRoot);
400
443
  if (!info)
401
444
  return false;
402
- const scanPath = join(projectRoot, '.peaks', info.sessionId, 'rd', 'project-scan.md');
445
+ // Canonical runtime location of the session dir (slice 2026-06-05).
446
+ const scanPath = join(projectRoot, '.peaks', '_runtime', info.sessionId, 'rd', 'project-scan.md');
403
447
  return existsSync(scanPath);
404
448
  }
@@ -14,16 +14,31 @@
14
14
  * removes only our own entry.
15
15
  */
16
16
  export type HookScope = 'project' | 'global';
17
- /** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
17
+ /** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
18
18
  export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\"";
19
- /** Substring that identifies a Peaks-managed PreToolUse hook entry. */
20
- export declare const HOOK_SENTINEL = "peaks gate enforce";
19
+ /**
20
+ * Hook command for the sub-agent progress auto-spawn. Fires on every Task
21
+ * tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
22
+ * command itself is non-blocking: `peaks progress start` is idempotent
23
+ * (5-minute TTL on the spawn record) so the LLM does not see a fresh
24
+ * terminal per Task. The `--quiet` flag keeps the LLM context clean — the
25
+ * hook output otherwise adds ~500 tokens per Task call.
26
+ */
27
+ export declare const HOOK_PROGRESS_COMMAND = "peaks progress start --project \"${CLAUDE_PROJECT_DIR}\" --reason \"auto-spawn for sub-agent Task\" --quiet";
28
+ /** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
29
+ export declare const HOOK_ENFORCE_SENTINEL = "peaks gate enforce";
30
+ /** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
31
+ export declare const HOOK_PROGRESS_SENTINEL = "peaks progress start";
21
32
  export type HookInstallPlan = {
22
33
  scope: HookScope;
23
34
  settingsPath: string;
24
35
  exists: boolean;
25
36
  alreadyInstalled: boolean;
26
37
  desiredCommand: string;
38
+ /** Substring sentinel used to detect the entry. */
39
+ sentinel: string;
40
+ /** Tool name (Bash | Task) the PreToolUse hook is keyed on. */
41
+ matcher: string;
27
42
  };
28
43
  export type HookInstallResult = HookInstallPlan & {
29
44
  applied: boolean;
@@ -39,6 +54,13 @@ export type HookStatus = {
39
54
  exists: boolean;
40
55
  installed: boolean;
41
56
  };
57
+ /** A typed descriptor for a single peaks-managed hook entry. */
58
+ export type PeaksHookEntry = {
59
+ sentinel: string;
60
+ matcher: string;
61
+ command: string;
62
+ };
63
+ export declare const PEAKS_HOOK_ENTRIES: ReadonlyArray<PeaksHookEntry>;
42
64
  export declare function planHookInstall(scope: HookScope, projectRoot?: string): HookInstallPlan;
43
65
  export declare function applyHookInstall(scope: HookScope, projectRoot?: string): HookInstallResult;
44
66
  export declare function removeHookInstall(scope: HookScope, projectRoot?: string): HookRemoveResult;