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.
@@ -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.2.9";
1
+ export declare const CLI_VERSION = "1.3.0";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.2.9";
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.2.9",
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/.session.json` already exists with a valid session, the existing session is reused.
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/<session-id>/` (where `<session-id>` is auto-generated as `YYYY-MM-DD-session-<6位hex>`):
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