peaks-cli 1.2.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +12 -0
  2. package/dist/src/cli/commands/project-commands.js +1 -1
  3. package/dist/src/cli/commands/scan-commands.js +22 -0
  4. package/dist/src/cli/commands/workspace-commands.js +59 -1
  5. package/dist/src/services/memory/project-memory-service.d.ts +1 -1
  6. package/dist/src/services/memory/project-memory-service.js +52 -23
  7. package/dist/src/services/sc/sc-service.d.ts +52 -1
  8. package/dist/src/services/sc/sc-service.js +266 -17
  9. package/dist/src/services/scan/libraries-service.d.ts +24 -0
  10. package/dist/src/services/scan/libraries-service.js +419 -0
  11. package/dist/src/services/scan/libraries-types.d.ts +59 -0
  12. package/dist/src/services/scan/libraries-types.js +9 -0
  13. package/dist/src/services/session/session-manager.d.ts +7 -5
  14. package/dist/src/services/session/session-manager.js +48 -14
  15. package/dist/src/services/skills/skill-presence-service.js +102 -68
  16. package/dist/src/services/skills/skill-runbook-service.js +36 -2
  17. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  18. package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
  19. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  20. package/dist/src/services/workspace/reconcile-service.js +464 -0
  21. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  22. package/dist/src/services/workspace/reconcile-types.js +13 -0
  23. package/dist/src/shared/change-id.d.ts +30 -0
  24. package/dist/src/shared/change-id.js +40 -6
  25. package/dist/src/shared/paths.d.ts +1 -1
  26. package/dist/src/shared/paths.js +2 -1
  27. package/dist/src/shared/version.d.ts +1 -1
  28. package/dist/src/shared/version.js +1 -1
  29. package/package.json +4 -1
  30. package/schemas/library-breaking-changes.data.json +141 -0
  31. package/schemas/library-breaking-changes.meta.json +6 -0
  32. package/schemas/library-breaking-changes.schema.json +50 -0
  33. package/skills/peaks-qa/SKILL.md +12 -0
  34. package/skills/peaks-rd/SKILL.md +145 -2
  35. package/skills/peaks-solo/SKILL.md +93 -319
  36. package/skills/peaks-solo/references/runbook.md +168 -0
  37. package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
  38. package/skills/peaks-solo-resume/SKILL.md +81 -0
  39. package/skills/peaks-solo-status/SKILL.md +120 -0
  40. package/skills/peaks-solo-test/SKILL.md +84 -0
  41. package/skills/peaks-txt/SKILL.md +8 -5
@@ -1,6 +1,6 @@
1
- import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
- import { basename, relative, resolve } from 'node:path';
3
+ import { basename, join, resolve } from 'node:path';
4
4
  import { isInsidePath } from '../../shared/path-utils.js';
5
5
  import { getWorkspaceConfigForPath } from '../config/config-service.js';
6
6
  import { getArtifactRemoteRepo, getArtifactWorkspaceStatus, getLocalArtifactPath } from '../artifacts/workspace-service.js';
@@ -21,6 +21,27 @@ const RETENTION_REQUIREMENTS = [
21
21
  ['sc', 'retention-boundary.md'],
22
22
  ['txt', 'context-capsule.md']
23
23
  ];
24
+ /**
25
+ * "Modern" retention requirements for the current peaks-cli artifact
26
+ * naming convention. The legacy `RETENTION_REQUIREMENTS` above assume
27
+ * older `refactor-goal.md` / `slice-spec.md` / `coverage-report.md` /
28
+ * `validation-report.md` / `change-impact.json` / `retention-boundary.md`
29
+ * / `context-capsule.md` filenames that predate the W4 session resolver.
30
+ *
31
+ * When the resolver finds a session that owns the slice, validate
32
+ * against the modern set: the actual files the current workflow emits
33
+ * (per-slice `prd/requests/<rid>.md`, per-slice `rd/requests/<rid>.md`,
34
+ * per-session `rd/tech-doc.md`, per-slice `qa/test-cases/<rid>.md`,
35
+ * per-slice `qa/test-reports/<rid>.md`, per-session `txt/handoff.md`).
36
+ * The legacy set is preserved for the workspace-artifact path so
37
+ * existing repos on the old convention keep working.
38
+ */
39
+ const MODERN_RETENTION_REQUIREMENTS = [
40
+ 'rd/tech-doc.md',
41
+ 'qa/test-cases/{sliceId}.md',
42
+ 'qa/test-reports/{sliceId}.md',
43
+ 'txt/handoff.md'
44
+ ];
24
45
  const SLICE_ID_PATTERN = /^(?!\.{1,2}$)[A-Za-z0-9._-]+$/;
