peaks-cli 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -231,6 +231,47 @@ is skipped (`PEAKS_SKIP_AUTO_UPGRADE=1` or `npm i --ignore-scripts`).
231
231
 
232
232
  ---
233
233
 
234
+ ## [2.0.1] — 2026-06-12
235
+
236
+ ### Fixed
237
+
238
+ - **Bug 1 — `~/.peaks/config.json` was bloated to 9 top-level fields.**
239
+ The 2.0.0 release moved per-project fields (`language`, `model`,
240
+ `economyMode`, `swarmMode`) to `<project>/.peaks/preferences.json`
241
+ per spec §10.4, but the runtime `DEFAULT_CONFIG` still shipped
242
+ `language` / `model` / `economyMode` / `swarmMode` / `tokens` /
243
+ `providers` / `proxy` / `progress` placeholders. The slim migration
244
+ (`executeMigration`) wrote `{ version: "2.0.0" }` only, but any
245
+ code path that went through `readConfig` and re-serialised
246
+ re-bloated the file. The 2.0.1 fix:
247
+
248
+ 1. **Slim `DEFAULT_CONFIG`** to `{ version, ocr: { llm: { url, authToken, model, useAnthropic, authHeader } } }`
249
+ (placeholders for the OCR LLM endpoint only).
250
+ 2. **Slim migration write** to the same 2-key form, so a fresh
251
+ `peaks config migrate --apply` produces a discoverable
252
+ `ocr.llm` block the user can paste their endpoint into.
253
+ 3. **Tolerant loader.** Legacy 1.x files with extra fields
254
+ (`language`, `model`, `tokens`, `providers`, `proxy`, etc.)
255
+ still load without throwing; the legacy fields are exposed
256
+ via `getConfig` for backward compatibility, and
257
+ `setConfig` rejects writes to `language` / `model` /
258
+ `economyMode` / `swarmMode` with a pointer to
259
+ `<project>/.peaks/preferences.json` (do not silently migrate).
260
+
261
+ The net effect: a freshly-installed peaks-cli writes a 2-key
262
+ `~/.peaks/config.json`; legacy 1.x files migrate to the same
263
+ 2-key form; the ocr second-opinion config is now the only
264
+ discoverable surface the user needs to populate to make
265
+ `peaks code-review detect-ocr` report `state: "ready"`.
266
+
267
+ ### Verification
268
+
269
+ - 70 config tests pass (`tests/unit/config-*`).
270
+ - `pnpm tsc -p tsconfig.json --noEmit` clean (excluding pre-existing
271
+ sync-service test scaffold for Bug 2).
272
+
273
+ ---
274
+
234
275
  ## [1.4.2] — 2026-06-08
235
276
 
236
277
  Last 1.x release. See git history pre-2.0.0 for details.
@@ -27,10 +27,11 @@ export function runCapabilityMap(io, options) {
27
27
  }
28
28
  const config = readConfig();
29
29
  const installedCapabilityIds = getInstalledCapabilityIds(config);
30
+ const httpProxy = config.proxy?.httpProxy;
30
31
  printResult(io, ok('capabilities.map', createCapabilityMapPlan({
31
32
  source,
32
33
  installedCapabilityIds,
33
- ...(config.proxy.httpProxy === undefined ? {} : { httpProxy: config.proxy.httpProxy })
34
+ ...(httpProxy === undefined ? {} : { httpProxy })
34
35
  })), options.json);
35
36
  }
