peaks-cli 2.0.3 → 2.0.5

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
@@ -7,6 +7,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ---
9
9
 
10
+ ## [2.0.4] — 2026-06-13 (hotfix)
11
+
12
+ ### Fixed
13
+
14
+ - **PreToolUse hook `command` field was bare JavaScript source, not a
15
+ `node -e "..."` one-liner.** `peaks workspace init` writes
16
+ `.claude/settings.local.json` containing two PreToolUse hooks (one
17
+ for `Bash`, one for `Write|Edit|MultiEdit`) whose `command` field
18
+ was the inner JS payload without the `node -e "..."` wrapper.
19
+ Claude Code executes the `command` field as a shell string, so
20
+ bash saw literal `const c=process.argv[1]...` and tripped
21
+ `syntax error near unexpected token`. Net effect on every 2.0.3
22
+ install on Windows + macOS + Linux:
23
+ - Every Bash tool call (peaks CLI or otherwise) was rejected.
24
+ - Every Write / Edit / MultiEdit call was rejected.
25
+ - The [Fact-Forcing Gate] bypass that `peaks workspace init` was
26
+ supposed to install was therefore self-defeating — the bypass
27
+ broke the gate itself, and the gate could not be reached to fix
28
+ it.
29
+ Recovery required the user to delete `.claude/settings.local.json`
30
+ manually (losing the bypass permanently) or hand-patch the
31
+ `command` field (drift vs the template).
32
+ The fix wraps both builders' JS payloads in a real shell-evaluable
33
+ `node -e "<js>"` form via a new `wrapAsNodeOneLiner` helper in
34
+ `src/services/workspace/claude-settings-template.ts`. Inner `"`
35
+ are escaped to `\"`; backslashes pass through unchanged so regex
36
+ literals like `/\.peaks\//` still match correctly. `process.argv[1]`
37
+ is the correct slot under `-e` per Node.js docs
38
+ (https://nodejs.org/api/process.html#processargv) — consistent
39
+ across Windows, macOS, and Linux. The docstring is reconciled
40
+ with the implementation (the previous docstring incorrectly said
41
+ `argv[2]`).
42
+
43
+ Regression tests cover:
44
+ - `buildBashHookCommand()` and `buildWriteHookCommand()` return
45
+ `node -e "..."` form.
46
+ - Inner `"` are escaped to `\"`.
47
+ - Spawning the wrapped command with `peaks workspace init --project . --json`
48
+ exits 0; with `npm install foo` exits non-zero.
49
+ - Spawning the Write hook with `.peaks/_runtime/...` and
50
+ `.peaks/<changeId>/...` paths exits 0; with `src/...`,
51
+ `package.json`, `.peaks/_archive/...` exits non-zero.
52
+ - The existing workspace-init round-trip test (case A/B/C) still
53
+ passes with the wrapper.
54
+
55
+ ---
56
+
10
57
  ## [2.0.3] — 2026-06-13
11
58
 
12
59
  ### Fixed
package/bin/peaks.js CHANGED
File without changes
@@ -1,5 +1,8 @@
1
1
  import { runUpgrade } from '../../services/upgrade/upgrade-service.js';
2
2
  import { detect1xProjectState } from '../../services/upgrade/1x-detector-service.js';
3
+ import { initWorkspace } from '../../services/workspace/workspace-service.js';
4
+ import { ensureSessionWithRotation } from '../../services/session/session-manager.js';
5
+ import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
3
6
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
4
7
  import { fail, ok } from '../../shared/result.js';
5
8
  export function registerUpgradeCommands(program, io) {
@@ -9,7 +12,8 @@ export function registerUpgradeCommands(program, io) {
9
12
  .option('--to <version>', 'target version (only "2.0" supported)', '2.0')
10
13
  .option('--project <path>', 'project root to upgrade (default: cwd)')
11
14
  .option('--auto', 'non-interactive: accept soft-fail on any sub-step (used by the postinstall hook)')
12
- .option('--detect-1x', 'read-only probe: returns the 1.x state as JSON (no file writes); consumed by peaks-solo Step 0.55 to gate the AskUserQuestion')).action((options) => {
15
+ .option('--detect-1x', 'read-only probe: returns the 1.x state as JSON (no file writes); consumed by peaks-solo Step 0.55 to gate the AskUserQuestion')
16
+ .option('--apply-init', 'slice 4 (slice 2026-06-13-selfheal-claude-settings-template): run initWorkspace so the drift-driven self-heal fires on the consumer-project .claude/settings.local.json and the offline .peaks/.claude-settings-template.json. Idempotent. Use after a peaks-cli version bump if you do not otherwise re-run init. Mutually exclusive with --detect-1x.')).action(async (options) => {
13
17
  const projectRoot = options.project ?? process.cwd();
14
18
  // Branch 1: --detect-1x (read-only probe)
15
19
  if (options.detect1x === true) {
@@ -32,7 +36,65 @@ export function registerUpgradeCommands(program, io) {
32
36
  }
33
37
  return;
34
38
  }
35
- // Branch 2: the umbrella (existing behavior)
39
+ // Branch 2: --apply-init (slice 4 — slice 2026-06-13-selfheal-claude-settings-template).
40
+ //
41
+ // The drift-driven self-heal inside initWorkspace only fires when
42
+ // the user invokes init. After a peaks-cli version bump, users who
43
+ // never re-run init are stuck with stale templates until they do.
44
+ // This flag is the post-bump escape hatch: it triggers init for them.
45
+ //
46
+ // We do NOT pass --session-id (the CLI auto-generates / reuses an
47
+ // existing binding). We do NOT pass --no-claude-hooks (the goal is to
48
+ // bring the project to the current peaks-cli baseline, including the
49
+ // consumer-project hook).
50
+ if (options.applyInit === true) {
51
+ try {
52
+ const canonicalRoot = resolveCanonicalProjectRoot(projectRoot);
53
+ // Match the workspace-init CLI's pattern: resolve the session id
54
+ // (auto-generate / reuse binding / rotate on outer-mismatch)
55
+ // BEFORE calling initWorkspace. initWorkspace itself validates
56
+ // the session id and does NOT auto-generate, so we have to do
57
+ // the rotation-aware resolution here.
58
+ const sessionResolution = await ensureSessionWithRotation(canonicalRoot, {
59
+ skipRotateOnOuterMismatch: false
60
+ });
61
+ const result = await initWorkspace({
62
+ projectRoot: canonicalRoot,
63
+ sessionId: sessionResolution.sessionId,
64
+ allowSessionRebind: false
65
+ });
66
+ const nextActions = [];
67
+ // Surface the same self-heal messaging that workspace-init uses.
68
+ if (result.claudeSettings.offlineTemplate.action === 'refreshed') {
69
+ nextActions.push(`Self-healed .peaks/.claude-settings-template.json (action: refreshed) — ` +
70
+ 'the offline recovery anchor now matches the current peaks-cli template.');
71
+ nextActions.push('⚠️ If you had manually edited .peaks/.claude-settings-template.json, ' +
72
+ 'those edits have been overwritten by the self-heal.');
73
+ }
74
+ else if (result.claudeSettings.offlineTemplate.action === 'written') {
75
+ nextActions.push('Wrote .peaks/.claude-settings-template.json (action: written) — ' +
76
+ 'the offline recovery anchor is now in place.');
77
+ }
78
+ if (result.claudeSettings.action === 'refreshed') {
79
+ nextActions.push(`Refreshed .claude/settings.local.json (action: refreshed) — ` +
80
+ 'the consumer-project hook now matches the current peaks-cli template. ' +
81
+ 'Restart Claude Code so the hooks take effect.');
82
+ }
83
+ else if (result.claudeSettings.action === 'written') {
84
+ nextActions.push('Wrote .claude/settings.local.json (action: written) — ' +
85
+ 'the [Fact-Forcing Gate] bypass is now in effect. Restart Claude Code so the hooks take effect.');
86
+ }
87
+ const envelope = ok('upgrade.apply-init', result, [], nextActions);
88
+ printResult(io, envelope, options.json);
89
+ }
90
+ catch (error) {
91
+ const message = getErrorMessage(error);
92
+ printResult(io, fail('upgrade.apply-init', 'APPLY_INIT_FAILED', message, { applied: false }, [message]), options.json);
93
+ process.exitCode = 1;
94
+ }
95
+ return;
96
+ }
97
+ // Branch 3: the umbrella (existing behavior)
36
98
  try {
37
99
  const result = runUpgrade({ projectRoot, auto: options.auto === true });
38
100
  const nextActions = [...result.nextActions];
@@ -217,6 +217,34 @@ export function registerWorkspaceCommands(program, io) {
217
217
  'again without --no-claude-hooks, or drop the contents of ' +
218
218
  '`.peaks/.claude-settings-template.json` into `.claude/settings.local.json` manually.');
219
219
  }
220
+ // Slice 2026-06-13-selfheal-claude-settings-template: surface
221
+ // the self-heal outcome for the offline
222
+ // `.peaks/.claude-settings-template.json` copy. When the offline
223
+ // file was refreshed (i.e. the previous peaks-cli release left
224
+ // a stale version without the `node -e "..."` wrapper), the user
225
+ // benefits from seeing that the manual-recovery anchor now
226
+ // points at the corrected template. We surface `written` and
227
+ // `refreshed` as the actionable events; `already-current` is
228
+ // silent (same rationale as the consumer-project no-op above).
229
+ //
230
+ // The `refreshed` nextAction also carries a loud warning that any
231
+ // MANUAL EDITS the user made to the offline template have been
232
+ // overwritten — drift detection cannot tell stale-from-prior-release
233
+ // apart from user-customised, so we surface the warning unconditionally
234
+ // to make sure anyone who customised sees the prompt.
235
+ if (report.claudeSettings.offlineTemplate.action === 'refreshed') {
236
+ nextActions.push(`Self-healed .peaks/.claude-settings-template.json (action: refreshed) — ` +
237
+ 'the offline recovery anchor now matches the current peaks-cli template. ' +
238
+ 'No action required; future manual recoveries will copy the corrected wrapper.');
239
+ nextActions.push('⚠️ If you had manually edited .peaks/.claude-settings-template.json, ' +
240
+ 'those edits have been overwritten by the self-heal. ' +
241
+ 'Re-apply your custom matchers / commands on top of the freshly-written template, ' +
242
+ 'or open an issue if your customisation is a recurring need (the team may promote it to the canonical template).');
243
+ }
244
+ else if (report.claudeSettings.offlineTemplate.action === 'written') {
245
+ nextActions.push(`Wrote .peaks/.claude-settings-template.json (action: written) — ` +
246
+ 'the offline recovery anchor is now in place for future manual recoveries.');
247
+ }
220
248
  // First-time hooks install decision. Sticky-marker at
221
249
  // .peaks/.peaks-init-hooks-decision.json records the user's answer
222
250
  // (or the auto-decision) so subsequent inits for new sessions in the
@@ -29,6 +29,32 @@
29
29
  * canonical list.
30
30
  */
31
31
  export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.json";
32
+ /**
33
+ * Informational version of the offline template shape. Bumped when the
34
+ * template's hooks tree (matchers, allow-list content, wrapper format)
35
+ * changes in a way that should trigger a refresh of stale on-disk
36
+ * copies. The comparator (`templateContentMatches`) is the source of
37
+ * truth for refresh decisions — this constant exists so a developer
38
+ * reading the diff can correlate a template change with a deliberate
39
+ * bump. Future work may write a version-marker file to short-circuit
40
+ * the comparator; for now the constant is informational only.
41
+ */
42
+ export declare const TEMPLATE_VERSION = "1.1.0";
43
+ /**
44
+ * Compare two serialized template strings for semantic equivalence.
45
+ *
46
+ * Returns `true` iff both strings parse to objects whose
47
+ * `hooks.PreToolUse` arrays are structurally identical (same length;
48
+ * each entry's `matcher`, `hooks[].type`, `hooks[].command` match).
49
+ *
50
+ * Returns `false` on any `JSON.parse` error, shape mismatch, or
51
+ * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
52
+ * the result — the comparison is on the parsed AST, not on bytes.
53
+ *
54
+ * This is the comparator `initWorkspace` uses to decide whether to
55
+ * refresh a stale `.peaks/.claude-settings-template.json` on disk.
56
+ */
57
+ export declare function templateContentMatches(generated: string, onDisk: string): boolean;
32
58
  type ClaudeHookCommand = {
33
59
  type: 'command';
34
60
  command: string;
@@ -52,10 +52,127 @@ const PEAKS_SUBCOMMAND_ALLOWLIST = [
52
52
  'upgrade'
53
53
  ];
54
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).
55
+ * Informational version of the offline template shape. Bumped when the
56
+ * template's hooks tree (matchers, allow-list content, wrapper format)
57
+ * changes in a way that should trigger a refresh of stale on-disk
58
+ * copies. The comparator (`templateContentMatches`) is the source of
59
+ * truth for refresh decisions — this constant exists so a developer
60
+ * reading the diff can correlate a template change with a deliberate
61
+ * bump. Future work may write a version-marker file to short-circuit
62
+ * the comparator; for now the constant is informational only.
63
+ */
64
+ export const TEMPLATE_VERSION = '1.1.0';
65
+ /**
66
+ * Compare two serialized template strings for semantic equivalence.
67
+ *
68
+ * Returns `true` iff both strings parse to objects whose
69
+ * `hooks.PreToolUse` arrays are structurally identical (same length;
70
+ * each entry's `matcher`, `hooks[].type`, `hooks[].command` match).
71
+ *
72
+ * Returns `false` on any `JSON.parse` error, shape mismatch, or
73
+ * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
74
+ * the result — the comparison is on the parsed AST, not on bytes.
75
+ *
76
+ * This is the comparator `initWorkspace` uses to decide whether to
77
+ * refresh a stale `.peaks/.claude-settings-template.json` on disk.
78
+ */
79
+ export function templateContentMatches(generated, onDisk) {
80
+ let parsedGenerated;
81
+ let parsedOnDisk;
82
+ try {
83
+ parsedGenerated = JSON.parse(generated);
84
+ }
85
+ catch {
86
+ return false;
87
+ }
88
+ try {
89
+ parsedOnDisk = JSON.parse(onDisk);
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ if (!isTemplateShape(parsedGenerated) || !isTemplateShape(parsedOnDisk)) {
95
+ return false;
96
+ }
97
+ const generatedEntries = parsedGenerated.hooks.PreToolUse;
98
+ const onDiskEntries = parsedOnDisk.hooks.PreToolUse;
99
+ if (generatedEntries.length !== onDiskEntries.length) {
100
+ return false;
101
+ }
102
+ for (let i = 0; i < generatedEntries.length; i += 1) {
103
+ const a = generatedEntries[i];
104
+ const b = onDiskEntries[i];
105
+ if (a.matcher !== b.matcher) {
106
+ return false;
107
+ }
108
+ if (!sameHooksArray(a.hooks, b.hooks)) {
109
+ return false;
110
+ }
111
+ }
112
+ return true;
113
+ }
114
+ function isTemplateShape(value) {
115
+ if (typeof value !== 'object' || value === null) {
116
+ return false;
117
+ }
118
+ const candidate = value;
119
+ if (typeof candidate.hooks !== 'object' || candidate.hooks === null) {
120
+ return false;
121
+ }
122
+ const hooksObj = candidate.hooks;
123
+ return Array.isArray(hooksObj.PreToolUse);
124
+ }
125
+ function sameHooksArray(a, b) {
126
+ if (a.length !== b.length) {
127
+ return false;
128
+ }
129
+ for (let i = 0; i < a.length; i += 1) {
130
+ const ha = a[i];
131
+ const hb = b[i];
132
+ if (ha.type !== hb.type || ha.command !== hb.command) {
133
+ return false;
134
+ }
135
+ }
136
+ return true;
137
+ }
138
+ /**
139
+ * Wrap an inner JavaScript payload as a shell-evaluable `node -e "..."`
140
+ * one-liner. The returned string is what Claude Code writes verbatim
141
+ * into `.claude/settings.local.json` under the `command` field. Per
142
+ * Node.js docs (https://nodejs.org/api/process.html#processargv), when
143
+ * using `-e` there is no script-file slot, so `process.argv[1]` is the
144
+ * first user-passed extra argument. This is consistent across Windows,
145
+ * macOS, and Linux.
146
+ *
147
+ * Every `"` character in the inner JS must be JSON-escaped as `\\"`
148
+ * so that the surrounding wrapper `node -e "..."` parses correctly:
149
+ * the shell sees the escape and passes a literal `"` to Node. A
150
+ * single missed escape closes the wrapper early and the entire hook
151
+ * regresses to the bash-syntax-error class of bug.
152
+ *
153
+ * @param js Inner JavaScript payload. Must be a single statement or a
154
+ * sequence of statements joined with `;`. The wrapper does
155
+ * not insert any `;` between the payload and the closing
156
+ * `"` because Node accepts a trailing expression with `;`
157
+ * already terminated by the payload itself.
158
+ */
159
+ function wrapAsNodeOneLiner(js) {
160
+ // Only `"` needs JSON-escaping: the wrapper uses double quotes, so an
161
+ // unescaped inner `"` would close the wrapper prematurely. Backslashes
162
+ // do NOT need escaping here — bash inside a `"..."` wrapper reduces
163
+ // `\\` to `\`, so any `\X` in the inner JS reaches Node as `\X`,
164
+ // which is what regex literals like `/\.peaks\//` need. Adding a
165
+ // second `\\` → `\\` pass would double-escape backslashes and break
166
+ // every regex literal the inner JS contains.
167
+ const escaped = js.replace(/"/g, '\\"');
168
+ return `node -e "${escaped}"`;
169
+ }
170
+ /**
171
+ * Build the Bash matcher command. The command is a `node -e "..."`
172
+ * one-liner that reads its candidate command string from `argv[1]`
173
+ * and exits 0 iff the command starts with
174
+ * `peaks <whitelisted-subcommand> ` (or is exactly
175
+ * `peaks <whitelisted-subcommand>` with no trailing args).
59
176
  *
60
177
  * The list is serialised as a JSON array literal embedded in the
61
178
  * command string so we avoid regex special-character pitfalls and
@@ -63,15 +180,17 @@ const PEAKS_SUBCOMMAND_ALLOWLIST = [
63
180
  */
64
181
  function buildBashHookCommand() {
65
182
  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]||"";' +
183
+ // The command reads process.argv[1] (the tool-call command string
184
+ // passed by Claude Code), checks it starts with `peaks `, splits on
185
+ // whitespace, and looks up the second token in the allowlist. Exit
186
+ // 0 = allow, exit 1 = deny (so the gate fires for non-peaks
187
+ // commands).
188
+ const js = 'const c=process.argv[1]||"";' +
71
189
  'if(!c.startsWith("peaks "))process.exit(1);' +
72
190
  'const sub=c.slice(6).trim().split(/\\s+/)[0];' +
73
191
  `if(${allowlistLiteral}.indexOf(sub)===-1)process.exit(1);` +
74
- 'process.exit(0)');
192
+ 'process.exit(0)';
193
+ return wrapAsNodeOneLiner(js);
75
194
  }
76
195
  /**
77
196
  * Build the Write|Edit|MultiEdit matcher command. The command reads
@@ -91,12 +210,14 @@ function buildWriteHookCommand() {
91
210
  // Path-matching: allow when the path contains `.peaks/_runtime/`
92
211
  // OR when the second `.peaks/` segment starts with anything that
93
212
  // looks like a change-id (kebab-case slug). Exit 0 for allow, exit
94
- // 1 for deny.
95
- return ('const p=process.argv[1]||"";' +
213
+ // 1 for deny. The candidate path arrives on `process.argv[1]` per
214
+ // Node.js argv layout under `-e` (cross-platform consistent).
215
+ const js = 'const p=process.argv[1]||"";' +
96
216
  'if(p.includes(".peaks/_runtime/"))process.exit(0);' +
97
217
  'const m=p.match(/\\.peaks\\/([a-z0-9][a-z0-9.-]*)\\//);' +
98
218
  '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)');
219
+ 'process.exit(1)';
220
+ return wrapAsNodeOneLiner(js);
100
221
  }
101
222
  /**
102
223
  * Build the full template object. The shape is the subset of Claude
@@ -46,10 +46,26 @@ export type WorkspaceInitReport = {
46
46
  * - skipped: the caller passed noClaudeHooks=true
47
47
  * The LLM and the user both see this in the JSON envelope so they
48
48
  * can decide whether the bypass is in effect.
49
+ *
50
+ * Slice 2026-06-13-selfheal-claude-settings-template: adds the
51
+ * `offlineTemplate` sub-field, which describes the self-heal action
52
+ * taken on the offline `.peaks/.claude-settings-template.json` copy.
53
+ * The offline copy is ALWAYS written/checked (regardless of
54
+ * noClaudeHooks) because it is the manual-recovery anchor — see
55
+ * `skills/peaks-solo/references/anchoring-and-session-info.md`.
56
+ * - written: the file did not exist; it was created
57
+ * - refreshed: the file existed but its parsed hooks tree
58
+ * diverged from the current `buildClaudeSettingsLocalJson()`;
59
+ * it was rewritten
60
+ * - already-current: the file already matched; no rewrite needed
49
61
  */
50
62
  claudeSettings: {
51
63
  action: 'written' | 'refreshed' | 'already-current' | 'skipped';
52
64
  path: string;
65
+ offlineTemplate: {
66
+ action: 'written' | 'refreshed' | 'already-current';
67
+ path: string;
68
+ };
53
69
  };
54
70
  };
55
71
  export declare class InvalidSessionIdError extends Error {
@@ -4,7 +4,7 @@ import { join } from 'node:path';
4
4
  import { isDirectory } from '../../shared/fs.js';
5
5
  import { getSessionId, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
6
6
  import { setCurrentChangeId } from '../../shared/change-id.js';
7
- import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME } from './claude-settings-template.js';
7
+ import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME, templateContentMatches } from './claude-settings-template.js';
8
8
  const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-[a-z][a-z0-9-]*[a-z0-9]$/;
9
9
  const PROHIBITED_SUFFIXES = ['session', 'work', 'task', 'test', 'temp', 'tmp'];
10
10
  // Auto-generated session ID pattern: YYYY-MM-DD-session-<6位hex>
@@ -192,6 +192,13 @@ const PEAKS_GITIGNORE_SNIPPET = [
192
192
  '# Consumer-project .claude/settings.local.json: written by `peaks workspace init`',
193
193
  '# to bypass Claude Code [Fact-Forcing Gate] for .peaks/** writes. Local-only.',
194
194
  '.claude/settings.local.json',
195
+ '# Offline template copy (.peaks/.claude-settings-template.json): written by',
196
+ '# `peaks workspace init` as a manual-recovery anchor. The source-of-truth is',
197
+ '# peaks-cli\'s own `buildClaudeSettingsLocalJson()` — NOT this committed copy.',
198
+ '# Gitignored so the init flow\'s drift-driven refresh does not show up as',
199
+ '# "modified" in `git status` on every release bump. Recovery path: re-run',
200
+ '# `peaks workspace init` to regenerate; or copy from peaks-cli source.',
201
+ '.peaks/.claude-settings-template.json',
195
202
  PEAKS_GITIGNORE_FOOTER,
196
203
  ''
197
204
  ].join('\n');
@@ -213,18 +220,29 @@ const PEAKS_GITIGNORE_SNIPPET = [
213
220
  * `.claude/settings.local.json` manually. The recovery path is
214
221
  * documented in
215
222
  * `skills/peaks-solo/references/anchoring-and-session-info.md`.
223
+ *
224
+ * Slice 2026-06-13-selfheal-claude-settings-template: the offline copy
225
+ * is now ALSO drift-checked (via `templateContentMatches`) so stale
226
+ * on-disk copies from earlier peaks-cli releases (which lacked the
227
+ * `node -e "..."` wrapper) get refreshed automatically on the next
228
+ * init. The action taken on the offline copy is surfaced in
229
+ * `claudeSettings.offlineTemplate.action`.
216
230
  */
217
231
  async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks) {
218
232
  const settingsRel = CLAUDE_SETTINGS_LOCAL_FILENAME;
219
233
  const settingsPath = join(projectRoot, settingsRel);
220
234
  const template = buildClaudeSettingsLocalJson();
221
235
  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);
236
+ // Always drop (or self-heal) a copy of the template under .peaks/
237
+ // so the --no-claude-hooks recovery flow has a known source-of-truth
238
+ // on disk. The file is gitignored by the snippet below.
239
+ const offlineAction = await writeOfflineTemplateCopy(projectRoot, serialized);
240
+ const offlineTemplate = {
241
+ action: offlineAction,
242
+ path: '.peaks/.claude-settings-template.json'
243
+ };
226
244
  if (noClaudeHooks) {
227
- return { action: 'skipped', path: settingsRel };
245
+ return { action: 'skipped', path: settingsRel, offlineTemplate };
228
246
  }
229
247
  // Best-effort: ensure .claude/ exists, then write the file. We do
230
248
  // not assertSafeSettingsPath here (the .claude/ dir is local to
@@ -257,20 +275,62 @@ async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks) {
257
275
  // settings file. The snippet is appended only when the header is
258
276
  // missing, so subsequent inits do not double-append.
259
277
  await upsertPeaksGitignoreSnippet(projectRoot);
260
- return { action, path: settingsRel };
278
+ return { action, path: settingsRel, offlineTemplate };
261
279
  }
262
280
  /**
263
281
  * Always write (or refresh) a copy of the template at
264
282
  * `.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.
283
+ * source-of-truth on disk for the manual recovery flow. The file is
284
+ * GITIGNORED (added to `.peaks/.gitignore` by
285
+ * `upsertPeaksGitignoreSnippet`) — the source-of-truth lives in
286
+ * peaks-cli's own `buildClaudeSettingsLocalJson()`, NOT in any
287
+ * committed copy. Gitignoring it ensures the init flow's drift-driven
288
+ * refresh does not show up as "modified" in `git status` on every
289
+ * peaks-cli release bump.
290
+ *
291
+ * Recovery path for users who need to re-create their
292
+ * `.claude/settings.local.json`: re-run `peaks workspace init`
293
+ * (the file is regenerated); or copy the template straight from
294
+ * peaks-cli source (`src/services/workspace/claude-settings-template.ts`).
295
+ *
296
+ * Slice 2026-06-13-selfheal-claude-settings-template: drift-check via
297
+ * `templateContentMatches` BEFORE writing. If the on-disk copy's
298
+ * parsed hooks tree matches the current `buildClaudeSettingsLocalJson()`
299
+ * output, the write is skipped (`already-current`). If the file is
300
+ * missing, it is written (`written`). If it exists but has drifted
301
+ * (e.g. an earlier release's template without the `node -e "..."`
302
+ * wrapper, or a user-customised copy), it is rewritten (`refreshed`).
303
+ * The CLI caller surfaces a warning when `refreshed` because manual
304
+ * edits the user may have made would be overwritten.
305
+ *
306
+ * Returns the action taken so the caller can surface it in the
307
+ * envelope. Read failures are treated as drift so a malformed
308
+ * on-disk file always self-heals on the next init.
269
309
  */
270
310
  async function writeOfflineTemplateCopy(projectRoot, serialized) {
271
311
  const copyPath = join(projectRoot, '.peaks', '.claude-settings-template.json');
272
312
  await mkdir(join(projectRoot, '.peaks'), { recursive: true });
273
- await writeFile(copyPath, serialized, 'utf8');
313
+ let action = 'written';
314
+ if (existsSync(copyPath)) {
315
+ try {
316
+ const { readFile } = await import('node:fs/promises');
317
+ const existing = await readFile(copyPath, 'utf8');
318
+ if (templateContentMatches(serialized, existing)) {
319
+ action = 'already-current';
320
+ }
321
+ else {
322
+ action = 'refreshed';
323
+ }
324
+ }
325
+ catch {
326
+ // Treat any read failure as drift so the file self-heals.
327
+ action = 'refreshed';
328
+ }
329
+ }
330
+ if (action !== 'already-current') {
331
+ await writeFile(copyPath, serialized, 'utf8');
332
+ }
333
+ return action;
274
334
  }
275
335
  /**
276
336
  * Append the peaks-managed `.claude/settings.local.json` snippet to
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "2.0.3";
1
+ export declare const CLI_VERSION = "2.0.5";
@@ -1 +1 @@
1
- export const CLI_VERSION = "2.0.3";
1
+ export const CLI_VERSION = "2.0.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
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",
@@ -31,4 +31,32 @@ If the bypass is not in effect (e.g. `.claude/` was read-only, or the user passe
31
31
 
32
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
33
 
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.
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.
35
+
36
+ ## Step 0.6 — Heal stale templates after a peaks-cli version bump
37
+
38
+ > Slice 2026-06-13-selfheal-claude-settings-template. Read when the envelope's `claudeSettings.offlineTemplate.action === 'refreshed'` OR when the user just bumped peaks-cli and you suspect the consumer project's templates are out of date.
39
+
40
+ **Why this step exists:** peaks-cli releases can change `buildClaudeSettingsLocalJson()` — the source-of-truth function for the consumer-project `.claude/settings.local.json` and the offline `.peaks/.claude-settings-template.json`. When that function changes (e.g. the `node -e "..."` wrapper added in commit `9551c52`), existing on-disk copies from previous peaks-cli releases become **stale** and can break Claude Code's [Fact-Forcing Gate] bypass. The drift-driven self-heal inside `initWorkspace` (added in this slice) catches the drift and refreshes both files automatically on the next init. This step is the **user-visible surfacing** of that heal.
41
+
42
+ **Three trigger paths bring the project to the current peaks-cli baseline:**
43
+
44
+ 1. **Normal workflow (auto):** any `peaks-solo` invocation → Step 0 anchor → `peaks workspace init` → drift check → self-heal if needed. This is the default path; users typically do NOT need to do anything explicit.
45
+ 2. **Manual init (idempotent):** `peaks workspace init --project <repo> --json` — same drift check, same self-heal. Safe to re-run any number of times.
46
+ 3. **Post-upgrade escape hatch:** `peaks upgrade --apply-init --project <repo> --json` — slice 4 (this slice). For users who upgrade peaks-cli but do not invoke `peaks-solo` after the bump (e.g. they installed 2.0.5 today but their next `peaks-solo` session is next week). The flag triggers `initWorkspace` directly.
47
+
48
+ **NextActions surfaced after Step 0 when self-heal fires:**
49
+
50
+ - `claudeSettings.offlineTemplate.action === 'refreshed'` → nextAction: "Self-healed `.peaks/.claude-settings-template.json` (action: refreshed) — the offline recovery anchor now matches the current peaks-cli template."
51
+ - Same → warning nextAction: "⚠️ If you had manually edited `.peaks/.claude-settings-template.json`, those edits have been overwritten by the self-heal. Re-apply your custom matchers / commands on top of the freshly-written template, or open an issue if your customisation is a recurring need (the team may promote it to the canonical template)."
52
+ - `claudeSettings.offlineTemplate.action === 'written'` → nextAction: "Wrote `.peaks/.claude-settings-template.json` (action: written) — the offline recovery anchor is now in place for future manual recoveries."
53
+ - `claudeSettings.action === 'refreshed'` (consumer-project file) → nextAction: "Materialized `.claude/settings.local.json` (action: refreshed) — the [Fact-Forcing Gate] is bypassed for tool calls inside .peaks/\*\*. Restart Claude Code so the hooks take effect."
54
+ - `claudeSettings.action === 'already-current'` → silent (no nextAction) — the bypass is already in effect and matches the current release; do not spam the nextAction list on every init.
55
+
56
+ **When to surface `--apply-init` to the user (LLM-only guidance):**
57
+
58
+ - When the user just upgraded peaks-cli (e.g. they ran `npm i -g peaks-cli@latest` between sessions) AND the Step 0 init envelope shows `offlineTemplate.action === 'refreshed'`, do NOT prompt for `--apply-init` — the heal already happened.
59
+ - When the user reports a stuck [Fact-Forcing Gate] that survives Step 0 (i.e. `peaks workspace init` ran without throwing but `Bash` / `Write` calls still get blocked), surface `peaks upgrade --apply-init --project <repo>` as a manual fallback. The flag is idempotent — safe to re-run.
60
+ - When the user's project has NO `.peaks/_runtime/` at all (i.e. they never ran init), do NOT recommend `--apply-init` first; recommend `peaks workspace init` instead. `--apply-init` works on first-time projects too, but `init` is the canonical entry point and produces a richer envelope.
61
+
62
+ **Anti-bail-out rule:** do NOT silently swallow the warning nextAction about manual-edits-overwritten. If the user customised `.peaks/.claude-settings-template.json` and the self-heal wiped their changes, that is data loss from their perspective — surface it. The loud ⚠️ is a feature, not noise.