peaks-cli 1.2.9 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/workspace-commands.js +59 -1
- package/dist/src/services/sc/sc-service.d.ts +52 -1
- package/dist/src/services/sc/sc-service.js +266 -17
- package/dist/src/services/session/session-manager.d.ts +7 -5
- package/dist/src/services/session/session-manager.js +48 -14
- 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/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/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +4 -1
- package/skills/peaks-solo/SKILL.md +17 -3
- package/skills/peaks-solo/references/runbook.md +2 -0
package/bin/peaks.js
CHANGED
|
File without changes
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
|
|
2
|
+
import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
|
|
2
3
|
import { ensureSession } from '../../services/session/session-manager.js';
|
|
3
4
|
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
4
5
|
import { fail, ok } from '../../shared/result.js';
|
|
5
6
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
7
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
8
|
+
const DEFAULT_RECONCILE_AGE_DAYS = 7;
|
|
6
9
|
export function registerWorkspaceCommands(program, io) {
|
|
7
10
|
const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
|
|
8
11
|
addJsonOption(workspace
|
|
@@ -18,7 +21,7 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
18
21
|
// session, unless --allow-session-rebind is set)
|
|
19
22
|
// - omitted: defer to ensureSession(), which reuses an existing
|
|
20
23
|
// binding or auto-generates a fresh one. The init then writes
|
|
21
|
-
// .session.json so the binding sticks.
|
|
24
|
+
// .peaks/_runtime/session.json so the binding sticks.
|
|
22
25
|
//
|
|
23
26
|
// Before that: canonicalise the project root. If the user (or the
|
|
24
27
|
// LLM via "$(pwd)") passed a sub-directory of a real git repo
|
|
@@ -75,4 +78,59 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
75
78
|
process.exitCode = 1;
|
|
76
79
|
}
|
|
77
80
|
});
|
|
81
|
+
addJsonOption(workspace
|
|
82
|
+
.command('reconcile')
|
|
83
|
+
.description('Scan .peaks/2026-MM-DD-session-*/ directories and re-point .peaks/_runtime/session.json ' +
|
|
84
|
+
'to the canonical session (4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
|
|
85
|
+
'latest any-file mtime -> dir-name sort). Also migrates any legacy .peaks/.session.json / ' +
|
|
86
|
+
'.peaks/.active-skill.json / .peaks/sop-state/ into .peaks/_runtime/ (idempotent; no-op on a ' +
|
|
87
|
+
'tree that is already on the new layout). By default the command is a dry-run: it reports empty / abandoned ' +
|
|
88
|
+
`session dirs older than ${DEFAULT_RECONCILE_AGE_DAYS} days as deletion candidates but does not delete them. ` +
|
|
89
|
+
'Pass --apply to actually remove the listed candidate dirs (destructive). ' +
|
|
90
|
+
'Override the age threshold with --older-than <days>.')
|
|
91
|
+
.requiredOption('--project <path>', 'target project root')
|
|
92
|
+
.option('--apply', 'actually delete the deletion candidates (destructive); without it, dry-run only', false)
|
|
93
|
+
.option('--older-than <days>', `age threshold in days for deletion candidates (default: ${DEFAULT_RECONCILE_AGE_DAYS})`, (value) => Number.parseFloat(value))).action((options) => {
|
|
94
|
+
try {
|
|
95
|
+
const projectRoot = resolveCanonicalProjectRoot(options.project);
|
|
96
|
+
const olderThanDays = options.olderThan ?? DEFAULT_RECONCILE_AGE_DAYS;
|
|
97
|
+
if (typeof olderThanDays !== 'number' || !Number.isFinite(olderThanDays) || olderThanDays <= 0) {
|
|
98
|
+
printResult(io, fail('workspace.reconcile', 'INVALID_AGE_THRESHOLD', `--older-than must be a positive number of days`, { provided: options.olderThan }, ['Use --older-than 7 (or omit it to accept the 7-day default)']), options.json);
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const olderThanMs = olderThanDays * MS_PER_DAY;
|
|
103
|
+
const apply = options.apply === true;
|
|
104
|
+
const result = reconcileWorkspace({
|
|
105
|
+
projectRoot,
|
|
106
|
+
apply,
|
|
107
|
+
olderThanMs
|
|
108
|
+
});
|
|
109
|
+
const warnings = [];
|
|
110
|
+
if (result.sessions.length === 0) {
|
|
111
|
+
warnings.push('No session directories found under .peaks/. Run peaks workspace init first.');
|
|
112
|
+
}
|
|
113
|
+
if (apply && result.deleted.length > 0) {
|
|
114
|
+
warnings.push(`Deleted ${result.deleted.length} session dir(s) older than ${olderThanDays} day(s).`);
|
|
115
|
+
}
|
|
116
|
+
const nextActions = [];
|
|
117
|
+
if (result.migratedFiles.length > 0) {
|
|
118
|
+
nextActions.push(`Migrated ${result.migratedFiles.length} legacy runtime file(s) into .peaks/_runtime/: ${result.migratedFiles.join(', ')}.`);
|
|
119
|
+
}
|
|
120
|
+
if (result.repointed) {
|
|
121
|
+
nextActions.push(`Re-pointed .peaks/_runtime/session.json from ${result.repointedFrom ?? '<unbound>'} to ${result.repointedTo}.`);
|
|
122
|
+
}
|
|
123
|
+
if (!apply && result.wouldDelete.length > 0) {
|
|
124
|
+
nextActions.push(`Re-run with --apply to delete ${result.wouldDelete.length} candidate dir(s).`);
|
|
125
|
+
}
|
|
126
|
+
printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
|
|
127
|
+
if (result.errors.length > 0) {
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
printResult(io, fail('workspace.reconcile', 'WORKSPACE_RECONCILE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
}
|
|
135
|
+
});
|
|
78
136
|
}
|
|
@@ -51,6 +51,52 @@ export type CommitBoundary = {
|
|
|
51
51
|
syncState: 'synced' | 'pending' | 'failed';
|
|
52
52
|
rollbackPoint: string | null;
|
|
53
53
|
};
|
|
54
|
+
/**
|
|
55
|
+
* Resolution sources for `resolveArtifactSession`, in priority order.
|
|
56
|
+
* - `active-skill`: the orchestrator's active-skill marker
|
|
57
|
+
* (`.peaks/_runtime/active-skill.json`, with a one-minor-release
|
|
58
|
+
* fallback to `.peaks/.active-skill.json`) `sessionId` points to a
|
|
59
|
+
* session dir that owns the slice's marker artifact.
|
|
60
|
+
* - `session-json`: the workspace binding in
|
|
61
|
+
* `.peaks/_runtime/session.json` (with back-compat fallback to
|
|
62
|
+
* `.peaks/.session.json`) points to a session dir that owns the
|
|
63
|
+
* slice's marker artifact (active-skill was checked but did not
|
|
64
|
+
* own it; session-json is the next source).
|
|
65
|
+
* - `find-fallback`: neither binding owned the artifact, but a `find`
|
|
66
|
+
* walk under `.peaks/` located a session dir that does own it.
|
|
67
|
+
*/
|
|
68
|
+
export type ArtifactSessionSource = 'active-skill' | 'session-json' | 'find-fallback';
|
|
69
|
+
export type ResolvedArtifactSession = {
|
|
70
|
+
resolvedSessionId: string | null;
|
|
71
|
+
candidateSources: ArtifactSessionSource[];
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* Resolve the session id that owns the slice's artifacts using a 3-tier
|
|
75
|
+
* precedence:
|
|
76
|
+
*
|
|
77
|
+
* 1. `.peaks/_runtime/active-skill.json` `sessionId` (with back-compat
|
|
78
|
+
* fallback to `.peaks/.active-skill.json`) if it points to a real
|
|
79
|
+
* session that owns the slice.
|
|
80
|
+
* 2. `.peaks/_runtime/session.json` `sessionId` (with back-compat
|
|
81
|
+
* fallback to `.peaks/.session.json`) if it points to a real
|
|
82
|
+
* session that owns the slice.
|
|
83
|
+
* 3. `find .peaks/ -name '<marker>'` — the first session dir under
|
|
84
|
+
* `.peaks/` that owns the slice.
|
|
85
|
+
* 4. else `{ resolvedSessionId: null, candidateSources: [] }`.
|
|
86
|
+
*
|
|
87
|
+
* The back-compat fallbacks are tolerated for one minor release so
|
|
88
|
+
* users with pre-migration trees (or running an older CLI version)
|
|
89
|
+
* still get a clean resolution. After the migration (or after v1.3.0
|
|
90
|
+
* is installed and `peaks workspace reconcile` has been run), only
|
|
91
|
+
* the new paths exist and the fallbacks never fire.
|
|
92
|
+
*
|
|
93
|
+
* `candidateSources` reports which sources were checked before the
|
|
94
|
+
* resolver found (or did not find) a winner; the list is in the order
|
|
95
|
+
* the resolver consulted them. This makes the precedence observable in
|
|
96
|
+
* the JSON envelope so a human reviewer can see "active-skill was empty
|
|
97
|
+
* AND session-json was empty, so find-fallback won".
|
|
98
|
+
*/
|
|
99
|
+
export declare function resolveArtifactSession(projectRoot: string, sliceId: string): ResolvedArtifactSession;
|
|
54
100
|
export declare function getChangeTraceabilityStatus(): ChangeTraceabilityStatus;
|
|
55
101
|
export declare function createChangeImpact(options: {
|
|
56
102
|
changeId: string;
|
|
@@ -71,10 +117,15 @@ export declare function recordCommitBoundary(options: {
|
|
|
71
117
|
sliceId: string;
|
|
72
118
|
artifacts?: string[];
|
|
73
119
|
codeFiles?: string[];
|
|
74
|
-
}): CommitBoundary
|
|
120
|
+
}): CommitBoundary & {
|
|
121
|
+
resolvedSessionId: string | null;
|
|
122
|
+
candidateSources: ArtifactSessionSource[];
|
|
123
|
+
};
|
|
75
124
|
export declare function validateArtifactRetention(sliceId: string): {
|
|
76
125
|
valid: boolean;
|
|
77
126
|
missingArtifacts: string[];
|
|
78
127
|
warnings: string[];
|
|
128
|
+
resolvedSessionId: string | null;
|
|
129
|
+
candidateSources: ArtifactSessionSource[];
|
|
79
130
|
};
|
|
80
131
|
export declare function getScHelpText(): string[];
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { basename,
|
|
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
|
-
|
|
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: ['
|
|
215
|
-
warnings: ['
|
|
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
|
-
|
|
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:
|
|
221
|
-
missingArtifacts
|
|
222
|
-
warnings:
|
|
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]) =>
|
|
230
|
-
.filter((
|
|
231
|
-
|
|
449
|
+
.map(([folder, file]) => `${folder}/${file}`)
|
|
450
|
+
.filter((rel) => !legacyPresent(...rel.split('/')) && !resolvedPresent(...rel.split('/')));
|
|
451
|
+
// If the legacy check is short (i.e. we're missing a lot of legacy-named
|
|
452
|
+
// files) but the resolver landed on a real session, ALSO accept the
|
|
453
|
+
// modern set. The legacy set was designed for an older workflow naming
|
|
454
|
+
// and a freshly-minted session in the current peaks-cli flow will not
|
|
455
|
+
// have the legacy names. This keeps `peaks sc validate --slice-id <rid>`
|
|
456
|
+
// returning `valid: true` for slices that completed under the current
|
|
457
|
+
// peaks-cli convention.
|
|
458
|
+
if (missingArtifacts.length > 0 && resolvedPeaksSessionDir !== null) {
|
|
459
|
+
const modernMissing = modernRequirementRelativePaths(sliceId).filter((rel) => !resolvedPresent(...rel.split('/')));
|
|
460
|
+
if (modernMissing.length === 0) {
|
|
461
|
+
return {
|
|
462
|
+
valid: true,
|
|
463
|
+
missingArtifacts: [],
|
|
464
|
+
warnings: [],
|
|
465
|
+
resolvedSessionId: resolution.resolvedSessionId,
|
|
466
|
+
candidateSources: resolution.candidateSources
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
}
|
|
232
470
|
return {
|
|
233
471
|
valid: missingArtifacts.length === 0,
|
|
234
472
|
missingArtifacts,
|
|
235
|
-
warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing']
|
|
473
|
+
warnings: missingArtifacts.length === 0 ? [] : ['Some required artifact files are missing'],
|
|
474
|
+
resolvedSessionId: resolution.resolvedSessionId,
|
|
475
|
+
candidateSources: resolution.candidateSources
|
|
236
476
|
};
|
|
237
477
|
}
|
|
478
|
+
/**
|
|
479
|
+
* Render the modern retention requirements as relative paths keyed
|
|
480
|
+
* against the slice id. The `{sliceId}` placeholder in the template
|
|
481
|
+
* is replaced with the actual slice id; per-session files (no
|
|
482
|
+
* placeholder) keep their literal name.
|
|
483
|
+
*/
|
|
484
|
+
function modernRequirementRelativePaths(sliceId) {
|
|
485
|
+
return MODERN_RETENTION_REQUIREMENTS.map((template) => template.replace('{sliceId}', sliceId));
|
|
486
|
+
}
|
|
238
487
|
export function getScHelpText() {
|
|
239
488
|
return [
|
|
240
489
|
'peaks sc status Show change traceability status',
|
|
@@ -33,11 +33,13 @@ export type SessionMeta = {
|
|
|
33
33
|
outerSessionId?: string;
|
|
34
34
|
};
|
|
35
35
|
/**
|
|
36
|
-
* Drop the project-level session binding
|
|
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,20 @@
|
|
|
6
6
|
* Each session gets a unique directory under .peaks/ with incrementing numbered files.
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { join, resolve } from 'node:path';
|
|
9
|
+
import { dirname, join, resolve } from 'node:path';
|
|
10
10
|
import { randomBytes } from 'node:crypto';
|
|
11
11
|
import { initWorkspace } from '../workspace/workspace-service.js';
|
|
12
|
-
|
|
12
|
+
// As of slice 2026-06-05-peaks-runtime-layer the project-level session
|
|
13
|
+
// binding lives under `.peaks/_runtime/session.json`. The legacy
|
|
14
|
+
// `.peaks/.session.json` path is preserved as a read-only fallback for one
|
|
15
|
+
// minor release so older CLI versions (or trees that have not been migrated
|
|
16
|
+
// by `peaks workspace reconcile`) keep working without a forced re-init.
|
|
17
|
+
const SESSION_FILE = join('_runtime', 'session.json');
|
|
18
|
+
const LEGACY_SESSION_FILE = '.session.json';
|
|
13
19
|
const META_FILE = 'session.json';
|
|
20
|
+
function getLegacySessionFilePath(projectRoot) {
|
|
21
|
+
return join(projectRoot, '.peaks', LEGACY_SESSION_FILE);
|
|
22
|
+
}
|
|
14
23
|
/**
|
|
15
24
|
* Canonicalize a project root path. Returns the realpath
|
|
16
25
|
* (resolving all symlinks — important on macOS where `/var`
|
|
@@ -68,7 +77,9 @@ function generateSessionId() {
|
|
|
68
77
|
return `${date}-session-${random}`;
|
|
69
78
|
}
|
|
70
79
|
/**
|
|
71
|
-
* Get the path to the session file for a project.
|
|
80
|
+
* Get the path to the session file for a project. The canonical home is
|
|
81
|
+
* `.peaks/_runtime/session.json`; the legacy `.peaks/.session.json` is
|
|
82
|
+
* read-only fallback (see `readSessionFile`).
|
|
72
83
|
*/
|
|
73
84
|
function getSessionFilePath(projectRoot) {
|
|
74
85
|
return join(projectRoot, '.peaks', SESSION_FILE);
|
|
@@ -92,10 +103,15 @@ function getSessionFilePath(projectRoot) {
|
|
|
92
103
|
*/
|
|
93
104
|
function readSessionFile(projectRoot) {
|
|
94
105
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
95
|
-
|
|
106
|
+
const legacyFile = getLegacySessionFilePath(projectRoot);
|
|
107
|
+
// Back-compat window: prefer the new canonical path; fall back to the
|
|
108
|
+
// legacy `.peaks/.session.json` so older CLI versions or pre-migration
|
|
109
|
+
// trees keep working. When both exist, the new path wins.
|
|
110
|
+
const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
|
|
111
|
+
if (!existsSync(pathToRead))
|
|
96
112
|
return null;
|
|
97
113
|
try {
|
|
98
|
-
const data = JSON.parse(readFileSync(
|
|
114
|
+
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
99
115
|
if (data.sessionId && data.projectRoot === projectRoot) {
|
|
100
116
|
return data;
|
|
101
117
|
}
|
|
@@ -116,10 +132,14 @@ function readSessionFile(projectRoot) {
|
|
|
116
132
|
*/
|
|
117
133
|
function readSessionFileCanonical(projectRoot) {
|
|
118
134
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
119
|
-
|
|
135
|
+
const legacyFile = getLegacySessionFilePath(projectRoot);
|
|
136
|
+
// Back-compat window: prefer the new canonical path; fall back to the
|
|
137
|
+
// legacy `.peaks/.session.json` for one minor release.
|
|
138
|
+
const pathToRead = existsSync(sessionFile) ? sessionFile : legacyFile;
|
|
139
|
+
if (!existsSync(pathToRead))
|
|
120
140
|
return null;
|
|
121
141
|
try {
|
|
122
|
-
const data = JSON.parse(readFileSync(
|
|
142
|
+
const data = JSON.parse(readFileSync(pathToRead, 'utf8'));
|
|
123
143
|
const storedRaw = typeof data.projectRoot === 'string' ? data.projectRoot : null;
|
|
124
144
|
if (data.sessionId &&
|
|
125
145
|
storedRaw !== null &&
|
|
@@ -133,22 +153,27 @@ function readSessionFileCanonical(projectRoot) {
|
|
|
133
153
|
}
|
|
134
154
|
}
|
|
135
155
|
/**
|
|
136
|
-
* Write session info to disk
|
|
156
|
+
* Write session info to disk at the canonical new path
|
|
157
|
+
* `.peaks/_runtime/session.json`. The `.peaks/_runtime/` directory is
|
|
158
|
+
* created on demand. The legacy `.peaks/.session.json` is NOT written by
|
|
159
|
+
* this slice; it is only read for back-compat.
|
|
137
160
|
*/
|
|
138
161
|
function writeSessionFile(projectRoot, info) {
|
|
139
162
|
const sessionFile = getSessionFilePath(projectRoot);
|
|
140
|
-
const dir =
|
|
163
|
+
const dir = dirname(sessionFile);
|
|
141
164
|
if (!existsSync(dir)) {
|
|
142
165
|
mkdirSync(dir, { recursive: true });
|
|
143
166
|
}
|
|
144
167
|
writeFileSync(sessionFile, JSON.stringify(info, null, 2), 'utf8');
|
|
145
168
|
}
|
|
146
169
|
/**
|
|
147
|
-
* Drop the project-level session binding
|
|
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.
|
|
170
|
+
* Drop the project-level session binding at the canonical
|
|
171
|
+
* `.peaks/_runtime/session.json` so the next `ensureSession()` call
|
|
172
|
+
* auto-generates a fresh session id. The on-disk session directory
|
|
173
|
+
* is left intact — rotating does NOT delete the user's data, it
|
|
174
|
+
* just unbinds the project from that session. Also drops the legacy
|
|
175
|
+
* `.peaks/.session.json` if present so a stale read from another
|
|
176
|
+
* tool cannot re-bind the project after rotation.
|
|
152
177
|
*
|
|
153
178
|
* Returns the id of the session that was unbound, or `null` if
|
|
154
179
|
* no binding was present. The caller is expected to do something
|
|
@@ -164,6 +189,15 @@ export function rotateSessionBinding(projectRoot) {
|
|
|
164
189
|
if (existsSync(sessionFile)) {
|
|
165
190
|
unlinkSync(sessionFile);
|
|
166
191
|
}
|
|
192
|
+
const legacyFile = getLegacySessionFilePath(projectRoot);
|
|
193
|
+
if (existsSync(legacyFile)) {
|
|
194
|
+
try {
|
|
195
|
+
unlinkSync(legacyFile);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// best-effort: a stale legacy binding is not blocking
|
|
199
|
+
}
|
|
200
|
+
}
|
|
167
201
|
return previous.sessionId;
|
|
168
202
|
}
|
|
169
203
|
/**
|