36
37
  export function getInstalledCapabilityIds(_config) {
@@ -108,7 +108,8 @@ export function registerWorkspaceCommands(program, io) {
108
108
  throw new Error(`--install-hooks must be one of: ask, auto, skip (got "${value}")`);
109
109
  }
110
110
  return value;
111
- })).action(async (options) => {
111
+ })
112
+ .option('--no-claude-hooks', 'do NOT materialize .claude/settings.local.json (slice 2.0.1-bug3 fact-forcing bypass). Default: hooks installed so tool calls inside .peaks/** are not blocked by the [Fact-Forcing Gate].')).action(async (options) => {
112
113
  try {
113
114
  // Resolve the session id. Two paths:
114
115
  // - explicit --session-id: use it as the requested binding target
@@ -168,7 +169,13 @@ export function registerWorkspaceCommands(program, io) {
168
169
  projectRoot,
169
170
  sessionId,
170
171
  allowSessionRebind: options.allowSessionRebind === true,
171
- ...(options.changeId !== undefined ? { changeId: options.changeId } : {})
172
+ ...(options.changeId !== undefined ? { changeId: options.changeId } : {}),
173
+ // Commander translates `--no-claude-hooks` into
174
+ // `options.claudeHooks = false`. The default (no flag) leaves
175
+ // `options.claudeHooks` undefined, which is not equal to
176
+ // `false`, so the default is "install hooks" (the bypass is
177
+ // on). Pass `--no-claude-hooks` to opt out.
178
+ noClaudeHooks: options.claudeHooks === false
172
179
  });
173
180
  const nextActions = [];
174
181
  if (report.previousSessionId !== null && report.bound) {
@@ -188,6 +195,28 @@ export function registerWorkspaceCommands(program, io) {
188
195
  else {
189
196
  nextActions.push('Run `peaks scan archetype --project <path> --json` next to populate rd/project-scan.md.');
190
197
  }
198
+ // Slice 2.0.1-bug3-fact-forcing-bypass: surface the consumer-
199
+ // project .claude/settings.local.json materialization outcome.
200
+ // When the bypass is in effect, the LLM knows subsequent Writes
201
+ // and Bash calls targeting .peaks/** will not be blocked by the
202
+ // [Fact-Forcing Gate]. When the user opted out, we surface a
203
+ // nextAction so the manual recovery is documented.
204
+ if (report.claudeSettings.action === 'written' || report.claudeSettings.action === 'refreshed') {
205
+ nextActions.push(`Materialized .claude/settings.local.json (action: ${report.claudeSettings.action}) — ` +
206
+ `the [Fact-Forcing Gate] is bypassed for tool calls inside .peaks/**. ` +
207
+ 'Restart Claude Code so the hooks take effect.');
208
+ }
209
+ else if (report.claudeSettings.action === 'already-current') {
210
+ // No-op: the bypass is already in effect and matches the
211
+ // current release. Do not spam the nextAction list on every
212
+ // init.
213
+ }
214
+ else if (report.claudeSettings.action === 'skipped') {
215
+ nextActions.push('Skipped .claude/settings.local.json materialization (--no-claude-hooks). ' +
216
+ 'If the [Fact-Forcing Gate] blocks subsequent Writes, run `peaks workspace init` ' +
217
+ 'again without --no-claude-hooks, or drop the contents of ' +
218
+ '`.peaks/.claude-settings-template.json` into `.claude/settings.local.json` manually.');
219
+ }
191
220
  // First-time hooks install decision. Sticky-marker at
192
221
  // .peaks/.peaks-init-hooks-decision.json records the user's answer
193
222
  // (or the auto-decision) so subsequent inits for new sessions in the
@@ -0,0 +1,20 @@
1
+ export type MessageRenderMode = 'tty' | 'plain';
2
+ export interface MessageRenderOptions {
3
+ mode: MessageRenderMode;
4
+ /**
5
+ * Optional override. When `true`, the renderer returns the input unchanged
6
+ * regardless of `mode`. Callers should set this when `NO_COLOR` is set,
7
+ * `--no-color` is passed, or `--json` is requested.
8
+ */
9
+ noColor?: boolean;
10
+ }
11
+ /**
12
+ * Render a human-readable message string for the terminal.
13
+ *
14
+ * Pure function. Returns the input unchanged when:
15
+ * - `input` is empty,
16
+ * - `input` is not a string (defensive: callers occasionally pass numbers/objects),
17
+ * - `mode === 'plain'`,
18
+ * - `noColor === true` (NO_COLOR / --no-color / --json opt-out).
19
+ */
20
+ export declare function renderMessage(input: string, options: MessageRenderOptions): string;
@@ -0,0 +1,80 @@
1
+ // Slice 2.0.1-ux-message-renderer — pure-function human-text renderer.
2
+ //
3
+ // PURE function contract:
4
+ // - No side effects (does not read process.stdout, does not call console.*).
5
+ // - Returns a string. The caller decides whether to write it to stdout.
6
+ // - Caller resolves the `mode` (TTY detection + opt-outs) and passes it in.
7
+ //
8
+ // Supported transformations (tty mode only — plain mode is a no-op pass-through):
9
+ // 1. OSC 8 hyperlink wrapping for http://, https://, and file:// URLs.
10
+ // Format: ESC]8;;URL ESC\ TEXT ESC]8;; ESC\
11
+ // 2. Markdown-lite: **bold** -> ANSI bold; `code` -> inverse-video.
12
+ // 3. Bullet markers (lines beginning with `- ` or `* `) are preserved as-is.
13
+ //
14
+ // `noColor: true` (caller's signal for `NO_COLOR` / `--no-color` / `--json`) forces
15
+ // the same pass-through behavior as `mode: 'plain'`, even if the caller somehow
16
+ // passed `mode: 'tty'`. This is a defence-in-depth opt-out: the caller should
17
+ // also pass `mode: 'plain'`, but we don't trust that either, because the JSON
18
+ // envelope contract must not leak escape sequences.
19
+ // OSC 8 escape sequence fragments. Format: ESC ] 8 ; ; URL ESC \ TEXT ESC ] 8 ; ; ESC \
20
+ // Browsers + terminals that understand OSC 8: Windows Terminal, iTerm2, WezTerm, recent GNOME Terminal.
21
+ const ESC = '';
22
+ const OSC8_OPEN = `${ESC}]8;;`;
23
+ const OSC8_SEP = `${ESC}\\`;
24
+ const OSC8_CLOSE = `${OSC8_OPEN}${OSC8_SEP}`;
25
+ // ANSI sequences used by the markdown-lite pass.
26
+ const ANSI_BOLD_OPEN = `${ESC}[1m`;
27
+ const ANSI_BOLD_CLOSE = `${ESC}[22m`;
28
+ const ANSI_INVERSE_OPEN = `${ESC}[7m`;
29
+ const ANSI_INVERSE_CLOSE = `${ESC}[27m`;
30
+ // Lightweight URL detector (deliberately not RFC-3986-perfect).
31
+ // Matches http(s):// and file:// tokens up to the first whitespace or
32
+ // common terminator. Trailing punctuation is captured as part of the URL,
33
+ // which is fine for display; the OSC 8 link still works because terminals
34
+ // re-tokenise on hover/click. To stay close to the slice spec's reference
35
+ // pattern we exclude <, >, ", ', and ` from URL characters.
36
+ const URL_PATTERN = /(https?:\/\/|file:\/\/)[^\s<>"'`]+/g;
37
+ // **bold** markers (non-greedy, multi-char safe). Allows ** at word boundaries.
38
+ const BOLD_PATTERN = /\*\*([^*\n]+?)\*\*/g;
39
+ // `inline-code` markers.
40
+ const CODE_PATTERN = /`([^`\n]+?)`/g;
41
+ /**
42
+ * Render a human-readable message string for the terminal.
43
+ *
44
+ * Pure function. Returns the input unchanged when:
45
+ * - `input` is empty,
46
+ * - `input` is not a string (defensive: callers occasionally pass numbers/objects),
47
+ * - `mode === 'plain'`,
48
+ * - `noColor === true` (NO_COLOR / --no-color / --json opt-out).
49
+ */
50
+ export function renderMessage(input, options) {
51
+ if (typeof input !== 'string' || input.length === 0) {
52
+ return input;
53
+ }
54
+ if (options.mode === 'plain' || options.noColor === true) {
55
+ return input;
56
+ }
57
+ // Markdown-lite first, then URL linking. The order matters: bold/code
58
+ // transformations can wrap parts of a URL (e.g. `\`code\`` containing a URL),
59
+ // so we link URLs on the already-formatted string. Either order would be
60
+ // acceptable; linking on the formatted string means a URL inside a code
61
+ // span still gets hyperlink-wrapped, which is the modern-terminal-friendly
62
+ // choice.
63
+ let out = applyMarkdownLite(input);
64
+ out = applyHyperlinks(out);
65
+ return out;
66
+ }
67
+ function applyMarkdownLite(input) {
68
+ let out = input.replace(BOLD_PATTERN, (_match, inner) => {
69
+ return `${ANSI_BOLD_OPEN}${inner}${ANSI_BOLD_CLOSE}`;
70
+ });
71
+ out = out.replace(CODE_PATTERN, (_match, inner) => {
72
+ return `${ANSI_INVERSE_OPEN}${inner}${ANSI_INVERSE_CLOSE}`;
73
+ });
74
+ return out;
75
+ }
76
+ function applyHyperlinks(input) {
77
+ return input.replace(URL_PATTERN, (url) => {
78
+ return `${OSC8_OPEN}${url}${OSC8_SEP}${url}${OSC8_CLOSE}`;
79
+ });
80
+ }
@@ -85,8 +85,27 @@ export function executeMigration(opts) {
85
85
  }
86
86
  savePreferences(opts.currentProjectRoot, overrides);
87
87
  }
