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