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 +47 -0
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/upgrade-commands.js +64 -2
- package/dist/src/cli/commands/workspace-commands.js +28 -0
- package/dist/src/services/workspace/claude-settings-template.d.ts +26 -0
- package/dist/src/services/workspace/claude-settings-template.js +134 -13
- package/dist/src/services/workspace/workspace-service.d.ts +16 -0
- package/dist/src/services/workspace/workspace-service.js +72 -12
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-solo/references/anchoring-and-session-info.md +29 -1
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')
|
|
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:
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
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[
|
|
67
|
-
// checks it starts with `peaks `, splits on
|
|
68
|
-
// up the second token in the allowlist. Exit
|
|
69
|
-
// deny (so the gate fires for non-peaks
|
|
70
|
-
|
|
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
|
-
|
|
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/
|
|
223
|
-
// --no-claude-hooks recovery flow has a known source-of-truth
|
|
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.
|
|
266
|
-
*
|
|
267
|
-
* —
|
|
268
|
-
*
|
|
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
|
-
|
|
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.
|
|
1
|
+
export declare const CLI_VERSION = "2.0.5";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export const CLI_VERSION = "2.0.
|
|
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
|
+
"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.
|