88
- // 3. Slim config.json — only the schema version remains.
88
+ // 3. Slim config.json — schema version + discoverable ocr.llm placeholders.
89
+ // Per the 2.0.1 slim spec, the on-disk `~/.peaks/config.json` is
90
+ // `{ "version": "2.0.1", "ocr": { "llm": { ... } } }`. Legacy fields
91
+ // (language, model, economyMode, swarmMode, tokens, providers,
92
+ // proxy) live in <project>/.peaks/preferences.json. peaks-cli writes
93
+ // the `ocr.llm.*` placeholders so the user has a discoverable spot
94
+ // to paste their endpoint; the placeholders are empty strings, not
95
+ // auto-configured values, so the post-migration file MUST contain
96
+ // the `ocr.llm.*` block with empty defaults.
89
97
  mkdirSync(join(homedir(), '.peaks'), { recursive: true });
90
- writeFileSync(configPath, JSON.stringify({ version: CONFIG_SCHEMA_VERSION_V2 }, null, 2) + '\n', 'utf8');
98
+ writeFileSync(configPath, JSON.stringify({
99
+ version: CONFIG_SCHEMA_VERSION_V2,
100
+ ocr: {
101
+ llm: {
102
+ url: '',
103
+ authToken: '',
104
+ model: '',
105
+ useAnthropic: false,
106
+ authHeader: 'authorization'
107
+ }
108
+ }
109
+ }, null, 2) + '\n', 'utf8');
91
110
  return { ...plan, applied: true, backupPath: bak, newConfigPath: configPath };
