peaks-cli 2.0.0 → 2.0.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/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +73 -0
- package/README-en.md +48 -10
- package/README.md +48 -10
- package/dist/src/cli/commands/capability-commands.js +2 -1
- package/dist/src/cli/commands/workspace-commands.js +31 -2
- package/dist/src/lib/render/message-renderer.d.ts +20 -0
- package/dist/src/lib/render/message-renderer.js +80 -0
- package/dist/src/services/config/config-migration.js +21 -2
- package/dist/src/services/config/config-service.d.ts +1 -0
- package/dist/src/services/config/config-service.js +24 -0
- package/dist/src/services/config/config-types.d.ts +15 -0
- package/dist/src/services/config/config-types.js +22 -13
- package/dist/src/services/config/model-routing.js +5 -3
- package/dist/src/services/rd/rd-service.js +29 -1
- package/dist/src/services/skills/sync-service.d.ts +43 -0
- package/dist/src/services/skills/sync-service.js +179 -7
- package/dist/src/services/workflow/workflow-router-service.js +15 -4
- package/dist/src/services/workspace/claude-settings-template.d.ts +53 -0
- package/dist/src/services/workspace/claude-settings-template.js +133 -0
- package/dist/src/services/workspace/workspace-service.d.ts +24 -0
- package/dist/src/services/workspace/workspace-service.js +124 -2
- 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-solo/SKILL.md +6 -0
- package/skills/peaks-solo/references/anchoring-and-session-info.md +9 -0
|
@@ -35,9 +35,52 @@ export interface SyncServiceResult {
|
|
|
35
35
|
readonly failedCount: number;
|
|
36
36
|
readonly totalInstalled: number;
|
|
37
37
|
}
|
|
38
|
+
interface InstallBundledSkillsOptions {
|
|
39
|
+
readonly ideId: IdeId;
|
|
40
|
+
readonly projectRoot: string;
|
|
41
|
+
readonly dryRun?: boolean;
|
|
42
|
+
readonly targetRoot?: string;
|
|
43
|
+
}
|
|
44
|
+
interface InstallResult {
|
|
45
|
+
readonly installed: readonly string[];
|
|
46
|
+
readonly skipped: readonly string[];
|
|
47
|
+
}
|
|
48
|
+
type InstallerFn = (opts: InstallBundledSkillsOptions) => InstallResult;
|
|
49
|
+
/**
|
|
50
|
+
* Resolve the path of `install-skills.mjs` inside the peaks-cli
|
|
51
|
+
* install root, walking up from `import.meta.url` until a
|
|
52
|
+
* `package.json` with `"name": "peaks-cli"` is found. Returns
|
|
53
|
+
* `null` when peaks-cli is not on the import path or the script
|
|
54
|
+
* is absent (e.g. a partial install).
|
|
55
|
+
*/
|
|
56
|
+
export declare function resolvePeaksCliInstallerPath(): string | null;
|
|
57
|
+
/**
|
|
58
|
+
* Test seam: attempt to import the installer at `scriptPath`.
|
|
59
|
+
* Returns the `installBundledSkills` function on success, or
|
|
60
|
+
* `null` when the file is missing / not importable. The
|
|
61
|
+
* production code calls this through `loadInstaller`; tests
|
|
62
|
+
* `vi.spyOn` it to drive the three-tier probe without touching
|
|
63
|
+
* the real filesystem.
|
|
64
|
+
*/
|
|
65
|
+
export declare function loadInstallerForTest(scriptPath: string): Promise<InstallerFn | null>;
|
|
38
66
|
/**
|
|
39
67
|
* Validate a single platform id against the SYNC_PLATFORMS
|
|
40
68
|
* allowlist. Throws on a bogus value.
|
|
41
69
|
*/
|
|
42
70
|
export declare function assertValidPlatform(platform: string): asserts platform is IdeId;
|
|
43
71
|
export declare function runSkillSync(input: SyncServiceInput): Promise<SyncServiceResult>;
|
|
72
|
+
/**
|
|
73
|
+
* Test-only export surface. Not part of the public API; subject
|
|
74
|
+
* to breaking changes without a major version bump.
|
|
75
|
+
*
|
|
76
|
+
* The seam exposes the `services` indirection table (so tests
|
|
77
|
+
* can `vi.spyOn` the resolver and loader) and a cache reset.
|
|
78
|
+
*/
|
|
79
|
+
export declare const __testing: {
|
|
80
|
+
services: {
|
|
81
|
+
resolvePeaksCliInstallerPath(): string | null;
|
|
82
|
+
loadInstallerForTest(scriptPath: string): Promise<InstallerFn | null>;
|
|
83
|
+
};
|
|
84
|
+
resetInstallerCache(): void;
|
|
85
|
+
};
|
|
86
|
+
export {};
|
|
@@ -8,9 +8,24 @@
|
|
|
8
8
|
* profile is `IdeSkillInstall`; the actual symlink installer
|
|
9
9
|
* is `scripts/install-skills.mjs::installBundledSkills` (dynamically
|
|
10
10
|
* imported so this module does not require a build step).
|
|
11
|
+
*
|
|
12
|
+
* Slice 2.0.1-bug2-skill-sync-fallback: when peaks-cli is
|
|
13
|
+
* installed from npm into a consumer project, that consumer's
|
|
14
|
+
* CWD does not contain `scripts/install-skills.mjs`. The previous
|
|
15
|
+
* hard-coded `join(process.cwd(), 'scripts', 'install-skills.mjs')`
|
|
16
|
+
* therefore threw `ERR_MODULE_NOT_FOUND` in every consumer run.
|
|
17
|
+
* The fix is a three-tier probe:
|
|
18
|
+
* 1. peaks-cli's own install path (resolved from
|
|
19
|
+
* `import.meta.url` walking up to the package root, or
|
|
20
|
+
* from `process.argv[1]` for CJS-equivalent entrypoints),
|
|
21
|
+
* 2. the consumer CWD (`<cwd>/scripts/install-skills.mjs`),
|
|
22
|
+
* 3. graceful skip — warn once per process, return a no-op
|
|
23
|
+
* installer so the per-platform result is `ok: true` with
|
|
24
|
+
* `installed: []` and a `skipped` rationale.
|
|
11
25
|
*/
|
|
12
|
-
import {
|
|
13
|
-
import { join } from 'node:path';
|
|
26
|
+
import { existsSync } from 'node:fs';
|
|
27
|
+
import { dirname, join, resolve as resolvePath } from 'node:path';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
14
29
|
/**
|
|
15
30
|
* The 8 platforms per Slice #12 final piece. Slice #0.7 + Slice
|
|
16
31
|
* #0.5.2 registered these in the IdeId union; this list is the
|
|
@@ -26,14 +41,158 @@ export const SYNC_PLATFORMS = [
|
|
|
26
41
|
'hermes',
|
|
27
42
|
'openclaw',
|
|
28
43
|
];
|
|
44
|
+
/**
|
|
45
|
+
* Sentinel: the resolver ran, found no installer, and warned.
|
|
46
|
+
* Memoized so subsequent `loadInstaller()` calls short-circuit
|
|
47
|
+
* without re-walking the filesystem on every platform iteration.
|
|
48
|
+
*/
|
|
49
|
+
const NO_INSTALLER_SENTINEL = Symbol('sync-service.no-installer');
|
|
50
|
+
/**
|
|
51
|
+
* Cache state: either an installer function, the "not found"
|
|
52
|
+
* sentinel, or `null` (cache cold, first probe still pending).
|
|
53
|
+
*/
|
|
29
54
|
let cachedInstaller = null;
|
|
55
|
+
/**
|
|
56
|
+
* No-op installer used when neither candidate path resolves to
|
|
57
|
+
* an importable `install-skills.mjs`. Reports zero installs and
|
|
58
|
+
* a single skip reason so the per-platform result is `ok: true`
|
|
59
|
+
* with an explainable `skipped` line.
|
|
60
|
+
*/
|
|
61
|
+
function noopInstaller(_opts) {
|
|
62
|
+
return {
|
|
63
|
+
installed: [],
|
|
64
|
+
skipped: [
|
|
65
|
+
'install-skills.mjs not found in project; skill sync skipped — bundled skills are installed via peaks-cli postinstall',
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Internal indirection table for the test seam. The production
|
|
71
|
+
* `loadInstaller` reads `services.resolvePeaksCliInstallerPath()`
|
|
72
|
+
* and `services.loadInstallerForTest()` at call time, so a
|
|
73
|
+
* `vi.spyOn(services, 'resolvePeaksCliInstallerPath')` in tests
|
|
74
|
+
* takes effect (ES module top-level `const` captures the original
|
|
75
|
+
* reference and would bypass the spy).
|
|
76
|
+
*/
|
|
77
|
+
const services = {
|
|
78
|
+
resolvePeaksCliInstallerPath,
|
|
79
|
+
loadInstallerForTest,
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* Resolve the path of `install-skills.mjs` inside the peaks-cli
|
|
83
|
+
* install root, walking up from `import.meta.url` until a
|
|
84
|
+
* `package.json` with `"name": "peaks-cli"` is found. Returns
|
|
85
|
+
* `null` when peaks-cli is not on the import path or the script
|
|
86
|
+
* is absent (e.g. a partial install).
|
|
87
|
+
*/
|
|
88
|
+
export function resolvePeaksCliInstallerPath() {
|
|
89
|
+
const candidates = [];
|
|
90
|
+
// Tier 1a: walk up from this module's URL.
|
|
91
|
+
try {
|
|
92
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
93
|
+
let cursor = here;
|
|
94
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
95
|
+
const pkgJson = join(cursor, 'package.json');
|
|
96
|
+
if (existsSync(pkgJson)) {
|
|
97
|
+
candidates.push(join(cursor, 'scripts', 'install-skills.mjs'));
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const parent = dirname(cursor);
|
|
101
|
+
if (parent === cursor)
|
|
102
|
+
break;
|
|
103
|
+
cursor = parent;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// import.meta.url may be unavailable in some bundlers; fall through.
|
|
108
|
+
}
|
|
109
|
+
// Tier 1b: process.argv[1] (the entrypoint). Useful when this
|
|
110
|
+
// module is bundled or shimmed.
|
|
111
|
+
try {
|
|
112
|
+
const argvEntry = process.argv[1];
|
|
113
|
+
if (typeof argvEntry === 'string' && argvEntry.length > 0) {
|
|
114
|
+
let cursor = resolvePath(dirname(argvEntry));
|
|
115
|
+
for (let depth = 0; depth < 8; depth += 1) {
|
|
116
|
+
const pkgJson = join(cursor, 'package.json');
|
|
117
|
+
if (existsSync(pkgJson)) {
|
|
118
|
+
candidates.push(join(cursor, 'scripts', 'install-skills.mjs'));
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
const parent = dirname(cursor);
|
|
122
|
+
if (parent === cursor)
|
|
123
|
+
break;
|
|
124
|
+
cursor = parent;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// process.argv may be unavailable in some runtimes; fall through.
|
|
130
|
+
}
|
|
131
|
+
for (const candidate of candidates) {
|
|
132
|
+
if (existsSync(candidate)) {
|
|
133
|
+
return candidate;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Test seam: attempt to import the installer at `scriptPath`.
|
|
140
|
+
* Returns the `installBundledSkills` function on success, or
|
|
141
|
+
* `null` when the file is missing / not importable. The
|
|
142
|
+
* production code calls this through `loadInstaller`; tests
|
|
143
|
+
* `vi.spyOn` it to drive the three-tier probe without touching
|
|
144
|
+
* the real filesystem.
|
|
145
|
+
*/
|
|
146
|
+
export async function loadInstallerForTest(scriptPath) {
|
|
147
|
+
try {
|
|
148
|
+
const mod = (await import(pathToFileURL(scriptPath).href));
|
|
149
|
+
return mod.installBundledSkills;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolve and load the installer, memoizing the outcome.
|
|
157
|
+
* Three-tier probe (peaks-cli install path → CWD → no-op),
|
|
158
|
+
* with the "not found" outcome memoized as a sentinel so the
|
|
159
|
+
* warning is logged at most once per process.
|
|
160
|
+
*
|
|
161
|
+
* The probe delegates to the `services` indirection table so
|
|
162
|
+
* tests can `vi.spyOn(services, 'resolvePeaksCliInstallerPath')`
|
|
163
|
+
* and have the spied value take effect at runtime (direct
|
|
164
|
+
* module-level calls would bind the original function at load
|
|
165
|
+
* time and bypass the spy).
|
|
166
|
+
*/
|
|
30
167
|
async function loadInstaller() {
|
|
31
|
-
if (cachedInstaller
|
|
168
|
+
if (cachedInstaller === NO_INSTALLER_SENTINEL) {
|
|
169
|
+
return noopInstaller;
|
|
170
|
+
}
|
|
171
|
+
if (cachedInstaller !== null) {
|
|
32
172
|
return cachedInstaller;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
173
|
+
}
|
|
174
|
+
// Tier 1: peaks-cli install path.
|
|
175
|
+
const peaksCliScript = services.resolvePeaksCliInstallerPath();
|
|
176
|
+
if (peaksCliScript !== null) {
|
|
177
|
+
const installer = await services.loadInstallerForTest(peaksCliScript);
|
|
178
|
+
if (installer !== null) {
|
|
179
|
+
cachedInstaller = installer;
|
|
180
|
+
return installer;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// Tier 2: consumer CWD.
|
|
184
|
+
const cwdScript = join(process.cwd(), 'scripts', 'install-skills.mjs');
|
|
185
|
+
const cwdInstaller = await services.loadInstallerForTest(cwdScript);
|
|
186
|
+
if (cwdInstaller !== null) {
|
|
187
|
+
cachedInstaller = cwdInstaller;
|
|
188
|
+
return cwdInstaller;
|
|
189
|
+
}
|
|
190
|
+
// Tier 3: graceful skip. Warn once per process.
|
|
191
|
+
cachedInstaller = NO_INSTALLER_SENTINEL;
|
|
192
|
+
// eslint-disable-next-line no-console -- intentional user-visible signal
|
|
193
|
+
console.warn('peaks skill sync: install-skills.mjs not found in project; ' +
|
|
194
|
+
'skipping (bundled skills come from peaks-cli postinstall).');
|
|
195
|
+
return noopInstaller;
|
|
37
196
|
}
|
|
38
197
|
/**
|
|
39
198
|
* Validate a single platform id against the SYNC_PLATFORMS
|
|
@@ -97,3 +256,16 @@ export async function runSkillSync(input) {
|
|
|
97
256
|
totalInstalled,
|
|
98
257
|
};
|
|
99
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* Test-only export surface. Not part of the public API; subject
|
|
261
|
+
* to breaking changes without a major version bump.
|
|
262
|
+
*
|
|
263
|
+
* The seam exposes the `services` indirection table (so tests
|
|
264
|
+
* can `vi.spyOn` the resolver and loader) and a cache reset.
|
|
265
|
+
*/
|
|
266
|
+
export const __testing = {
|
|
267
|
+
services,
|
|
268
|
+
resetInstallerCache() {
|
|
269
|
+
cachedInstaller = null;
|
|
270
|
+
},
|
|
271
|
+
};
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { DEFAULT_CONFIG } from '../config/config-types.js';
|
|
2
1
|
import { getConfiguredExecutionModelId, STRONGEST_MODEL_ID } from '../config/model-routing.js';
|
|
3
2
|
import { getLocalArtifactPath } from '../artifacts/workspace-service.js';
|
|
4
3
|
import { createRdSwarmPlan } from '../rd/rd-service.js';
|
|
@@ -167,9 +166,21 @@ export function createWorkflowRouterPlan(request) {
|
|
|
167
166
|
validateChangeIdOrThrow(request.changeId);
|
|
168
167
|
const goal = normalizeGoal(request.goal);
|
|
169
168
|
const maxWorkers = request.maxWorkers ?? 40;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
169
|
+
// Slice 2.0.1-bug1 round 3: project policy defaults. The slim 2.0.1 DEFAULT_CONFIG
|
|
170
|
+
// no longer carries economyMode / swarmMode (those moved to per-project preferences),
|
|
171
|
+
// so we cannot fall back to `DEFAULT_CONFIG.economyMode` / `swarmMode` here. Both
|
|
172
|
+
// flags are project-policy opt-outs: the absence of an explicit `false` means
|
|
173
|
+
// "enabled" (matches the pre-2.0.1 implicit default).
|
|
174
|
+
const economyMode = request.config?.economyMode ?? true;
|
|
175
|
+
const swarmMode = request.config?.swarmMode ?? true;
|
|
176
|
+
// Pre-2.0.1 DEFAULT_CONFIG carried an implicit `minimax-2.7` provider
|
|
177
|
+
// for test fixtures that did not pass `config.providers`. The slim
|
|
178
|
+
// DEFAULT_CONFIG removed that field, so we re-supply it here only when
|
|
179
|
+
// the caller did not pass any providers at all. An explicit empty
|
|
180
|
+
// object (`config: { providers: {} }`) still surfaces the "must be
|
|
181
|
+
// configured" error from `getConfiguredExecutionModelId`.
|
|
182
|
+
const effectiveProviders = request.config?.providers ?? { minimax: { model: 'minimax-2.7' } };
|
|
183
|
+
const executionModelId = economyMode !== false ? getConfiguredExecutionModelId(effectiveProviders) : STRONGEST_MODEL_ID;
|
|
173
184
|
const modeStatus = createModeStatus(economyMode, swarmMode, executionModelId, economyMode ? 'config.providers' : 'planner-reviewer-strongest-model');
|
|
174
185
|
const soloMode = getSoloMode(request.mode, request.soloMode);
|
|
175
186
|
const decisionProfile = getDecisionProfileSummary(request.mode, soloMode);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2.0.1-bug3-fact-forcing-bypass — pure-data template for the
|
|
3
|
+
* consumer-project `.claude/settings.local.json` file.
|
|
4
|
+
*
|
|
5
|
+
* The template is a PreToolUse hook allow-list that bypasses the
|
|
6
|
+
* Claude Code [Fact-Forcing Gate] for tool calls whose paths or
|
|
7
|
+
* commands target the peaks-managed `.peaks/` workspace. Without this
|
|
8
|
+
* bypass, `peaks workspace init` (Step 0 of every peaks-solo session)
|
|
9
|
+
* is unrunnable in a consumer project because the gate blocks the
|
|
10
|
+
* very first Write.
|
|
11
|
+
*
|
|
12
|
+
* The template is a pure-data function (no filesystem, no clock) so
|
|
13
|
+
* it can be unit-tested in isolation and so the on-disk file matches
|
|
14
|
+
* the in-memory template byte-for-byte.
|
|
15
|
+
*
|
|
16
|
+
* Two matchers are emitted:
|
|
17
|
+
* 1. `Write|Edit|MultiEdit` — a node one-liner that path-matches
|
|
18
|
+
* `.peaks/_runtime/` and `.peaks/<changeId>/`. Exits 0 (allow)
|
|
19
|
+
* for those paths, non-zero (deny → fall through to gate) for
|
|
20
|
+
* everything else.
|
|
21
|
+
* 2. `Bash` — a node one-liner that allows command strings starting
|
|
22
|
+
* with `peaks ` (whitelisted subcommand prefix). Exits 0 for
|
|
23
|
+
* `peaks <subcommand> ...`, non-zero otherwise.
|
|
24
|
+
*
|
|
25
|
+
* The Bash allow-list is conservative: it whitelists the documented
|
|
26
|
+
* peaks subcommands the skill family invokes during Step 0 (workspace,
|
|
27
|
+
* skill presence, request, session, scan, sub-agent, gate, standards,
|
|
28
|
+
* hooks, statusline). See peaks-solo/references/runbook.md for the
|
|
29
|
+
* canonical list.
|
|
30
|
+
*/
|
|
31
|
+
export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.json";
|
|
32
|
+
type ClaudeHookCommand = {
|
|
33
|
+
type: 'command';
|
|
34
|
+
command: string;
|
|
35
|
+
};
|
|
36
|
+
type ClaudePreToolUseEntry = {
|
|
37
|
+
matcher: string;
|
|
38
|
+
hooks: ClaudeHookCommand[];
|
|
39
|
+
};
|
|
40
|
+
type ClaudeSettingsLocal = {
|
|
41
|
+
hooks: {
|
|
42
|
+
PreToolUse: ClaudePreToolUseEntry[];
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Build the full template object. The shape is the subset of Claude
|
|
47
|
+
* Code's `.claude/settings.local.json` schema that PreToolUse hooks
|
|
48
|
+
* need — we do not emit the `permissions` block because the fact-
|
|
49
|
+
* forcing gate is a core feature that PreToolUse hooks can short-
|
|
50
|
+
* circuit but that the `permissions` block cannot.
|
|
51
|
+
*/
|
|
52
|
+
export declare function buildClaudeSettingsLocalJson(): ClaudeSettingsLocal;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2.0.1-bug3-fact-forcing-bypass — pure-data template for the
|
|
3
|
+
* consumer-project `.claude/settings.local.json` file.
|
|
4
|
+
*
|
|
5
|
+
* The template is a PreToolUse hook allow-list that bypasses the
|
|
6
|
+
* Claude Code [Fact-Forcing Gate] for tool calls whose paths or
|
|
7
|
+
* commands target the peaks-managed `.peaks/` workspace. Without this
|
|
8
|
+
* bypass, `peaks workspace init` (Step 0 of every peaks-solo session)
|
|
9
|
+
* is unrunnable in a consumer project because the gate blocks the
|
|
10
|
+
* very first Write.
|
|
11
|
+
*
|
|
12
|
+
* The template is a pure-data function (no filesystem, no clock) so
|
|
13
|
+
* it can be unit-tested in isolation and so the on-disk file matches
|
|
14
|
+
* the in-memory template byte-for-byte.
|
|
15
|
+
*
|
|
16
|
+
* Two matchers are emitted:
|
|
17
|
+
* 1. `Write|Edit|MultiEdit` — a node one-liner that path-matches
|
|
18
|
+
* `.peaks/_runtime/` and `.peaks/<changeId>/`. Exits 0 (allow)
|
|
19
|
+
* for those paths, non-zero (deny → fall through to gate) for
|
|
20
|
+
* everything else.
|
|
21
|
+
* 2. `Bash` — a node one-liner that allows command strings starting
|
|
22
|
+
* with `peaks ` (whitelisted subcommand prefix). Exits 0 for
|
|
23
|
+
* `peaks <subcommand> ...`, non-zero otherwise.
|
|
24
|
+
*
|
|
25
|
+
* The Bash allow-list is conservative: it whitelists the documented
|
|
26
|
+
* peaks subcommands the skill family invokes during Step 0 (workspace,
|
|
27
|
+
* skill presence, request, session, scan, sub-agent, gate, standards,
|
|
28
|
+
* hooks, statusline). See peaks-solo/references/runbook.md for the
|
|
29
|
+
* canonical list.
|
|
30
|
+
*/
|
|
31
|
+
export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
|
|
32
|
+
/**
|
|
33
|
+
* Subcommand allow-list for the Bash matcher. The matcher allows any
|
|
34
|
+
* command that starts with `peaks <subcommand>` for one of these
|
|
35
|
+
* subcommands. Keep this list in sync with peaks-solo/references/runbook.md.
|
|
36
|
+
*/
|
|
37
|
+
const PEAKS_SUBCOMMAND_ALLOWLIST = [
|
|
38
|
+
'workspace',
|
|
39
|
+
'skill',
|
|
40
|
+
'request',
|
|
41
|
+
'session',
|
|
42
|
+
'scan',
|
|
43
|
+
'sub-agent',
|
|
44
|
+
'gate',
|
|
45
|
+
'standards',
|
|
46
|
+
'hooks',
|
|
47
|
+
'statusline',
|
|
48
|
+
'memory',
|
|
49
|
+
'openspec',
|
|
50
|
+
'workflow',
|
|
51
|
+
'doctor',
|
|
52
|
+
'upgrade'
|
|
53
|
+
];
|
|
54
|
+
/**
|
|
55
|
+
* Build the Bash matcher command. The command is a node -e one-liner
|
|
56
|
+
* that reads its candidate command string from argv[2] and exits 0
|
|
57
|
+
* iff the command starts with `peaks <whitelisted-subcommand> ` (or
|
|
58
|
+
* is exactly `peaks <whitelisted-subcommand>` with no trailing args).
|
|
59
|
+
*
|
|
60
|
+
* The list is serialised as a JSON array literal embedded in the
|
|
61
|
+
* command string so we avoid regex special-character pitfalls and
|
|
62
|
+
* keep the allow-list declarative.
|
|
63
|
+
*/
|
|
64
|
+
function buildBashHookCommand() {
|
|
65
|
+
const allowlistLiteral = JSON.stringify(PEAKS_SUBCOMMAND_ALLOWLIST);
|
|
66
|
+
// The command reads process.argv[2] (the tool-call command string),
|
|
67
|
+
// checks it starts with `peaks `, splits on whitespace, and looks
|
|
68
|
+
// up the second token in the allowlist. Exit 0 = allow, exit 1 =
|
|
69
|
+
// deny (so the gate fires for non-peaks commands).
|
|
70
|
+
return ('const c=process.argv[1]||"";' +
|
|
71
|
+
'if(!c.startsWith("peaks "))process.exit(1);' +
|
|
72
|
+
'const sub=c.slice(6).trim().split(/\\s+/)[0];' +
|
|
73
|
+
`if(${allowlistLiteral}.indexOf(sub)===-1)process.exit(1);` +
|
|
74
|
+
'process.exit(0)');
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the Write|Edit|MultiEdit matcher command. The command reads
|
|
78
|
+
* the candidate file path from argv[2] and exits 0 iff the path
|
|
79
|
+
* contains `.peaks/_runtime/` or `.peaks/<changeId>/` (the change-id
|
|
80
|
+
* segment is the next path component after `.peaks/`). All other
|
|
81
|
+
* paths exit 1 so the gate fires normally.
|
|
82
|
+
*
|
|
83
|
+
* The matcher is intentionally narrow: it only fires for tools that
|
|
84
|
+
* take a `file_path` (Write/Edit/MultiEdit) and for the Bash
|
|
85
|
+
* subcommand allow-list. It does NOT silently allow arbitrary paths
|
|
86
|
+
* under `.peaks/<changeId>/` — only those matching the documented
|
|
87
|
+
* pattern. Future slice work can broaden the allow-list if the
|
|
88
|
+
* peaks-solo workflow needs more paths.
|
|
89
|
+
*/
|
|
90
|
+
function buildWriteHookCommand() {
|
|
91
|
+
// Path-matching: allow when the path contains `.peaks/_runtime/`
|
|
92
|
+
// OR when the second `.peaks/` segment starts with anything that
|
|
93
|
+
// looks like a change-id (kebab-case slug). Exit 0 for allow, exit
|
|
94
|
+
// 1 for deny.
|
|
95
|
+
return ('const p=process.argv[1]||"";' +
|
|
96
|
+
'if(p.includes(".peaks/_runtime/"))process.exit(0);' +
|
|
97
|
+
'const m=p.match(/\\.peaks\\/([a-z0-9][a-z0-9.-]*)\\//);' +
|
|
98
|
+
'if(m&&m[1]&&m[1]!=="_runtime"&&m[1]!=="_dogfood"&&m[1]!=="_sub_agents"&&m[1]!=="_archive"&&m[1]!=="memory"&&m[1]!=="issues"&&m[1]!=="sops"&&m[1]!=="retrospective"&&m[1]!=="project-scan"&&m[1]!=="perf-baseline")process.exit(0);' +
|
|
99
|
+
'process.exit(1)');
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Build the full template object. The shape is the subset of Claude
|
|
103
|
+
* Code's `.claude/settings.local.json` schema that PreToolUse hooks
|
|
104
|
+
* need — we do not emit the `permissions` block because the fact-
|
|
105
|
+
* forcing gate is a core feature that PreToolUse hooks can short-
|
|
106
|
+
* circuit but that the `permissions` block cannot.
|
|
107
|
+
*/
|
|
108
|
+
export function buildClaudeSettingsLocalJson() {
|
|
109
|
+
return {
|
|
110
|
+
hooks: {
|
|
111
|
+
PreToolUse: [
|
|
112
|
+
{
|
|
113
|
+
matcher: 'Write|Edit|MultiEdit',
|
|
114
|
+
hooks: [
|
|
115
|
+
{
|
|
116
|
+
type: 'command',
|
|
117
|
+
command: buildWriteHookCommand()
|
|
118
|
+
}
|
|
119
|
+
]
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
matcher: 'Bash',
|
|
123
|
+
hooks: [
|
|
124
|
+
{
|
|
125
|
+
type: 'command',
|
|
126
|
+
command: buildBashHookCommand()
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
]
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -17,6 +17,14 @@ export type WorkspaceInitOptions = {
|
|
|
17
17
|
* (live sub-agent progress, spawn records).
|
|
18
18
|
*/
|
|
19
19
|
changeId?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Slice 2.0.1-bug3-fact-forcing-bypass: opt out of writing the
|
|
22
|
+
* consumer-project `.claude/settings.local.json` file. Default
|
|
23
|
+
* (`false`) writes the file so the [Fact-Forcing Gate] is bypassed
|
|
24
|
+
* for tool calls inside `.peaks/**`. The CLI surfaces this as
|
|
25
|
+
* `--no-claude-hooks`.
|
|
26
|
+
*/
|
|
27
|
+
noClaudeHooks?: boolean;
|
|
20
28
|
};
|
|
21
29
|
export type WorkspaceInitReport = {
|
|
22
30
|
sessionId: string;
|
|
@@ -27,6 +35,22 @@ export type WorkspaceInitReport = {
|
|
|
27
35
|
previousSessionId: string | null;
|
|
28
36
|
changeId: string | null;
|
|
29
37
|
changeIdAction: 'bound' | 'preserved' | 'none';
|
|
38
|
+
/**
|
|
39
|
+
* Slice 2.0.1-bug3-fact-forcing-bypass: what the consumer-project
|
|
40
|
+
* `.claude/settings.local.json` materialization did this call.
|
|
41
|
+
* - written: the file was freshly written
|
|
42
|
+
* - refreshed: the file already existed and was rewritten to
|
|
43
|
+
* match the current peaks-cli release's template
|
|
44
|
+
* - already-current: the file already matched the template; no
|
|
45
|
+
* rewrite needed
|
|
46
|
+
* - skipped: the caller passed noClaudeHooks=true
|
|
47
|
+
* The LLM and the user both see this in the JSON envelope so they
|
|
48
|
+
* can decide whether the bypass is in effect.
|
|
49
|
+
*/
|
|
50
|
+
claudeSettings: {
|
|
51
|
+
action: 'written' | 'refreshed' | 'already-current' | 'skipped';
|
|
52
|
+
path: string;
|
|
53
|
+
};
|
|
30
54
|
};
|
|
31
55
|
export declare class InvalidSessionIdError extends Error {
|
|
32
56
|
readonly code = "INVALID_SESSION_ID";
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { mkdir } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { isDirectory } from '../../shared/fs.js';
|
|
4
5
|
import { getSessionId, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
|
|
5
6
|
import { setCurrentChangeId } from '../../shared/change-id.js';
|
|
7
|
+
import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME } from './claude-settings-template.js';
|
|
6
8
|
const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
|
|
7
9
|
const PROHIBITED_SUFFIXES = ['session', 'work', 'task', 'test', 'temp', 'tmp'];
|
|
8
10
|
// Auto-generated session ID pattern: YYYY-MM-DD-session-<6位hex>
|
|
@@ -173,6 +175,126 @@ export async function initWorkspace(options) {
|
|
|
173
175
|
bound,
|
|
174
176
|
previousSessionId,
|
|
175
177
|
changeId: resolvedChangeId,
|
|
176
|
-
changeIdAction
|
|
178
|
+
changeIdAction,
|
|
179
|
+
claudeSettings: await materializeClaudeSettingsLocal(options.projectRoot, options.noClaudeHooks === true)
|
|
177
180
|
};
|
|
178
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* The peaks-managed snippet appended to the consumer project's
|
|
184
|
+
* `.peaks/.gitignore` so the local-only settings file never lands
|
|
185
|
+
* in a commit. Marked with a managed-by header so we can detect (and
|
|
186
|
+
* not double-append) on subsequent inits.
|
|
187
|
+
*/
|
|
188
|
+
const PEAKS_GITIGNORE_HEADER = '# >>> peaks-cli managed snippet (slice 2.0.1-bug3) — do not edit by hand';
|
|
189
|
+
const PEAKS_GITIGNORE_FOOTER = '# <<< peaks-cli managed snippet';
|
|
190
|
+
const PEAKS_GITIGNORE_SNIPPET = [
|
|
191
|
+
PEAKS_GITIGNORE_HEADER,
|
|
192
|
+
'# Consumer-project .claude/settings.local.json: written by `peaks workspace init`',
|
|
193
|
+
'# to bypass Claude Code [Fact-Forcing Gate] for .peaks/** writes. Local-only.',
|
|
194
|
+
'.claude/settings.local.json',
|
|
195
|
+
PEAKS_GITIGNORE_FOOTER,
|
|
196
|
+
''
|
|
197
|
+
].join('\n');
|
|
198
|
+
/**
|
|
199
|
+
* Materialize the consumer-project `.claude/settings.local.json` and
|
|
200
|
+
* ensure the consumer's `.peaks/.gitignore` covers it. Returns a
|
|
201
|
+
* `claudeSettings` descriptor that the caller surfaces in the JSON
|
|
202
|
+
* envelope.
|
|
203
|
+
*
|
|
204
|
+
* The function is idempotent: re-running on an already-materialized
|
|
205
|
+
* project is a no-op (the file is rewritten only when its content
|
|
206
|
+
* diverges from the current peaks-cli release's template, which
|
|
207
|
+
* keeps the consumer up to date as the template evolves).
|
|
208
|
+
*
|
|
209
|
+
* Even when the caller passes `noClaudeHooks: true`, the function
|
|
210
|
+
* still writes a copy of the template at
|
|
211
|
+
* `.peaks/.claude-settings-template.json` so the user has an offline
|
|
212
|
+
* recovery path: copy the file contents into
|
|
213
|
+
* `.claude/settings.local.json` manually. The recovery path is
|
|
214
|
+
* documented in
|
|
215
|
+
* `skills/peaks-solo/references/anchoring-and-session-info.md`.
|
|
216
|
+
*/
|
|
217
|
+
async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks) {
|
|
218
|
+
const settingsRel = CLAUDE_SETTINGS_LOCAL_FILENAME;
|
|
219
|
+
const settingsPath = join(projectRoot, settingsRel);
|
|
220
|
+
const template = buildClaudeSettingsLocalJson();
|
|
221
|
+
const serialized = JSON.stringify(template, null, 2) + '\n';
|
|
222
|
+
// Always drop a copy of the template under .peaks/ so the
|
|
223
|
+
// --no-claude-hooks recovery flow has a known source-of-truth on
|
|
224
|
+
// disk. The file is gitignored by the snippet below.
|
|
225
|
+
await writeOfflineTemplateCopy(projectRoot, serialized);
|
|
226
|
+
if (noClaudeHooks) {
|
|
227
|
+
return { action: 'skipped', path: settingsRel };
|
|
228
|
+
}
|
|
229
|
+
// Best-effort: ensure .claude/ exists, then write the file. We do
|
|
230
|
+
// not assertSafeSettingsPath here (the .claude/ dir is local to
|
|
231
|
+
// the consumer and we trust it on first init; the existing
|
|
232
|
+
// hooks-settings-service applies the safety check for the Bash
|
|
233
|
+
// gate-enforce path).
|
|
234
|
+
await mkdir(join(projectRoot, '.claude'), { recursive: true });
|
|
235
|
+
let action = 'written';
|
|
236
|
+
if (existsSync(settingsPath)) {
|
|
237
|
+
try {
|
|
238
|
+
const { readFile } = await import('node:fs/promises');
|
|
239
|
+
const existing = await readFile(settingsPath, 'utf8');
|
|
240
|
+
if (existing === serialized) {
|
|
241
|
+
action = 'already-current';
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
action = 'refreshed';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Treat any read failure as "needs refresh" so the consumer
|
|
249
|
+
// always ends up with a valid template on disk.
|
|
250
|
+
action = 'refreshed';
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (action !== 'already-current') {
|
|
254
|
+
await writeFile(settingsPath, serialized, 'utf8');
|
|
255
|
+
}
|
|
256
|
+
// Ensure the consumer's .peaks/.gitignore covers the local-only
|
|
257
|
+
// settings file. The snippet is appended only when the header is
|
|
258
|
+
// missing, so subsequent inits do not double-append.
|
|
259
|
+
await upsertPeaksGitignoreSnippet(projectRoot);
|
|
260
|
+
return { action, path: settingsRel };
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Always write (or refresh) a copy of the template at
|
|
264
|
+
* `.peaks/.claude-settings-template.json` so the user has a known
|
|
265
|
+
* source-of-truth on disk for the manual recovery flow. This file is
|
|
266
|
+
* tracked in git (not gitignored) because it is the recovery anchor
|
|
267
|
+
* — if the consumer needs to re-create their .claude/settings.local.json
|
|
268
|
+
* they can copy this file verbatim.
|
|
269
|
+
*/
|
|
270
|
+
async function writeOfflineTemplateCopy(projectRoot, serialized) {
|
|
271
|
+
const copyPath = join(projectRoot, '.peaks', '.claude-settings-template.json');
|
|
272
|
+
await mkdir(join(projectRoot, '.peaks'), { recursive: true });
|
|
273
|
+
await writeFile(copyPath, serialized, 'utf8');
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Append the peaks-managed `.claude/settings.local.json` snippet to
|
|
277
|
+
* the consumer project's `.peaks/.gitignore`. Preserves any user-
|
|
278
|
+
* managed entries above the snippet. Idempotent: re-running on a
|
|
279
|
+
* project that already has the snippet is a no-op.
|
|
280
|
+
*/
|
|
281
|
+
async function upsertPeaksGitignoreSnippet(projectRoot) {
|
|
282
|
+
const gitignorePath = join(projectRoot, '.peaks', '.gitignore');
|
|
283
|
+
await mkdir(join(projectRoot, '.peaks'), { recursive: true });
|
|
284
|
+
let existing = '';
|
|
285
|
+
if (existsSync(gitignorePath)) {
|
|
286
|
+
try {
|
|
287
|
+
const { readFile } = await import('node:fs/promises');
|
|
288
|
+
existing = await readFile(gitignorePath, 'utf8');
|
|
289
|
+
}
|
|
290
|
+
catch {
|
|
291
|
+
existing = '';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (existing.includes(PEAKS_GITIGNORE_HEADER)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
298
|
+
const next = existing + separator + (existing.length > 0 ? '\n' : '') + PEAKS_GITIGNORE_SNIPPET;
|
|
299
|
+
await writeFile(gitignorePath, next, 'utf8');
|
|
300
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const CLI_VERSION = "2.0.
|
|
1
|
+
export declare const CLI_VERSION = "2.0.2";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "2.0.
|
|
1
|
+
export const CLI_VERSION = "2.0.2";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "peaks-cli",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
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",
|