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.
@@ -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 { pathToFileURL } from 'node:url';
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 !== null)
168
+ if (cachedInstaller === NO_INSTALLER_SENTINEL) {
169
+ return noopInstaller;
170
+ }
171
+ if (cachedInstaller !== null) {
32
172
  return cachedInstaller;
33
- const scriptPath = join(process.cwd(), 'scripts', 'install-skills.mjs');
34
- const mod = (await import(pathToFileURL(scriptPath).href));
35
- cachedInstaller = mod.installBundledSkills;
36
- return cachedInstaller;
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
- const economyMode = request.config?.economyMode ?? DEFAULT_CONFIG.economyMode;
171
- const swarmMode = request.config?.swarmMode ?? DEFAULT_CONFIG.swarmMode;
172
- const executionModelId = economyMode ? getConfiguredExecutionModelId(request.config?.providers) : STRONGEST_MODEL_ID;
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.0";
1
+ export declare const CLI_VERSION = "2.0.2";
@@ -1 +1 @@
1
- export const CLI_VERSION = "2.0.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.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",