peaks-cli 1.3.0 → 1.3.1
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/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 +42 -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 +288 -4
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +172 -54
- package/dist/src/services/doctor/doctor-service.d.ts +7 -0
- package/dist/src/services/doctor/doctor-service.js +20 -2
- 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.js +12 -2
- 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 +248 -0
- package/dist/src/services/slice/slice-check-types.d.ts +61 -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 +484 -0
- package/dist/src/services/workspace/migrate-types.d.ts +84 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +87 -7
- 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/skills/peaks-solo/SKILL.md +11 -1
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
|
@@ -0,0 +1,84 @@
|
|
|
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 MigrateResult = {
|
|
57
|
+
/** Absolute project root the command operated on. */
|
|
58
|
+
projectRoot: string;
|
|
59
|
+
/** All discovered legacy session dirs, sorted by name. */
|
|
60
|
+
sessions: MigrateSessionPlan[];
|
|
61
|
+
/** All moves the apply step WOULD perform (only populated when `apply === false` as well, for symmetry). */
|
|
62
|
+
wouldMove: MigrateFilePlan[];
|
|
63
|
+
/** All moves actually performed (only populated when `apply === true`). */
|
|
64
|
+
moved: MigrateFilePlan[];
|
|
65
|
+
/** Sessions that became empty after the move and were/will be removed. */
|
|
66
|
+
deletedSessions: string[];
|
|
67
|
+
/** Sessions that WOULD become empty (dry-run only). */
|
|
68
|
+
wouldDeleteSessions: string[];
|
|
69
|
+
/** Files that already exist at the target (collision). Dry-run reports; apply skips + warns. */
|
|
70
|
+
conflicts: Array<{
|
|
71
|
+
from: string;
|
|
72
|
+
to: string;
|
|
73
|
+
reason: string;
|
|
74
|
+
}>;
|
|
75
|
+
/** Whether `--apply` was set. */
|
|
76
|
+
apply: boolean;
|
|
77
|
+
/** Total files moved or scheduled to move. */
|
|
78
|
+
totalFilesMoved: number;
|
|
79
|
+
};
|
|
80
|
+
export type MigrateOptions = {
|
|
81
|
+
projectRoot: string;
|
|
82
|
+
/** When true, actually `git mv` the files + `rm -rf` the emptied session dirs. */
|
|
83
|
+
apply: boolean;
|
|
84
|
+
};
|
|
@@ -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 {};
|
|
@@ -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";
|
|
@@ -2,7 +2,16 @@ import { mkdir } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { isDirectory } from '../../shared/fs.js';
|
|
4
4
|
import { getSessionId, setCurrentSessionBinding } from '../session/session-manager.js';
|
|
5
|
-
|
|
5
|
+
import { setCurrentChangeId } from '../../shared/change-id.js';
|
|
6
|
+
/**
|
|
7
|
+
* Per-slice subdirectories created **inside the change-id dir**
|
|
8
|
+
* (`.peaks/<change-id>/...`). These are the reviewable
|
|
9
|
+
* artifacts and are tracked in git. The `system/` subdir is
|
|
10
|
+
* intentionally NOT in this list — it lives under the session
|
|
11
|
+
* dir (`.peaks/_runtime/<session-id>/system/`), since it holds
|
|
12
|
+
* live sub-agent progress and spawn records, which are ephemeral.
|
|
13
|
+
*/
|
|
14
|
+
const CHANGE_ARTIFACT_SUBDIRECTORIES = [
|
|
6
15
|
'prd/source',
|
|
7
16
|
'prd/requests',
|
|
8
17
|
'ui/requests',
|
|
@@ -12,7 +21,14 @@ const SUBDIRECTORIES = [
|
|
|
12
21
|
'qa/requests',
|
|
13
22
|
'qa/screenshots',
|
|
14
23
|
'sc',
|
|
15
|
-
'txt'
|
|
24
|
+
'txt'
|
|
25
|
+
];
|
|
26
|
+
/**
|
|
27
|
+
* Per-session subdirectories created **inside the session dir**
|
|
28
|
+
* (`.peaks/_runtime/<session-id>/...`). These are the ephemeral
|
|
29
|
+
* state and are gitignored.
|
|
30
|
+
*/
|
|
31
|
+
const SESSION_EPHEMERAL_SUBDIRECTORIES = [
|
|
16
32
|
'system'
|
|
17
33
|
];
|
|
18
34
|
const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
@@ -61,9 +77,26 @@ export function validateSessionId(sessionId) {
|
|
|
61
77
|
}
|
|
62
78
|
export async function initWorkspace(options) {
|
|
63
79
|
validateSessionId(options.sessionId);
|
|
64
|
-
|
|
80
|
+
// Phase 6 refactor (slice 2026-06-05-change-id-as-unit-of-work):
|
|
81
|
+
// - Reviewable artifacts (rd/, qa/, prd/, txt/) are created at
|
|
82
|
+
// `.peaks/<change-id>/<role>/` (tracked in git) when a change-id
|
|
83
|
+
// is given. This is the canonical home for cross-session content.
|
|
84
|
+
// - The session dir `.peaks/_runtime/<session-id>/` (gitignored)
|
|
85
|
+
// holds only ephemeral state — currently `system/` (live
|
|
86
|
+
// sub-agent progress + spawn records). The session id remains
|
|
87
|
+
// the binding for that ephemeral state.
|
|
88
|
+
//
|
|
89
|
+
// The CLI accepts `--change-id <id>` to bind the change. The legacy
|
|
90
|
+
// session-scoped layout (`.peaks/<session-id>/<role>/<file>`) is
|
|
91
|
+
// no longer used by writes; pre-1.3.1 trees get their session
|
|
92
|
+
// files migrated to the change-id dir by `peaks workspace reconcile`.
|
|
93
|
+
const runtimeRoot = join(options.projectRoot, '.peaks', '_runtime');
|
|
94
|
+
const sessionRoot = join(runtimeRoot, options.sessionId);
|
|
65
95
|
const created = [];
|
|
66
96
|
const alreadyExisted = [];
|
|
97
|
+
// 1. Create the session dir (canonical location `.peaks/_runtime/<sid>/`)
|
|
98
|
+
// with ONLY ephemeral subdirs (`system/`). The session dir is
|
|
99
|
+
// gitignored.
|
|
67
100
|
if (await isDirectory(sessionRoot)) {
|
|
68
101
|
alreadyExisted.push('.');
|
|
69
102
|
}
|
|
@@ -71,7 +104,7 @@ export async function initWorkspace(options) {
|
|
|
71
104
|
await mkdir(sessionRoot, { recursive: true });
|
|
72
105
|
created.push('.');
|
|
73
106
|
}
|
|
74
|
-
for (const sub of
|
|
107
|
+
for (const sub of SESSION_EPHEMERAL_SUBDIRECTORIES) {
|
|
75
108
|
const full = join(sessionRoot, sub);
|
|
76
109
|
if (await isDirectory(full)) {
|
|
77
110
|
alreadyExisted.push(sub);
|
|
@@ -81,7 +114,45 @@ export async function initWorkspace(options) {
|
|
|
81
114
|
created.push(sub);
|
|
82
115
|
}
|
|
83
116
|
}
|
|
84
|
-
//
|
|
117
|
+
// 2. If a change-id is given, also create the change-id dir at
|
|
118
|
+
// `.peaks/<change-id>/` (tracked) with the reviewable subdirs.
|
|
119
|
+
// When the caller did NOT specify a change-id, this step is
|
|
120
|
+
// skipped — reviewable writes for this session are then blocked
|
|
121
|
+
// until a change-id is bound (or the user re-runs init with
|
|
122
|
+
// `--change-id`). Surfaces in `changeIdAction: 'none'`.
|
|
123
|
+
let resolvedChangeId = null;
|
|
124
|
+
let changeIdAction = 'none';
|
|
125
|
+
if (options.changeId !== undefined && options.changeId.length > 0) {
|
|
126
|
+
resolvedChangeId = options.changeId;
|
|
127
|
+
const changeDir = join(options.projectRoot, '.peaks', resolvedChangeId);
|
|
128
|
+
if (await isDirectory(changeDir)) {
|
|
129
|
+
alreadyExisted.push(resolvedChangeId);
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
await mkdir(changeDir, { recursive: true });
|
|
133
|
+
created.push(resolvedChangeId);
|
|
134
|
+
}
|
|
135
|
+
for (const sub of CHANGE_ARTIFACT_SUBDIRECTORIES) {
|
|
136
|
+
const full = join(changeDir, sub);
|
|
137
|
+
if (await isDirectory(full)) {
|
|
138
|
+
alreadyExisted.push(sub);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
await mkdir(full, { recursive: true });
|
|
142
|
+
created.push(sub);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// 3. Bind the change-id so RD/QA/PRD services know where to write
|
|
146
|
+
// reviewable artifacts. The binding is a symlink at
|
|
147
|
+
// `.peaks/_runtime/current-change` pointing at the change-id dir.
|
|
148
|
+
setCurrentChangeId(options.projectRoot, resolvedChangeId);
|
|
149
|
+
changeIdAction = 'bound';
|
|
150
|
+
}
|
|
151
|
+
else if (options.changeId !== undefined && options.changeId.length === 0) {
|
|
152
|
+
// Empty string — same as undefined; treat as no change-id.
|
|
153
|
+
changeIdAction = 'none';
|
|
154
|
+
}
|
|
155
|
+
// 4. Bind this session as the project's current one.
|
|
85
156
|
//
|
|
86
157
|
// Single source of truth: `peaks workspace init` is the only CLI entry point
|
|
87
158
|
// that takes an explicit --session-id, so it owns the binding to .session.json.
|
|
@@ -112,7 +183,7 @@ export async function initWorkspace(options) {
|
|
|
112
183
|
// etc.) regardless of whether the per-session metadata file is present.
|
|
113
184
|
// Refuse to rebind without explicit authorization.
|
|
114
185
|
previousSessionId = existingSessionId;
|
|
115
|
-
const existingSessionDir = join(
|
|
186
|
+
const existingSessionDir = join(runtimeRoot, existingSessionId);
|
|
116
187
|
if (await isDirectory(existingSessionDir) && !options.allowSessionRebind) {
|
|
117
188
|
const { readdirSync } = await import('node:fs');
|
|
118
189
|
const entries = readdirSync(existingSessionDir);
|
|
@@ -127,5 +198,14 @@ export async function initWorkspace(options) {
|
|
|
127
198
|
setCurrentSessionBinding(options.projectRoot, options.sessionId);
|
|
128
199
|
bound = true;
|
|
129
200
|
}
|
|
130
|
-
return {
|
|
201
|
+
return {
|
|
202
|
+
sessionId: options.sessionId,
|
|
203
|
+
sessionRoot,
|
|
204
|
+
created,
|
|
205
|
+
alreadyExisted,
|
|
206
|
+
bound,
|
|
207
|
+
previousSessionId,
|
|
208
|
+
changeId: resolvedChangeId,
|
|
209
|
+
changeIdAction
|
|
210
|
+
};
|
|
131
211
|
}
|
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
* Shared change-id validation and artifact path helpers.
|
|
3
3
|
* All Peaks planner commands must use these to prevent path traversal
|
|
4
4
|
* and keep artifacts inside the Peaks artifact workspace.
|
|
5
|
+
*
|
|
6
|
+
* Layout (as of slice 2026-06-05-change-id-as-unit-of-work):
|
|
7
|
+
* - Reviewable artifacts (rd/, qa/, prd/, txt/, prd/source/):
|
|
8
|
+
* .peaks/<change-id>/<role>/... (tracked in git)
|
|
9
|
+
* - Ephemeral state (live sub-agent progress, spawn records):
|
|
10
|
+
* .peaks/_runtime/<session-id>/... (gitignored)
|
|
11
|
+
* - The active change-id binding lives at `.peaks/_runtime/current-change`
|
|
12
|
+
* (symlink pointing at `.peaks/<change-id>/` for one-minor-release back-compat
|
|
13
|
+
* also accepts a plain file with the change-id as its sole content).
|
|
14
|
+
*
|
|
15
|
+
* The session id remains in use as a binding (which developer's local
|
|
16
|
+
* working session is active) but it is NOT the durable scope for
|
|
17
|
+
* reviewable content — change-id is. Sessions are ephemeral and
|
|
18
|
+
* gitignored; changes are durable and tracked.
|
|
5
19
|
*/
|
|
6
20
|
export declare function isValidChangeId(changeId: string): boolean;
|
|
7
21
|
export declare function isUnsafePathInput(input: string): boolean;
|
|
@@ -56,3 +70,48 @@ export declare function buildArtifactRelativePathInRoot(projectRoot: string, cha
|
|
|
56
70
|
*/
|
|
57
71
|
export declare function buildArtifactRelativePath(changeId: string, ...segments: string[]): string;
|
|
58
72
|
export declare function isPathInsideArtifactRoot(path: string, artifactRoot: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Read the active change-id binding for a project. Returns null when
|
|
75
|
+
* no binding exists or the binding is malformed / escapes the project
|
|
76
|
+
* root. As of slice 2026-06-05-change-id-as-unit-of-work this is the
|
|
77
|
+
* primary routing key for reviewable artifact writes — RD/QA/PRD
|
|
78
|
+
* services should call this (rather than guess from the session id)
|
|
79
|
+
* to decide which `.peaks/<change-id>/` directory to write into.
|
|
80
|
+
*/
|
|
81
|
+
export declare function getCurrentChangeId(projectRoot: string): string | null;
|
|
82
|
+
/**
|
|
83
|
+
* Source of the resolved change-id binding. Useful for tests that
|
|
84
|
+
* need to confirm whether the binding came from the canonical
|
|
85
|
+
* `_runtime/current-change` symlink or the legacy `current-change`
|
|
86
|
+
* plain-file path.
|
|
87
|
+
*/
|
|
88
|
+
export declare function getCurrentChangeIdSource(projectRoot: string): {
|
|
89
|
+
changeId: string;
|
|
90
|
+
source: 'symlink' | 'file';
|
|
91
|
+
} | null;
|
|
92
|
+
/**
|
|
93
|
+
* Write a change-id binding for a project. Two forms are supported
|
|
94
|
+
* (the same as `getCurrentChangeId` reads):
|
|
95
|
+
*
|
|
96
|
+
* - `{ form: 'symlink' }` (default): creates
|
|
97
|
+
* `.peaks/_runtime/current-change` as a symlink pointing at
|
|
98
|
+
* `.peaks/<changeId>/`. Requires the target dir to exist (the
|
|
99
|
+
* caller is responsible for `initWorkspace` + the change-id dir).
|
|
100
|
+
* - `{ form: 'file' }`: writes the change-id as the sole content of
|
|
101
|
+
* `.peaks/_runtime/current-change`. The legacy plain-file form.
|
|
102
|
+
*
|
|
103
|
+
* Idempotent: re-running with the same changeId + form is a no-op.
|
|
104
|
+
* Re-running with a different changeId on an existing symlink throws —
|
|
105
|
+
* the caller must remove the binding first (or use a different path).
|
|
106
|
+
*/
|
|
107
|
+
export declare function setCurrentChangeId(projectRoot: string, changeId: string, options?: {
|
|
108
|
+
form?: 'symlink' | 'file';
|
|
109
|
+
}): void;
|
|
110
|
+
/**
|
|
111
|
+
* Canonical on-disk path to a change-id's reviewable artifacts
|
|
112
|
+
* (`.peaks/<change-id>/`). Writes that target reviewable content
|
|
113
|
+
* (RD/QA/PRD/txt) should land here regardless of which session
|
|
114
|
+
* is active. Ephemeral state (live sub-agent progress, spawn records)
|
|
115
|
+
* stays in the session dir (`.peaks/_runtime/<session-id>/...`).
|
|
116
|
+
*/
|
|
117
|
+
export declare function getChangeArtifactRoot(projectRoot: string, changeId: string): string;
|
|
@@ -2,11 +2,25 @@
|
|
|
2
2
|
* Shared change-id validation and artifact path helpers.
|
|
3
3
|
* All Peaks planner commands must use these to prevent path traversal
|
|
4
4
|
* and keep artifacts inside the Peaks artifact workspace.
|
|
5
|
+
*
|
|
6
|
+
* Layout (as of slice 2026-06-05-change-id-as-unit-of-work):
|
|
7
|
+
* - Reviewable artifacts (rd/, qa/, prd/, txt/, prd/source/):
|
|
8
|
+
* .peaks/<change-id>/<role>/... (tracked in git)
|
|
9
|
+
* - Ephemeral state (live sub-agent progress, spawn records):
|
|
10
|
+
* .peaks/_runtime/<session-id>/... (gitignored)
|
|
11
|
+
* - The active change-id binding lives at `.peaks/_runtime/current-change`
|
|
12
|
+
* (symlink pointing at `.peaks/<change-id>/` for one-minor-release back-compat
|
|
13
|
+
* also accepts a plain file with the change-id as its sole content).
|
|
14
|
+
*
|
|
15
|
+
* The session id remains in use as a binding (which developer's local
|
|
16
|
+
* working session is active) but it is NOT the durable scope for
|
|
17
|
+
* reviewable content — change-id is. Sessions are ephemeral and
|
|
18
|
+
* gitignored; changes are durable and tracked.
|
|
5
19
|
*/
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { getSessionId } from '../services/session/session-manager.js';
|
|
20
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { posix, join, basename } from 'node:path';
|
|
9
22
|
import { findProjectRoot } from '../services/config/config-safety.js';
|
|
23
|
+
import { isInsidePath } from './path-utils.js';
|
|
10
24
|
const CHANGE_ID_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
11
25
|
function normalizeForwardSlashes(input) {
|
|
12
26
|
return input.replace(/\\/g, '/');
|
|
@@ -86,22 +100,21 @@ export function isUnsafeArtifactPath(path) {
|
|
|
86
100
|
*/
|
|
87
101
|
export function buildArtifactRelativePathInRoot(projectRoot, changeId, ...segments) {
|
|
88
102
|
validateChangeIdOrThrow(changeId);
|
|
103
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, reviewable
|
|
104
|
+
// artifacts (RD/QA/PRD/txt) are routed to the change-id-scoped
|
|
105
|
+
// directory `.peaks/<change-id>/<segments-joined>`. The session id
|
|
106
|
+
// is the binding for ephemeral state (live sub-agent progress,
|
|
107
|
+
// spawn records) only and is NOT part of the reviewable-artifact
|
|
108
|
+
// path. Pre-1.3.1 trees get their old session-scoped files migrated
|
|
109
|
+
// to the change-id dir by `peaks workspace reconcile`.
|
|
89
110
|
const resolvedProjectRoot = projectRoot && projectRoot.length > 0
|
|
90
111
|
? projectRoot
|
|
91
112
|
: (findProjectRoot(process.cwd()) ?? process.cwd());
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
throw new ChangeIdValidationError(changeId);
|
|
98
|
-
}
|
|
99
|
-
const number = getNextNumber(dirPath);
|
|
100
|
-
const filename = buildNumberedFilename(number, changeId);
|
|
101
|
-
const candidatePath = `.peaks/${sessionId}/${role}/${filename}`;
|
|
102
|
-
return normalizeArtifactPath(candidatePath);
|
|
103
|
-
}
|
|
104
|
-
// Fallback: no session or no segments - use legacy behavior
|
|
113
|
+
// Use segments verbatim as the sub-path. This preserves the
|
|
114
|
+
// legacy behavior where `buildArtifactRelativePath(changeId, 'rd', 'architecture')`
|
|
115
|
+
// produces `.peaks/<changeId>/rd/architecture` (the caller specifies
|
|
116
|
+
// the full sub-path, including any custom filename like
|
|
117
|
+
// `architecture`, `001-foo.md`, `swarm/workers/rd-impl-001`, etc.).
|
|
105
118
|
const joined = segments.map((segment) => normalizeForwardSlashes(segment)).join('/');
|
|
106
119
|
const candidatePath = `.peaks/${changeId}/${joined}`;
|
|
107
120
|
if (isUnsafeArtifactPath(joined) || isUnsafeArtifactPath(candidatePath)) {
|
|
@@ -138,3 +151,168 @@ export function isPathInsideArtifactRoot(path, artifactRoot) {
|
|
|
138
151
|
const normalizedRoot = normalizeArtifactPath(artifactRoot);
|
|
139
152
|
return normalizedPath === normalizedRoot || normalizedPath.startsWith(`${normalizedRoot}/`);
|
|
140
153
|
}
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Change-id binding (.peaks/_runtime/current-change)
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
//
|
|
158
|
+
// The active change-id binding lives at `.peaks/_runtime/current-change`.
|
|
159
|
+
// Two forms are accepted (back-compat for the legacy file-based binding
|
|
160
|
+
// that pre-dates the runtime-layer refactor):
|
|
161
|
+
//
|
|
162
|
+
// 1. Symlink: the symlink target resolves to `.peaks/<change-id>/`
|
|
163
|
+
// inside the project root. This is the canonical form that
|
|
164
|
+
// `peaks workspace init --change-id <id>` writes.
|
|
165
|
+
//
|
|
166
|
+
// 2. Plain file: the file's first non-empty line is the change-id.
|
|
167
|
+
// Older `peaks workspace init` (pre-1.3.1) wrote the change-id
|
|
168
|
+
// as a plain file at `.peaks/current-change`. We still read it.
|
|
169
|
+
//
|
|
170
|
+
// In either case the change-id is validated against CHANGE_ID_PATTERN
|
|
171
|
+
// (letters/digits/dots/underscores/dashes, no `..`) and the resolved
|
|
172
|
+
// path must stay inside the project root (defense against a symlink
|
|
173
|
+
// pointing outside).
|
|
174
|
+
//
|
|
175
|
+
// The binding is read by RD/QA/PRD services when they need to know
|
|
176
|
+
// which `.peaks/<change-id>/` directory to write reviewable artifacts
|
|
177
|
+
// into, and by reconciliation to figure out which slice each legacy
|
|
178
|
+
// session file belongs to.
|
|
179
|
+
const CURRENT_CHANGE_REL = '_runtime/current-change';
|
|
180
|
+
const LEGACY_CURRENT_CHANGE_REL = 'current-change';
|
|
181
|
+
const CHANGE_DIR_PATTERN = /^[A-Za-z0-9._-]+$/;
|
|
182
|
+
function safeReadBinding(projectRoot) {
|
|
183
|
+
const peaksRoot = join(projectRoot, '.peaks');
|
|
184
|
+
const realPeaks = (() => {
|
|
185
|
+
try {
|
|
186
|
+
return realpathSync(peaksRoot);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
return peaksRoot;
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
for (const rel of [CURRENT_CHANGE_REL, LEGACY_CURRENT_CHANGE_REL]) {
|
|
193
|
+
const bindingPath = join(peaksRoot, rel);
|
|
194
|
+
if (!existsSync(bindingPath))
|
|
195
|
+
continue;
|
|
196
|
+
try {
|
|
197
|
+
const stat = lstatSync(bindingPath);
|
|
198
|
+
if (stat.isSymbolicLink()) {
|
|
199
|
+
const targetPath = realpathSync(bindingPath);
|
|
200
|
+
if (!isInsidePath(targetPath, realPeaks))
|
|
201
|
+
return null;
|
|
202
|
+
const targetId = basename(targetPath);
|
|
203
|
+
if (!CHANGE_DIR_PATTERN.test(targetId) || targetId === '.' || targetId === '..')
|
|
204
|
+
return null;
|
|
205
|
+
return { changeId: targetId, source: 'symlink' };
|
|
206
|
+
}
|
|
207
|
+
const raw = readFileSync(bindingPath, 'utf-8').trim();
|
|
208
|
+
if (!raw || !CHANGE_DIR_PATTERN.test(raw) || raw === '.' || raw === '..')
|
|
209
|
+
return null;
|
|
210
|
+
return { changeId: raw, source: 'file' };
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Read the active change-id binding for a project. Returns null when
|
|
220
|
+
* no binding exists or the binding is malformed / escapes the project
|
|
221
|
+
* root. As of slice 2026-06-05-change-id-as-unit-of-work this is the
|
|
222
|
+
* primary routing key for reviewable artifact writes — RD/QA/PRD
|
|
223
|
+
* services should call this (rather than guess from the session id)
|
|
224
|
+
* to decide which `.peaks/<change-id>/` directory to write into.
|
|
225
|
+
*/
|
|
226
|
+
export function getCurrentChangeId(projectRoot) {
|
|
227
|
+
return safeReadBinding(projectRoot)?.changeId ?? null;
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Source of the resolved change-id binding. Useful for tests that
|
|
231
|
+
* need to confirm whether the binding came from the canonical
|
|
232
|
+
* `_runtime/current-change` symlink or the legacy `current-change`
|
|
233
|
+
* plain-file path.
|
|
234
|
+
*/
|
|
235
|
+
export function getCurrentChangeIdSource(projectRoot) {
|
|
236
|
+
return safeReadBinding(projectRoot);
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Write a change-id binding for a project. Two forms are supported
|
|
240
|
+
* (the same as `getCurrentChangeId` reads):
|
|
241
|
+
*
|
|
242
|
+
* - `{ form: 'symlink' }` (default): creates
|
|
243
|
+
* `.peaks/_runtime/current-change` as a symlink pointing at
|
|
244
|
+
* `.peaks/<changeId>/`. Requires the target dir to exist (the
|
|
245
|
+
* caller is responsible for `initWorkspace` + the change-id dir).
|
|
246
|
+
* - `{ form: 'file' }`: writes the change-id as the sole content of
|
|
247
|
+
* `.peaks/_runtime/current-change`. The legacy plain-file form.
|
|
248
|
+
*
|
|
249
|
+
* Idempotent: re-running with the same changeId + form is a no-op.
|
|
250
|
+
* Re-running with a different changeId on an existing symlink throws —
|
|
251
|
+
* the caller must remove the binding first (or use a different path).
|
|
252
|
+
*/
|
|
253
|
+
export function setCurrentChangeId(projectRoot, changeId, options = {}) {
|
|
254
|
+
validateChangeIdOrThrow(changeId);
|
|
255
|
+
const form = options.form ?? 'symlink';
|
|
256
|
+
const peaksRoot = join(projectRoot, '.peaks');
|
|
257
|
+
const bindingPath = join(peaksRoot, CURRENT_CHANGE_REL);
|
|
258
|
+
// Ensure `_runtime/` exists.
|
|
259
|
+
const runtimeDir = join(peaksRoot, '_runtime');
|
|
260
|
+
if (!existsSync(runtimeDir)) {
|
|
261
|
+
// Lazy import: do not pull fs/promises at the top to keep the
|
|
262
|
+
// module's import graph minimal.
|
|
263
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
264
|
+
const { mkdirSync } = require('node:fs');
|
|
265
|
+
mkdirSync(runtimeDir, { recursive: true });
|
|
266
|
+
}
|
|
267
|
+
if (form === 'file') {
|
|
268
|
+
writeFileSync(bindingPath, changeId + '\n', 'utf-8');
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// symlink form: point at .peaks/<changeId>/
|
|
272
|
+
const targetDir = join(peaksRoot, changeId);
|
|
273
|
+
if (!existsSync(targetDir)) {
|
|
274
|
+
mkdirSync(targetDir, { recursive: true });
|
|
275
|
+
}
|
|
276
|
+
if (existsSync(bindingPath)) {
|
|
277
|
+
// Re-running with a different changeId is a configuration error;
|
|
278
|
+
// surface it loudly so the caller (peaks workspace init) can decide
|
|
279
|
+
// whether to --allow-session-rebind for the underlying session.
|
|
280
|
+
try {
|
|
281
|
+
const existing = readFileSync(bindingPath, 'utf-8').trim();
|
|
282
|
+
if (existing !== changeId) {
|
|
283
|
+
if (existsSync(join(peaksRoot, changeId))) {
|
|
284
|
+
unlinkSync(bindingPath);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
throw new Error(`current-change binding points at "${existing}" but caller asked to set "${changeId}". ` +
|
|
288
|
+
`Remove .peaks/_runtime/current-change first or pass the existing changeId.`);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
return; // identical — no-op
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (error instanceof Error && error.message.startsWith('current-change binding points'))
|
|
297
|
+
throw error;
|
|
298
|
+
// Could not read the existing binding (e.g. it's a broken symlink).
|
|
299
|
+
// Replace.
|
|
300
|
+
try {
|
|
301
|
+
unlinkSync(bindingPath);
|
|
302
|
+
}
|
|
303
|
+
catch { /* best effort */ }
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
symlinkSync(targetDir, bindingPath);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Canonical on-disk path to a change-id's reviewable artifacts
|
|
310
|
+
* (`.peaks/<change-id>/`). Writes that target reviewable content
|
|
311
|
+
* (RD/QA/PRD/txt) should land here regardless of which session
|
|
312
|
+
* is active. Ephemeral state (live sub-agent progress, spawn records)
|
|
313
|
+
* stays in the session dir (`.peaks/_runtime/<session-id>/...`).
|
|
314
|
+
*/
|
|
315
|
+
export function getChangeArtifactRoot(projectRoot, changeId) {
|
|
316
|
+
validateChangeIdOrThrow(changeId);
|
|
317
|
+
return join(projectRoot, '.peaks', changeId);
|
|
318
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "1.3.
|
|
1
|
+
export declare const CLI_VERSION = "1.3.1";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "1.3.
|
|
1
|
+
export const CLI_VERSION = "1.3.1";
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-cli",
|
|
3
|
-
"version": "1.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"description": "Cross-AI-IDE workflow-gating CLI + skill family (Claude Code shipped, Trae in progress; Codex / Cursor / Qoder / Tongyi Lingma on the roadmap).",
|
|
5
5
|
"author": "SquabbyZ",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"type": "module",
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
"publishConfig": {
|
|
10
10
|
"access": "public"
|
|
11
11
|
},
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "https://github.com/SquabbyZ/peaks-cli.git"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/SquabbyZ/peaks-cli",
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/SquabbyZ/peaks-cli/issues"
|
|
19
|
+
},
|
|
12
20
|
"bin": {
|
|
13
21
|
"peaks": "./bin/peaks.js"
|
|
14
22
|
},
|
|
@@ -755,6 +755,15 @@ Maintenance: when adding new CLI commands to the runbook, mirror them into both
|
|
|
755
755
|
|
|
756
756
|
Repair loop details: see `## Mandatory RD QA repair loop` above for the full 5-step procedure and the 3-cycle cap. Append transition notes via `--reason` rather than rewriting artifacts during repair cycles.
|
|
757
757
|
|
|
758
|
+
## RD micro-cycle (TDD small-step rapid-test loop)
|
|
759
|
+
|
|
760
|
+
> **Slice 内部**的修复 / refactor / lint 修复走 micro-cycle(5-10s/cycle)。
|
|
761
|
+
> Slice 边界走 `peaks slice check`(一次性 4 项自检)。
|
|
762
|
+
> 不要把 micro-cycle 跟边界 check 混用——前者 100ms 反馈循环,后者 30s+ 全套。
|
|
763
|
+
> 完整手册:`references/micro-cycle.md`。
|
|
764
|
+
> 摘要:micro-cycle 内只跑 `vitest run <file> -t "<name>"`;边界跑 `peaks slice check`(tsc + vitest + 3-way + verify-pipeline)。
|
|
765
|
+
> 硬约束:违反任一"micro-cycle 内禁止触发"列表 = workflow violation;边界不全绿 = 禁止 ship。
|
|
766
|
+
|
|
758
767
|
## Peaks-Cli Project standards preflight
|
|
759
768
|
|
|
760
769
|
Before orchestrating an end-to-end code repository workflow, gather the project standards preflight status from RD and QA by calling the Peaks-Cli CLI:
|
|
@@ -805,8 +814,9 @@ These commands harden the workflow against silent skips. Use them in the runbook
|
|
|
805
814
|
| `peaks request repair-status <rid> --project <path>` | Count RD↔QA repair cycles from `--reason` transition notes ("QA cycle N: ...") | Before every RD repair iteration in step 7 | Cycle count reached the 3-cycle cap |
|
|
806
815
|
| `peaks scan request-type-sanity --project <path> --type <type>` | Cross-verify declared `--type` against the actual `git diff` file mix (catches "feature mis-declared as docs" workflow violations) | After PRD type lock-in AND after each RD repair iteration | Declared type disagrees with the file mix |
|
|
807
816
|
| `peaks scan libraries --project <path>` | Enumerate every dependency + devDependency + peerDependency + optionalDependency with parsed major version; output goes to `## Library versions` in `rd/project-scan.md`. Read-only. | At Solo step 0.6 (alongside `peaks scan archetype`) | Always exits 0 (warnings in JSON envelope; never blocks) |
|
|
817
|
+
| `peaks slice check [--rid <rid>] [--project <path>]` | 4-stage slice 边界 check (typecheck + unit-tests + review-fanout + gate-verify-pipeline). Aggregate pass/fail; non-zero exit if any stage fails. See "Slice 边界 check" below for usage rules (boundary only, never inside a micro-cycle). | At slice 边界(post-micro-cycle, pre-peaks-qa)| Any stage fails |
|
|
808
818
|
|
|
809
|
-
Together with `peaks request transition` (which already CLI-enforces per-type artifact prerequisites), these
|
|
819
|
+
Together with `peaks request transition` (which already CLI-enforces per-type artifact prerequisites), these five commands form the runtime quality net. SKILL.md prose is descriptive; the CLI is what physically blocks bad workflows.
|
|
810
820
|
|
|
811
821
|
## Peaks-Cli Completion handoff
|
|
812
822
|
|