peaks-cli 1.3.1 → 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 (28) hide show
  1. package/bin/peaks.js +0 -0
  2. package/dist/src/cli/commands/core-artifact-commands.js +49 -11
  3. package/dist/src/cli/commands/slice-commands.js +4 -2
  4. package/dist/src/cli/commands/workspace-commands.js +67 -14
  5. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +12 -0
  6. package/dist/src/services/artifacts/artifact-prerequisites.js +39 -8
  7. package/dist/src/services/artifacts/request-artifact-service.js +116 -76
  8. package/dist/src/services/doctor/doctor-service.d.ts +62 -0
  9. package/dist/src/services/doctor/doctor-service.js +276 -1
  10. package/dist/src/services/session/session-manager.d.ts +22 -1
  11. package/dist/src/services/session/session-manager.js +137 -28
  12. package/dist/src/services/slice/slice-check-service.js +20 -1
  13. package/dist/src/services/slice/slice-check-types.d.ts +9 -0
  14. package/dist/src/services/workspace/migrate-service.js +124 -2
  15. package/dist/src/services/workspace/migrate-types.d.ts +50 -7
  16. package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
  17. package/dist/src/services/workspace/reconcile-service.js +160 -42
  18. package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
  19. package/dist/src/services/workspace/workspace-service.js +29 -62
  20. package/dist/src/shared/version.d.ts +1 -1
  21. package/dist/src/shared/version.js +1 -1
  22. package/package.json +1 -1
  23. package/schemas/doctor-report.schema.json +2 -2
  24. package/skills/peaks-qa/SKILL.md +1 -0
  25. package/skills/peaks-rd/SKILL.md +2 -1
  26. package/skills/peaks-solo/SKILL.md +6 -0
  27. package/skills/peaks-txt/SKILL.md +2 -0
  28. package/skills/peaks-ui/SKILL.md +1 -0
package/bin/peaks.js CHANGED
File without changes
@@ -8,7 +8,7 @@ import { runDoctor } from '../../services/doctor/doctor-service.js';
8
8
  import { listSkills } from '../../services/skills/skill-registry.js';
9
9
  import { inspectSkillRunbook } from '../../services/skills/skill-runbook-service.js';
10
10
  import { setSkillPresence, clearSkillPresence, getSkillPresence, isSkillPresenceMode, touchSkillHeartbeat } from '../../services/skills/skill-presence-service.js';
11
- import { ensureSession, getSessionMeta, rotateSessionBinding, setSessionMeta, setSessionTitle, listSessionMetas } from '../../services/session/session-manager.js';
11
+ import { getSessionId, getSessionMeta, rotateSessionBinding, setSessionMeta, setSessionTitle, listSessionMetas } from '../../services/session/session-manager.js';
12
12
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
13
13
  import { findProjectRoot } from '../../services/config/config-safety.js';
14
14
  import { generateProjectContext } from '../../services/memory/project-context-service.js';