92
111
  }
@@ -16,6 +16,7 @@ export { resolveProjectRootForConfig, resolveCanonicalProjectRoot } from './conf
16
16
  export declare function loadGlobalConfig(): ConfigV2 | null;
17
17
  export declare function isConfigLayer(value: string): value is ConfigLayer;
18
18
  export declare function isSensitiveConfigPath(path: string): boolean;
19
+ export declare function isLegacyConfigKey(path: string): boolean;
19
20
  export declare function containsSensitiveConfigValue(value: unknown): boolean;
20
21
  export type RedactedConfigValue = string | number | boolean | null | RedactedConfigValue[] | {
21
22
  [key: string]: RedactedConfigValue;
@@ -116,6 +116,26 @@ export function isSensitiveConfigPath(path) {
116
116
  const normalized = path.toLowerCase().replace(/[^a-z0-9]/g, '');
117
117
  return normalized.includes('apikey') || normalized.includes('accesskey') || normalized.includes('privatekey') || normalized.includes('token') || normalized.includes('secret') || normalized.includes('password') || normalized.includes('bearer') || normalized.includes('credential') || normalized.includes('auth');
118
118
  }
119
+ /**
120
+ * 2.0.1 slim-config contract: `~/.peaks/config.json` only stores
121
+ * `version` + `ocr.llm.*` placeholders. The 1.x → 2.0 migration
122
+ * moved per-project fields (`language`, `model`, `economyMode`,
123
+ * `swarmMode`) to `<project>/.peaks/preferences.json` (per spec
124
+ * §10.4). `setConfig` rejects writes to those keys and points the
125
+ * user to the preferences path; tokens / providers / proxy still
126
+ * live in `~/.peaks/config.json` (the loader is tolerant of them
127
+ * but does not synthesise defaults for them anymore).
128
+ */
129
+ const LEGACY_CONFIG_KEYS = new Set([
130
+ 'language',
131
+ 'model',
132
+ 'economyMode',
133
+ 'swarmMode'
134
+ ]);
135
+ export function isLegacyConfigKey(path) {
136
+ const topLevel = path.split(/[.[].*/, 1)[0] ?? '';
137
+ return LEGACY_CONFIG_KEYS.has(topLevel);
138
+ }
119
139
  function isProviderConfigPath(path) {
120
140
  return path === 'providers' || path.startsWith('providers.');
121
141
  }
@@ -551,6 +571,10 @@ export function setConfig(options) {
551
571
  if (!isConfigLayer(layer)) {
552
572
  throw new Error('Invalid config layer');
553
573
  }
574
+ if (isLegacyConfigKey(options.key)) {
575
+ throw new Error(`Legacy config key "${options.key}" is no longer stored in ~/.peaks/config.json. ` +
576
+ 'Set it under <project>/.peaks/preferences.json (e.g. `peaks preferences set --key <key> --value <value>`).');
577
+ }
554
578
  if (layer === 'project' && (isProviderConfigPath(options.key) || isProxyConfigPath(options.key) || isSensitiveConfigPath(options.key) || containsSensitiveConfigValue(options.value))) {
555
579
  throw new Error('Sensitive config keys must be stored in the user config layer');
556
580
  }
@@ -132,6 +132,21 @@ export type ConfigSetOptions = {
132
132
  value: unknown;
133
133
  layer?: ConfigLayer;
134
134
  };
135
+ /**
136
+ * 2.0.1 slim runtime default. The on-disk `~/.peaks/config.json`
137
+ * only carries `version` + `ocr.llm.*` placeholders. Legacy fields
138
+ * (language / model / economyMode / swarmMode / tokens / providers /
139
+ * proxy) live in `<project>/.peaks/preferences.json` (per spec
140
+ * §10.4) and are NOT synthesised here — `readConfig()` merges the
141
+ * user file over this default, and any legacy field that the user
142
+ * file still carries (1.x file) is exposed via `getConfig` for
143
+ * backward compatibility.
144
+ *
145
+ * Cast to `PeaksConfig` because the type still declares the legacy
146
+ * fields as required (they are part of the `readConfig()` contract
147
+ * for tolerant loading of pre-2.0.1 files); the runtime default
148
+ * itself does not supply them.
149
+ */
135
150
  export declare const DEFAULT_CONFIG: PeaksConfig;
136
151
  /**
137
152
  * Slim 2.0 schema for `~/.peaks/config.json`. After migration,
@@ -1,21 +1,30 @@
1
1
  import { CLI_VERSION } from '../../shared/version.js';
2
2
  import { CONFIG_SCHEMA_VERSION_V2 } from './config-migration.js';
3
+ /**
4
+ * 2.0.1 slim runtime default. The on-disk `~/.peaks/config.json`
5
+ * only carries `version` + `ocr.llm.*` placeholders. Legacy fields
6
+ * (language / model / economyMode / swarmMode / tokens / providers /
7
+ * proxy) live in `<project>/.peaks/preferences.json` (per spec
8
+ * §10.4) and are NOT synthesised here — `readConfig()` merges the
9
+ * user file over this default, and any legacy field that the user
10
+ * file still carries (1.x file) is exposed via `getConfig` for
11
+ * backward compatibility.
12
+ *
13
+ * Cast to `PeaksConfig` because the type still declares the legacy
14
+ * fields as required (they are part of the `readConfig()` contract
15
+ * for tolerant loading of pre-2.0.1 files); the runtime default
16
+ * itself does not supply them.
17
+ */
3
18
  export const DEFAULT_CONFIG = {
4
19
  version: CLI_VERSION,
5
- language: 'en',
6
- model: 'sonnet',
7
- economyMode: true,
8
- swarmMode: true,
9
- tokens: {},
10
- providers: {
11
- minimax: {
12
- model: 'minimax-2.7'
20
+ ocr: {
21
+ llm: {
22
+ url: '',
23
+ authToken: '',
24
+ model: '',
25
+ useAnthropic: false,
26
+ authHeader: 'authorization'
13
27
  }
14
- },
15
- proxy: {},
16
- progress: {
17
- enabled: true,
18
- heartbeatIntervalMs: 60000
19
28
  }
20
29
  };
21
30
  export function isConfigV2(raw) {
@@ -1,7 +1,6 @@
1
- import { DEFAULT_CONFIG } from './config-types.js';
2
1
  export const STRONGEST_MODEL_ID = 'claude-opus-4-7';
3
2
  export function getConfiguredExecutionModelId(providers) {
4
- const providerConfigs = Object.values(providers ?? DEFAULT_CONFIG.providers);
3
+ const providerConfigs = Object.values(providers ?? {});
5
4
  const configuredModel = providerConfigs
6
5
  .map((provider) => provider?.model?.trim())
7
6
  .find((model) => typeof model === 'string' && model.length > 0);
@@ -11,5 +10,8 @@ export function getConfiguredExecutionModelId(providers) {
11
10
  return configuredModel;
12
11
  }
13
12
  export function getEconomyAwareExecutionModelId(config) {
14
- return config.economyMode ? getConfiguredExecutionModelId(config.providers) : STRONGEST_MODEL_ID;
13
+ // Slice 2.0.1-bug1 round 3: economy is the project default. Treat undefined as enabled
14
+ // (matches the pre-slice implicit default from DEFAULT_CONFIG.economyMode = true). Only an
15
+ // explicit `economyMode === false` switches execution to STRONGEST_MODEL_ID.
16
+ return config.economyMode !== false ? getConfiguredExecutionModelId(config.providers) : STRONGEST_MODEL_ID;
15
17
  }
@@ -5,6 +5,22 @@ import { validateChangeIdOrThrow, buildArtifactRelativePath } from '../../shared
5
5
  import { WORKSPACE_UNAVAILABLE_NEXT_ACTIONS } from '../../shared/planner-response.js';
6
6
  import { getLocalArtifactPath, hasValidArtifactWorkspace } from '../artifacts/workspace-service.js';
7
7
  import { getConfiguredExecutionModelId, STRONGEST_MODEL_ID } from '../config/model-routing.js';
8
+ /**
9
+ * 2.0.1-bug1: the slim 2.0 `~/.peaks/config.json` no longer carries a
10
+ * `providers` block (legacy model config lives in
11
+ * `.peaks/preferences.json` per spec §10.4). `buildPlan` is a pure
12
+ * planner function and historically took its execution model from
13
+ * `DEFAULT_CONFIG.providers.minimax.model`; with the slim default
14
+ * that field is `undefined`, so `getConfiguredExecutionModelId`
15
+ * would throw. We retain the pre-2.0 default here as a literal so
16
+ * the planner remains usable when the caller has not passed an
17
+ * explicit `executionModelId` (unit tests, dry-run previews, the
18
+ * `peaks swarm plan` onboarding path). Production callers that
19
+ * have a real `ocr.llm.model` configured pass it via
20
+ * `request.executionModelId` (or via the legacy preferences.json
21
+ * bridge) and bypass this fallback.
22
+ */
23
+ const DEFAULT_EXECUTION_MODEL_ID = 'minimax-2.7';
8
24
  import { getTechStatus, TECH_REQUIRED_ARTIFACTS } from '../tech/tech-service.js';
9
25
  function normalizeGoal(goal) {
10
26
  const normalized = goal.trim();
@@ -136,6 +152,18 @@ function readArtifactFile(rootPath, artifactWorkspacePath, artifact) {
136
152
  return null;
137
153
  }
138
154
  }
155
+ function resolveExecutionModelId() {
156
+ try {
157
+ return getConfiguredExecutionModelId(undefined);
158
+ }
159
+ catch {
160
+ // 2.0.1-bug1: with the slim `~/.peaks/config.json` the legacy
161
+ // `providers` block is gone, so the configured-model lookup is
162
+ // expected to throw. Fall back to the pre-2.0 default so the
163
+ // planner remains usable in unit tests and the dry-run path.
164
+ return DEFAULT_EXECUTION_MODEL_ID;
165
+ }
166
+ }
139
167
  function getConcreteTargetAreas(request, artifactWorkspacePath, hasApprovedTechArtifacts) {
140
168
  if (!artifactWorkspacePath || !hasApprovedTechArtifacts || !hasPlannerArtifactWorkspace(request, artifactWorkspacePath)) {
141
169
  return [];
@@ -154,7 +182,7 @@ function buildPlan(request) {
154
182
  validateChangeIdOrThrow(request.changeId);
155
183
  const goal = normalizeGoal(request.goal);
156
184
  const swarmMode = request.swarmMode ?? true;
157
- const executionModelId = request.executionModelId?.trim() || getConfiguredExecutionModelId(undefined);
185
+ const executionModelId = request.executionModelId?.trim() || resolveExecutionModelId();
158
186
  const { workerTarget, blockedReasons } = resolveWorkerTarget(request.maxWorkers);
159
187
  const artifactWorkspacePath = resolveArtifactWorkspacePath(request);
160
188
  const artifactRoot = buildArtifactRelativePath(request.changeId, 'rd', 'swarm');
@@ -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.1";
@@ -1 +1 @@
1
- export const CLI_VERSION = "2.0.0";
1
+ export const CLI_VERSION = "2.0.1";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
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",
@@ -66,6 +66,12 @@ Run `peaks workspace init` + `peaks skill presence:set peaks-solo` BEFORE any an
66
66
 
67
67
  → see `references/anchoring-and-session-info.md` for the full Step 0 contract + session-conflict resolution.
68
68
 
69
+ #### Step 0 bypass — Claude Code [Fact-Forcing Gate] (slice 2.0.1-bug3)
70
+
71
+ `peaks workspace init` materializes `.claude/settings.local.json` (a PreToolUse hook allow-list for `.peaks/**` paths and `peaks <subcommand>` Bash) so the [Fact-Forcing Gate] does not block Step 0. Pass `--no-claude-hooks` to opt out.
72
+
73
+ → see `references/anchoring-and-session-info.md` for the full bypass contract, recovery flow, and anti-bail-out rule.
74
+
69
75
  ### Peaks-Cli Step 0.7: Detect unfinished work and offer resume (BLOCKING on first invocation per session)
70
76
 
71
77
  After Step 0 anchored the workspace, run the resume-detection probe (one `find` + one `grep` + classification table). Surface resume options via `AskUserQuestion` if a slice is in flight. Never silently auto-resume.
@@ -22,4 +22,13 @@ peaks skill presence:set peaks-solo --project <repo> --gate startup
22
22
  **Session conflict resolution (read once, internalise):** If `peaks workspace init` returns `code: "CONFLICTING_SESSION"` with a body like
23
23
  `{"existingSessionId":"<Y>","requestedSessionId":"<X>"}`, the project is already bound to a different in-flight session `<Y>` (the one you or a prior run was working on). The fix is **NOT** to pass `--allow-session-rebind` to clobber `<Y>` — that destroys an active session's data. Instead: finish or abandon `<Y>` first (use `peaks session list --json` to see what it is, then `peaks session finish --id <Y>` or `peaks session abandon --id <Y>` — see your session command's help for the exact verbs). Only after `<Y>` is closed should you re-run `peaks workspace init`. The same rule applies to `peaks workspace init --session-id "<manually-forged>"` — do not pre-forge session ids; the CLI's auto-generated value is the binding.
24
24
 
25
+ **Fact-Forcing Gate bypass (slice 2.0.1-bug3):** Claude Code's [Fact-Forcing Gate] fires in two shapes inside peaks-solo Step 0: (1) Bash-side "Quote the user's current instruction verbatim. Then retry the same operation." and (2) Write-side 4-facts prompt. Both block the very first Write to `.peaks/_runtime/<sid>/session.json` and the first `peaks skill presence:set peaks-solo` invocation. The default `peaks workspace init` (no flag) materializes `.claude/settings.local.json` in the consumer project root with a PreToolUse hook allow-list that exits 0 for tool calls inside `.peaks/**` and for `peaks <whitelisted-subcommand>` Bash commands. The hook bypasses the gate before it fires.
26
+
27
+ If the bypass is not in effect (e.g. `.claude/` was read-only, or the user passed `--no-claude-hooks`, or the consumer manually deleted the file), the recovery flow is:
28
+
29
+ 1. Re-run `peaks workspace init --project <repo> --json` **without** `--no-claude-hooks`. The next-action list will say "Materialized .claude/settings.local.json (action: written/refreshed)". Restart Claude Code so the hook takes effect.
30
+ 2. If `.claude/` cannot be written (read-only mount, container with no write access, etc.), drop the contents of `.peaks/.claude-settings-template.json` into `.claude/settings.local.json` manually. The peaks-cli init always writes the offline template copy (regardless of `--no-claude-hooks`) so the user has a known source-of-truth on disk. After copying, restart Claude Code.
31
+
32
+ **Anti-bail-out rule for the gate:** Do NOT skip Step 0 because the gate fired. The gate is a Claude Code core feature that peaks-cli cannot modify directly; peaks-cli can only sidestep it via the hook allow-list. If the gate still blocks Step 0 after the bypass is in effect, the user has a misconfigured `.claude/settings.json` upstream — surface that as a separate `AskUserQuestion` ("Your `.claude/settings.json` is overriding the local allow-list. May peaks-cli delete the local file and regenerate it?") rather than skipping Step 0.
33
+
25
34
  `presence:set` accepts no `--mode` here on purpose — mode is unknown until Step 1. It is re-run with the selected mode in Step 2. Setting presence early guarantees the status header/line shows `peaks-solo` from the very first turn even if the user never reaches mode selection.