peaks-cli 1.2.4 → 1.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/openspec-commands.js +31 -0
- package/dist/src/cli/commands/workspace-commands.js +50 -4
- package/dist/src/services/config/config-safety.d.ts +26 -0
- package/dist/src/services/config/config-safety.js +76 -0
- package/dist/src/services/config/config-service.d.ts +1 -1
- package/dist/src/services/config/config-service.js +2 -2
- package/dist/src/services/memory/project-memory-service.d.ts +18 -0
- package/dist/src/services/memory/project-memory-service.js +131 -13
- package/dist/src/services/openspec/openspec-init-service.d.ts +23 -0
- package/dist/src/services/openspec/openspec-init-service.js +122 -0
- package/dist/src/services/session/index.d.ts +1 -1
- package/dist/src/services/session/index.js +1 -1
- package/dist/src/services/session/session-manager.d.ts +11 -0
- package/dist/src/services/session/session-manager.js +19 -0
- package/dist/src/services/skills/skill-presence-service.js +11 -0
- package/dist/src/services/workspace/workspace-service.d.ts +15 -0
- package/dist/src/services/workspace/workspace-service.js +60 -1
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-prd/SKILL.md +36 -0
- package/skills/peaks-qa/SKILL.md +91 -2
- package/skills/peaks-rd/SKILL.md +69 -2
- package/skills/peaks-solo/SKILL.md +249 -41
- package/skills/peaks-solo/references/a2a-artifact-mapping.md +115 -0
- package/skills/peaks-solo/references/swarm-dispatch-contract.md +186 -0
- package/skills/peaks-txt/SKILL.md +16 -0
- package/skills/peaks-ui/SKILL.md +61 -2
package/bin/peaks.js
CHANGED
|
File without changes
|
|
@@ -4,6 +4,7 @@ import { projectOpenSpecToRdInput } from '../../services/openspec/openspec-bridg
|
|
|
4
4
|
import { renderOpenSpecChange } from '../../services/openspec/openspec-render-service.js';
|
|
5
5
|
import { validateOpenSpecChange } from '../../services/openspec/openspec-validate-service.js';
|
|
6
6
|
import { archiveOpenSpecChange } from '../../services/openspec/openspec-archive-service.js';
|
|
7
|
+
import { executeOpenSpecInit } from '../../services/openspec/openspec-init-service.js';
|
|
7
8
|
import { fail, ok } from '../../shared/result.js';
|
|
8
9
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
9
10
|
function resolveScanOptions(project) {
|
|
@@ -166,4 +167,34 @@ export function registerOpenSpecCommands(program, io) {
|
|
|
166
167
|
process.exitCode = 1;
|
|
167
168
|
}
|
|
168
169
|
});
|
|
170
|
+
addJsonOption(openspec
|
|
171
|
+
.command('init')
|
|
172
|
+
.description('Scaffold the openspec/ directory in the target project (changes/, archive/, README.md, CHANGES.md). Idempotent — refuses to overwrite an existing openspec/')
|
|
173
|
+
.requiredOption('--project <path>', 'target project root')
|
|
174
|
+
.option('--apply', 'write files to disk (default: dry-run preview)', false)).action(async (options) => {
|
|
175
|
+
try {
|
|
176
|
+
const result = await executeOpenSpecInit({
|
|
177
|
+
projectRoot: options.project,
|
|
178
|
+
apply: options.apply === true
|
|
179
|
+
});
|
|
180
|
+
const nextActions = [];
|
|
181
|
+
if (result.alreadyInitialized) {
|
|
182
|
+
nextActions.push('openspec/ already exists; no files were created or modified.');
|
|
183
|
+
if (result.existingFiles.length > 0) {
|
|
184
|
+
nextActions.push(`Existing files preserved: ${result.existingFiles.join(', ')}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if (!result.apply) {
|
|
188
|
+
nextActions.push('Re-run with --apply to write the planned files to disk.');
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
nextActions.push('Run `peaks openspec render --request <path> --apply` to scaffold a first change proposal.');
|
|
192
|
+
}
|
|
193
|
+
printResult(io, ok('openspec.init', result, [], nextActions), options.json);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
printResult(io, fail('openspec.init', 'OPENSPEC_INIT_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json);
|
|
197
|
+
process.exitCode = 1;
|
|
198
|
+
}
|
|
199
|
+
});
|
|
169
200
|
}
|
|
@@ -1,16 +1,51 @@
|
|
|
1
|
-
import { initWorkspace, InvalidSessionIdError } from '../../services/workspace/workspace-service.js';
|
|
1
|
+
import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
|
|
2
|
+
import { ensureSession } from '../../services/session/session-manager.js';
|
|
3
|
+
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
2
4
|
import { fail, ok } from '../../shared/result.js';
|
|
3
5
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
4
6
|
export function registerWorkspaceCommands(program, io) {
|
|
5
7
|
const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
|
|
6
8
|
addJsonOption(workspace
|
|
7
9
|
.command('init')
|
|
8
|
-
.description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system)
|
|
10
|
+
.description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) and bind the session as the project current one. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present).')
|
|
9
11
|
.requiredOption('--project <path>', 'target project root')
|
|
10
|
-
.
|
|
12
|
+
.option('--session-id <id>', 'optional session id in YYYY-MM-DD-<kebab-slug> format. When omitted, the CLI is the single source of truth: an existing binding is reused, otherwise a fresh id is auto-generated.')
|
|
13
|
+
.option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)).action(async (options) => {
|
|
11
14
|
try {
|
|
12
|
-
|
|
15
|
+
// Resolve the session id. Two paths:
|
|
16
|
+
// - explicit --session-id: use it as the requested binding target
|
|
17
|
+
// (ConflictingSessionError fires if it conflicts with an in-flight
|
|
18
|
+
// session, unless --allow-session-rebind is set)
|
|
19
|
+
// - omitted: defer to ensureSession(), which reuses an existing
|
|
20
|
+
// binding or auto-generates a fresh one. The init then writes
|
|
21
|
+
// .session.json so the binding sticks.
|
|
22
|
+
//
|
|
23
|
+
// Before that: canonicalise the project root. If the user (or the
|
|
24
|
+
// LLM via "$(pwd)") passed a sub-directory of a real git repo
|
|
25
|
+
// (e.g. prompt-project/prompt-project/ inside the outer
|
|
26
|
+
// prompt-project/.git), promote the path to the git root. Without
|
|
27
|
+
// this, peaks would build a parallel .peaks/ tree under the
|
|
28
|
+
// nested sub-folder and silently break the project-binding model
|
|
29
|
+
// (the same regression that produced prompt-project/.peaks/ in
|
|
30
|
+
// the 5/27-5/29 sessions). When startPath is not inside any
|
|
31
|
+
// git repo, the helper falls through to the cwd verbatim.
|
|
32
|
+
const projectRoot = resolveCanonicalProjectRoot(options.project);
|
|
33
|
+
let sessionId;
|
|
34
|
+
if (options.sessionId !== undefined && options.sessionId.length > 0) {
|
|
35
|
+
sessionId = options.sessionId;
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
sessionId = await ensureSession(projectRoot);
|
|
39
|
+
}
|
|
40
|
+
const report = await initWorkspace({
|
|
41
|
+
projectRoot,
|
|
42
|
+
sessionId,
|
|
43
|
+
allowSessionRebind: options.allowSessionRebind === true
|
|
44
|
+
});
|
|
13
45
|
const nextActions = [];
|
|
46
|
+
if (report.previousSessionId !== null && report.bound) {
|
|
47
|
+
nextActions.push(`Replaced prior session binding "${report.previousSessionId}" with "${report.sessionId}".`);
|
|
48
|
+
}
|
|
14
49
|
if (report.created.length === 0) {
|
|
15
50
|
nextActions.push('Workspace already initialized — proceed to project scan.');
|
|
16
51
|
}
|
|
@@ -25,6 +60,17 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
25
60
|
process.exitCode = 1;
|
|
26
61
|
return;
|
|
27
62
|
}
|
|
63
|
+
if (error instanceof ConflictingSessionError) {
|
|
64
|
+
printResult(io, fail('workspace.init', error.code, error.message, {
|
|
65
|
+
existingSessionId: error.existingSessionId,
|
|
66
|
+
requestedSessionId: error.requestedSessionId
|
|
67
|
+
}, [
|
|
68
|
+
`Finish or abandon session "${error.existingSessionId}" first, then re-run workspace init.`,
|
|
69
|
+
'Or pass --allow-session-rebind to override the binding (overwrites the prior binding).'
|
|
70
|
+
]), options.json);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
28
74
|
printResult(io, fail('workspace.init', 'WORKSPACE_INIT_FAILED', getErrorMessage(error), { projectRoot: options.project, sessionId: options.sessionId }, ['Verify the project path exists and is writable']), options.json);
|
|
29
75
|
process.exitCode = 1;
|
|
30
76
|
}
|
|
@@ -2,6 +2,32 @@ export declare function getUserConfigPath(): string;
|
|
|
2
2
|
export declare function isInsidePath(childPath: string, parentPath: string): boolean;
|
|
3
3
|
export declare function findProjectRoot(startPath: string): string | null;
|
|
4
4
|
export declare function resolveProjectRootForConfig(startPath: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Canonicalise a user-supplied project root path against git's view of the
|
|
7
|
+
* repository root. This is the fix for the nested-directory regression
|
|
8
|
+
* where peaks-cli would write `.peaks/` under a nested sub-folder
|
|
9
|
+
* (e.g. `prompt-project/prompt-project/.peaks/`) because the LLM passed
|
|
10
|
+
* `$(pwd)` from inside a sub-directory of a real git repo. Without
|
|
11
|
+
* canonicalisation, peaks accepted the cwd as-is, built the .peaks/
|
|
12
|
+
* tree there, and left the team with two parallel state stores.
|
|
13
|
+
*
|
|
14
|
+
* Strategy:
|
|
15
|
+
* 1. If `startPath` (or any ancestor) is inside a git repo, return
|
|
16
|
+
* `git rev-parse --show-toplevel` from `startPath`. The git root
|
|
17
|
+
* is the *only* correct answer for "where does the .peaks/ tree
|
|
18
|
+
* belong?" — sub-folders of a git repo are not their own projects.
|
|
19
|
+
* 2. If `startPath` is not inside a git repo, fall back to
|
|
20
|
+
* `findProjectRoot` (the existing heuristic) so the CLI still
|
|
21
|
+
* works for non-git projects.
|
|
22
|
+
* 3. If both fail, return `startPath` unchanged — better to write
|
|
23
|
+
* to the cwd than to refuse the command.
|
|
24
|
+
*
|
|
25
|
+
* This is intentionally fail-open: it only *promotes* a path towards
|
|
26
|
+
* the git root, it never demotes one. A non-git user is unaffected.
|
|
27
|
+
* The function does NOT throw on a missing git binary or a non-zero
|
|
28
|
+
* `git rev-parse` exit; both fall through to the heuristic.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveCanonicalProjectRoot(startPath: string): string;
|
|
5
31
|
export declare function getProjectConfigPath(projectRoot: string | null): string | null;
|
|
6
32
|
export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
|
|
7
33
|
export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { closeSync, constants, existsSync, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
4
5
|
import { homedir } from 'node:os';
|
|
@@ -89,6 +90,81 @@ export function resolveProjectRootForConfig(startPath) {
|
|
|
89
90
|
}
|
|
90
91
|
return pkgRoot ?? start;
|
|
91
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Canonicalise a user-supplied project root path against git's view of the
|
|
95
|
+
* repository root. This is the fix for the nested-directory regression
|
|
96
|
+
* where peaks-cli would write `.peaks/` under a nested sub-folder
|
|
97
|
+
* (e.g. `prompt-project/prompt-project/.peaks/`) because the LLM passed
|
|
98
|
+
* `$(pwd)` from inside a sub-directory of a real git repo. Without
|
|
99
|
+
* canonicalisation, peaks accepted the cwd as-is, built the .peaks/
|
|
100
|
+
* tree there, and left the team with two parallel state stores.
|
|
101
|
+
*
|
|
102
|
+
* Strategy:
|
|
103
|
+
* 1. If `startPath` (or any ancestor) is inside a git repo, return
|
|
104
|
+
* `git rev-parse --show-toplevel` from `startPath`. The git root
|
|
105
|
+
* is the *only* correct answer for "where does the .peaks/ tree
|
|
106
|
+
* belong?" — sub-folders of a git repo are not their own projects.
|
|
107
|
+
* 2. If `startPath` is not inside a git repo, fall back to
|
|
108
|
+
* `findProjectRoot` (the existing heuristic) so the CLI still
|
|
109
|
+
* works for non-git projects.
|
|
110
|
+
* 3. If both fail, return `startPath` unchanged — better to write
|
|
111
|
+
* to the cwd than to refuse the command.
|
|
112
|
+
*
|
|
113
|
+
* This is intentionally fail-open: it only *promotes* a path towards
|
|
114
|
+
* the git root, it never demotes one. A non-git user is unaffected.
|
|
115
|
+
* The function does NOT throw on a missing git binary or a non-zero
|
|
116
|
+
* `git rev-parse` exit; both fall through to the heuristic.
|
|
117
|
+
*/
|
|
118
|
+
export function resolveCanonicalProjectRoot(startPath) {
|
|
119
|
+
const start = resolve(startPath);
|
|
120
|
+
const gitRoot = resolveProjectRootFromGit(start);
|
|
121
|
+
if (gitRoot !== null) {
|
|
122
|
+
return gitRoot;
|
|
123
|
+
}
|
|
124
|
+
// Non-git fallback: walk the heuristic up to the home boundary.
|
|
125
|
+
// We do NOT call realpathSync on the heuristic result because the
|
|
126
|
+
// heuristic may legitimately return a path through a symlink that
|
|
127
|
+
// the caller passed in (no canonicalisation needed in that case).
|
|
128
|
+
const heuristicRoot = findProjectRoot(start);
|
|
129
|
+
if (heuristicRoot !== null) {
|
|
130
|
+
return heuristicRoot;
|
|
131
|
+
}
|
|
132
|
+
return start;
|
|
133
|
+
}
|
|
134
|
+
function resolveProjectRootFromGit(startPath) {
|
|
135
|
+
// execFileSync (not execSync) so a malicious `startPath` cannot
|
|
136
|
+
// inject argv into the spawned git invocation. The child only
|
|
137
|
+
// receives `startPath` as the cwd, never as a flag.
|
|
138
|
+
let rawRoot;
|
|
139
|
+
try {
|
|
140
|
+
const stdout = execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
|
141
|
+
cwd: startPath,
|
|
142
|
+
encoding: 'utf8',
|
|
143
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
144
|
+
});
|
|
145
|
+
const trimmed = stdout.trim();
|
|
146
|
+
if (trimmed.length === 0)
|
|
147
|
+
return null;
|
|
148
|
+
rawRoot = resolve(trimmed);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// git not on PATH, startPath is not in a repo, or some other
|
|
152
|
+
// benign failure — fall through to the heuristic.
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
// On macOS, /tmp is a symlink to /private/tmp; git returns the
|
|
156
|
+
// realpath. If the caller passed a path through the symlink, the
|
|
157
|
+
// two strings won't match byte-for-byte even though they refer
|
|
158
|
+
// to the same directory. realpathSync the git root and the
|
|
159
|
+
// startPath through the same lens so callers get a canonical
|
|
160
|
+
// answer that compares equal to the path they passed in.
|
|
161
|
+
try {
|
|
162
|
+
return realpathSync(rawRoot);
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
return rawRoot;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
92
168
|
export function getProjectConfigPath(projectRoot) {
|
|
93
169
|
if (!projectRoot)
|
|
94
170
|
return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ConfigGetOptions, ConfigLayer, ConfigSetOptions, MiniMaxProviderConfig, PeaksConfig, TokenRef, WorkspaceConfig } from './config-types.js';
|
|
2
|
-
export { resolveProjectRootForConfig } from './config-safety.js';
|
|
2
|
+
export { resolveProjectRootForConfig, resolveCanonicalProjectRoot } from './config-safety.js';
|
|
3
3
|
export declare function isConfigLayer(value: string): value is ConfigLayer;
|
|
4
4
|
export declare function isSensitiveConfigPath(path: string): boolean;
|
|
5
5
|
export declare function containsSensitiveConfigValue(value: unknown): boolean;
|
|
@@ -3,8 +3,8 @@ import { dirname, isAbsolute, resolve } from 'node:path';
|
|
|
3
3
|
import { DEFAULT_CONFIG } from './config-types.js';
|
|
4
4
|
import { stablePath } from '../../shared/path-utils.js';
|
|
5
5
|
import { findProjectRoot, getProjectBootstrapConfigPath, getProjectConfigPath, getUserConfigPath, isInsidePath, readConfigFileSafely, resolveProjectRootForConfig, validateArtifactWorkspaceMarkerPath, validateArtifactWorkspaceRoot, validateProjectBootstrapConfigPathForWrite, validateUserConfigPathForWrite, writeConfigFileSafely, writeProjectConfigFile, writeUserConfigFile } from './config-safety.js';
|
|
6
|
-
// Re-export resolveProjectRootForConfig for external consumers
|
|
7
|
-
export { resolveProjectRootForConfig } from './config-safety.js';
|
|
6
|
+
// Re-export resolveProjectRootForConfig and resolveCanonicalProjectRoot for external consumers
|
|
7
|
+
export { resolveProjectRootForConfig, resolveCanonicalProjectRoot } from './config-safety.js';
|
|
8
8
|
function readJsonFile(path, validateBeforeRead, errorMessage = 'Config path must stay inside the config root') {
|
|
9
9
|
if (!path || !existsSync(path))
|
|
10
10
|
return null;
|
|
@@ -123,5 +123,23 @@ export declare function createProjectMemoryBackupPlan(options: BackupPlanOptions
|
|
|
123
123
|
export declare function executeProjectMemoryBackup(options: BackupPlanOptions): ProjectMemoryBackupResult;
|
|
124
124
|
export declare function summarizeProjectMemoryExtractResult(result: ProjectMemoryExtractResult): ProjectMemoryExtractSummary;
|
|
125
125
|
export declare function summarizeProjectMemoryBackupResult(result: ProjectMemoryBackupResult): ProjectMemoryBackupSummary;
|
|
126
|
+
/**
|
|
127
|
+
* Ensure `.peaks/memory/` and its `index.json` exist for a project, with
|
|
128
|
+
* the same full-shape empty index the generator emits when there are zero
|
|
129
|
+
* memories. Idempotent — safe to call on every skill activation.
|
|
130
|
+
*
|
|
131
|
+
* Why this exists: before this helper, `.peaks/memory/` was only created
|
|
132
|
+
* by `extractSessionMemories` when at least one memory markdown was being
|
|
133
|
+
* written, and `index.json` was only emitted by the generator when at
|
|
134
|
+
* least one markdown was on disk. Stock projects therefore had no
|
|
135
|
+
* `.peaks/memory/` directory and no index, even after `peaks project
|
|
136
|
+
* memories` was read. Bootstrap closes that cold-start gap.
|
|
137
|
+
*
|
|
138
|
+
* This function is fail-open for the same reason the rest of the
|
|
139
|
+
* presence layer is fail-open: a failure here must NOT block skill
|
|
140
|
+
* activation. Any error is swallowed and surfaced only via the returned
|
|
141
|
+
* boolean. Callers that need the truth should check the result.
|
|
142
|
+
*/
|
|
143
|
+
export declare function ensureMemoryBootstrap(projectRoot: string): boolean;
|
|
126
144
|
export declare function readProjectMemories(projectRoot: string): ProjectMemoryReadResult;
|
|
127
145
|
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { closeSync, constants, copyFileSync, existsSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
2
|
+
import { dirname, basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { isInsidePath, isWindowsAbsolutePath, normalizePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
|
|
4
4
|
import { containsSensitiveConfigValue, isSensitiveConfigPath } from '../config/config-service.js';
|
|
5
5
|
// Hot kinds: full body kept in index for always-available context
|
|
@@ -270,11 +270,26 @@ function readMemoryFileMtime(filePath) {
|
|
|
270
270
|
}
|
|
271
271
|
}
|
|
272
272
|
function readStoredMemoryNames(memoryDir) {
|
|
273
|
+
// Two source-of-truth fallbacks for the slug-collision check:
|
|
274
|
+
// 1. Parse frontmatter (the canonical form rendered by
|
|
275
|
+
// renderMemoryFile / written by both extract paths).
|
|
276
|
+
// 2. Fall back to the bare filename stem, so user-dropped files
|
|
277
|
+
// without frontmatter (e.g. hand-written memories, legacy
|
|
278
|
+
// content) still count as a collision and are not overwritten
|
|
279
|
+
// by an idempotent re-extract.
|
|
273
280
|
const names = new Set();
|
|
274
281
|
for (const filePath of listMarkdownFiles(memoryDir)) {
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
names.add(
|
|
282
|
+
const stem = basename(filePath, '.md');
|
|
283
|
+
if (stem.length > 0 && stem !== 'index')
|
|
284
|
+
names.add(stem);
|
|
285
|
+
try {
|
|
286
|
+
const parsed = parseStoredMemoryFile(readFileSync(filePath, 'utf8'), filePath);
|
|
287
|
+
if (parsed)
|
|
288
|
+
names.add(parsed.name);
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
// ignore unreadable files
|
|
292
|
+
}
|
|
278
293
|
}
|
|
279
294
|
return names;
|
|
280
295
|
}
|
|
@@ -339,15 +354,30 @@ export function readMemoryIndex(projectRoot) {
|
|
|
339
354
|
const normalizedRoot = normalizeRoot(projectRoot);
|
|
340
355
|
const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
|
|
341
356
|
const indexPath = join(memoryDir, 'index.json');
|
|
342
|
-
if
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
357
|
+
// Read-side bootstrap: if the memory dir is missing entirely, build a full
|
|
358
|
+
// empty index so downstream readers always see a well-formed result. We
|
|
359
|
+
// also fall through if the dir is present but the index is missing — the
|
|
360
|
+
// user may have nuked the index file, or never had one because no
|
|
361
|
+
// memory has ever been extracted in this project.
|
|
362
|
+
if (!existsSync(memoryDir)) {
|
|
363
|
+
ensureMemoryBootstrap(normalizedRoot);
|
|
364
|
+
return readExistingIndex(indexPath);
|
|
365
|
+
}
|
|
366
|
+
if (!existsSync(indexPath)) {
|
|
367
|
+
try {
|
|
368
|
+
writeFileSync(indexPath, renderEmptyIndex(), { mode: 0o644 });
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// fall through — readExistingIndex will return null
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const files = listMarkdownFiles(memoryDir);
|
|
375
|
+
if (files.length > 0) {
|
|
376
|
+
try {
|
|
377
|
+
generateMemoryIndexFile(normalizedRoot, memoryDir, indexPath);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// fall through to read existing
|
|
351
381
|
}
|
|
352
382
|
}
|
|
353
383
|
return readExistingIndex(indexPath);
|
|
@@ -537,7 +567,18 @@ export function executeProjectMemoryExtract(options) {
|
|
|
537
567
|
if (plan.apply) {
|
|
538
568
|
mkdirSync(plan.primaryMemoryDir, { recursive: true });
|
|
539
569
|
const safeMemoryDir = assertSafeProjectMemoryDir(plan.projectRoot);
|
|
570
|
+
// Idempotency: skip writes for memories whose slug already lives in
|
|
571
|
+
// .peaks/memory/. Re-running `peaks memory extract --apply` on the
|
|
572
|
+
// same handoff is a normal peaks-solo / peaks-txt retry pattern (the
|
|
573
|
+
// skill prompt may invoke extract more than once when a handoff is
|
|
574
|
+
// edited and re-extracted). Without this, writeNewFile's O_EXCL
|
|
575
|
+
// throws EEXIST and aborts the whole batch. Symmetric with
|
|
576
|
+
// extractSessionMemories (line ~614) which does the same skip.
|
|
577
|
+
const existingNames = readStoredMemoryNames(plan.primaryMemoryDir);
|
|
540
578
|
for (const write of plan.plannedWrites) {
|
|
579
|
+
const slug = slugify(write.memory.title);
|
|
580
|
+
if (existingNames.has(slug))
|
|
581
|
+
continue;
|
|
541
582
|
const targetPath = resolveInputPath(write.filePath);
|
|
542
583
|
const stableTargetPath = stablePath(targetPath);
|
|
543
584
|
if (!isInsidePath(stableTargetPath, stableRealPath(safeMemoryDir))) {
|
|
@@ -546,6 +587,18 @@ export function executeProjectMemoryExtract(options) {
|
|
|
546
587
|
writeNewFile(targetPath, write.content);
|
|
547
588
|
writtenFiles.push(targetPath);
|
|
548
589
|
}
|
|
590
|
+
// After writing any markdown, regenerate the index so downstream
|
|
591
|
+
// readers (peaks project memory-index, peaks-txt re-runs, the next
|
|
592
|
+
// session's presence-set bootstrap) see the new memory. Without
|
|
593
|
+
// this, `peaks memory extract --apply` would leave the index stale
|
|
594
|
+
// and `readMemoryIndex` would either return the empty bootstrap or
|
|
595
|
+
// — pre-bootstrap-fix — return null. Symmetric with
|
|
596
|
+
// extractSessionMemories, which already regenerates the index on
|
|
597
|
+
// apply (see line ~626). We regen whenever --apply is set, even
|
|
598
|
+
// if every write was skipped by idempotency, so the index is
|
|
599
|
+
// always rebuilt against the current .peaks/memory/ directory.
|
|
600
|
+
const indexPath = join(plan.primaryMemoryDir, 'index.json');
|
|
601
|
+
generateMemoryIndexFile(plan.projectRoot, plan.primaryMemoryDir, indexPath);
|
|
549
602
|
}
|
|
550
603
|
return { ...plan, writtenFiles };
|
|
551
604
|
}
|
|
@@ -609,9 +662,74 @@ function emptyByKind() {
|
|
|
609
662
|
module: []
|
|
610
663
|
};
|
|
611
664
|
}
|
|
665
|
+
function emptyIndex() {
|
|
666
|
+
// Cast through unknown: we *intend* the two halves to together cover the
|
|
667
|
+
// union `ProjectMemoryKind`, but TS does not know that. The `MemoryIndex`
|
|
668
|
+
// type's `hot` / `warm` fields together cover the union; we split the
|
|
669
|
+
// construction so the JSON output mirrors the hot/warm layout the reader
|
|
670
|
+
// expects.
|
|
671
|
+
return {
|
|
672
|
+
version: 1,
|
|
673
|
+
updatedAt: new Date().toISOString(),
|
|
674
|
+
hot: {
|
|
675
|
+
feedback: [],
|
|
676
|
+
decision: [],
|
|
677
|
+
rule: [],
|
|
678
|
+
convention: [],
|
|
679
|
+
module: []
|
|
680
|
+
},
|
|
681
|
+
warm: {
|
|
682
|
+
project: [],
|
|
683
|
+
reference: []
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
function renderEmptyIndex() {
|
|
688
|
+
return JSON.stringify(emptyIndex(), null, 2) + '\n';
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Ensure `.peaks/memory/` and its `index.json` exist for a project, with
|
|
692
|
+
* the same full-shape empty index the generator emits when there are zero
|
|
693
|
+
* memories. Idempotent — safe to call on every skill activation.
|
|
694
|
+
*
|
|
695
|
+
* Why this exists: before this helper, `.peaks/memory/` was only created
|
|
696
|
+
* by `extractSessionMemories` when at least one memory markdown was being
|
|
697
|
+
* written, and `index.json` was only emitted by the generator when at
|
|
698
|
+
* least one markdown was on disk. Stock projects therefore had no
|
|
699
|
+
* `.peaks/memory/` directory and no index, even after `peaks project
|
|
700
|
+
* memories` was read. Bootstrap closes that cold-start gap.
|
|
701
|
+
*
|
|
702
|
+
* This function is fail-open for the same reason the rest of the
|
|
703
|
+
* presence layer is fail-open: a failure here must NOT block skill
|
|
704
|
+
* activation. Any error is swallowed and surfaced only via the returned
|
|
705
|
+
* boolean. Callers that need the truth should check the result.
|
|
706
|
+
*/
|
|
707
|
+
export function ensureMemoryBootstrap(projectRoot) {
|
|
708
|
+
try {
|
|
709
|
+
const normalizedRoot = normalizeRoot(projectRoot);
|
|
710
|
+
const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
|
|
711
|
+
const indexPath = join(memoryDir, 'index.json');
|
|
712
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
713
|
+
if (!existsSync(indexPath)) {
|
|
714
|
+
writeFileSync(indexPath, renderEmptyIndex(), { mode: 0o644 });
|
|
715
|
+
}
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}
|
|
612
722
|
export function readProjectMemories(projectRoot) {
|
|
613
723
|
const normalizedRoot = normalizeRoot(projectRoot);
|
|
614
724
|
const memoryDir = assertSafeProjectMemoryDir(normalizedRoot);
|
|
725
|
+
// Read-side bootstrap: on a stock project the directory does not exist
|
|
726
|
+
// yet. Reading must not return an error, but we also want the directory
|
|
727
|
+
// to materialise (along with a full-shape empty index) so subsequent
|
|
728
|
+
// `peaks project memories` invocations, `readMemoryIndex`, and any
|
|
729
|
+
// extraction call find a stable target. The helper is fail-open.
|
|
730
|
+
if (!existsSync(memoryDir)) {
|
|
731
|
+
ensureMemoryBootstrap(normalizedRoot);
|
|
732
|
+
}
|
|
615
733
|
const memories = [];
|
|
616
734
|
for (const filePath of listMarkdownFiles(memoryDir)) {
|
|
617
735
|
const parsed = parseStoredMemoryFile(readFileSync(filePath, 'utf8'), filePath);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type OpenSpecInitOptions = {
|
|
2
|
+
projectRoot: string;
|
|
3
|
+
apply?: boolean;
|
|
4
|
+
};
|
|
5
|
+
export type OpenSpecInitPlan = {
|
|
6
|
+
apply: boolean;
|
|
7
|
+
projectRoot: string;
|
|
8
|
+
openspecRoot: string;
|
|
9
|
+
plannedWrites: Array<{
|
|
10
|
+
path: string;
|
|
11
|
+
kind: 'directory' | 'file';
|
|
12
|
+
bytes: number;
|
|
13
|
+
content: string;
|
|
14
|
+
}>;
|
|
15
|
+
alreadyInitialized: boolean;
|
|
16
|
+
existingFiles: string[];
|
|
17
|
+
};
|
|
18
|
+
export type OpenSpecInitResult = OpenSpecInitPlan & {
|
|
19
|
+
writtenFiles: string[];
|
|
20
|
+
createdDirectories: string[];
|
|
21
|
+
};
|
|
22
|
+
export declare function planOpenSpecInit(options: OpenSpecInitOptions): Promise<OpenSpecInitPlan>;
|
|
23
|
+
export declare function executeOpenSpecInit(options: OpenSpecInitOptions): Promise<OpenSpecInitResult>;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { isDirectory, pathExists } from '../../shared/fs.js';
|
|
4
|
+
const README_BODY = `# OpenSpec — change proposals for this project
|
|
5
|
+
|
|
6
|
+
This directory hosts the \`peaks openspec\` change proposal lifecycle:
|
|
7
|
+
|
|
8
|
+
render → validate → show → to-rd → ... → archive
|
|
9
|
+
|
|
10
|
+
Each in-flight proposal lives in \`changes/<id>/\` and contains:
|
|
11
|
+
|
|
12
|
+
- \`proposal.md\` — why, what, non-goals, impact
|
|
13
|
+
- \`tasks.md\` — concrete slices and commit boundaries (consumed by peaks-sc)
|
|
14
|
+
- \`design.md\` — optional, for non-trivial designs
|
|
15
|
+
- \`specs/\` — optional, delta-style spec changes (## ADDED / ## MODIFIED / ## REMOVED)
|
|
16
|
+
- \`*.md\` — any additional narrative the change needs
|
|
17
|
+
|
|
18
|
+
When a change ships, \`peaks openspec archive <id> --apply\` moves it into
|
|
19
|
+
\`changes/archive/<id>/\`. The archive is the historical record of what
|
|
20
|
+
landed and why.
|
|
21
|
+
|
|
22
|
+
To scaffold a fresh change in this project:
|
|
23
|
+
|
|
24
|
+
peaks openspec render --request <path-to-render-request.json> --apply
|
|
25
|
+
|
|
26
|
+
For the full lifecycle see \`peaks openspec --help\` and the
|
|
27
|
+
peaks-clause-code skill family.
|
|
28
|
+
`;
|
|
29
|
+
const CHANGES_INDEX_HEADER = `# OpenSpec — change log
|
|
30
|
+
|
|
31
|
+
This file is the human-readable index of every change that has shipped in
|
|
32
|
+
this project. New entries are added by \`peaks openspec archive\` (when
|
|
33
|
+
\`.apply\` is used); do not hand-edit.
|
|
34
|
+
|
|
35
|
+
| Date | Change | Status |
|
|
36
|
+
|------|--------|--------|
|
|
37
|
+
`;
|
|
38
|
+
function renderReadme() {
|
|
39
|
+
return README_BODY;
|
|
40
|
+
}
|
|
41
|
+
function renderChangesIndex() {
|
|
42
|
+
return CHANGES_INDEX_HEADER;
|
|
43
|
+
}
|
|
44
|
+
function buildPlan(projectRoot, apply) {
|
|
45
|
+
const openspecRoot = join(projectRoot, 'openspec');
|
|
46
|
+
const changesRoot = join(openspecRoot, 'changes');
|
|
47
|
+
const archiveRoot = join(changesRoot, 'archive');
|
|
48
|
+
const plannedWrites = [
|
|
49
|
+
{ path: openspecRoot, kind: 'directory', bytes: 0, content: '' },
|
|
50
|
+
{ path: changesRoot, kind: 'directory', bytes: 0, content: '' },
|
|
51
|
+
{ path: archiveRoot, kind: 'directory', bytes: 0, content: '' },
|
|
52
|
+
{ path: join(openspecRoot, 'README.md'), kind: 'file', bytes: 0, content: renderReadme() },
|
|
53
|
+
{ path: join(openspecRoot, 'CHANGES.md'), kind: 'file', bytes: 0, content: renderChangesIndex() }
|
|
54
|
+
];
|
|
55
|
+
// Stamp byte counts now that content is finalised.
|
|
56
|
+
for (const write of plannedWrites) {
|
|
57
|
+
if (write.kind === 'file') {
|
|
58
|
+
write.bytes = Buffer.byteLength(write.content, 'utf8');
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
apply,
|
|
63
|
+
projectRoot,
|
|
64
|
+
openspecRoot,
|
|
65
|
+
plannedWrites,
|
|
66
|
+
alreadyInitialized: false,
|
|
67
|
+
existingFiles: []
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
export async function planOpenSpecInit(options) {
|
|
71
|
+
const openspecRoot = join(options.projectRoot, 'openspec');
|
|
72
|
+
const plan = buildPlan(options.projectRoot, options.apply ?? false);
|
|
73
|
+
if (await isDirectory(openspecRoot)) {
|
|
74
|
+
// Already initialised. Report the existing files so the user can audit
|
|
75
|
+
// before re-running with --apply. We never overwrite an existing
|
|
76
|
+
// openspec/ — that is a destructive action and out of scope for init.
|
|
77
|
+
const existing = [];
|
|
78
|
+
for (const write of plan.plannedWrites) {
|
|
79
|
+
if (write.kind === 'file' && (await pathExists(write.path))) {
|
|
80
|
+
existing.push(write.path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// Pre-compute which directory writes to keep (those whose target does
|
|
84
|
+
// not exist yet). .filter cannot be async, so resolve the boolean
|
|
85
|
+
// set up front.
|
|
86
|
+
const directoryKeep = new Set();
|
|
87
|
+
for (const write of plan.plannedWrites) {
|
|
88
|
+
if (write.kind === 'directory' && !(await isDirectory(write.path))) {
|
|
89
|
+
directoryKeep.add(write.path);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
plan.plannedWrites = plan.plannedWrites.filter((write) => {
|
|
93
|
+
if (write.kind === 'directory')
|
|
94
|
+
return directoryKeep.has(write.path);
|
|
95
|
+
return !existing.includes(write.path);
|
|
96
|
+
});
|
|
97
|
+
plan.alreadyInitialized = existing.length > 0 || (await isDirectory(join(openspecRoot, 'changes')));
|
|
98
|
+
plan.existingFiles = existing;
|
|
99
|
+
}
|
|
100
|
+
return plan;
|
|
101
|
+
}
|
|
102
|
+
export async function executeOpenSpecInit(options) {
|
|
103
|
+
const plan = await planOpenSpecInit(options);
|
|
104
|
+
const writtenFiles = [];
|
|
105
|
+
const createdDirectories = [];
|
|
106
|
+
if (plan.apply && !plan.alreadyInitialized) {
|
|
107
|
+
for (const write of plan.plannedWrites) {
|
|
108
|
+
if (write.kind === 'directory') {
|
|
109
|
+
if (!(await isDirectory(write.path))) {
|
|
110
|
+
await mkdir(write.path, { recursive: true });
|
|
111
|
+
createdDirectories.push(write.path);
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (write.content.length === 0)
|
|
116
|
+
continue;
|
|
117
|
+
await writeFile(write.path, write.content, 'utf8');
|
|
118
|
+
writtenFiles.push(write.path);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { ...plan, writtenFiles, createdDirectories };
|
|
122
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, type SessionInfo, type SessionMeta } from './session-manager.js';
|
|
1
|
+
export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding, type SessionInfo, type SessionMeta } from './session-manager.js';
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan } from './session-manager.js';
|
|
1
|
+
export { ensureSession, getSessionId, getCurrentSessionDir, listSessions, getSessionMeta, setSessionMeta, setSessionTitle, listSessionMetas, getProjectScanPath, hasProjectScan, setCurrentSessionBinding } from './session-manager.js';
|
|
@@ -20,6 +20,17 @@ export type SessionMeta = {
|
|
|
20
20
|
lastActivity?: string;
|
|
21
21
|
projectRoot: string;
|
|
22
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Bind the project's current session to the given session id by writing
|
|
25
|
+
* `.peaks/.session.json`. The single-session binding is the source of truth
|
|
26
|
+
* for `ensureSession()` and any other path that needs to discover the
|
|
27
|
+
* active session without an explicit --session-id flag.
|
|
28
|
+
*
|
|
29
|
+
* This does NOT touch the per-session `session.json` inside `.peaks/<id>/`;
|
|
30
|
+
* that file is owned by `setSessionMeta` and records session-scoped
|
|
31
|
+
* metadata (title, skill, mode, gate, etc.).
|
|
32
|
+
*/
|
|
33
|
+
export declare function setCurrentSessionBinding(projectRoot: string, sessionId: string): SessionInfo;
|
|
23
34
|
/**
|
|
24
35
|
* Read metadata for a specific session directory.
|
|
25
36
|
* Returns null if the session directory or its session.json does not exist.
|