@@ -120,7 +120,7 @@ export function registerCoreAndArtifactCommands(program, io) {
120
120
  .description('Set the currently active Peaks skill for session-wide visibility')
121
121
  .option('--mode <mode>', 'execution mode')
122
122
  .option('--gate <gate>', 'current gate')
123
- .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action(async (name, options) => {
123
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((name, options) => {
124
124
  const projectRoot = options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
125
125
  if (options.mode !== undefined && !isSkillPresenceMode(options.mode)) {
126
126
  printResult(io, fail('skill.presence:set', 'INVALID_MODE', `Invalid mode: ${options.mode} (expected one of: full-auto, assisted, swarm, strict)`, { name, mode: options.mode }, ['Use a valid mode: full-auto, assisted, swarm, or strict']), options.json);
@@ -128,13 +128,26 @@ export function registerCoreAndArtifactCommands(program, io) {
128
128
  return;
129
129
  }
130
130
  const presence = setSkillPresence(name, options.mode, options.gate, options.project);
131
- // Also update session metadata so session dirs self-document
132
- const sessionId = await ensureSession(projectRoot);
133
- setSessionMeta(projectRoot, sessionId, {
134
- skill: name,
135
- ...(options.mode ? { mode: options.mode } : {}),
136
- ...(options.gate ? { gate: options.gate } : {})
137
- });
131
+ // As of slice 003-2026-06-06-session-layout-canonicalize we do NOT
132
+ // call `ensureSession` here. The CLI wrapper previously spawned a
133
+ // new session on every presence call, which made the canonical
134
+ // session binding drift (the LLM saw the session id change every
135
+ // turn). The presence now reuses the session bound at
136
+ // `.peaks/_runtime/session.json` (or the legacy `.peaks/.session.json`
137
+ // during the back-compat window). If no session is bound, the
138
+ // presence still writes the active-skill marker — downstream code
139
+ // can `peaks workspace init` separately to create the session.
140
+ //
141
+ // Session metadata is updated when a session is bound (read-only
142
+ // path: `getSessionId`). We do not auto-spawn a session.
143
+ const boundSessionId = getSessionId(projectRoot);
144
+ if (boundSessionId !== null) {
145
+ setSessionMeta(projectRoot, boundSessionId, {
146
+ skill: name,
147
+ ...(options.mode ? { mode: options.mode } : {}),
148
+ ...(options.gate ? { gate: options.gate } : {})
149
+ });
150
+ }
138
151
  printResult(io, ok('skill.presence:set', { active: true, ...presence }), options.json);
139
152
  });
140
153
  addJsonOption(skill
@@ -191,9 +204,34 @@ export function registerCoreAndArtifactCommands(program, io) {
191
204
  printResult(io, ok('session.list', { sessions: metas, total: metas.length }), options.json);
192
205
  });
193
206
  addJsonOption(session
194
- .command('info <sessionId>')
195
- .description('Show full metadata for a session directory')).action((sessionId, options) => {
207
+ .command('info [sessionId]')
208
+ .description('Show full metadata for a session directory. Pass --active to resolve the canonical binding from .peaks/_runtime/session.json (the "one command a sub-agent runs to find the parent\'s sid" primitive).')
209
+ .option('--active', 'resolve the canonical session id from .peaks/_runtime/session.json (ignores [sessionId] when set)')).action(async (sessionId, options) => {
196
210
  const projectRoot = findProjectRoot(process.cwd()) ?? process.cwd();
211
+ // Slice 007 — sub-agent session sharing. A sub-agent that does
212
+ // not know the parent's sid reads it from the binding via
213
+ // `peaks session info --active`. The call uses the
214
+ // canonicalize-on-read path so a stored "projectRoot: '.'" and a
215
+ // caller-passed absolute realpath both resolve to the same
216
+ // binding. Without this primitive the sub-agent has no way to
217
+ // discover the parent sid short of scanning the filesystem.
218
+ if (options.active === true) {
219
+ // Import lazily to avoid a cycle with workspace-commands.
220
+ const { getSessionIdCanonical } = await import('../../services/session/session-manager.js');
221
+ const activeSid = getSessionIdCanonical(projectRoot);
222
+ if (activeSid === null) {
223
+ printResult(io, fail('session.info', 'NO_ACTIVE_BINDING', 'No canonical session binding at .peaks/_runtime/session.json', { projectRoot }, ['Run `peaks workspace init` or `peaks skill presence:set` to anchor a session']), options.json);
224
+ process.exitCode = 1;
225
+ return;
226
+ }
227
+ printResult(io, ok('session.info', { active: true, sessionId: activeSid, source: '.peaks/_runtime/session.json' }), options.json);
228
+ return;
229
+ }
230
+ if (sessionId === undefined) {
231
+ printResult(io, fail('session.info', 'SESSION_ID_REQUIRED', 'session.info requires a <sessionId> or --active', {}, ['Pass a <sessionId> argument, or use --active to resolve the canonical binding']), options.json);
232
+ process.exitCode = 1;
233
+ return;
234
+ }
197
235
  const meta = getSessionMeta(projectRoot, sessionId);
198
236
  if (meta === null) {
199
237
  printResult(io, fail('session.info', 'SESSION_NOT_FOUND', `Session "${sessionId}" not found or has no metadata`, { sessionId }, ['Use `peaks session list` to see available sessions']), options.json);
@@ -16,14 +16,16 @@ export function registerSliceCommands(program, io) {
16
16
  .option('--project <path>', 'target project root', '.')
17
17
  .option('--rid <rid>', 'request id; defaults to the active current-change binding')
18
18
  .option('--refresh-fanout', 're-run the 3-way review fan-out (peaks-rd) even if the review files already exist', false)
19
- .option('--skip-tests', 'skip the unit-test stage (e.g. docs-only slices)', false)).action(async (options) => {
19
+ .option('--skip-tests', 'skip the unit-test stage (e.g. docs-only slices)', false)
20
+ .option('--allow-pre-existing-failures', 'opt-in: if the unit-test stage fails, report it as `skipped` with a reason naming the failure count (useful when the repo has unrelated pre-existing failures; the long-term fix is to .skip or coverage.exclude those tests)', false)).action(async (options) => {
20
21
  try {
21
22
  const projectRoot = resolveCanonicalProjectRoot(options.project);
22
23
  const result = await sliceCheck({
23
24
  projectRoot,
24
25
  ...(options.rid ? { rid: options.rid } : {}),
25
26
  refreshFanout: options.refreshFanout === true,
26
- skipTests: options.skipTests === true
27
+ skipTests: options.skipTests === true,
28
+ allowPreExistingFailures: options.allowPreExistingFailures === true
27
29
  });
28
30
  const warnings = [];
29
31
  if (result.stages.some((s) => s.status === 'fail')) {
@@ -89,7 +89,7 @@ export function registerWorkspaceCommands(program, io) {
89
89
  const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
90
90
  addJsonOption(workspace
91
91
  .command('init')
92
- .description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) and bind the session as the project current one. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present). On the first call for a project, also handles the one-time "install peaks hooks" decision (sticky-marker stored in .peaks/.peaks-init-hooks-decision.json).')
92
+ .description('Create the .peaks/_runtime/<session-id>/ directory with ONLY the session.json metadata file (slice 006: role subdirs prd/ui/rd/qa/sc/txt and the system/ subdir are created lazily by writers, not pre-created at init). When --change-id is given, also creates the .peaks/<change-id>/ dir. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present). On the first call for a project, also handles the one-time "install peaks hooks" decision (sticky-marker stored in .peaks/.peaks-init-hooks-decision.json).')
93
93
  .requiredOption('--project <path>', 'target project root')
94
94
  .option('--session-id <id>', 'optional session id in YYYY-MM-DD-<kebab-slug> format. When omitted, the CLI is the single source of truth: an existing binding is reused, otherwise a fresh id is auto-generated.')
95
95
  .option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)
@@ -199,14 +199,24 @@ export function registerWorkspaceCommands(program, io) {
199
199
  });
200
200
  addJsonOption(workspace
201
201
  .command('reconcile')
202
- .description('Scan .peaks/2026-MM-DD-session-*/ directories and re-point .peaks/_runtime/session.json ' +
203
- 'to the canonical session (4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
204
- 'latest any-file mtime -> dir-name sort). Also migrates any legacy .peaks/.session.json / ' +
205
- '.peaks/.active-skill.json / .peaks/sop-state/ into .peaks/_runtime/ (idempotent; no-op on a ' +
206
- 'tree that is already on the new layout). By default the command is a dry-run: it reports empty / abandoned ' +
207
- `session dirs older than ${DEFAULT_RECONCILE_AGE_DAYS} days as deletion candidates but does not delete them. ` +
208
- 'Pass --apply to actually remove the listed candidate dirs (destructive). ' +
209
- 'Override the age threshold with --older-than <days>.')
202
+ .description('Scan .peaks/2026-MM-DD-session-*/ directories and consolidate the runtime state. ' +
203
+ 'By default (no --apply) the command performs four actions:\n' +
204
+ ' 1. Migrates legacy runtime files into .peaks/_runtime/: ' +
205
+ '.peaks/.session.json -> .peaks/_runtime/session.json, ' +
206
+ '.peaks/.active-skill.json -> .peaks/_runtime/active-skill.json, ' +
207
+ '.peaks/sop-state/ -> .peaks/_runtime/sop-state/ ' +
208
+ '(idempotent; no-op if already on the new layout).\n' +
209
+ ' 2. Re-points .peaks/_runtime/session.json to the canonical session ' +
210
+ 'using a 4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
211
+ 'latest any-file mtime -> dir-name sort.\n' +
212
+ ' 3. (slice 006) Syncs the single change/<sid>/ live marker under ' +
213
+ '.peaks/_runtime/change/. The marker is an empty directory; every other ' +
214
+ 'entry under change/ is removed. Also cleans up the F3-introduced ' +
215
+ '.peaks/_runtime/<sid>/system/ subdir (no-op if already absent).\n' +
216
+ ' 4. REPORTS (but does not delete) session dirs older than --older-than <days> ' +
217
+ `(default ${DEFAULT_RECONCILE_AGE_DAYS}) as deletion candidates; this is the only step that is dry-run by default.\n` +
218
+ 'Pass --apply to additionally REMOVE the listed candidate dirs (destructive). ' +
219
+ 'Migration (1), repoint (2), and marker sync (3) always run regardless of --apply.')
210
220
  .requiredOption('--project <path>', 'target project root')
211
221
  .option('--apply', 'actually delete the deletion candidates (destructive); without it, dry-run only', false)
212
222
  .option('--older-than <days>', `age threshold in days for deletion candidates (default: ${DEFAULT_RECONCILE_AGE_DAYS})`, (value) => Number.parseFloat(value))).action((options) => {
@@ -242,6 +252,18 @@ export function registerWorkspaceCommands(program, io) {
242
252
  if (!apply && result.wouldDelete.length > 0) {
243
253
  nextActions.push(`Re-run with --apply to delete ${result.wouldDelete.length} candidate dir(s).`);
244
254
  }
255
+ if (result.changeMarker.created !== null) {
256
+ nextActions.push(`Synced change/<${result.changeMarker.created}>/ live marker.`);
257
+ }
258
+ else if (result.canonicalSessionId !== null) {
259
+ nextActions.push(`change/<${result.canonicalSessionId}>/ live marker already in place.`);
260
+ }
261
+ if (result.changeMarker.removed.length > 0) {
262
+ nextActions.push(`Removed ${result.changeMarker.removed.length} stale change/<oldSid>/ marker(s).`);
263
+ }
264
+ if (result.systemCleaned.length > 0) {
265
+ nextActions.push(`Removed ${result.systemCleaned.length} F3 system/ subdir(s).`);
266
+ }
245
267
  printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
246
268
  if (result.errors.length > 0) {
247
269
  process.exitCode = 1;
@@ -262,18 +284,24 @@ export function registerWorkspaceCommands(program, io) {
262
284
  'response. By default the command is a dry-run: it reports the planned moves + conflicts ' +
263
285
  'and the session dirs that WOULD be deleted. Pass --apply to actually `git mv` the files ' +
264
286
  'and `rm -rf` the emptied session dirs. Idempotent: re-running on an already-migrated tree ' +
265
- 'is a no-op (all files report conflicts with identical content).')
287
+ 'is a no-op (all files report conflicts with identical content).' +
288
+ '\n\nSlice 003 (--to-runtime): moves every top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/` ' +
289
+ 'for projects still on the pre-runtime-layer layout. Idempotent: re-running on a tree ' +
290
+ 'that is already canonical is a no-op. F15 carve-out: top-level `rd/project-scan.md` is ' +
291
+ 'never overwritten when the runtime copy already exists with different content.')
266
292
  .requiredOption('--project <path>', 'target project root')
267
- .option('--apply', 'actually `git mv` the files and delete the emptied session dirs (destructive); without it, dry-run only', false)).action(async (options) => {
293
+ .option('--apply', 'actually `git mv` the files and delete the emptied session dirs (destructive); without it, dry-run only', false)
294
+ .option('--to-runtime', 'slice 003: also consolidate every top-level .peaks/<sid>/ dir into .peaks/_runtime/<sid>/. Idempotent; conflicts are logged but never overwrite.', false)).action(async (options) => {
268
295
  try {
269
296
  const projectRoot = resolveCanonicalProjectRoot(options.project);
270
297
  const apply = options.apply === true;
271
- const result = await migrateWorkspace({ projectRoot, apply });
298
+ const toRuntime = options.toRuntime === true;
299
+ const result = await migrateWorkspace({ projectRoot, apply, toRuntime });
272
300
  const warnings = [];
273
- if (result.sessions.length === 0) {
301
+ if (result.sessions.length === 0 && (result.toRuntimePlans?.length ?? 0) === 0) {
274
302
  warnings.push('No legacy session directories found under .peaks/. Nothing to migrate.');
275
303
  }
276
- else if (result.wouldMove.length === 0) {
304
+ else if (result.wouldMove.length === 0 && (result.toRuntimePlans?.length ?? 0) === 0) {
277
305
  warnings.push('Legacy session dirs found but no reviewable content to migrate (all files were cross-cutting or transient).');
278
306
  }
279
307
  const nextActions = [];
@@ -283,6 +311,31 @@ export function registerWorkspaceCommands(program, io) {
283
311
  if (result.conflicts.length > 0) {
284
312
  nextActions.push(`${result.conflicts.length} file(s) already exist at the target path; review before --apply (or re-run after a partial migrate).`);
285
313
  }
314
+ if (toRuntime) {
315
+ const plans = result.toRuntimePlans ?? [];
316
+ if (apply) {
317
+ if ((result.toRuntimeMoved?.length ?? 0) > 0) {
318
+ nextActions.push(`Moved ${result.toRuntimeMoved?.length} top-level session dir(s) to .peaks/_runtime/ (slice 003 --to-runtime).`);
319
+ }
320
+ if ((result.toRuntimeConflicts?.length ?? 0) > 0) {
321
+ nextActions.push(`${result.toRuntimeConflicts?.length} --to-runtime conflict(s) — see response. ${plans.filter((p) => p.action === 'f15-conflict-project-scan').length} are F15 carve-outs (deferred to a separate slice).`);
322
+ }
323
+ }
324
+ else {
325
+ const wouldMoveCount = plans.filter((p) => p.action === 'moved').length;
326
+ const wouldSkipCount = plans.filter((p) => p.action === 'skipped-already-canonical').length;
327
+ if (wouldMoveCount > 0) {
328
+ nextActions.push(`Re-run with --apply to move ${wouldMoveCount} top-level session dir(s) to .peaks/_runtime/; ${wouldSkipCount} already canonical.`);
329
+ }
330
+ else if (wouldSkipCount > 0) {
331
+ nextActions.push(`All ${wouldSkipCount} top-level session dir(s) are already canonical — no moves needed.`);
332
+ }
333
+ const f15Count = plans.filter((p) => p.action === 'f15-conflict-project-scan').length;
334
+ if (f15Count > 0) {
335
+ nextActions.push(`${f15Count} F15 carve-out conflict(s) (rd/project-scan.md differs from runtime copy) — see response.`);
336
+ }
337
+ }
338
+ }
286
339
  if (apply) {
287
340
  if (result.moved.length > 0) {
288
341
  nextActions.push(`Migrated ${result.moved.length} file(s) into .peaks/retrospective/.`);
@@ -37,6 +37,18 @@ export type CheckPrerequisitesOptions = {
37
37
  * on-disk path now agree on the same top-level dir.
38
38
  */
39
39
  changeId: string;
40
+ /**
41
+ * Session binding (the developer's local session that wrote the
42
+ * request artifact). Read from the file body's `- session:` line.
43
+ * Optional, but when present the gate falls back to
44
+ * `.peaks/_runtime/<sid>/<role>/` and then `.peaks/<sid>/<role>/`
45
+ * for prerequisite artifacts that don't exist at the per-change-id
46
+ * path. This mirrors the F1/F2 back-compat pattern (read new path
47
+ * first, then legacy) and keeps the gate working for users whose
48
+ * QA / tech-doc / initiated artifacts still live under the session
49
+ * dir rather than under the change-id dir.
50
+ */
51
+ sessionId?: string;
40
52
  role: RequestArtifactRole;
41
53
  newState: RequestArtifactState;
42
54
  requestId: string;
@@ -162,17 +162,25 @@ export async function checkPrerequisites(options) {
162
162
  if (requirements.length === 0) {
163
163
  return { ok: true, missing: [] };
164
164
  }
165
- // As of slice 2026-06-05-change-id-as-unit-of-work, the prerequisite
166
- // gate resolves paths under `.peaks/<changeId>/<role>/...` where the
167
- // changeId is the file's durable scope (the top-level dir the file
168
- // lives in), NOT the body's `- session:` line. The body and the path
169
- // can now disagree (e.g. a request written in one session but read
170
- // across sessions), and the gate follows the on-disk location.
171
- const changeRoot = join(options.projectRoot, '.peaks', options.changeId);
165
+ // Slice 006 simplifies the resolution to a 2-tier fallback. The
166
+ // per-change-id scope (`.peaks/<changeId>/<role>/`) is gone — new
167
+ // artifacts go to the session dir directly. The 2 tiers are:
168
+ // 1. `.peaks/_runtime/<sid>/<role>/...` (post-F3 canonical
169
+ // session home; primary).
170
+ // 2. `.peaks/<sid>/<role>/...` (pre-F3 legacy session home;
171
+ // back-compat).
172
+ // The changeId is preserved in the artifact body's frontmatter for
173
+ // human navigation; it is no longer a filesystem path key.
174
+ const canonicalSessionRoot = options.sessionId !== undefined
175
+ ? join(options.projectRoot, '.peaks', '_runtime', options.sessionId)
176
+ : null;
177
+ const legacySessionRoot = options.sessionId !== undefined
178
+ ? join(options.projectRoot, '.peaks', options.sessionId)
179
+ : null;
172
180
  const missing = [];
173
181
  for (const prerequisite of requirements) {
174
182
  const relative = resolvePrerequisitePath(prerequisite, options.requestId);
175
- const absolute = await resolvePrerequisiteAbsolutePath(changeRoot, prerequisite, options.requestId);
183
+ const absolute = await resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot, legacySessionRoot, prerequisite, options.requestId);
176
184
  if (absolute === null) {
177
185
  missing.push({ path: relative, description: prerequisite.description });
178
186
  continue;
@@ -202,3 +210,26 @@ export async function checkPrerequisites(options) {
202
210
  }
203
211
  return { ok: missing.length === 0, missing };
204
212
  }
213
+ /**
214
+ * Resolve a prerequisite to an on-disk path, with a 2-tier fallback
215
+ * (slice 006 — the per-change-id tier was dropped because per-change-id
216
+ * dirs are no longer created):
217
+ * 1. `<canonicalSessionRoot>/<relative>` (post-F3 canonical session
218
+ * home, when `canonicalSessionRoot` is provided).
219
+ * 2. `<legacySessionRoot>/<relative>` (pre-F3 legacy session home,
220
+ * when `legacySessionRoot` is provided).
221
+ * Tolerates the numbered filename prefix that `request init` writes
222
+ * (e.g. `001-<rid>.md`) at every tier. Returns the matched absolute
223
+ * path, or null when nothing matches.
224
+ */
225
+ async function resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot, legacySessionRoot, prerequisite, requestId) {
226
+ const roots = [canonicalSessionRoot, legacySessionRoot];
227
+ for (const root of roots) {
228
+ if (root === null)
229
+ continue;
230
+ const found = await resolvePrerequisiteAbsolutePath(root, prerequisite, requestId);
231
+ if (found !== null)
232
+ return found;
233
+ }
234
+ return null;
235
+ }
@@ -3,8 +3,8 @@ import { existsSync } from 'node:fs';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { isDirectory, listDirectories } from '../../shared/fs.js';
5
5
  import { checkPrerequisites, DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES } from './artifact-prerequisites.js';
6
- import { ensureSession } from '../session/session-manager.js';
7
- import { getCurrentChangeId, getChangeArtifactRoot } from '../../shared/change-id.js';
6
+ import { ensureSession, getSessionIdCanonical } from '../session/session-manager.js';
7
+ import { getCurrentChangeId } from '../../shared/change-id.js';
8
8
  import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
9
9
  import { lintRequestArtifact } from './artifact-lint-service.js';
10
10
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
@@ -313,25 +313,48 @@ export async function createRequestArtifact(options) {
313
313
  const clock = options.clock ?? defaultClock;
314
314
  const timestamp = clock();
315
315
  // Use provided session ID or get/create current session. The session
316
- // id is kept as the in-memory binding (so the artifact body can record
317
- // which session wrote it), but the artifact file is now written
318
- // under the change-id dir, NOT the session dir.
316
+ // id is the binding for the artifact file's location.
319
317
  //
320
- // The change-id is the file's durable scope. As of slice
321
- // 2026-06-05-change-id-as-unit-of-work, the requestId IS the
322
- // change-id (per the legacy `001-<change-id>.md` filename convention);
323
- // a request that lives in a session dir under a different change-id
324
- // is no longer the model. We honor a `current-change` binding if
325
- // one is set, and otherwise fall back to the requestId itself.
318
+ // Slice 006 collapses the per-change-id top-level dirs. The artifact
319
+ // file is now written under the SESSION dir
320
+ // (`.peaks/_runtime/<sid>/<role>/requests/`) instead of the
321
+ // change-id dir. The 2-tier fallback (canonical session legacy
322
+ // session) replaces the F3 3-tier fallback (per-change-id
323
+ // canonical session legacy session). The change-id is preserved
324
+ // in the artifact body's frontmatter (under `- change-id:`) for
325
+ // human navigation; it is no longer a filesystem path key.
326
326
  const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
327
327
  const boundChangeId = getCurrentChangeId(options.projectRoot);
328
- // Resolution order for the change-id (file path key):
329
- // 1. Explicit `options.changeId` (CLI `--session-id` pre-1.3.0 set this).
328
+ // Resolution order for the change-id (file body metadata):
329
+ // 1. Explicit `options.changeId` (CLI `--change-id`).
330
330
  // 2. `current-change` binding (live developer working context).
331
331
  // 3. The requestId itself (every request is its own scope by default).
332
332
  const changeId = options.changeId ?? boundChangeId ?? options.requestId;
333
- // Build numbered path under the change-id dir
334
- const requestsDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/' + options.role + '/requests';
333
+ // Slice 008 (F21 fix): fail fast when the resolved session id
334
+ // looks like a real session id (matches the date+session prefix)
335
+ // but does NOT correspond to an actual session dir under
336
+ // `.peaks/_runtime/`. Pre-F21 a sub-agent with a typo or stale
337
+ // binding (e.g. `2025-01-01-session-deadbe`) silently planned
338
+ // to write to a non-existent path. The check is intentionally
339
+ // scoped to "looks like a real session id" — a sid like
340
+ // `test-session` or `s` (no date prefix) is allowed through so
341
+ // the existing F3 / slice-007 back-compat flows (e.g. the
342
+ // `peaks request init --session-id <arbitrary-scope>` tests)
343
+ // can still create the dir on demand via the writer's
344
+ // `mkdir(..., { recursive: true })`.
345
+ const LOOKS_LIKE_SESSION_ID = /^\d{4}-\d{2}-\d{2}-session-/;
346
+ if (LOOKS_LIKE_SESSION_ID.test(sessionId)) {
347
+ const sessionDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
348
+ if (!(await isDirectory(sessionDir))) {
349
+ const canonicalSid = getSessionIdCanonical(options.projectRoot);
350
+ const hint = canonicalSid !== null
351
+ ? `Use --session-id ${canonicalSid} or run 'peaks workspace init' to create a new session.`
352
+ : `Run 'peaks workspace init' to create a new session.`;
353
+ throw new Error(`session id '${sessionId}' does not exist in _runtime/. Current canonical binding is '${canonicalSid ?? '<none>'}'. ${hint}`);
354
+ }
355
+ }
356
+ // Build numbered path under the session dir (canonical post-F3 home).
357
+ const requestsDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, options.role, 'requests');
335
358
  // Check if a file with this requestId already exists (regardless of number prefix)
336
359
  if (await isDirectory(requestsDir)) {
337
360
  const existingFiles = await listMarkdownFiles(requestsDir);
@@ -356,11 +379,11 @@ export async function createRequestArtifact(options) {
356
379
  await mkdir(dirname(path), { recursive: true });
357
380
  await writeFile(path, content, 'utf8');
358
381
  // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
359
- // As of slice 2026-06-05-change-id-as-unit-of-work, the marker lives under
360
- // the change-id dir (not the session dir), so the gate's prereq scan
361
- // (which reads from `.peaks/<change-id>/<role>/...`) finds it.
382
+ // Slice 006: the marker lives under the SESSION dir (canonical post-F3
383
+ // home), not the change-id dir. The gate's prereq scan finds it at
384
+ // `.peaks/_runtime/<sid>/qa/.initiated`.
362
385
  if (options.role === 'qa') {
363
- const qaDir = getChangeArtifactRoot(options.projectRoot, changeId) + '/qa';
386
+ const qaDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, 'qa');
364
387
  const initiatedPath = join(qaDir, '.initiated');
365
388
  if (!existsSync(initiatedPath)) {
366
389
  await mkdir(qaDir, { recursive: true });
@@ -448,45 +471,45 @@ export async function listRequestArtifacts(options) {
448
471
  if (!(await isDirectory(peaksRoot))) {
449
472
  return [];
450
473
  }
451
- // As of slice 2026-06-05-change-id-as-unit-of-work, artifact files live
452
- // in `.peaks/<change-id>/<role>/requests/`. The top-level `.peaks/<dir>/`
453
- // entries we scan here are change-id dirs (new layout) AND legacy
454
- // session-id dirs (pre-1.3.0 layout). Both have the same
455
- // `<role>/requests/<file>.md` shape, so we read them uniformly.
474
+ // Slice 006 collapsed the per-change-id top-level dirs. The 2-tier
475
+ // resolution model is:
476
+ // 1. `.peaks/_runtime/<sid>/<role>/requests/` (post-F3 canonical
477
+ // session home; slice 006's primary home for request artifacts).
478
+ // 2. `.peaks/<sid>/<role>/requests/` (pre-F3 legacy home; back-compat
479
+ // for users who have not yet migrated).
456
480
  //
457
- // Additionally, shipped slices are archived under
458
- // `.peaks/retrospective/<change-id>/<role>/requests/` and dogfood
459
- // evidence lives under `.peaks/_dogfood/<change-id>/<role>/requests/`.
460
- // When `sessionId` is NOT pinned, we scan ALL three umbrella dirs
461
- // (`<top>`, `retrospective/`, `_dogfood/`) so a `peaks request show
462
- // <rid>` resolves shipped slices too which is what the slice check's
463
- // gate-verify-pipeline stage needs to find evidence for the retrospective
464
- // slice being verified.
465
- //
466
- // Skip well-known non-artifact dirs: `_runtime/` holds ephemeral state
467
- // (no `requests/` subdirs anyway, but skip explicitly to avoid noise).
468
- const allDirs = await listDirectories(peaksRoot);
469
- const candidateDirs = allDirs.filter((dir) => dir !== '_runtime');
470
- // Expand scopes to include the nested umbrellas that host change-id dirs
471
- // (retrospective/, _dogfood/). For each, list its sub-dirs and treat
472
- // them as additional scopes. This makes the lookup span the entire
473
- // .peaks tree.
474
- const expandedScopes = [];
481
+ // When `sessionId` is pinned, the function scans that one session's
482
+ // two tiers (canonical + legacy). When `sessionId` is NOT pinned,
483
+ // the function scans every session dir under `.peaks/_runtime/`
484
+ // (canonical) AND every legacy session dir under `.peaks/`
485
+ // (top-level). Per-change-id dirs (the old `.peaks/<changeId>/<role>/`
486
+ // layout) are NOT scannedslice 008 will migrate the 5
487
+ // already-shipped slices' artifacts to the new layout; new request
488
+ // artifacts are written to the session dir directly.
489
+ const scopes = [];
475
490
  if (options.sessionId !== undefined) {
476
- expandedScopes.push(options.sessionId);
491
+ scopes.push(join('_runtime', options.sessionId));
492
+ scopes.push(options.sessionId);
477
493
  }
478
494
  else {
479
- for (const dir of candidateDirs) {
480
- expandedScopes.push(dir);
481
- if (dir === 'retrospective' || dir === '_dogfood') {
482
- const nested = await listDirectories(join(peaksRoot, dir));
483
- for (const n of nested) {
484
- expandedScopes.push(join(dir, n));
485
- }
495
+ const runtimeRoot = join(peaksRoot, '_runtime');
496
+ if (await isDirectory(runtimeRoot)) {
497
+ for (const sid of await listDirectories(runtimeRoot)) {
498
+ scopes.push(join('_runtime', sid));
486
499
  }
487
500
  }
501
+ // Legacy top-level session dirs: scan every non-`._peaks` top-level
502
+ // dir as a potential legacy scope. Slice 006 dropped per-change-id
503
+ // dirs, so any top-level dir name under `.peaks/` that is NOT
504
+ // `_runtime` (and not a well-known umbrella like retrospective,
505
+ // _dogfood, memory, etc. — those have no `<role>/requests/` tree)
506
+ // is treated as a candidate legacy session dir.
507
+ for (const dir of await listDirectories(peaksRoot)) {
508
+ if (dir === '_runtime')
509
+ continue;
510
+ scopes.push(dir);
511
+ }
488
512
  }
489
- const scopes = expandedScopes;
490
513
  const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
491
514
  const summaries = [];
492
515
  for (const scope of scopes) {
@@ -525,45 +548,56 @@ export async function showRequestArtifact(options) {
525
548
  // As of slice 2026-06-05-change-id-as-unit-of-work, the dir key is the
526
549
  // change-id (not the session-id). When the caller pins `sessionId` we
527
550
  // use it as the scope anyway (legacy callers, and tests that pass
528
- // `STABLE_SESSION` as a stand-in). The directory layout is identical
529
- // for both old session dirs and new change-id dirs, so a single
530
- // read path works for both.
551
+ // `STABLE_SESSION` as a stand-in).
552
+ //
553
+ // As of slice 2026-06-06-session-layout-canonicalize (F3), the
554
+ // canonical home for session dirs is `.peaks/_runtime/<sid>/`.
555
+ // The pre-F3 layout `.peaks/<sid>/` is preserved as a one-minor
556
+ // back-compat fallback (the new path wins when both exist). We
557
+ // resolve the dir to use UP FRONT (not lazily after a miss) so the
558
+ // prerequisite gate's "request artifact present" check observes
559
+ // the same path the rest of the canonical layout uses.
531
560
  if (options.sessionId !== undefined) {
532
- const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
561
+ const canonicalDir = join(options.projectRoot, '.peaks', '_runtime', options.sessionId, options.role, 'requests');
562
+ const legacyDir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
563
+ // Try the canonical (post-F3) path first; fall back to the legacy
564
+ // path only if the canonical path is absent. The legacy path is
565
+ // expected to be empty after a `peaks workspace migrate --to-runtime`
566
+ // run; this fallback exists for users who have not yet migrated.
567
+ const dir = (await isDirectory(canonicalDir)) ? canonicalDir : legacyDir;
568
+ const scope = dir === canonicalDir
569
+ ? join('_runtime', options.sessionId)
570
+ : options.sessionId;
533
571
  const found = await findFileInDir(dir);
534
572
  if (found === null) {
535
573
  return null;
536
574
  }
537
- return await readRequestArtifact(options.projectRoot, options.sessionId, options.role, found);
575
+ return await readRequestArtifact(options.projectRoot, scope, options.role, found);
538
576
  }
539
577
  const peaksRoot = join(options.projectRoot, '.peaks');
540
578
  if (!(await isDirectory(peaksRoot))) {
541
579
  return null;
542
580
  }
543
- // Scan all top-level dirs in `.peaks/` AND nested change-id dirs
544
- // under `retrospective/` and `_dogfood/`. The expanded scope list
545
- // lets us find request artifacts that live one or two levels deep
546
- // (shipped slices, dogfood evidence). Without this expansion,
547
- // verify-pipeline can't find the RD/QA request files for any
548
- // retrospective slice.
549
- const allDirs = await listDirectories(peaksRoot);
550
- const scopes = [];
551
- for (const dir of allDirs) {
552
- if (dir === '_runtime')
553
- continue;
554
- scopes.push(dir);
555
- if (dir === 'retrospective' || dir === '_dogfood') {
556
- const nested = await listDirectories(join(peaksRoot, dir));
557
- for (const n of nested) {
558
- scopes.push(join(dir, n));
581
+ // Slice 006: scan only session-scoped dirs (canonical + legacy)
582
+ // for the artifact. The per-change-id top-level dirs are no longer
583
+ // scanned they are frozen until slice 008 migrates them.
584
+ const runtimeRoot = join(peaksRoot, '_runtime');
585
+ if (await isDirectory(runtimeRoot)) {
586
+ for (const sid of await listDirectories(runtimeRoot)) {
587
+ const dir = join(runtimeRoot, sid, options.role, 'requests');
588
+ const found = await findFileInDir(dir);
589
+ if (found !== null) {
590
+ return await readRequestArtifact(options.projectRoot, join('_runtime', sid), options.role, found);
559
591
  }
560
592
  }
561
593
  }
562
- for (const scope of scopes) {
563
- const dir = join(peaksRoot, scope, options.role, 'requests');
564
- const found = await findFileInDir(dir);
594
+ for (const dir of await listDirectories(peaksRoot)) {
595
+ if (dir === '_runtime')
596
+ continue;
597
+ const target = join(peaksRoot, dir, options.role, 'requests');
598
+ const found = await findFileInDir(target);
565
599
  if (found !== null) {
566
- return await readRequestArtifact(options.projectRoot, scope, options.role, found);
600
+ return await readRequestArtifact(options.projectRoot, dir, options.role, found);
567
601
  }
568
602
  }
569
603
  return null;
@@ -719,6 +753,12 @@ export async function transitionRequestArtifact(options) {
719
753
  const prerequisiteResult = await checkPrerequisites({
720
754
  projectRoot: options.projectRoot,
721
755
  changeId: existing.changeId,
756
+ // F3 repair cycle 1: pass the session binding so the gate can fall
757
+ // back to `.peaks/_runtime/<sid>/<role>/` (and the legacy
758
+ // `.peaks/<sid>/<role>/`) for prerequisite artifacts that still
759
+ // live under the session dir rather than the change-id dir. This
760
+ // mirrors the F1/F2 back-compat pattern.
761
+ sessionId: existing.sessionId,
722
762
  role: options.role,
723
763
  newState: options.newState,
724
764
  requestId: options.requestId,