25
46
  function getPeaksPath(workspaceRoot) {
26
47
  return resolve(workspaceRoot, '.peaks');
@@ -108,6 +129,150 @@ function isRetainedArtifactFile(filePath, artifactWorkspacePath, changesRoot, ch
108
129
  return false;
109
130
  }
110
131
  }
132
+ /**
133
+ * Read the orchestrator's active-skill marker and return its
134
+ * `sessionId`, or null when the file is missing / malformed.
135
+ *
136
+ * As of slice 2026-06-05-peaks-runtime-layer the canonical home is
137
+ * `<projectRoot>/.peaks/_runtime/active-skill.json`. The legacy
138
+ * `<projectRoot>/.peaks/.active-skill.json` is consulted as a
139
+ * one-minor-release back-compat fallback: if the new path is
140
+ * absent but the legacy path is present and valid, we use the
141
+ * legacy value. The new path always wins when both exist.
142
+ */
143
+ function readActiveSkillSessionId(projectRoot) {
144
+ const newPath = join(projectRoot, '.peaks', '_runtime', 'active-skill.json');
145
+ const legacyPath = join(projectRoot, '.peaks', '.active-skill.json');
146
+ const pathToRead = existsSync(newPath) ? newPath : legacyPath;
147
+ if (!existsSync(pathToRead))
148
+ return null;
149
+ try {
150
+ const raw = readFileSync(pathToRead, 'utf8');
151
+ const parsed = JSON.parse(raw);
152
+ if (typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0) {
153
+ return parsed.sessionId;
154
+ }
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ return null;
160
+ }
161
+ /**
162
+ * Read the workspace session binding and return its `sessionId`,
163
+ * or null when the file is missing / malformed.
164
+ *
165
+ * As of slice 2026-06-05-peaks-runtime-layer the canonical home is
166
+ * `<projectRoot>/.peaks/_runtime/session.json`. The legacy
167
+ * `<projectRoot>/.peaks/.session.json` is consulted as a
168
+ * one-minor-release back-compat fallback: if the new path is
169
+ * absent but the legacy path is present and valid, we use the
170
+ * legacy value. The new path always wins when both exist.
171
+ */
172
+ function readSessionJsonBinding(projectRoot) {
173
+ const newPath = join(projectRoot, '.peaks', '_runtime', 'session.json');
174
+ const legacyPath = join(projectRoot, '.peaks', '.session.json');
175
+ const pathToRead = existsSync(newPath) ? newPath : legacyPath;
176
+ if (!existsSync(pathToRead))
177
+ return null;
178
+ try {
179
+ const raw = readFileSync(pathToRead, 'utf8');
180
+ const parsed = JSON.parse(raw);
181
+ if (typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0) {
182
+ return parsed.sessionId;
183
+ }
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ return null;
189
+ }
190
+ /**
191
+ * The "marker" artifact whose existence under a session dir is the signal
192
+ * that the session owns the slice. We look for `qa/test-cases/<sliceId>.md`
193
+ * first (a present test plan is the most decisive signal of session
194
+ * ownership for a slice id). If the test plan is absent we also accept
195
+ * `qa/test-reports/<sliceId>.md` (a finished QA report is also a decisive
196
+ * ownership signal). When neither exists for a candidate session, that
197
+ * session does not own the slice.
198
+ */
199
+ function sessionOwnsSlice(projectRoot, sessionId, sliceId) {
200
+ const sessionDir = join(projectRoot, '.peaks', sessionId);
201
+ if (!existsSync(sessionDir))
202
+ return false;
203
+ for (const marker of [`qa/test-cases/${sliceId}.md`, `qa/test-reports/${sliceId}.md`]) {
204
+ if (existsSync(join(sessionDir, marker)))
205
+ return true;
206
+ }
207
+ return false;
208
+ }
209
+ /**
210
+ * Find a session dir under `<projectRoot>/.peaks/` that owns the slice
211
+ * (see `sessionOwnsSlice`). Returns the first match in lexicographic
212
+ * order, or null when no session owns the slice.
213
+ */
214
+ function findSessionOwningSlice(projectRoot, sliceId) {
215
+ const peaksRoot = join(projectRoot, '.peaks');
216
+ if (!existsSync(peaksRoot))
217
+ return null;
218
+ let names;
219
+ try {
220
+ names = readdirSync(peaksRoot);
221
+ }
222
+ catch {
223
+ return null;
224
+ }
225
+ names.sort();
226
+ for (const name of names) {
227
+ if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
228
+ continue;
229
+ if (sessionOwnsSlice(projectRoot, name, sliceId)) {
230
+ return name;
231
+ }
232
+ }
233
+ return null;
234
+ }
235
+ /**
236
+ * Resolve the session id that owns the slice's artifacts using a 3-tier
237
+ * precedence:
238
+ *
239
+ * 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
240
+ * fallback to `.peaks/.active-skill.json`) if it points to a real
241
+ * session that owns the slice.
242
+ * 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
243
+ * fallback to `.peaks/.session.json`) if it points to a real
244
+ * session that owns the slice.
245
+ * 3. `find .peaks/ -name '<marker>'` — the first session dir under
246
+ * `.peaks/` that owns the slice.
247
+ * 4. else `{ resolvedSessionId: null, candidateSources: [] }`.
248
+ *
249
+ * The back-compat fallbacks are tolerated for one minor release so
250
+ * users with pre-migration trees (or running an older CLI version)
251
+ * still get a clean resolution. After the migration (or after v1.3.0
252
+ * is installed and `peaks workspace reconcile` has been run), only
253
+ * the new paths exist and the fallbacks never fire.
254
+ *
255
+ * `candidateSources` reports which sources were checked before the
256
+ * resolver found (or did not find) a winner; the list is in the order
257
+ * the resolver consulted them. This makes the precedence observable in
258
+ * the JSON envelope so a human reviewer can see "active-skill was empty
259
+ * AND session-json was empty, so find-fallback won".
260
+ */
261
+ export function resolveArtifactSession(projectRoot, sliceId) {
262
+ const activeSkill = readActiveSkillSessionId(projectRoot);
263
+ if (activeSkill !== null && sessionOwnsSlice(projectRoot, activeSkill, sliceId)) {
264
+ return { resolvedSessionId: activeSkill, candidateSources: ['active-skill'] };
265
+ }
266
+ const sessionJson = readSessionJsonBinding(projectRoot);
267
+ if (sessionJson !== null && sessionOwnsSlice(projectRoot, sessionJson, sliceId)) {
268
+ return { resolvedSessionId: sessionJson, candidateSources: ['active-skill', 'session-json'] };
269
+ }
270
+ const findHit = findSessionOwningSlice(projectRoot, sliceId);
271
+ if (findHit !== null) {
272
+ return { resolvedSessionId: findHit, candidateSources: ['active-skill', 'session-json', 'find-fallback'] };
273
+ }
274
+ return { resolvedSessionId: null, candidateSources: [] };
275
+ }
111
276
  export function getChangeTraceabilityStatus() {
112
277
  const workspace = getWorkspaceConfigForPath(process.cwd());
113
278
  const artifactStatus = getArtifactWorkspaceStatus(workspace?.workspaceId);
@@ -196,6 +361,8 @@ export function recordCommitBoundary(options) {
196
361
  const workspace = getWorkspaceConfigForPath(process.cwd());
197
362
  const artifactStatus = getArtifactWorkspaceStatus(workspace?.workspaceId);
198
363
  const commitHash = getCurrentCommitHash(workspace?.rootPath);
364
+ const projectRoot = workspace?.rootPath ?? process.cwd();
365
+ const resolution = resolveArtifactSession(projectRoot, options.sliceId);
199
366
  return {
200
367
  sliceId: options.sliceId,
201
368
  commitHash,
@@ -203,38 +370,120 @@ export function recordCommitBoundary(options) {
203
370
  artifacts: options.artifacts ?? [],
204
371
  codeFiles: options.codeFiles ?? [],
205
372
  syncState: mapSyncState(artifactStatus.syncStatus),
206
- rollbackPoint: commitHash
373
+ rollbackPoint: commitHash,
374
+ resolvedSessionId: resolution.resolvedSessionId,
375
+ candidateSources: resolution.candidateSources
207
376
  };
208
377
  }
209
378
  export function validateArtifactRetention(sliceId) {
210
379
  const workspace = getWorkspaceConfigForPath(process.cwd());
211
- if (!workspace) {
380
+ // Resolve from `process.cwd()` even when no workspace is configured, so
381
+ // the W4 session resolver can still find the slice's owning session.
382
+ // The legacy "no workspace" check still surfaces as a missing artifact,
383
+ // but the resolution happens first so the JSON envelope's additive
384
+ // `resolvedSessionId` is populated regardless of workspace state.
385
+ const projectRoot = workspace?.rootPath ?? process.cwd();
386
+ if (!SLICE_ID_PATTERN.test(sliceId)) {
212
387
  return {
213
388
  valid: false,
214
- missingArtifacts: ['No workspace configured'],
215
- warnings: ['Cannot validate without a configured workspace']
389
+ missingArtifacts: ['Invalid slice id'],
390
+ warnings: ['Slice id must stay inside .peaks/<session-id> and only contain letters, numbers, dots, underscores, or hyphens'],
391
+ resolvedSessionId: null,
392
+ candidateSources: []
216
393
  };
217
394
  }
218
- if (!SLICE_ID_PATTERN.test(sliceId)) {
395
+ const resolution = resolveArtifactSession(projectRoot, sliceId);
396
+ const effectiveSliceId = resolution.resolvedSessionId ?? sliceId;
397
+ // W4: if the resolver found a session, ALSO accept artifacts under
398
+ // `<projectRoot>/.peaks/<resolvedSessionId>/` (the canonical per-slice
399
+ // dir). The project-root peaks is where the orchestrator's skills
400
+ // actually write (see `initWorkspace` in `workspace-service.ts`), so
401
+ // when the resolution chain lands on a real session the artifacts are
402
+ // usually there. We accept either location — workspace artifact path
403
+ // OR project-root peaks — so the additive behavior does not regress
404
+ // existing workspaces.
405
+ const resolvedPeaksSessionDir = resolution.resolvedSessionId !== null
406
+ ? join(projectRoot, '.peaks', resolution.resolvedSessionId)
407
+ : null;
408
+ // Collect present files: legacy workspace-artifact-path check, OR the
409
+ // resolved session's project-root peaks dir.
410
+ const legacyPresent = (folder, file) => {
411
+ if (!workspace)
412
+ return false;
413
+ const artifactWorkspacePath = getLocalArtifactPath(workspace);
414
+ const { peaksPath, changeDir } = getRetentionChangeDir(artifactWorkspacePath, effectiveSliceId);
415
+ const filePath = resolve(changeDir, folder, file);
416
+ return isRetainedArtifactFile(filePath, artifactWorkspacePath, peaksPath, changeDir);
417
+ };
418
+ const resolvedPresent = (folder, file) => {
419
+ if (resolvedPeaksSessionDir === null)
420
+ return false;
421
+ return existsSync(join(resolvedPeaksSessionDir, folder, file));
422
+ };
423
+ if (!workspace) {
424
+ // No workspace: validate against the resolved session dir directly
425
+ // (this is the common peaks-solo / peaks-rd invocation: the slice
426
+ // lives under the project-root `.peaks/<sessionId>/`, and the
427
+ // workspace artifact path is irrelevant). When the resolution also
428
+ // fails, fall back to the legacy "No workspace configured" failure
429
+ // mode so the existing CLI contract is preserved.
430
+ if (resolvedPeaksSessionDir === null) {
431
+ return {
432
+ valid: false,
433
+ missingArtifacts: ['No workspace configured'],
434
+ warnings: ['Cannot validate without a configured workspace'],
435
+ resolvedSessionId: resolution.resolvedSessionId,
436
+ candidateSources: resolution.candidateSources
437
+ };
438
+ }
439
+ const missingArtifacts = modernRequirementRelativePaths(sliceId).filter((rel) => !existsSync(join(resolvedPeaksSessionDir, rel)));
219
440
  return {
220
- valid: false,
221
- missingArtifacts: ['Invalid slice id'],
222
- warnings: ['Slice id must stay inside .peaks/<session-id> and only contain letters, numbers, dots, underscores, or hyphens']
441
+ valid: missingArtifacts.length === 0,
442
+ missingArtifacts,
443
+ warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing'],
444
+ resolvedSessionId: resolution.resolvedSessionId,
445
+ candidateSources: resolution.candidateSources
223
446
  };
224
447
  }
225
- const artifactWorkspacePath = getLocalArtifactPath(workspace);
226
- const { peaksPath, changeDir } = getRetentionChangeDir(artifactWorkspacePath, sliceId);
227
- const changesRoot = peaksPath;
228
448
  const missingArtifacts = RETENTION_REQUIREMENTS
229
- .map(([folder, file]) => resolve(changeDir, folder, file))
230
- .filter((filePath) => !isRetainedArtifactFile(filePath, artifactWorkspacePath, changesRoot, changeDir))
231
- .map((filePath) => relative(changeDir, filePath).replace(/\\/g, '/'));
449
+ .map(([folder, file]) => `${folder}/${file}`)
450
+ .filter((rel) => !legacyPresent(...rel.split('/')) && !resolvedPresent(...rel.split('/')));
451
+ // If the legacy check is short (i.e. we're missing a lot of legacy-named
452
+ // files) but the resolver landed on a real session, ALSO accept the
453
+ // modern set. The legacy set was designed for an older workflow naming
454
+ // and a freshly-minted session in the current peaks-cli flow will not
455
+ // have the legacy names. This keeps `peaks sc validate --slice-id <rid>`
456
+ // returning `valid: true` for slices that completed under the current
457
+ // peaks-cli convention.
458
+ if (missingArtifacts.length > 0 && resolvedPeaksSessionDir !== null) {
459
+ const modernMissing = modernRequirementRelativePaths(sliceId).filter((rel) => !resolvedPresent(...rel.split('/')));
460
+ if (modernMissing.length === 0) {
461
+ return {
462
+ valid: true,
463
+ missingArtifacts: [],
464
+ warnings: [],
465
+ resolvedSessionId: resolution.resolvedSessionId,
466
+ candidateSources: resolution.candidateSources
467
+ };
468
+ }
469
+ }
232
470
  return {
233
471
  valid: missingArtifacts.length === 0,
234
472
  missingArtifacts,
235
- warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing']
473
+ warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing'],
474
+ resolvedSessionId: resolution.resolvedSessionId,
475
+ candidateSources: resolution.candidateSources
236
476
  };
237
477
  }
478
+ /**
479
+ * Render the modern retention requirements as relative paths keyed
480
+ * against the slice id. The `{sliceId}` placeholder in the template
481
+ * is replaced with the actual slice id; per-session files (no
482
+ * placeholder) keep their literal name.
483
+ */
484
+ function modernRequirementRelativePaths(sliceId) {
485
+ return MODERN_RETENTION_REQUIREMENTS.map((template) => template.replace('{sliceId}', sliceId));
486
+ }
238
487
  export function getScHelpText() {
239
488
  return [
240
489
  'peaks sc status Show change traceability status',
@@ -0,0 +1,24 @@
1
+ import type { LibraryReport } from './libraries-types.js';
2
+ export type ScanLibrariesOptions = {
3
+ projectRoot: string;
4
+ };
5
+ /**
6
+ * Parse the major version from a semver-ish spec.
7
+ *
8
+ * Handles the common shapes:
9
+ * "^5.18.0" → 5
10
+ * "~1.2.3" → 1
11
+ * "1.2.3" → 1
12
+ * ">=1.0.0" → 1
13
+ * "5" → 5
14
+ * "5.x" → 5
15
+ *
16
+ * Returns null for non-semver specs that the LLM should not assume a
17
+ * major for:
18
+ * "workspace:*" → null
19
+ * "file:../..." → null
20
+ * "git+https..." → null
21
+ * "npm:@scope/x@1" → 1 (alias spec, we extract what we can)
22
+ */
23
+ export declare function parseMajorVersion(spec: string): number | null;
24
+ export declare function scanLibraries(options: ScanLibrariesOptions): Promise<LibraryReport>;