peaks-cli 1.3.0 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -46
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/core-artifact-commands.js +49 -11
- package/dist/src/cli/commands/hooks-commands.js +24 -9
- package/dist/src/cli/commands/progress-commands.js +26 -2
- package/dist/src/cli/commands/request-commands.js +5 -0
- package/dist/src/cli/commands/slice-commands.d.ts +3 -0
- package/dist/src/cli/commands/slice-commands.js +44 -0
- package/dist/src/cli/commands/workflow-commands.js +3 -3
- package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
- package/dist/src/cli/commands/workspace-commands.js +349 -12
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +214 -56
- package/dist/src/services/doctor/doctor-service.d.ts +69 -0
- package/dist/src/services/doctor/doctor-service.js +296 -3
- package/dist/src/services/progress/progress-service.d.ts +26 -0
- package/dist/src/services/progress/progress-service.js +25 -0
- package/dist/src/services/sc/sc-service.js +71 -13
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.d.ts +22 -1
- package/dist/src/services/session/session-manager.js +149 -30
- package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
- package/dist/src/services/skills/hooks-settings-service.js +57 -13
- package/dist/src/services/slice/slice-check-service.d.ts +2 -0
- package/dist/src/services/slice/slice-check-service.js +267 -0
- package/dist/src/services/slice/slice-check-types.d.ts +70 -0
- package/dist/src/services/slice/slice-check-types.js +18 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
- package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
- package/dist/src/services/workspace/migrate-service.d.ts +2 -0
- package/dist/src/services/workspace/migrate-service.js +606 -0
- package/dist/src/services/workspace/migrate-types.d.ts +127 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
- package/dist/src/services/workspace/reconcile-service.js +160 -42
- package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +71 -24
- package/dist/src/shared/change-id.d.ts +59 -0
- package/dist/src/shared/change-id.js +194 -16
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +10 -2
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +1 -0
- package/skills/peaks-rd/SKILL.md +2 -1
- package/skills/peaks-solo/SKILL.md +17 -1
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
- package/skills/peaks-txt/SKILL.md +2 -0
- package/skills/peaks-ui/SKILL.md +1 -0
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type envelope for the `peaks workspace migrate` CLI command.
|
|
3
|
+
*
|
|
4
|
+
* The migrate command is the downstream-project counterpart to the
|
|
5
|
+
* one-time Phase 5 migration script that ran on the peaks-cli self-host
|
|
6
|
+
* for slice 2026-06-05-change-id-as-unit-of-work. Where
|
|
7
|
+
* `peaks workspace reconcile` only handles the top-level runtime state
|
|
8
|
+
* files (`.peaks/.session.json`, `.peaks/.active-skill.json`,
|
|
9
|
+
* `.peaks/sop-state/` → `.peaks/_runtime/`), `peaks workspace migrate`
|
|
10
|
+
* handles the much bigger case: legacy reviewable content under
|
|
11
|
+
* `.peaks/<session-id>/<role>/<file>` → `.peaks/retrospective/<change-id>/<role>/<file>`.
|
|
12
|
+
*
|
|
13
|
+
* Each legacy session dir typically contains multiple change-ids worth
|
|
14
|
+
* of work (the old layout allowed one session to host several slices).
|
|
15
|
+
* Change-id resolution uses a 4-tier per-file heuristic (filename
|
|
16
|
+
* regex → content H1 → body frontmatter → per-session fallback to the
|
|
17
|
+
* most-recent `<change-id>` in `rd/requests/`).
|
|
18
|
+
*
|
|
19
|
+
* Types are hand-rolled, no new top-level dependencies.
|
|
20
|
+
*/
|
|
21
|
+
export type MigrateFilePlan = {
|
|
22
|
+
/** Absolute source path (under `.peaks/<session-id>/...`). */
|
|
23
|
+
from: string;
|
|
24
|
+
/** Absolute target path (under `.peaks/retrospective/<change-id>/...`). */
|
|
25
|
+
to: string;
|
|
26
|
+
/** The session dir this file came from. */
|
|
27
|
+
sessionId: string;
|
|
28
|
+
/** The change-id the file is being routed to. */
|
|
29
|
+
changeId: string;
|
|
30
|
+
/** The role inferred from the file's path (rd/qa/prd/ui/sc). */
|
|
31
|
+
role: 'prd' | 'ui' | 'rd' | 'qa' | 'sc' | 'system' | 'unknown';
|
|
32
|
+
/** Path of the file relative to the session dir, e.g. `rd/tech-doc.md`. */
|
|
33
|
+
relativePath: string;
|
|
34
|
+
/**
|
|
35
|
+
* Which tier of the 4-tier heuristic produced the change-id. Null
|
|
36
|
+
* when the file is not a per-slice artifact (e.g. cross-cutting
|
|
37
|
+
* `rd/project-scan.md` or `qa/.initiated`).
|
|
38
|
+
*/
|
|
39
|
+
source: 'filename-regex' | 'content-h1' | 'content-frontmatter' | 'session-fallback' | 'cross-cutting' | null;
|
|
40
|
+
/** True if the file was skipped (e.g. `session.json`, cross-cutting, conflict). */
|
|
41
|
+
skipped?: boolean;
|
|
42
|
+
/** Why the file was skipped (only set when `skipped === true`). */
|
|
43
|
+
skipReason?: 'transient-runtime' | 'conflict' | 'no-change-id' | 'unsupported-role';
|
|
44
|
+
};
|
|
45
|
+
export type MigrateSessionPlan = {
|
|
46
|
+
sessionId: string;
|
|
47
|
+
/** Absolute path to the session dir. */
|
|
48
|
+
path: string;
|
|
49
|
+
/** True if the dir is empty / only has `session.json`. */
|
|
50
|
+
empty: boolean;
|
|
51
|
+
/** All files in the dir, planned. */
|
|
52
|
+
files: MigrateFilePlan[];
|
|
53
|
+
/** The fallback change-id derived from the session's most recent `rd/requests/` entry. Null if no requests exist. */
|
|
54
|
+
fallbackChangeId: string | null;
|
|
55
|
+
};
|
|
56
|
+
export type MigrateOptions = {
|
|
57
|
+
projectRoot: string;
|
|
58
|
+
/** When true, actually `git mv` the files + `rm -rf` the emptied session dirs. */
|
|
59
|
+
apply: boolean;
|
|
60
|
+
/**
|
|
61
|
+
* Slice 003 (2026-06-06-session-layout-canonicalize): when true, the
|
|
62
|
+
* command performs the **session-dir consolidation** — moves every
|
|
63
|
+
* top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/`. Idempotent;
|
|
64
|
+
* conflicts (target exists with different content) are logged but
|
|
65
|
+
* never overwrite. With `apply: false` (the default), the response
|
|
66
|
+
* lists what WOULD move + the conflicts.
|
|
67
|
+
*
|
|
68
|
+
* Mutually exclusive with the reviewable-content migration: the
|
|
69
|
+
* `--to-runtime` step is the data side of slice 003, while the
|
|
70
|
+
* default `migrate` step is the cross-cutting content side
|
|
71
|
+
* (reviewable files → retrospective). Both run when both flags are
|
|
72
|
+
* set; the order is `--to-runtime` first (so the cross-cutting
|
|
73
|
+
* step sees the canonical tree) and then the reviewable-content
|
|
74
|
+
* step.
|
|
75
|
+
*/
|
|
76
|
+
toRuntime?: boolean;
|
|
77
|
+
};
|
|
78
|
+
export type MigrateToRuntimeFilePlan = {
|
|
79
|
+
/** Absolute source path (top-level `.peaks/<sid>/`). */
|
|
80
|
+
from: string;
|
|
81
|
+
/** Absolute target path (`.peaks/_runtime/<sid>/`). */
|
|
82
|
+
to: string;
|
|
83
|
+
/** The session id the dir belongs to. */
|
|
84
|
+
sessionId: string;
|
|
85
|
+
/** 'moved' or 'skipped-already-canonical' or 'conflict'. */
|
|
86
|
+
action: 'moved' | 'skipped-already-canonical' | 'conflict-target-exists-with-different-content' | 'f15-conflict-project-scan';
|
|
87
|
+
/** Human-readable reason for the action (for the conflicts list). */
|
|
88
|
+
reason: string;
|
|
89
|
+
};
|
|
90
|
+
export type MigrateResult = {
|
|
91
|
+
/** Absolute project root the command operated on. */
|
|
92
|
+
projectRoot: string;
|
|
93
|
+
/** All discovered legacy session dirs, sorted by name. */
|
|
94
|
+
sessions: MigrateSessionPlan[];
|
|
95
|
+
/** All moves the apply step WOULD perform (only populated when `apply: false` as well, for symmetry). */
|
|
96
|
+
wouldMove: MigrateFilePlan[];
|
|
97
|
+
/** All moves actually performed (only populated when `apply: true`). */
|
|
98
|
+
moved: MigrateFilePlan[];
|
|
99
|
+
/** Sessions that became empty after the move and were/will be removed. */
|
|
100
|
+
deletedSessions: string[];
|
|
101
|
+
/** Sessions that WOULD become empty (dry-run only). */
|
|
102
|
+
wouldDeleteSessions: string[];
|
|
103
|
+
/** Files that already exist at the target (collision). Dry-run reports; apply skips + warns. */
|
|
104
|
+
conflicts: Array<{
|
|
105
|
+
from: string;
|
|
106
|
+
to: string;
|
|
107
|
+
reason: string;
|
|
108
|
+
}>;
|
|
109
|
+
/** Whether `--apply` was set. */
|
|
110
|
+
apply: boolean;
|
|
111
|
+
/** Total files moved or scheduled to move. */
|
|
112
|
+
totalFilesMoved: number;
|
|
113
|
+
/**
|
|
114
|
+
* Slice 003: per-session-dir move plans for the `--to-runtime` step.
|
|
115
|
+
* Empty when `toRuntime` was not set. Conflicts include both the
|
|
116
|
+
* top-level/<sid>/ → _runtime/<sid>/ collisions AND the F15 carve-out
|
|
117
|
+
* for `rd/project-scan.md`.
|
|
118
|
+
*/
|
|
119
|
+
toRuntimePlans?: MigrateToRuntimeFilePlan[];
|
|
120
|
+
toRuntimeMoved?: string[];
|
|
121
|
+
toRuntimeSkipped?: string[];
|
|
122
|
+
toRuntimeConflicts?: Array<{
|
|
123
|
+
from: string;
|
|
124
|
+
to: string;
|
|
125
|
+
reason: string;
|
|
126
|
+
}>;
|
|
127
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type envelope for the `peaks workspace migrate` CLI command.
|
|
3
|
+
*
|
|
4
|
+
* The migrate command is the downstream-project counterpart to the
|
|
5
|
+
* one-time Phase 5 migration script that ran on the peaks-cli self-host
|
|
6
|
+
* for slice 2026-06-05-change-id-as-unit-of-work. Where
|
|
7
|
+
* `peaks workspace reconcile` only handles the top-level runtime state
|
|
8
|
+
* files (`.peaks/.session.json`, `.peaks/.active-skill.json`,
|
|
9
|
+
* `.peaks/sop-state/` → `.peaks/_runtime/`), `peaks workspace migrate`
|
|
10
|
+
* handles the much bigger case: legacy reviewable content under
|
|
11
|
+
* `.peaks/<session-id>/<role>/<file>` → `.peaks/retrospective/<change-id>/<role>/<file>`.
|
|
12
|
+
*
|
|
13
|
+
* Each legacy session dir typically contains multiple change-ids worth
|
|
14
|
+
* of work (the old layout allowed one session to host several slices).
|
|
15
|
+
* Change-id resolution uses a 4-tier per-file heuristic (filename
|
|
16
|
+
* regex → content H1 → body frontmatter → per-session fallback to the
|
|
17
|
+
* most-recent `<change-id>` in `rd/requests/`).
|
|
18
|
+
*
|
|
19
|
+
* Types are hand-rolled, no new top-level dependencies.
|
|
20
|
+
*/
|
|
21
|
+
export {};
|
|
@@ -75,6 +75,39 @@ export declare function applyDeletions(candidates: SessionEntry[], apply: boolea
|
|
|
75
75
|
message: string;
|
|
76
76
|
}>;
|
|
77
77
|
};
|
|
78
|
+
/**
|
|
79
|
+
* Sync the single `change/<canonicalSessionId>/` live marker under
|
|
80
|
+
* `.peaks/_runtime/change/`. The marker is an EMPTY directory (no
|
|
81
|
+
* symlinks, no manifest, no content). Slice 006 collapses the F3
|
|
82
|
+
* per-change-id symlink layer to a single live marker so the
|
|
83
|
+
* `change/` layer is a single sentinel — easy for the LLM to
|
|
84
|
+
* navigate, easy for tests to assert on.
|
|
85
|
+
*
|
|
86
|
+
* Steps:
|
|
87
|
+
* 1. Ensure `.peaks/_runtime/change/` exists.
|
|
88
|
+
* 2. List its entries.
|
|
89
|
+
* 3. Remove every entry whose name is NOT `<canonicalSessionId>/`
|
|
90
|
+
* (no-op for those whose name matches).
|
|
91
|
+
* 4. If `<canonicalSessionId>/` is missing, create it (empty).
|
|
92
|
+
* 5. Return the diff for telemetry.
|
|
93
|
+
*
|
|
94
|
+
* Path-traversal guards: the canonical session id is validated by
|
|
95
|
+
* the caller (`SESSION_ID_PATTERN` in the session-manager helper).
|
|
96
|
+
* The function only ever operates under the project-root-resolved
|
|
97
|
+
* `.peaks/_runtime/change/` dir, so the resolved paths cannot
|
|
98
|
+
* escape the project tree.
|
|
99
|
+
*
|
|
100
|
+
* @returns `{ removed, created, error }`:
|
|
101
|
+
* - `removed`: list of entry names that were deleted (e.g. `['<oldSid>/']`).
|
|
102
|
+
* - `created`: name of the new marker (e.g. `'<newSid>/'`) or null
|
|
103
|
+
* when the canonical marker already existed (no-op).
|
|
104
|
+
* - `error`: error message string or null.
|
|
105
|
+
*/
|
|
106
|
+
export declare function syncChangeMarker(projectRoot: string, canonicalSessionId: string): {
|
|
107
|
+
removed: string[];
|
|
108
|
+
created: string | null;
|
|
109
|
+
error: string | null;
|
|
110
|
+
};
|
|
78
111
|
/**
|
|
79
112
|
* One-time migration step (added in slice 2026-06-05-peaks-runtime-layer).
|
|
80
113
|
*
|
|
@@ -55,59 +55,75 @@ function runtimeNewBasename(oldBasename) {
|
|
|
55
55
|
* of files under the dir excluding `session.json` itself.
|
|
56
56
|
*/
|
|
57
57
|
export function discoverSessions(projectRoot) {
|
|
58
|
+
const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
|
|
58
59
|
const peaksRoot = join(projectRoot, '.peaks');
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return [];
|
|
67
|
-
}
|
|
60
|
+
// As of slice 003, the canonical home for session dirs is
|
|
61
|
+
// `.peaks/_runtime/<sid>/`. The legacy top-level layout is
|
|
62
|
+
// read for back-compat (one minor release) so pre-migration
|
|
63
|
+
// trees keep working. Both are scanned; duplicates (same sid
|
|
64
|
+
// in both homes) are de-duplicated with the canonical home
|
|
65
|
+
// winning.
|
|
66
|
+
const seen = new Set();
|
|
68
67
|
const entries = [];
|
|
69
|
-
|
|
70
|
-
if (!
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
let stat;
|
|
68
|
+
const collect = (root) => {
|
|
69
|
+
if (!existsSync(root))
|
|
70
|
+
return;
|
|
71
|
+
let names;
|
|
74
72
|
try {
|
|
75
|
-
|
|
76
|
-
// malicious symlink that points outside the project root).
|
|
77
|
-
stat = lstatSync(dir);
|
|
73
|
+
names = readdirSync(root);
|
|
78
74
|
}
|
|
79
75
|
catch {
|
|
80
|
-
|
|
76
|
+
return;
|
|
81
77
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
lastActivity = null;
|
|
92
|
-
}
|
|
78
|
+
for (const name of names) {
|
|
79
|
+
if (!SESSION_ID_PATTERN.test(name))
|
|
80
|
+
continue;
|
|
81
|
+
if (seen.has(name))
|
|
82
|
+
continue;
|
|
83
|
+
seen.add(name);
|
|
84
|
+
scanSessionDir(root, name, entries);
|
|
93
85
|
}
|
|
94
|
-
|
|
86
|
+
};
|
|
87
|
+
collect(runtimeRoot);
|
|
88
|
+
collect(peaksRoot);
|
|
89
|
+
entries.sort((a, b) => a.sessionId.localeCompare(b.sessionId));
|
|
90
|
+
return entries;
|
|
91
|
+
}
|
|
92
|
+
function scanSessionDir(peaksRoot, name, entries) {
|
|
93
|
+
const dir = join(peaksRoot, name);
|
|
94
|
+
let stat;
|
|
95
|
+
try {
|
|
96
|
+
stat = lstatSync(dir);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (!stat.isDirectory())
|
|
102
|
+
return;
|
|
103
|
+
const metaPath = join(dir, META_FILE);
|
|
104
|
+
let lastActivity = null;
|
|
105
|
+
if (existsSync(metaPath)) {
|
|
95
106
|
try {
|
|
96
|
-
|
|
107
|
+
lastActivity = statSync(metaPath).mtimeMs;
|
|
97
108
|
}
|
|
98
109
|
catch {
|
|
99
|
-
|
|
110
|
+
lastActivity = null;
|
|
100
111
|
}
|
|
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
112
|
}
|
|
109
|
-
|
|
110
|
-
|
|
113
|
+
let childNames;
|
|
114
|
+
try {
|
|
115
|
+
childNames = readdirSync(dir);
|
|
116
|
+
}
|
|
117
|
+
catch {
|
|
118
|
+
childNames = [];
|
|
119
|
+
}
|
|
120
|
+
let artifactCount = 0;
|
|
121
|
+
for (const child of childNames) {
|
|
122
|
+
if (child === META_FILE)
|
|
123
|
+
continue;
|
|
124
|
+
artifactCount += 1;
|
|
125
|
+
}
|
|
126
|
+
entries.push({ sessionId: name, path: dir, lastActivity, artifactCount });
|
|
111
127
|
}
|
|
112
128
|
/**
|
|
113
129
|
* 4-tier canonical selection. Tiers evaluated in order; first one that
|
|
@@ -299,6 +315,76 @@ function readActiveSkillSessionId(projectRoot) {
|
|
|
299
315
|
}
|
|
300
316
|
return null;
|
|
301
317
|
}
|
|
318
|
+
/**
|
|
319
|
+
* Sync the single `change/<canonicalSessionId>/` live marker under
|
|
320
|
+
* `.peaks/_runtime/change/`. The marker is an EMPTY directory (no
|
|
321
|
+
* symlinks, no manifest, no content). Slice 006 collapses the F3
|
|
322
|
+
* per-change-id symlink layer to a single live marker so the
|
|
323
|
+
* `change/` layer is a single sentinel — easy for the LLM to
|
|
324
|
+
* navigate, easy for tests to assert on.
|
|
325
|
+
*
|
|
326
|
+
* Steps:
|
|
327
|
+
* 1. Ensure `.peaks/_runtime/change/` exists.
|
|
328
|
+
* 2. List its entries.
|
|
329
|
+
* 3. Remove every entry whose name is NOT `<canonicalSessionId>/`
|
|
330
|
+
* (no-op for those whose name matches).
|
|
331
|
+
* 4. If `<canonicalSessionId>/` is missing, create it (empty).
|
|
332
|
+
* 5. Return the diff for telemetry.
|
|
333
|
+
*
|
|
334
|
+
* Path-traversal guards: the canonical session id is validated by
|
|
335
|
+
* the caller (`SESSION_ID_PATTERN` in the session-manager helper).
|
|
336
|
+
* The function only ever operates under the project-root-resolved
|
|
337
|
+
* `.peaks/_runtime/change/` dir, so the resolved paths cannot
|
|
338
|
+
* escape the project tree.
|
|
339
|
+
*
|
|
340
|
+
* @returns `{ removed, created, error }`:
|
|
341
|
+
* - `removed`: list of entry names that were deleted (e.g. `['<oldSid>/']`).
|
|
342
|
+
* - `created`: name of the new marker (e.g. `'<newSid>/'`) or null
|
|
343
|
+
* when the canonical marker already existed (no-op).
|
|
344
|
+
* - `error`: error message string or null.
|
|
345
|
+
*/
|
|
346
|
+
export function syncChangeMarker(projectRoot, canonicalSessionId) {
|
|
347
|
+
const root = resolve(projectRoot);
|
|
348
|
+
const changeDir = join(root, '.peaks', '_runtime', 'change');
|
|
349
|
+
const removed = [];
|
|
350
|
+
let created = null;
|
|
351
|
+
let error = null;
|
|
352
|
+
try {
|
|
353
|
+
if (!existsSync(changeDir)) {
|
|
354
|
+
mkdirSync(changeDir, { recursive: true });
|
|
355
|
+
}
|
|
356
|
+
const markerPath = join(changeDir, canonicalSessionId);
|
|
357
|
+
let existingNames = [];
|
|
358
|
+
try {
|
|
359
|
+
existingNames = readdirSync(changeDir);
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
existingNames = [];
|
|
363
|
+
}
|
|
364
|
+
for (const name of existingNames) {
|
|
365
|
+
if (name === canonicalSessionId)
|
|
366
|
+
continue;
|
|
367
|
+
const stale = join(changeDir, name);
|
|
368
|
+
try {
|
|
369
|
+
rmSync(stale, { recursive: true, force: true });
|
|
370
|
+
removed.push(name);
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
// Best-effort: a single un-deletable entry must not abort the
|
|
374
|
+
// sync (the caller gets the rest of the diff).
|
|
375
|
+
error = e instanceof Error ? e.message : String(e);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (!existsSync(markerPath)) {
|
|
379
|
+
mkdirSync(markerPath, { recursive: true });
|
|
380
|
+
created = canonicalSessionId;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
catch (e) {
|
|
384
|
+
error = e instanceof Error ? e.message : String(e);
|
|
385
|
+
}
|
|
386
|
+
return { removed, created, error };
|
|
387
|
+
}
|
|
302
388
|
/**
|
|
303
389
|
* One-time migration step (added in slice 2026-06-05-peaks-runtime-layer).
|
|
304
390
|
*
|
|
@@ -445,6 +531,36 @@ export function reconcileWorkspace(options) {
|
|
|
445
531
|
}
|
|
446
532
|
const deletionCandidates = findDeletionCandidates(sessions, ageThresholdMs);
|
|
447
533
|
const deletionResult = applyDeletions(deletionCandidates, apply);
|
|
534
|
+
// Slice 006: sync the single `change/<canonicalSessionId>/` live
|
|
535
|
+
// marker (replaces the F3 per-change-id symlink layer). The marker
|
|
536
|
+
// is an empty dir; the function removes every other entry under
|
|
537
|
+
// `.peaks/_runtime/change/`. Idempotent. This step is independent
|
|
538
|
+
// of the apply flag — syncing the marker is a derived-state write,
|
|
539
|
+
// not a destructive side effect.
|
|
540
|
+
const changeMarker = canonical === null
|
|
541
|
+
? { removed: [], created: null, error: 'no canonical session' }
|
|
542
|
+
: syncChangeMarker(projectRoot, canonical.sessionId);
|
|
543
|
+
// Slice 006: clean up the F3-introduced `.peaks/_runtime/<sid>/system/`
|
|
544
|
+
// subdir under EVERY session dir (not just the canonical one). The
|
|
545
|
+
// subdir was created eagerly by `initWorkspace` (F3) but was never
|
|
546
|
+
// used. The cleanup is idempotent: re-running on a tree without the
|
|
547
|
+
// subdir is a no-op. We walk every discovered session, not just the
|
|
548
|
+
// canonical one, because the user has 6 F3 sessions with the cruft
|
|
549
|
+
// and the spec says all of them must be removed. Logged in the
|
|
550
|
+
// systemCleaned array.
|
|
551
|
+
const systemCleaned = [];
|
|
552
|
+
for (const session of sessions) {
|
|
553
|
+
const systemDir = join(session.path, 'system');
|
|
554
|
+
if (existsSync(systemDir)) {
|
|
555
|
+
try {
|
|
556
|
+
rmSync(systemDir, { recursive: true, force: true });
|
|
557
|
+
systemCleaned.push(systemDir);
|
|
558
|
+
}
|
|
559
|
+
catch {
|
|
560
|
+
// Best-effort: a locked subdir does not block the rest of reconcile.
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
448
564
|
return {
|
|
449
565
|
projectRoot,
|
|
450
566
|
sessions,
|
|
@@ -459,6 +575,8 @@ export function reconcileWorkspace(options) {
|
|
|
459
575
|
apply,
|
|
460
576
|
repointed,
|
|
461
577
|
migratedFiles: migration.migratedFiles,
|
|
462
|
-
errors: [...migrateErrors, ...deletionResult.errors]
|
|
578
|
+
errors: [...migrateErrors, ...deletionResult.errors],
|
|
579
|
+
changeMarker,
|
|
580
|
+
systemCleaned
|
|
463
581
|
};
|
|
464
582
|
}
|
|
@@ -78,6 +78,31 @@ export type ReconcileResult = {
|
|
|
78
78
|
path: string;
|
|
79
79
|
message: string;
|
|
80
80
|
}>;
|
|
81
|
+
/**
|
|
82
|
+
* Slice 006 (2026-06-06-change-folder-simplify-and-lazy-role-subdirs):
|
|
83
|
+
* result of syncing the single `change/<canonicalSessionId>/` live
|
|
84
|
+
* marker under `.peaks/_runtime/change/`. The marker is an empty
|
|
85
|
+
* directory; every other entry under `change/` is removed. The
|
|
86
|
+
* `removed` array lists entry names that were deleted (e.g.
|
|
87
|
+
* `['<oldSid>/']`); `created` is the name of the new marker (or
|
|
88
|
+
* `null` when the canonical marker already existed — no-op);
|
|
89
|
+
* `error` is the first error message (if any) encountered during
|
|
90
|
+
* the sync. Additive — older consumers can ignore this field.
|
|
91
|
+
* Replaces the F3 `changeLinks` field.
|
|
92
|
+
*/
|
|
93
|
+
changeMarker: {
|
|
94
|
+
removed: string[];
|
|
95
|
+
created: string | null;
|
|
96
|
+
error: string | null;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Slice 006: list of absolute paths to `.peaks/_runtime/<sid>/system/`
|
|
100
|
+
* subdirs that were removed by this reconcile run. The F3
|
|
101
|
+
* `initWorkspace` eagerly created the `system/` subdir; slice 006
|
|
102
|
+
* deletes it during reconcile. Empty when the canonical session
|
|
103
|
+
* had no `system/` subdir. Additive.
|
|
104
|
+
*/
|
|
105
|
+
systemCleaned: string[];
|
|
81
106
|
};
|
|
82
107
|
export type ReconcileOptions = {
|
|
83
108
|
projectRoot: string;
|
|
@@ -8,6 +8,15 @@ export type WorkspaceInitOptions = {
|
|
|
8
8
|
* — the CLI surfaces this as `--allow-session-rebind`.
|
|
9
9
|
*/
|
|
10
10
|
allowSessionRebind?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Optional change-id to bind as the active unit of work. When set,
|
|
13
|
+
* `peaks workspace init` also writes a `.peaks/_runtime/current-change`
|
|
14
|
+
* symlink pointing at `.peaks/<changeId>/`, so RD/QA/PRD services
|
|
15
|
+
* know which `<change-id>` directory to write reviewable artifacts
|
|
16
|
+
* into. The session id is still the binding for ephemeral state
|
|
17
|
+
* (live sub-agent progress, spawn records).
|
|
18
|
+
*/
|
|
19
|
+
changeId?: string;
|
|
11
20
|
};
|
|
12
21
|
export type WorkspaceInitReport = {
|
|
13
22
|
sessionId: string;
|
|
@@ -16,6 +25,8 @@ export type WorkspaceInitReport = {
|
|
|
16
25
|
alreadyExisted: string[];
|
|
17
26
|
bound: boolean;
|
|
18
27
|
previousSessionId: string | null;
|
|
28
|
+
changeId: string | null;
|
|
29
|
+
changeIdAction: 'bound' | 'preserved' | 'none';
|
|
19
30
|
};
|
|
20
31
|
export declare class InvalidSessionIdError extends Error {
|
|
21
32
|
readonly code = "INVALID_SESSION_ID";
|
|
@@ -1,20 +1,8 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isDirectory } from '../../shared/fs.js';
|
|
4
|
-
import { getSessionId, setCurrentSessionBinding } from '../session/session-manager.js';
|
|
5
|
-
|
|
6
|
-
'prd/source',
|
|
7
|
-
'prd/requests',
|
|
8
|
-
'ui/requests',
|
|
9
|
-
'rd/requests',
|
|
10
|
-
'qa/test-cases',
|
|
11
|
-
'qa/test-reports',
|
|
12
|
-
'qa/requests',
|
|
13
|
-
'qa/screenshots',
|
|
14
|
-
'sc',
|
|
15
|
-
'txt',
|
|
16
|
-
'system'
|
|
17
|
-
];
|
|
4
|
+
import { getSessionId, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
|
|
5
|
+
import { setCurrentChangeId } from '../../shared/change-id.js';
|
|
18
6
|
const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
19
7
|
const PROHIBITED_SUFFIXES = ['session', 'work', 'task', 'test', 'temp', 'tmp'];
|
|
20
8
|
// Auto-generated session ID pattern: YYYY-MM-DD-session-<6位hex>
|
|
@@ -61,9 +49,33 @@ export function validateSessionId(sessionId) {
|
|
|
61
49
|
}
|
|
62
50
|
export async function initWorkspace(options) {
|
|
63
51
|
validateSessionId(options.sessionId);
|
|
64
|
-
|
|
52
|
+
// Phase 6 refactor (slice 2026-06-05-change-id-as-unit-of-work) +
|
|
53
|
+
// slice 006 (2026-06-06-change-folder-simplify-and-lazy-role-subdirs):
|
|
54
|
+
// - Reviewable artifacts (rd/, qa/, prd/, txt/) live at
|
|
55
|
+
// `.peaks/<change-id>/<role>/` (tracked in git) when a change-id
|
|
56
|
+
// is given. The role subdirs are NOT pre-created — the writer
|
|
57
|
+
// (e.g. `peaks request init`, `peaks rd`) creates the parent
|
|
58
|
+
// dirs on demand via `mkdirSync(..., { recursive: true })`.
|
|
59
|
+
// - The session dir `.peaks/_runtime/<session-id>/` (gitignored)
|
|
60
|
+
// now holds ONLY the canonical `session.json` metadata. The
|
|
61
|
+
// F3-introduced `system/` subdir is gone — slice 006 removes
|
|
62
|
+
// it via `peaks workspace reconcile`; new init calls do not
|
|
63
|
+
// pre-create it.
|
|
64
|
+
// - The change-id dir is created when `--change-id` is given,
|
|
65
|
+
// but its role subdirs (prd/, qa/, rd/, sc/, txt/) are NOT
|
|
66
|
+
// pre-created either — same lazy-mkdir rule.
|
|
67
|
+
//
|
|
68
|
+
// The CLI accepts `--change-id <id>` to bind the change. The legacy
|
|
69
|
+
// session-scoped layout (`.peaks/<session-id>/<role>/<file>`) is
|
|
70
|
+
// no longer used by writes; pre-1.3.1 trees get their session
|
|
71
|
+
// files migrated to the change-id dir by `peaks workspace reconcile`.
|
|
72
|
+
const runtimeRoot = join(options.projectRoot, '.peaks', '_runtime');
|
|
73
|
+
const sessionRoot = join(runtimeRoot, options.sessionId);
|
|
65
74
|
const created = [];
|
|
66
75
|
const alreadyExisted = [];
|
|
76
|
+
// 1. Create the session dir (canonical location `.peaks/_runtime/<sid>/`)
|
|
77
|
+
// with NO subdirs. The session dir is gitignored; the role
|
|
78
|
+
// subdirs and the `system/` subdir are gone entirely (slice 006).
|
|
67
79
|
if (await isDirectory(sessionRoot)) {
|
|
68
80
|
alreadyExisted.push('.');
|
|
69
81
|
}
|
|
@@ -71,17 +83,43 @@ export async function initWorkspace(options) {
|
|
|
71
83
|
await mkdir(sessionRoot, { recursive: true });
|
|
72
84
|
created.push('.');
|
|
73
85
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
// 1a. Write the per-session metadata file. Slice 006 makes
|
|
87
|
+
// `.peaks/_runtime/<sid>/session.json` the durable session
|
|
88
|
+
// metadata (the body's source of truth, the `peaks workspace
|
|
89
|
+
// reconcile` discovery source). The file is created on first
|
|
90
|
+
// init and refreshed on every subsequent init. Idempotent.
|
|
91
|
+
setSessionMeta(options.projectRoot, options.sessionId, {});
|
|
92
|
+
// 2. If a change-id is given, also create the change-id dir at
|
|
93
|
+
// `.peaks/<change-id>/` (tracked) with NO role subdirs. The
|
|
94
|
+
// role subdirs (prd/, qa/, rd/, sc/, txt/) are created on demand
|
|
95
|
+
// by the writer at the write site. When the caller did NOT
|
|
96
|
+
// specify a change-id, this step is skipped — reviewable writes
|
|
97
|
+
// for this session are then blocked until a change-id is bound
|
|
98
|
+
// (or the user re-runs init with `--change-id`). Surfaces in
|
|
99
|
+
// `changeIdAction: 'none'`.
|
|
100
|
+
let resolvedChangeId = null;
|
|
101
|
+
let changeIdAction = 'none';
|
|
102
|
+
if (options.changeId !== undefined && options.changeId.length > 0) {
|
|
103
|
+
resolvedChangeId = options.changeId;
|
|
104
|
+
const changeDir = join(options.projectRoot, '.peaks', resolvedChangeId);
|
|
105
|
+
if (await isDirectory(changeDir)) {
|
|
106
|
+
alreadyExisted.push(resolvedChangeId);
|
|
78
107
|
}
|
|
79
108
|
else {
|
|
80
|
-
await mkdir(
|
|
81
|
-
created.push(
|
|
109
|
+
await mkdir(changeDir, { recursive: true });
|
|
110
|
+
created.push(resolvedChangeId);
|
|
82
111
|
}
|
|
112
|
+
// 3. Bind the change-id so RD/QA/PRD services know where to write
|
|
113
|
+
// reviewable artifacts. The binding is a symlink at
|
|
114
|
+
// `.peaks/_runtime/current-change` pointing at the change-id dir.
|
|
115
|
+
setCurrentChangeId(options.projectRoot, resolvedChangeId);
|
|
116
|
+
changeIdAction = 'bound';
|
|
117
|
+
}
|
|
118
|
+
else if (options.changeId !== undefined && options.changeId.length === 0) {
|
|
119
|
+
// Empty string — same as undefined; treat as no change-id.
|
|
120
|
+
changeIdAction = 'none';
|
|
83
121
|
}
|
|
84
|
-
// Bind this session as the project's current one.
|
|
122
|
+
// 4. Bind this session as the project's current one.
|
|
85
123
|
//
|
|
86
124
|
// Single source of truth: `peaks workspace init` is the only CLI entry point
|
|
87
125
|
// that takes an explicit --session-id, so it owns the binding to .session.json.
|
|
@@ -112,7 +150,7 @@ export async function initWorkspace(options) {
|
|
|
112
150
|
// etc.) regardless of whether the per-session metadata file is present.
|
|
113
151
|
// Refuse to rebind without explicit authorization.
|
|
114
152
|
previousSessionId = existingSessionId;
|
|
115
|
-
const existingSessionDir = join(
|
|
153
|
+
const existingSessionDir = join(runtimeRoot, existingSessionId);
|
|
116
154
|
if (await isDirectory(existingSessionDir) && !options.allowSessionRebind) {
|
|
117
155
|
const { readdirSync } = await import('node:fs');
|
|
118
156
|
const entries = readdirSync(existingSessionDir);
|
|
@@ -127,5 +165,14 @@ export async function initWorkspace(options) {
|
|
|
127
165
|
setCurrentSessionBinding(options.projectRoot, options.sessionId);
|
|
128
166
|
bound = true;
|
|
129
167
|
}
|
|
130
|
-
return {
|
|
168
|
+
return {
|
|
169
|
+
sessionId: options.sessionId,
|
|
170
|
+
sessionRoot,
|
|
171
|
+
created,
|
|
172
|
+
alreadyExisted,
|
|
173
|
+
bound,
|
|
174
|
+
previousSessionId,
|
|
175
|
+
changeId: resolvedChangeId,
|
|
176
|
+
changeIdAction
|
|
177
|
+
};
|
|
131
178
|
}
|