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.
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/core-artifact-commands.js +49 -11
- package/dist/src/cli/commands/slice-commands.js +4 -2
- package/dist/src/cli/commands/workspace-commands.js +67 -14
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +12 -0
- package/dist/src/services/artifacts/artifact-prerequisites.js +39 -8
- package/dist/src/services/artifacts/request-artifact-service.js +116 -76
- package/dist/src/services/doctor/doctor-service.d.ts +62 -0
- package/dist/src/services/doctor/doctor-service.js +276 -1
- package/dist/src/services/session/session-manager.d.ts +22 -1
- package/dist/src/services/session/session-manager.js +137 -28
- package/dist/src/services/slice/slice-check-service.js +20 -1
- package/dist/src/services/slice/slice-check-types.d.ts +9 -0
- package/dist/src/services/workspace/migrate-service.js +124 -2
- package/dist/src/services/workspace/migrate-types.d.ts +50 -7
- package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
- package/dist/src/services/workspace/reconcile-service.js +160 -42
- package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
- package/dist/src/services/workspace/workspace-service.js +29 -62
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +1 -0
- package/skills/peaks-rd/SKILL.md +2 -1
- package/skills/peaks-solo/SKILL.md +6 -0
- package/skills/peaks-txt/SKILL.md +2 -0
- 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 {
|
|
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(
|
|
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
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
195
|
-
.description('Show full metadata for a session directory
|
|
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)
|
|
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
|
|
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
|
|
203
|
-
'
|
|
204
|
-
'
|
|
205
|
-
'.peaks/.
|
|
206
|
-
'
|
|
207
|
-
|
|
208
|
-
'
|
|
209
|
-
'
|
|
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)
|
|
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
|
|
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
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
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
|
|
329
|
-
// 1. Explicit `options.changeId` (CLI `--
|
|
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
|
-
//
|
|
334
|
-
|
|
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
|
-
//
|
|
360
|
-
// the change-id dir
|
|
361
|
-
//
|
|
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 =
|
|
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
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
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
|
-
//
|
|
458
|
-
//
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
// (
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
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 scanned — slice 008 will migrate the 5
|
|
487
|
+
// already-shipped slices' artifacts to the new layout; new request
|
|
488
|
+
// artifacts are written to the session dir directly.
|
|
489
|
+
const scopes = [];
|
|
475
490
|
if (options.sessionId !== undefined) {
|
|
476
|
-
|
|
491
|
+
scopes.push(join('_runtime', options.sessionId));
|
|
492
|
+
scopes.push(options.sessionId);
|
|
477
493
|
}
|
|
478
494
|
else {
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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).
|
|
529
|
-
//
|
|
530
|
-
//
|
|
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
|
|
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,
|
|
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
|
-
//
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
563
|
-
|
|
564
|
-
|
|
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,
|
|
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,
|