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
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconcile service for `.peaks/2026-MM-DD-session-<6hex>/` directories.
|
|
3
|
+
*
|
|
4
|
+
* Reconcile scans the project root's `.peaks/` directory, identifies a
|
|
5
|
+
* canonical session via a 4-tier heuristic, re-points
|
|
6
|
+
* `.peaks/_runtime/session.json` (the canonical new home of the
|
|
7
|
+
* binding; legacy `.peaks/.session.json` is read-only back-compat),
|
|
8
|
+
* and (optionally, with apply === true) deletes empty / abandoned
|
|
9
|
+
* session dirs older than olderThanMs.
|
|
10
|
+
*
|
|
11
|
+
* As of slice 2026-06-05-peaks-runtime-layer the top-level orchestrator
|
|
12
|
+
* also runs `migrateOldRuntimeState` at the start so pre-migration
|
|
13
|
+
* trees have their `.peaks/.session.json` / `.peaks/.active-skill.json`
|
|
14
|
+
* / `.peaks/sop-state/` files moved into `.peaks/_runtime/`.
|
|
15
|
+
*
|
|
16
|
+
* Pure hand-rolled; uses only node:fs, node:path, and the existing
|
|
17
|
+
* session-manager helper for writing the binding. No new dependencies.
|
|
18
|
+
*/
|
|
19
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs';
|
|
20
|
+
import { dirname, join, resolve } from 'node:path';
|
|
21
|
+
import { getSessionIdCanonical, setCurrentSessionBinding } from '../session/session-manager.js';
|
|
22
|
+
const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/;
|
|
23
|
+
const META_FILE = 'session.json';
|
|
24
|
+
// As of slice 2026-06-05-peaks-runtime-layer these old paths are the
|
|
25
|
+
// back-compat read-only fallbacks; the canonical new home is
|
|
26
|
+
// `.peaks/_runtime/`. `migrateOldRuntimeState` moves them to the new
|
|
27
|
+
// location on disk. The leading dot is dropped when computing the
|
|
28
|
+
// new basename (e.g. `.session.json` → `session.json`), so the new
|
|
29
|
+
// layout is `.peaks/_runtime/{session.json,active-skill.json,sop-state/}`.
|
|
30
|
+
const RUNTIME_OLD_PATHS = [
|
|
31
|
+
'.session.json',
|
|
32
|
+
'.active-skill.json',
|
|
33
|
+
'sop-state'
|
|
34
|
+
];
|
|
35
|
+
const RUNTIME_DIR = join('.peaks', '_runtime');
|
|
36
|
+
/**
|
|
37
|
+
* Map a legacy path basename (e.g. `.session.json`) to its canonical
|
|
38
|
+
* new basename (e.g. `session.json`). The dot is dropped so the new
|
|
39
|
+
* layer reads naturally. Directories pass through unchanged.
|
|
40
|
+
*/
|
|
41
|
+
function runtimeNewBasename(oldBasename) {
|
|
42
|
+
if (oldBasename.startsWith('.') && oldBasename.length > 1) {
|
|
43
|
+
return oldBasename.slice(1);
|
|
44
|
+
}
|
|
45
|
+
return oldBasename;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Walk the project root's `.peaks/` directory and return an entry per
|
|
49
|
+
* session dir matching the standard naming pattern, sorted by name
|
|
50
|
+
* ascending (the most recent is last by sort order, since the date
|
|
51
|
+
* prefix dominates the lexicographic order).
|
|
52
|
+
*
|
|
53
|
+
* Each entry's `lastActivity` is the mtime of the inner `session.json`
|
|
54
|
+
* file, or null if that file is missing. `artifactCount` is the count
|
|
55
|
+
* of files under the dir excluding `session.json` itself.
|
|
56
|
+
*/
|
|
57
|
+
export function discoverSessions(projectRoot) {
|
|
58
|
+
const peaksRoot = join(projectRoot, '.peaks');
|
|
59
|
+
if (!existsSync(peaksRoot))
|
|
60
|
+
return [];
|
|
61
|
+
let names;
|
|
62
|
+
try {
|
|
63
|
+
names = readdirSync(peaksRoot);
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
const entries = [];
|
|
69
|
+
for (const name of names) {
|
|
70
|
+
if (!SESSION_ID_PATTERN.test(name))
|
|
71
|
+
continue;
|
|
72
|
+
const dir = join(peaksRoot, name);
|
|
73
|
+
let stat;
|
|
74
|
+
try {
|
|
75
|
+
// lstatSync: false for symlinks (prevents rm -rf from following a
|
|
76
|
+
// malicious symlink that points outside the project root).
|
|
77
|
+
stat = lstatSync(dir);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!stat.isDirectory())
|
|
83
|
+
continue;
|
|
84
|
+
const metaPath = join(dir, META_FILE);
|
|
85
|
+
let lastActivity = null;
|
|
86
|
+
if (existsSync(metaPath)) {
|
|
87
|
+
try {
|
|
88
|
+
lastActivity = statSync(metaPath).mtimeMs;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
lastActivity = null;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
let childNames;
|
|
95
|
+
try {
|
|
96
|
+
childNames = readdirSync(dir);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
childNames = [];
|
|
100
|
+
}
|
|
101
|
+
let artifactCount = 0;
|
|
102
|
+
for (const child of childNames) {
|
|
103
|
+
if (child === META_FILE)
|
|
104
|
+
continue;
|
|
105
|
+
artifactCount += 1;
|
|
106
|
+
}
|
|
107
|
+
entries.push({ sessionId: name, path: dir, lastActivity, artifactCount });
|
|
108
|
+
}
|
|
109
|
+
entries.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
110
|
+
return entries;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* 4-tier canonical selection. Tiers evaluated in order; first one that
|
|
114
|
+
* yields a session id wins.
|
|
115
|
+
*
|
|
116
|
+
* 1. active-skill sessionId, if it matches a real entry
|
|
117
|
+
* 2. entry with the most recent `session.json` mtime
|
|
118
|
+
* 3. entry with the most recent mtime of any file inside it
|
|
119
|
+
* 4. entry whose dir name sorts last lexicographically
|
|
120
|
+
*/
|
|
121
|
+
export function pickCanonicalSession(entries, activeSkillSessionId) {
|
|
122
|
+
if (entries.length === 0)
|
|
123
|
+
return null;
|
|
124
|
+
// Tier 1
|
|
125
|
+
if (activeSkillSessionId !== null) {
|
|
126
|
+
const hit = entries.find((e) => e.sessionId === activeSkillSessionId);
|
|
127
|
+
if (hit !== undefined) {
|
|
128
|
+
return { sessionId: hit.sessionId, source: 'active-skill' };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
// Tier 2
|
|
132
|
+
let tier2Best = null;
|
|
133
|
+
for (const e of entries) {
|
|
134
|
+
if (e.lastActivity === null)
|
|
135
|
+
continue;
|
|
136
|
+
if (tier2Best === null || e.lastActivity > tier2Best.lastActivity) {
|
|
137
|
+
tier2Best = { sessionId: e.sessionId, lastActivity: e.lastActivity };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (tier2Best !== null) {
|
|
141
|
+
return { sessionId: tier2Best.sessionId, source: 'latest-session-json-mtime' };
|
|
142
|
+
}
|
|
143
|
+
// Tier 3
|
|
144
|
+
let tier3Best = null;
|
|
145
|
+
let tier3Mtime = -Infinity;
|
|
146
|
+
for (const e of entries) {
|
|
147
|
+
const mtime = newestMtimeRecursive(e.path);
|
|
148
|
+
if (mtime === null)
|
|
149
|
+
continue;
|
|
150
|
+
if (mtime > tier3Mtime) {
|
|
151
|
+
tier3Mtime = mtime;
|
|
152
|
+
tier3Best = { sessionId: e.sessionId, path: e.path };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (tier3Best !== null) {
|
|
156
|
+
return { sessionId: tier3Best.sessionId, source: 'latest-any-file-mtime' };
|
|
157
|
+
}
|
|
158
|
+
// Tier 4
|
|
159
|
+
const sortedAsc = [...entries].sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
160
|
+
const last = sortedAsc[sortedAsc.length - 1];
|
|
161
|
+
if (last !== undefined) {
|
|
162
|
+
return { sessionId: last.sessionId, source: 'dir-name-sort' };
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
function newestMtimeRecursive(dirPath) {
|
|
167
|
+
let best = null;
|
|
168
|
+
let stack = [dirPath];
|
|
169
|
+
while (stack.length > 0) {
|
|
170
|
+
const current = stack.pop();
|
|
171
|
+
let names;
|
|
172
|
+
try {
|
|
173
|
+
names = readdirSync(current);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const name of names) {
|
|
179
|
+
const childPath = join(current, name);
|
|
180
|
+
let stat;
|
|
181
|
+
try {
|
|
182
|
+
stat = statSync(childPath);
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (stat.isDirectory()) {
|
|
188
|
+
stack.push(childPath);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (stat.mtimeMs > (best ?? -Infinity)) {
|
|
192
|
+
best = stat.mtimeMs;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return best;
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Write `.peaks/.session.json` to bind the project to `canonicalSessionId`.
|
|
200
|
+
* Preserves the projectRoot. The previous binding is returned so the CLI
|
|
201
|
+
* can surface the re-point delta.
|
|
202
|
+
*/
|
|
203
|
+
export function repointSessionJson(projectRoot, canonicalSessionId, repointedFrom) {
|
|
204
|
+
setCurrentSessionBinding(projectRoot, canonicalSessionId);
|
|
205
|
+
return { repointedFrom, repointedTo: canonicalSessionId };
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Identify deletion candidates. A session is a candidate when:
|
|
209
|
+
* - the resolved `lastActivity` is older than `ageThresholdMs`, AND
|
|
210
|
+
* - the dir is "empty or auto-only" (artifactCount === 0, OR the
|
|
211
|
+
* only file is `session.json` which is auto-generated).
|
|
212
|
+
*
|
|
213
|
+
* If `lastActivity` is null (no `session.json` inside), the session's
|
|
214
|
+
* own dir mtime is used as a fallback so empty dirs without inner
|
|
215
|
+
* metadata are still fair-game.
|
|
216
|
+
*/
|
|
217
|
+
export function findDeletionCandidates(entries, ageThresholdMs) {
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const candidates = [];
|
|
220
|
+
for (const e of entries) {
|
|
221
|
+
const isEmptyOrAutoOnly = e.artifactCount === 0;
|
|
222
|
+
if (!isEmptyOrAutoOnly)
|
|
223
|
+
continue;
|
|
224
|
+
const mtime = e.lastActivity !== null
|
|
225
|
+
? e.lastActivity
|
|
226
|
+
: readDirMtime(e.path);
|
|
227
|
+
if (mtime === null)
|
|
228
|
+
continue;
|
|
229
|
+
if (now - mtime < ageThresholdMs)
|
|
230
|
+
continue;
|
|
231
|
+
candidates.push(e);
|
|
232
|
+
}
|
|
233
|
+
return candidates;
|
|
234
|
+
}
|
|
235
|
+
function readDirMtime(dirPath) {
|
|
236
|
+
try {
|
|
237
|
+
return statSync(dirPath).mtimeMs;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Apply or report deletion of the given candidates. When `apply` is
|
|
245
|
+
* false, just return `wouldDelete` and do not touch disk. When `apply`
|
|
246
|
+
* is true, actually `rm -rf` each dir and accumulate any per-dir
|
|
247
|
+
* errors in the result.
|
|
248
|
+
*/
|
|
249
|
+
export function applyDeletions(candidates, apply) {
|
|
250
|
+
if (!apply) {
|
|
251
|
+
return {
|
|
252
|
+
deleted: [],
|
|
253
|
+
wouldDelete: candidates.map((c) => c.sessionId),
|
|
254
|
+
errors: []
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
const deleted = [];
|
|
258
|
+
const errors = [];
|
|
259
|
+
for (const c of candidates) {
|
|
260
|
+
try {
|
|
261
|
+
rmSync(c.path, { recursive: true, force: true });
|
|
262
|
+
deleted.push(c.sessionId);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
errors.push({
|
|
266
|
+
sessionId: c.sessionId,
|
|
267
|
+
message: error instanceof Error ? error.message : String(error)
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { deleted, wouldDelete: [], errors };
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Read the orchestrator's active-skill marker and extract the
|
|
275
|
+
* session id. As of slice 2026-06-05-peaks-runtime-layer the
|
|
276
|
+
* canonical home is `.peaks/_runtime/active-skill.json`; the legacy
|
|
277
|
+
* `.peaks/.active-skill.json` is consulted as a one-minor-release
|
|
278
|
+
* back-compat fallback (the new path wins when both exist).
|
|
279
|
+
*
|
|
280
|
+
* Returns null when the file is missing or malformed.
|
|
281
|
+
*/
|
|
282
|
+
function readActiveSkillSessionId(projectRoot) {
|
|
283
|
+
const newPath = join(projectRoot, '.peaks', '_runtime', 'active-skill.json');
|
|
284
|
+
const legacyPath = join(projectRoot, '.peaks', '.active-skill.json');
|
|
285
|
+
const pathToRead = existsSync(newPath) ? newPath : legacyPath;
|
|
286
|
+
if (!existsSync(pathToRead))
|
|
287
|
+
return null;
|
|
288
|
+
try {
|
|
289
|
+
// Sync read: tiny file, no I/O benefit from async
|
|
290
|
+
const { readFileSync } = require('node:fs');
|
|
291
|
+
const raw = readFileSync(pathToRead, 'utf8');
|
|
292
|
+
const parsed = JSON.parse(raw);
|
|
293
|
+
if (typeof parsed?.sessionId === 'string' && parsed.sessionId.length > 0) {
|
|
294
|
+
return parsed.sessionId;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* One-time migration step (added in slice 2026-06-05-peaks-runtime-layer).
|
|
304
|
+
*
|
|
305
|
+
* Move the legacy runtime files at:
|
|
306
|
+
* - `.peaks/.session.json`
|
|
307
|
+
* - `.peaks/.active-skill.json`
|
|
308
|
+
* - `.peaks/sop-state/`
|
|
309
|
+
* into their new canonical home at:
|
|
310
|
+
* - `.peaks/_runtime/session.json`
|
|
311
|
+
* - `.peaks/_runtime/active-skill.json`
|
|
312
|
+
* - `.peaks/_runtime/sop-state/`
|
|
313
|
+
*
|
|
314
|
+
* Behavior:
|
|
315
|
+
* - Idempotent: re-running on a tree that is already on the new
|
|
316
|
+
* layout produces `migratedFiles: []`.
|
|
317
|
+
* - Best-effort: uses `fs.renameSync` (atomic on POSIX, best-effort
|
|
318
|
+
* on Windows) and falls back to `copyFileSync` + `unlinkSync` if
|
|
319
|
+
* rename throws (e.g. cross-device move on Windows). Errors are
|
|
320
|
+
* collected per file and returned in the `errors` array so the
|
|
321
|
+
* reconcile envelope can surface them without blocking the rest of
|
|
322
|
+
* the migration.
|
|
323
|
+
* - Creates `.peaks/_runtime/` on demand if any of the old paths
|
|
324
|
+
* are present.
|
|
325
|
+
*
|
|
326
|
+
* @returns `{ migratedFiles, errors }`. `migratedFiles` lists the
|
|
327
|
+
* *old* relative paths (e.g. `.peaks/.session.json`) that were
|
|
328
|
+
* successfully moved, in move order. `errors` lists per-file
|
|
329
|
+
* failures with the old path and a human-readable message.
|
|
330
|
+
*/
|
|
331
|
+
export function migrateOldRuntimeState(projectRoot) {
|
|
332
|
+
const root = resolve(projectRoot);
|
|
333
|
+
const peaksRoot = join(root, '.peaks');
|
|
334
|
+
const newDir = join(root, RUNTIME_DIR);
|
|
335
|
+
const migratedFiles = [];
|
|
336
|
+
const errors = [];
|
|
337
|
+
for (const rel of RUNTIME_OLD_PATHS) {
|
|
338
|
+
const oldPath = join(peaksRoot, rel);
|
|
339
|
+
if (!existsSync(oldPath))
|
|
340
|
+
continue;
|
|
341
|
+
// Skip if the corresponding new path already exists — we treat the
|
|
342
|
+
// new path as authoritative when both exist, so the old file would
|
|
343
|
+
// only be stale data.
|
|
344
|
+
const newPath = join(newDir, runtimeNewBasename(rel));
|
|
345
|
+
if (existsSync(newPath)) {
|
|
346
|
+
// Best-effort cleanup of the stale old file so a re-run stays
|
|
347
|
+
// idempotent and the tree converges on the new layout.
|
|
348
|
+
try {
|
|
349
|
+
rmSync(oldPath, { recursive: true, force: true });
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
errors.push({
|
|
353
|
+
path: rel,
|
|
354
|
+
message: `Could not remove stale legacy file after migration: ${error instanceof Error ? error.message : String(error)}`
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
// Ensure the new parent dir exists. `mkdirSync(dirname(newPath), { recursive: true })`
|
|
361
|
+
// covers both the file case (`.peaks/_runtime`) and the
|
|
362
|
+
// directory case (`.peaks/_runtime/sop-state`).
|
|
363
|
+
mkdirSync(dirname(newPath), { recursive: true });
|
|
364
|
+
try {
|
|
365
|
+
renameSync(oldPath, newPath);
|
|
366
|
+
}
|
|
367
|
+
catch (renameError) {
|
|
368
|
+
// Cross-device or locked-file fallback: copy + unlink.
|
|
369
|
+
const stat = lstatSync(oldPath);
|
|
370
|
+
if (stat.isDirectory()) {
|
|
371
|
+
// Recursive copy for the sop-state dir.
|
|
372
|
+
copyDirRecursiveSync(oldPath, newPath);
|
|
373
|
+
rmSync(oldPath, { recursive: true, force: true });
|
|
374
|
+
}
|
|
375
|
+
else {
|
|
376
|
+
copyFileSync(oldPath, newPath);
|
|
377
|
+
unlinkSync(oldPath);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
migratedFiles.push(join('.peaks', rel));
|
|
381
|
+
}
|
|
382
|
+
catch (error) {
|
|
383
|
+
errors.push({
|
|
384
|
+
path: rel,
|
|
385
|
+
message: error instanceof Error ? error.message : String(error)
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { migratedFiles, errors };
|
|
390
|
+
}
|
|
391
|
+
function copyDirRecursiveSync(src, dest) {
|
|
392
|
+
mkdirSync(dest, { recursive: true });
|
|
393
|
+
for (const name of readdirSync(src)) {
|
|
394
|
+
const childSrc = join(src, name);
|
|
395
|
+
const childDest = join(dest, name);
|
|
396
|
+
const stat = lstatSync(childSrc);
|
|
397
|
+
if (stat.isDirectory()) {
|
|
398
|
+
copyDirRecursiveSync(childSrc, childDest);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
copyFileSync(childSrc, childDest);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Top-level orchestrator. Wires migration (added in slice
|
|
407
|
+
* 2026-06-05-peaks-runtime-layer), discovery, canonical pick, re-point,
|
|
408
|
+
* deletion-candidate selection, and deletion into a single result.
|
|
409
|
+
*/
|
|
410
|
+
export function reconcileWorkspace(options) {
|
|
411
|
+
const projectRoot = resolve(options.projectRoot);
|
|
412
|
+
const apply = options.apply === true;
|
|
413
|
+
const ageThresholdMs = options.olderThanMs;
|
|
414
|
+
// Migration runs FIRST. The canonical-session logic still consults
|
|
415
|
+
// the session-manager helper which already reads the new path first
|
|
416
|
+
// and falls back to the old path; moving the old file out of the way
|
|
417
|
+
// before that read means the new path is the only path observed by
|
|
418
|
+
// `getSessionIdCanonical` after this call returns.
|
|
419
|
+
const migration = migrateOldRuntimeState(projectRoot);
|
|
420
|
+
const migrateErrors = migration.errors.map((e) => ({
|
|
421
|
+
kind: 'migrate',
|
|
422
|
+
path: e.path,
|
|
423
|
+
message: e.message
|
|
424
|
+
}));
|
|
425
|
+
const sessions = discoverSessions(projectRoot);
|
|
426
|
+
const activeSkillSessionId = readActiveSkillSessionId(projectRoot);
|
|
427
|
+
const canonical = pickCanonicalSession(sessions, activeSkillSessionId);
|
|
428
|
+
const previousBinding = getSessionIdCanonical(projectRoot);
|
|
429
|
+
let repointedFrom = previousBinding;
|
|
430
|
+
let repointedTo = null;
|
|
431
|
+
let repointed = false;
|
|
432
|
+
if (canonical !== null) {
|
|
433
|
+
if (previousBinding !== canonical.sessionId) {
|
|
434
|
+
const repoint = repointSessionJson(projectRoot, canonical.sessionId, previousBinding);
|
|
435
|
+
repointedFrom = repoint.repointedFrom;
|
|
436
|
+
repointedTo = repoint.repointedTo;
|
|
437
|
+
repointed = true;
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
// No-op: re-point the same binding so lastActivity is refreshed
|
|
441
|
+
const repoint = repointSessionJson(projectRoot, canonical.sessionId, previousBinding);
|
|
442
|
+
repointedFrom = repoint.repointedFrom;
|
|
443
|
+
repointedTo = repoint.repointedTo;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
const deletionCandidates = findDeletionCandidates(sessions, ageThresholdMs);
|
|
447
|
+
const deletionResult = applyDeletions(deletionCandidates, apply);
|
|
448
|
+
return {
|
|
449
|
+
projectRoot,
|
|
450
|
+
sessions,
|
|
451
|
+
canonicalSessionId: canonical === null ? null : canonical.sessionId,
|
|
452
|
+
canonicalSource: canonical === null ? null : canonical.source,
|
|
453
|
+
repointedFrom,
|
|
454
|
+
repointedTo,
|
|
455
|
+
deletionCandidates,
|
|
456
|
+
deleted: deletionResult.deleted,
|
|
457
|
+
wouldDelete: deletionResult.wouldDelete,
|
|
458
|
+
ageThresholdMs,
|
|
459
|
+
apply,
|
|
460
|
+
repointed,
|
|
461
|
+
migratedFiles: migration.migratedFiles,
|
|
462
|
+
errors: [...migrateErrors, ...deletionResult.errors]
|
|
463
|
+
};
|
|
464
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type envelope for the `peaks workspace reconcile` CLI command.
|
|
3
|
+
*
|
|
4
|
+
* The reconcile command scans `.peaks/2026-MM-DD-session-<6hex>/` directories
|
|
5
|
+
* under the project root, identifies a canonical session via a 4-tier
|
|
6
|
+
* heuristic (active-skill binding -> most-recent session.json mtime ->
|
|
7
|
+
* most-recent any-file mtime -> dir-name sort), re-points
|
|
8
|
+
* `.peaks/.session.json`, and (optionally) deletes empty / abandoned
|
|
9
|
+
* session dirs older than a configurable age threshold.
|
|
10
|
+
*
|
|
11
|
+
* Types are hand-rolled, no new top-level dependencies.
|
|
12
|
+
*/
|
|
13
|
+
export type SessionEntry = {
|
|
14
|
+
/** Directory name (e.g. "2026-06-04-session-89f7cb"). */
|
|
15
|
+
sessionId: string;
|
|
16
|
+
/** Absolute path to the session directory. */
|
|
17
|
+
path: string;
|
|
18
|
+
/** mtime of `session.json` inside the dir in ms since epoch; null if missing. */
|
|
19
|
+
lastActivity: number | null;
|
|
20
|
+
/**
|
|
21
|
+
* Count of files under the session dir, EXCLUDING `session.json`
|
|
22
|
+
* (which is auto-generated by `peaks workspace init`). A count of
|
|
23
|
+
* 0 means the dir is empty / abandoned.
|
|
24
|
+
*/
|
|
25
|
+
artifactCount: number;
|
|
26
|
+
};
|
|
27
|
+
export type DeletionCandidateReason = 'empty-or-auto-only' | 'older-than-threshold';
|
|
28
|
+
export type ReconcileResult = {
|
|
29
|
+
/** Absolute project root the command operated on. */
|
|
30
|
+
projectRoot: string;
|
|
31
|
+
/** All discovered `.peaks/2026-MM-DD-session-<6hex>/` directories, sorted by name. */
|
|
32
|
+
sessions: SessionEntry[];
|
|
33
|
+
/** The session id selected by the 4-tier canonical heuristic. */
|
|
34
|
+
canonicalSessionId: string | null;
|
|
35
|
+
/**
|
|
36
|
+
* The tier that decided the canonical pick (1..4), where tier 1 is
|
|
37
|
+
* active-skill and tier 4 is lexicographic last. Null when there are
|
|
38
|
+
* no sessions at all.
|
|
39
|
+
*/
|
|
40
|
+
canonicalSource: 'active-skill' | 'latest-session-json-mtime' | 'latest-any-file-mtime' | 'dir-name-sort' | null;
|
|
41
|
+
/** The session id the binding pointed at before reconcile. Null if no prior binding. */
|
|
42
|
+
repointedFrom: string | null;
|
|
43
|
+
/** The session id the binding now points at. Null if there were no sessions. */
|
|
44
|
+
repointedTo: string | null;
|
|
45
|
+
/** Sessions that match the age-threshold + empty-or-auto-only deletion rule. */
|
|
46
|
+
deletionCandidates: SessionEntry[];
|
|
47
|
+
/** Actual deletions performed (only populated when `apply === true`). */
|
|
48
|
+
deleted: string[];
|
|
49
|
+
/** Sessions that WOULD be deleted if `--apply` were set (only populated when `apply === false`). */
|
|
50
|
+
wouldDelete: string[];
|
|
51
|
+
/** Age threshold in ms used to compute deletion candidates. */
|
|
52
|
+
ageThresholdMs: number;
|
|
53
|
+
/** Whether `--apply` was set. */
|
|
54
|
+
apply: boolean;
|
|
55
|
+
/** Whether the canonical session id differs from the prior binding. */
|
|
56
|
+
repointed: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Old-path runtime files that `migrateOldRuntimeState` moved into
|
|
59
|
+
* `.peaks/_runtime/` during this reconcile run. Each entry is the
|
|
60
|
+
* legacy path relative to the project root (e.g. ".peaks/.session.json",
|
|
61
|
+
* ".peaks/.active-skill.json", ".peaks/sop-state"). Empty when the
|
|
62
|
+
* tree is already on the new layout (idempotent re-runs return []).
|
|
63
|
+
*
|
|
64
|
+
* Added in slice 2026-06-05-peaks-runtime-layer; additive — older
|
|
65
|
+
* consumers can ignore this field.
|
|
66
|
+
*/
|
|
67
|
+
migratedFiles: string[];
|
|
68
|
+
/**
|
|
69
|
+
* Errors encountered during the migration step. Each entry has a
|
|
70
|
+
* `kind: 'migrate'` discriminator so consumers can tell migration
|
|
71
|
+
* errors apart from deletion errors. The shape is additive.
|
|
72
|
+
*/
|
|
73
|
+
errors: Array<{
|
|
74
|
+
sessionId: string;
|
|
75
|
+
message: string;
|
|
76
|
+
} | {
|
|
77
|
+
kind: 'migrate';
|
|
78
|
+
path: string;
|
|
79
|
+
message: string;
|
|
80
|
+
}>;
|
|
81
|
+
};
|
|
82
|
+
export type ReconcileOptions = {
|
|
83
|
+
projectRoot: string;
|
|
84
|
+
/** When true, actually `rm -rf` the deletion candidates. */
|
|
85
|
+
apply: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Age threshold in milliseconds; sessions whose mtime-based
|
|
88
|
+
* `lastActivity` is older than this AND whose `artifactCount === 0`
|
|
89
|
+
* (or 1 if the only file is `session.json`) are deletion candidates.
|
|
90
|
+
* Default: 7 days in the CLI layer.
|
|
91
|
+
*/
|
|
92
|
+
olderThanMs: number;
|
|
93
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type envelope for the `peaks workspace reconcile` CLI command.
|
|
3
|
+
*
|
|
4
|
+
* The reconcile command scans `.peaks/2026-MM-DD-session-<6hex>/` directories
|
|
5
|
+
* under the project root, identifies a canonical session via a 4-tier
|
|
6
|
+
* heuristic (active-skill binding -> most-recent session.json mtime ->
|
|
7
|
+
* most-recent any-file mtime -> dir-name sort), re-points
|
|
8
|
+
* `.peaks/.session.json`, and (optionally) deletes empty / abandoned
|
|
9
|
+
* session dirs older than a configurable age threshold.
|
|
10
|
+
*
|
|
11
|
+
* Types are hand-rolled, no new top-level dependencies.
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.
|
|
1
|
+
export declare const CLI_VERSION = "1.3.0";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.
|
|
1
|
+
export const CLI_VERSION = "1.3.0";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Peaks CLI and short skill family for Claude Code automation.",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,9 +30,12 @@
|
|
|
30
30
|
"scripts": {
|
|
31
31
|
"build": "node ./scripts/sync-version.mjs && node ./scripts/clean-dist.mjs && tsc -p tsconfig.json",
|
|
32
32
|
"prepack": "npm run build",
|
|
33
|
+
"prepublish": "node ./scripts/sync-version.mjs",
|
|
33
34
|
"postinstall": "node ./scripts/install-skills.mjs",
|
|
35
|
+
"predev": "node ./scripts/sync-version.mjs",
|
|
34
36
|
"dev": "tsx src/cli/index.ts",
|
|
35
37
|
"dev:watch": "node ./scripts/watch.mjs",
|
|
38
|
+
"pretest": "node ./scripts/sync-version.mjs",
|
|
36
39
|
"test": "vitest run",
|
|
37
40
|
"test:coverage": "vitest run --coverage",
|
|
38
41
|
"pretest:coverage": "node ./scripts/pretest-coverage.mjs",
|
|
@@ -294,9 +294,9 @@ For frontend workflows, RD and QA must use Playwright MCP (`mcp__playwright__` t
|
|
|
294
294
|
|
|
295
295
|
### Workspace initialization gate
|
|
296
296
|
|
|
297
|
-
The workspace is created in Step 0 (Startup sequence) as a mandatory first action — before any analysis, role handoff, or artifact write, and regardless of how lightweight the request is. Session IDs are now **auto-generated** with the format `YYYY-MM-DD-session-<6位hex>` (e.g. `2026-05-26-session-a3f8b1`). The user does not provide a session ID — the system creates and persists it in `.peaks/.session.json
|
|
297
|
+
The workspace is created in Step 0 (Startup sequence) as a mandatory first action — before any analysis, role handoff, or artifact write, and regardless of how lightweight the request is. Session IDs are now **auto-generated** with the format `YYYY-MM-DD-session-<6位hex>` (e.g. `2026-05-26-session-a3f8b1`). The user does not provide a session ID — the system creates and persists it in `.peaks/_runtime/session.json` (the canonical home as of slice `2026-06-05-peaks-runtime-layer`; the legacy `.peaks/.session.json` is read-only back-compat for one minor release).
|
|
298
298
|
|
|
299
|
-
When `peaks workspace init` is run without `--session-id`, it automatically generates a new session ID using today's date and a random hex suffix. If `.peaks
|
|
299
|
+
When `peaks workspace init` is run without `--session-id`, it automatically generates a new session ID using today's date and a random hex suffix. If a valid session binding exists at `.peaks/_runtime/session.json` (or the legacy `.peaks/.session.json` for pre-migration trees), the existing session is reused.
|
|
300
300
|
|
|
301
301
|
**Existing old-session cleanup**: If `.peaks/` contains numeric-only or generic session directories from prior runs (e.g. `2026-05-25-auth-system`), create the new correctly-named session, migrate any reusable artifacts into it, and note the migration in the TXT handoff. Delete empty old-session directories.
|
|
302
302
|
|
|
@@ -304,9 +304,23 @@ When `peaks workspace init` is run without `--session-id`, it automatically gene
|
|
|
304
304
|
peaks workspace init --project <repo> --json
|
|
305
305
|
```
|
|
306
306
|
|
|
307
|
-
The workspace initialization creates this structure under `.peaks
|
|
307
|
+
The workspace initialization creates this structure under `.peaks/`:
|
|
308
308
|
|
|
309
309
|
```
|
|
310
|
+
# Canonical home for all per-project ephemeral state (active-skill
|
|
311
|
+
# marker, session binding, sop-state). All writes go here; reads also
|
|
312
|
+
# tolerate the legacy paths (`.peaks/.active-skill.json`,
|
|
313
|
+
# `.peaks/.session.json`, `.peaks/sop-state/`) for one minor release
|
|
314
|
+
# so a fresh upgrade does not break in-flight workflows. Older trees
|
|
315
|
+
# are auto-migrated by `peaks workspace reconcile --apply`.
|
|
316
|
+
.peaks/_runtime/
|
|
317
|
+
├── active-skill.json # orchestrator presence marker (peaks-solo / -rd / -qa / -ui / -sc / -sop / -txt)
|
|
318
|
+
├── session.json # project → session binding (the only single-session source of truth)
|
|
319
|
+
└── sop-state/ # current phase + history; definitions live globally in ~/.peaks
|
|
320
|
+
|
|
321
|
+
# Per-slice artifact dirs (auto-generated, one per session). Files
|
|
322
|
+
# inside ARE tracked by the 提交中间产物 convention.
|
|
323
|
+
.peaks/<session-id>/
|
|
310
324
|
prd/source/ # PRD source documents (Feishu exports, pasted content)
|
|
311
325
|
prd/requests/ # PRD request artifacts (goals, non-goals, acceptance, frontend delta)
|
|
312
326
|
ui/requests/ # UI request artifacts (visual direction, taste reports)
|
|
@@ -18,6 +18,7 @@ peaks doctor --json
|
|
|
18
18
|
peaks project dashboard --project <repo> --json
|
|
19
19
|
peaks skill runbook peaks-solo --json
|
|
20
20
|
peaks workspace init --project <repo> --json
|
|
21
|
+
peaks workspace reconcile --project <repo> --json
|
|
21
22
|
peaks scan archetype --project <repo> --json
|
|
22
23
|
# → copy archetype, frontendOnly, signals into .peaks/<session-id>/rd/project-scan.md (Peaks-Cli Gate A)
|
|
23
24
|
# → copy libraries[] into .peaks/<session-id>/rd/project-scan.md under `## Library versions`
|
|
@@ -131,6 +132,7 @@ peaks sc boundary --slice-id <rid> --artifact <artifact> --code <file> --json
|
|
|
131
132
|
# 9. Peaks-Cli OpenSpec archive (exit gate; only after QA pass, when openspec/ exists)
|
|
132
133
|
peaks openspec validate <cid> --project <repo> --json
|
|
133
134
|
peaks openspec archive <cid> --project <repo> --apply --json
|
|
135
|
+
peaks workspace reconcile --project <repo> --apply --older-than 7
|
|
134
136
|
|
|
135
137
|
# 10. Peaks-Cli TXT handoff — invoke peaks-txt which embeds memory markers and extracts
|
|
136
138
|
# peaks-txt writes the handoff capsule to .peaks/<id>/txt/handoff.md. Inside the
|