peaks-cli 1.2.9 → 1.3.0

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.
package/bin/peaks.js CHANGED
File without changes
@@ -1,8 +1,11 @@
1
1
  import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
2
+ import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
2
3
  import { ensureSession } from '../../services/session/session-manager.js';
3
4
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
4
5
  import { fail, ok } from '../../shared/result.js';
5
6
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
7
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
8
+ const DEFAULT_RECONCILE_AGE_DAYS = 7;
6
9
  export function registerWorkspaceCommands(program, io) {
7
10
  const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
8
11
  addJsonOption(workspace
@@ -18,7 +21,7 @@ export function registerWorkspaceCommands(program, io) {
18
21
  // session, unless --allow-session-rebind is set)
19
22
  // - omitted: defer to ensureSession(), which reuses an existing
20
23
  // binding or auto-generates a fresh one. The init then writes
21
- // .session.json so the binding sticks.
24
+ // .peaks/_runtime/session.json so the binding sticks.
22
25
  //
23
26
  // Before that: canonicalise the project root. If the user (or the
24
27
  // LLM via "$(pwd)") passed a sub-directory of a real git repo
@@ -75,4 +78,59 @@ export function registerWorkspaceCommands(program, io) {
75
78
  process.exitCode = 1;
76
79
  }
77
80
  });
81
+ addJsonOption(workspace
82
+ .command('reconcile')
83
+ .description('Scan .peaks/2026-MM-DD-session-*/ directories and re-point .peaks/_runtime/session.json ' +
84
+ 'to the canonical session (4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
85
+ 'latest any-file mtime -> dir-name sort). Also migrates any legacy .peaks/.session.json / ' +
86
+ '.peaks/.active-skill.json / .peaks/sop-state/ into .peaks/_runtime/ (idempotent; no-op on a ' +
87
+ 'tree that is already on the new layout). By default the command is a dry-run: it reports empty / abandoned ' +
88
+ `session dirs older than ${DEFAULT_RECONCILE_AGE_DAYS} days as deletion candidates but does not delete them. ` +
89
+ 'Pass --apply to actually remove the listed candidate dirs (destructive). ' +
90
+ 'Override the age threshold with --older-than <days>.')
91
+ .requiredOption('--project <path>', 'target project root')
92
+ .option('--apply', 'actually delete the deletion candidates (destructive); without it, dry-run only', false)
93
+ .option('--older-than <days>', `age threshold in days for deletion candidates (default: ${DEFAULT_RECONCILE_AGE_DAYS})`, (value) => Number.parseFloat(value))).action((options) => {
94
+ try {
95
+ const projectRoot = resolveCanonicalProjectRoot(options.project);
96
+ const olderThanDays = options.olderThan ?? DEFAULT_RECONCILE_AGE_DAYS;
97
+ if (typeof olderThanDays !== 'number' || !Number.isFinite(olderThanDays) || olderThanDays <= 0) {
98
+ printResult(io, fail('workspace.reconcile', 'INVALID_AGE_THRESHOLD', `--older-than must be a positive number of days`, { provided: options.olderThan }, ['Use --older-than 7 (or omit it to accept the 7-day default)']), options.json);
99
+ process.exitCode = 1;
100
+ return;
101
+ }
102
+ const olderThanMs = olderThanDays * MS_PER_DAY;
103
+ const apply = options.apply === true;
104
+ const result = reconcileWorkspace({
105
+ projectRoot,
106
+ apply,
107
+ olderThanMs
108
+ });
109
+ const warnings = [];
110
+ if (result.sessions.length === 0) {
111
+ warnings.push('No session directories found under .peaks/. Run peaks workspace init first.');
112
+ }
113
+ if (apply && result.deleted.length > 0) {
114
+ warnings.push(`Deleted ${result.deleted.length} session dir(s) older than ${olderThanDays} day(s).`);
115
+ }
116
+ const nextActions = [];
117
+ if (result.migratedFiles.length > 0) {
118
+ nextActions.push(`Migrated ${result.migratedFiles.length} legacy runtime file(s) into .peaks/_runtime/: ${result.migratedFiles.join(', ')}.`);
119
+ }
120
+ if (result.repointed) {
121
+ nextActions.push(`Re-pointed .peaks/_runtime/session.json from ${result.repointedFrom ?? '<unbound>'} to ${result.repointedTo}.`);
122
+ }
123
+ if (!apply && result.wouldDelete.length > 0) {
124
+ nextActions.push(`Re-run with --apply to delete ${result.wouldDelete.length} candidate dir(s).`);
125
+ }
126
+ printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
127
+ if (result.errors.length > 0) {
128
+ process.exitCode = 1;
129
+ }
130
+ }
131
+ catch (error) {
132
+ printResult(io, fail('workspace.reconcile', 'WORKSPACE_RECONCILE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json);
133
+ process.exitCode = 1;
134
+ }
135
+ });
78
136
  }
@@ -51,6 +51,52 @@ export type CommitBoundary = {
51
51
  syncState: 'synced' | 'pending' | 'failed';
52
52
  rollbackPoint: string | null;
53
53
  };
54
+ /**
55
+ * Resolution sources for `resolveArtifactSession`, in priority order.
56
+ * - `active-skill`: the orchestrator's active-skill marker
57
+ * (`.peaks/_runtime/active-skill.json`, with a one-minor-release
58
+ * fallback to `.peaks/.active-skill.json`) `sessionId` points to a
59
+ * session dir that owns the slice's marker artifact.
60
+ * - `session-json`: the workspace binding in
61
+ * `.peaks/_runtime/session.json` (with back-compat fallback to
62
+ * `.peaks/.session.json`) points to a session dir that owns the
63
+ * slice's marker artifact (active-skill was checked but did not
64
+ * own it; session-json is the next source).
65
+ * - `find-fallback`: neither binding owned the artifact, but a `find`
66
+ * walk under `.peaks/` located a session dir that does own it.
67
+ */
68
+ export type ArtifactSessionSource = 'active-skill' | 'session-json' | 'find-fallback';
69
+ export type ResolvedArtifactSession = {
70
+ resolvedSessionId: string | null;
71
+ candidateSources: ArtifactSessionSource[];
72
+ };
73
+ /**
74
+ * Resolve the session id that owns the slice's artifacts using a 3-tier
75
+ * precedence:
76
+ *
77
+ * 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
78
+ * fallback to `.peaks/.active-skill.json`) if it points to a real
79
+ * session that owns the slice.
80
+ * 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
81
+ * fallback to `.peaks/.session.json`) if it points to a real
82
+ * session that owns the slice.
83
+ * 3. `find .peaks/ -name '<marker>'` — the first session dir under
84
+ * `.peaks/` that owns the slice.
85
+ * 4. else `{ resolvedSessionId: null, candidateSources: [] }`.
86
+ *
87
+ * The back-compat fallbacks are tolerated for one minor release so
88
+ * users with pre-migration trees (or running an older CLI version)
89
+ * still get a clean resolution. After the migration (or after v1.3.0
90
+ * is installed and `peaks workspace reconcile` has been run), only
91
+ * the new paths exist and the fallbacks never fire.
92
+ *
93
+ * `candidateSources` reports which sources were checked before the
94
+ * resolver found (or did not find) a winner; the list is in the order
95
+ * the resolver consulted them. This makes the precedence observable in
96
+ * the JSON envelope so a human reviewer can see "active-skill was empty
97
+ * AND session-json was empty, so find-fallback won".
98
+ */
99
+ export declare function resolveArtifactSession(projectRoot: string, sliceId: string): ResolvedArtifactSession;
54
100
  export declare function getChangeTraceabilityStatus(): ChangeTraceabilityStatus;
55
101
  export declare function createChangeImpact(options: {
56
102
  changeId: string;
@@ -71,10 +117,15 @@ export declare function recordCommitBoundary(options: {
71
117
  sliceId: string;
72
118
  artifacts?: string[];
73
119
  codeFiles?: string[];
74
- }): CommitBoundary;
120
+ }): CommitBoundary & {
121
+ resolvedSessionId: string | null;
122
+ candidateSources: ArtifactSessionSource[];
123
+ };
75
124
  export declare function validateArtifactRetention(sliceId: string): {
76
125
  valid: boolean;
77
126
  missingArtifacts: string[];
78
127
  warnings: string[];
128
+ resolvedSessionId: string | null;
129
+ candidateSources: ArtifactSessionSource[];
79
130
  };
80
131
  export declare function getScHelpText(): string[];
@@ -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,150 @@ 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
+ const sessionDir = join(projectRoot, '.peaks', sessionId);
201
+ if (!existsSync(sessionDir))
202
+ return false;
203
+ for (const marker of [`qa/test-cases/${sliceId}.md`, `qa/test-reports/${sliceId}.md`]) {
204
+ if (existsSync(join(sessionDir, marker)))
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+ /**
210
+ * Find a session dir under `<projectRoot>/.peaks/` that owns the slice
211
+ * (see `sessionOwnsSlice`). Returns the first match in lexicographic
212
+ * order, or null when no session owns the slice.
213
+ */
214
+ function findSessionOwningSlice(projectRoot, sliceId) {
215
+ const peaksRoot = join(projectRoot, '.peaks');
216
+ if (!existsSync(peaksRoot))
217
+ return null;
218
+ let names;
219
+ try {
220
+ names = readdirSync(peaksRoot);
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ names.sort();
226
+ for (const name of names) {
227
+ if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
228
+ continue;
229
+ if (sessionOwnsSlice(projectRoot, name, sliceId)) {
230
+ return name;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+ /**
236
+ * Resolve the session id that owns the slice's artifacts using a 3-tier
237
+ * precedence:
238
+ *
239
+ * 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
240
+ * fallback to `.peaks/.active-skill.json`) if it points to a real
241
+ * session that owns the slice.
242
+ * 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
243
+ * fallback to `.peaks/.session.json`) if it points to a real
244
+ * session that owns the slice.
245
+ * 3. `find .peaks/ -name '<marker>'` — the first session dir under
246
+ * `.peaks/` that owns the slice.
247
+ * 4. else `{ resolvedSessionId: null, candidateSources: [] }`.
248
+ *
249
+ * The back-compat fallbacks are tolerated for one minor release so
250
+ * users with pre-migration trees (or running an older CLI version)
251
+ * still get a clean resolution. After the migration (or after v1.3.0
252
+ * is installed and `peaks workspace reconcile` has been run), only
253
+ * the new paths exist and the fallbacks never fire.
254
+ *
255
+ * `candidateSources` reports which sources were checked before the
256
+ * resolver found (or did not find) a winner; the list is in the order
257
+ * the resolver consulted them. This makes the precedence observable in
258
+ * the JSON envelope so a human reviewer can see "active-skill was empty
259
+ * AND session-json was empty, so find-fallback won".
260
+ */
261
+ export function resolveArtifactSession(projectRoot, sliceId) {
262
+ const activeSkill = readActiveSkillSessionId(projectRoot);
263
+ if (activeSkill !== null && sessionOwnsSlice(projectRoot, activeSkill, sliceId)) {
264
+ return { resolvedSessionId: activeSkill, candidateSources: ['active-skill'] };
265
+ }
266
+ const sessionJson = readSessionJsonBinding(projectRoot);
267
+ if (sessionJson !== null && sessionOwnsSlice(projectRoot, sessionJson, sliceId)) {
268
+ return { resolvedSessionId: sessionJson, candidateSources: ['active-skill', 'session-json'] };
269
+ }
270
+ const findHit = findSessionOwningSlice(projectRoot, sliceId);
271
+ if (findHit !== null) {
272
+ return { resolvedSessionId: findHit, candidateSources: ['active-skill', 'session-json', 'find-fallback'] };
273
+ }
274
+ return { resolvedSessionId: null, candidateSources: [] };
275
+ }
111
276
  export function getChangeTraceabilityStatus() {
112
277
  const workspace = getWorkspaceConfigForPath(process.cwd());
113
278
  const artifactStatus = getArtifactWorkspaceStatus(workspace?.workspaceId);
@@ -196,6 +361,8 @@ export function recordCommitBoundary(options) {
196
361
  const workspace = getWorkspaceConfigForPath(process.cwd());
197
362
  const artifactStatus = getArtifactWorkspaceStatus(workspace?.workspaceId);
198
363
  const commitHash = getCurrentCommitHash(workspace?.rootPath);
364
+ const projectRoot = workspace?.rootPath ?? process.cwd();
365
+ const resolution = resolveArtifactSession(projectRoot, options.sliceId);
199
366
  return {
200
367
  sliceId: options.sliceId,
201
368
  commitHash,
@@ -203,38 +370,120 @@ export function recordCommitBoundary(options) {
203
370
  artifacts: options.artifacts ?? [],
204
371
  codeFiles: options.codeFiles ?? [],
205
372
  syncState: mapSyncState(artifactStatus.syncStatus),
206
- rollbackPoint: commitHash
373
+ rollbackPoint: commitHash,
374
+ resolvedSessionId: resolution.resolvedSessionId,
375
+ candidateSources: resolution.candidateSources
207
376
  };
208
377
  }
209
378
  export function validateArtifactRetention(sliceId) {
210
379
  const workspace = getWorkspaceConfigForPath(process.cwd());
211
- if (!workspace) {
380
+ // Resolve from `process.cwd()` even when no workspace is configured, so
381
+ // the W4 session resolver can still find the slice's owning session.
382
+ // The legacy "no workspace" check still surfaces as a missing artifact,
383
+ // but the resolution happens first so the JSON envelope's additive
384
+ // `resolvedSessionId` is populated regardless of workspace state.
385
+ const projectRoot = workspace?.rootPath ?? process.cwd();
386
+ if (!SLICE_ID_PATTERN.test(sliceId)) {
212
387
  return {
213
388
  valid: false,
214
- missingArtifacts: ['No workspace configured'],
215
- warnings: ['Cannot validate without a configured workspace']
389
+ missingArtifacts: ['Invalid slice id'],
390
+ warnings: ['Slice id must stay inside .peaks/<session-id> and only contain letters, numbers, dots, underscores, or hyphens'],
391
+ resolvedSessionId: null,
392
+ candidateSources: []
216
393
  };
217
394
  }
218
- if (!SLICE_ID_PATTERN.test(sliceId)) {
395
+ const resolution = resolveArtifactSession(projectRoot, sliceId);
396
+ const effectiveSliceId = resolution.resolvedSessionId ?? sliceId;
397
+ // W4: if the resolver found a session, ALSO accept artifacts under
398
+ // `<projectRoot>/.peaks/<resolvedSessionId>/` (the canonical per-slice
399
+ // dir). The project-root peaks is where the orchestrator's skills
400
+ // actually write (see `initWorkspace` in `workspace-service.ts`), so
401
+ // when the resolution chain lands on a real session the artifacts are
402
+ // usually there. We accept either location — workspace artifact path
403
+ // OR project-root peaks — so the additive behavior does not regress
404
+ // existing workspaces.
405
+ const resolvedPeaksSessionDir = resolution.resolvedSessionId !== null
406
+ ? join(projectRoot, '.peaks', resolution.resolvedSessionId)
407
+ : null;
408
+ // Collect present files: legacy workspace-artifact-path check, OR the
409
+ // resolved session's project-root peaks dir.
410
+ const legacyPresent = (folder, file) => {
411
+ if (!workspace)
412
+ return false;
413
+ const artifactWorkspacePath = getLocalArtifactPath(workspace);
414
+ const { peaksPath, changeDir } = getRetentionChangeDir(artifactWorkspacePath, effectiveSliceId);
415
+ const filePath = resolve(changeDir, folder, file);
416
+ return isRetainedArtifactFile(filePath, artifactWorkspacePath, peaksPath, changeDir);
417
+ };
418
+ const resolvedPresent = (folder, file) => {
419
+ if (resolvedPeaksSessionDir === null)
420
+ return false;
421
+ return existsSync(join(resolvedPeaksSessionDir, folder, file));
422
+ };
423
+ if (!workspace) {
424
+ // No workspace: validate against the resolved session dir directly
425
+ // (this is the common peaks-solo / peaks-rd invocation: the slice
426
+ // lives under the project-root `.peaks/<sessionId>/`, and the
427
+ // workspace artifact path is irrelevant). When the resolution also
428
+ // fails, fall back to the legacy "No workspace configured" failure
429
+ // mode so the existing CLI contract is preserved.
430
+ if (resolvedPeaksSessionDir === null) {
431
+ return {
432
+ valid: false,
433
+ missingArtifacts: ['No workspace configured'],
434
+ warnings: ['Cannot validate without a configured workspace'],
435
+ resolvedSessionId: resolution.resolvedSessionId,
436
+ candidateSources: resolution.candidateSources
437
+ };
438
+ }
439
+ const missingArtifacts = modernRequirementRelativePaths(sliceId).filter((rel) => !existsSync(join(resolvedPeaksSessionDir, rel)));
219
440
  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']
441
+ valid: missingArtifacts.length === 0,
442
+ missingArtifacts,
443
+ warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing'],
444
+ resolvedSessionId: resolution.resolvedSessionId,
445
+ candidateSources: resolution.candidateSources
223
446
  };
224
447
  }
225
- const artifactWorkspacePath = getLocalArtifactPath(workspace);
226
- const { peaksPath, changeDir } = getRetentionChangeDir(artifactWorkspacePath, sliceId);
227
- const changesRoot = peaksPath;
228
448
  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, '/'));
449
+ .map(([folder, file]) => `${folder}/${file}`)
450
+ .filter((rel) => !legacyPresent(...rel.split('/')) && !resolvedPresent(...rel.split('/')));
451
+ // If the legacy check is short (i.e. we're missing a lot of legacy-named
452
+ // files) but the resolver landed on a real session, ALSO accept the
453
+ // modern set. The legacy set was designed for an older workflow naming
454
+ // and a freshly-minted session in the current peaks-cli flow will not
455
+ // have the legacy names. This keeps `peaks sc validate --slice-id <rid>`
456
+ // returning `valid: true` for slices that completed under the current
457
+ // peaks-cli convention.
458
+ if (missingArtifacts.length > 0 && resolvedPeaksSessionDir !== null) {
459
+ const modernMissing = modernRequirementRelativePaths(sliceId).filter((rel) => !resolvedPresent(...rel.split('/')));
460
+ if (modernMissing.length === 0) {
461
+ return {
462
+ valid: true,
463
+ missingArtifacts: [],
464
+ warnings: [],
465
+ resolvedSessionId: resolution.resolvedSessionId,
466
+ candidateSources: resolution.candidateSources
467
+ };
468
+ }
469
+ }
232
470
  return {
233
471
  valid: missingArtifacts.length === 0,
234
472
  missingArtifacts,
235
- warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing']
473
+ warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing'],
474
+ resolvedSessionId: resolution.resolvedSessionId,
475
+ candidateSources: resolution.candidateSources
236
476
  };
237
477
  }
478
+ /**
479
+ * Render the modern retention requirements as relative paths keyed
480
+ * against the slice id. The `{sliceId}` placeholder in the template
481
+ * is replaced with the actual slice id; per-session files (no
482
+ * placeholder) keep their literal name.
483
+ */
484
+ function modernRequirementRelativePaths(sliceId) {
485
+ return MODERN_RETENTION_REQUIREMENTS.map((template) => template.replace('{sliceId}', sliceId));
486
+ }
238
487
  export function getScHelpText() {
239
488
  return [
240
489
  'peaks sc status Show change traceability status',
@@ -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,20 @@
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 { dirname, join, resolve } from 'node:path';
10
10
  import { randomBytes } from 'node:crypto';
11
11
  import { initWorkspace } from '../workspace/workspace-service.js';
12
- const SESSION_FILE = '.session.json';
12
+ // As of slice 2026-06-05-peaks-runtime-layer the project-level session
13
+ // binding lives under `.peaks/_runtime/session.json`. The legacy
14
+ // `.peaks/.session.json` path is preserved as a read-only fallback for one
15
+ // minor release so older CLI versions (or trees that have not been migrated
16
+ // by `peaks workspace reconcile`) keep working without a forced re-init.
17
+ const SESSION_FILE = join('_runtime', 'session.json');
18
+ const LEGACY_SESSION_FILE = '.session.json';
13
19
  const META_FILE = 'session.json';
20
+ function getLegacySessionFilePath(projectRoot) {
21
+ return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
22
+ }
14
23
  /**
15
24
  * Canonicalize a project root path. Returns the realpath
16
25
  * (resolving all symlinks — important on macOS where `/var`
@@ -68,7 +77,9 @@ function generateSessionId() {
68
77
  return `${date}-session-${random}`;
69
78
  }
70
79
  /**
71
- * Get the path to the session file for a project.
80
+ * Get the path to the session file for a project. The canonical home is
81
+ * `.peaks/_runtime/session.json`; the legacy `.peaks/.session.json` is
82
+ * read-only fallback (see `readSessionFile`).
72
83
  */
73
84
  function getSessionFilePath(projectRoot) {
74
85
  return join(projectRoot, '.peaks', SESSION_FILE);
@@ -92,10 +103,15 @@ function getSessionFilePath(projectRoot) {
92
103
  */
93
104
  function readSessionFile(projectRoot) {
94
105
  const sessionFile = getSessionFilePath(projectRoot);
95
- if (!existsSync(sessionFile))
106
+ const legacyFile = getLegacySessionFilePath(projectRoot);
107
+ // Back-compat window: prefer the new canonical path; fall back to the
108
+ // legacy `.peaks/.session.json` so older CLI versions or pre-migration
109
+ // trees keep working. When both exist, the new path wins.
110
+ const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
111
+ if (!existsSync(pathToRead))
96
112
  return null;
97
113
  try {
98
- const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
114
+ const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
99
115
  if (data.sessionId && data.projectRoot === projectRoot) {
100
116
  return data;
101
117
  }
@@ -116,10 +132,14 @@ function readSessionFile(projectRoot) {
116
132
  */
117
133
  function readSessionFileCanonical(projectRoot) {
118
134
  const sessionFile = getSessionFilePath(projectRoot);
119
- if (!existsSync(sessionFile))
135
+ const legacyFile = getLegacySessionFilePath(projectRoot);
136
+ // Back-compat window: prefer the new canonical path; fall back to the
137
+ // legacy `.peaks/.session.json` for one minor release.
138
+ const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
139
+ if (!existsSync(pathToRead))
120
140
  return null;
121
141
  try {
122
- const data = JSON.parse(readFileSync(sessionFile, 'utf8'));
142
+ const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
123
143
  const storedRaw = typeof data.projectRoot === 'string' ? data.projectRoot : null;
124
144
  if (data.sessionId &&
125
145
  storedRaw !== null &&
@@ -133,22 +153,27 @@ function readSessionFileCanonical(projectRoot) {
133
153
  }
134
154
  }
135
155
  /**
136
- * Write session info to disk.
156
+ * Write session info to disk at the canonical new path
157
+ * `.peaks/_runtime/session.json`. The `.peaks/_runtime/` directory is
158
+ * created on demand. The legacy `.peaks/.session.json` is NOT written by
159
+ * this slice; it is only read for back-compat.
137
160
  */
138
161
  function writeSessionFile(projectRoot, info) {
139
162
  const sessionFile = getSessionFilePath(projectRoot);
140
- const dir = join(projectRoot, '.peaks');
163
+ const dir = dirname(sessionFile);
141
164
  if (!existsSync(dir)) {
142
165
  mkdirSync(dir, { recursive: true });
143
166
  }
144
167
  writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
145
168
  }
146
169
  /**
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.
170
+ * Drop the project-level session binding at the canonical
171
+ * `.peaks/_runtime/session.json` so the next `ensureSession()` call
172
+ * auto-generates a fresh session id. The on-disk session directory
173
+ * is left intact — rotating does NOT delete the user's data, it
174
+ * just unbinds the project from that session. Also drops the legacy
175
+ * `.peaks/.session.json` if present so a stale read from another
176
+ * tool cannot re-bind the project after rotation.
152
177
  *
153
178
  * Returns the id of the session that was unbound, or `null` if
154
179
  * no binding was present. The caller is expected to do something
@@ -164,6 +189,15 @@ export function rotateSessionBinding(projectRoot) {
164
189
  if (existsSync(sessionFile)) {
165
190
  unlinkSync(sessionFile);
166
191
  }
192
+ const legacyFile = getLegacySessionFilePath(projectRoot);
193
+ if (existsSync(legacyFile)) {
194
+ try {
195
+ unlinkSync(legacyFile);
196
+ }
197
+ catch {
198
+ // best-effort: a stale legacy binding is not blocking
199
+ }
200
+ }
167
201
  return previous.sessionId;
168
202
  }
169
203
  /**