peaks-loop 4.0.36 → 4.0.38

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.
Files changed (93) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/peaks.js +71 -1
  5. package/dist/cli/cli-helpers.js +7 -0
  6. package/dist/cli/commands/_register.js +2 -0
  7. package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
  8. package/dist/cli/commands/best-practice-scan-command.js +67 -9
  9. package/dist/cli/commands/code-runtime-commands.js +21 -5
  10. package/dist/cli/commands/hooks-commands.js +41 -7
  11. package/dist/cli/commands/job-commands.js +107 -25
  12. package/dist/cli/commands/scan-commands.js +1 -1
  13. package/dist/cli/commands/web-commands.d.ts +28 -0
  14. package/dist/cli/commands/web-commands.js +327 -0
  15. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  16. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  17. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  18. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  19. package/dist/services/code/orchestrator-can-do.js +27 -4
  20. package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
  21. package/dist/services/context/build-dispatch-system-prompt.js +55 -3
  22. package/dist/services/context/context-audit-hint.d.ts +79 -0
  23. package/dist/services/context/context-audit-hint.js +150 -0
  24. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  25. package/dist/services/hooks/write-gate.js +88 -0
  26. package/dist/services/lint/detect-eslint.d.ts +2 -0
  27. package/dist/services/lint/detect-eslint.js +23 -9
  28. package/dist/services/lint/detect-ocr-18.d.ts +2 -0
  29. package/dist/services/lint/detect-ocr-18.js +36 -5
  30. package/dist/services/lint/npx-resolver.d.ts +6 -0
  31. package/dist/services/lint/npx-resolver.js +38 -14
  32. package/dist/services/lint/ocr-multilang-adapter.js +9 -2
  33. package/dist/services/release/version-precheck-service.js +9 -2
  34. package/dist/services/scan/file-size-scan.d.ts +29 -0
  35. package/dist/services/scan/file-size-scan.js +63 -0
  36. package/dist/services/session/caller-binding-service.d.ts +24 -0
  37. package/dist/services/session/caller-binding-service.js +34 -0
  38. package/dist/services/session/getSessionDir.js +15 -10
  39. package/dist/services/skills/hooks-codegate-superpowers.d.ts +74 -0
  40. package/dist/services/skills/hooks-codegate-superpowers.js +129 -3
  41. package/dist/services/skills/hooks-settings-service.d.ts +26 -0
  42. package/dist/services/skills/hooks-settings-service.js +186 -62
  43. package/dist/services/slice/slice-check-service.d.ts +14 -0
  44. package/dist/services/slice/slice-check-service.js +110 -50
  45. package/dist/services/slice/slice-check-types.d.ts +12 -7
  46. package/dist/services/slice/slice-check-types.js +8 -3
  47. package/dist/services/slice/slice-decompose-runners.js +24 -21
  48. package/dist/services/sop/sop-check-service.js +12 -1
  49. package/dist/services/web/bounded-output.d.ts +34 -0
  50. package/dist/services/web/bounded-output.js +68 -0
  51. package/dist/services/web/browser-acquire.d.ts +14 -0
  52. package/dist/services/web/browser-acquire.js +84 -0
  53. package/dist/services/web/browser-session-manager.d.ts +111 -0
  54. package/dist/services/web/browser-session-manager.js +413 -0
  55. package/dist/services/web/daemon-entry.d.ts +1 -0
  56. package/dist/services/web/daemon-entry.js +65 -0
  57. package/dist/services/web/daemon-registry.d.ts +42 -0
  58. package/dist/services/web/daemon-registry.js +164 -0
  59. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  60. package/dist/services/web/daemon-supervisor.js +455 -0
  61. package/dist/services/web/playwright-loader.d.ts +89 -0
  62. package/dist/services/web/playwright-loader.js +253 -0
  63. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  64. package/dist/services/web/snapshot-pruner.js +241 -0
  65. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  66. package/dist/services/web/untrusted-envelope.js +44 -0
  67. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  68. package/dist/services/web/web-artifact-paths.js +163 -0
  69. package/dist/services/web/web-client.d.ts +19 -0
  70. package/dist/services/web/web-client.js +55 -0
  71. package/dist/services/web/web-daemon-service.d.ts +38 -0
  72. package/dist/services/web/web-daemon-service.js +416 -0
  73. package/dist/services/web/web-fallback.d.ts +70 -0
  74. package/dist/services/web/web-fallback.js +121 -0
  75. package/dist/services/web/web-install-service.d.ts +91 -0
  76. package/dist/services/web/web-install-service.js +346 -0
  77. package/dist/services/web/web-login-profile.d.ts +89 -0
  78. package/dist/services/web/web-login-profile.js +612 -0
  79. package/dist/services/web/web-login-staging.d.ts +27 -0
  80. package/dist/services/web/web-login-staging.js +173 -0
  81. package/dist/services/web/web-protocol.d.ts +58 -0
  82. package/dist/services/web/web-protocol.js +58 -0
  83. package/dist/services/web/web-status-report.d.ts +33 -0
  84. package/dist/services/web/web-status-report.js +47 -0
  85. package/dist/services/workspace/claude-settings-template.d.ts +59 -7
  86. package/dist/services/workspace/claude-settings-template.js +139 -67
  87. package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -19
  88. package/dist/services/workspace/workspace-service.js +33 -0
  89. package/package.json +5 -5
  90. package/scripts/copy-templates.mjs +12 -0
  91. package/scripts/sync-version.mjs +20 -0
  92. package/skills/peaks-code/SKILL.md +10 -0
  93. package/skills/peaks-code/references/browser-workflow.md +10 -1
@@ -14,10 +14,12 @@
14
14
  * the in-memory template byte-for-byte.
15
15
  *
16
16
  * One matcher is emitted:
17
- * 1. `Write|Edit|MultiEdit` — a node one-liner that path-matches
18
- * `.peaks/_runtime/` and `.peaks/_runtime/<sessionId>/`. Exits 0 (allow)
19
- * for those paths, non-zero (deny → fall through to gate) for
20
- * everything else.
17
+ * 1. `Write|Edit|MultiEdit` — a `node <script>` handler that runs the
18
+ * path gate shipped at `src/services/hooks/write-gate.js`. Exits 0
19
+ * (allow) for the paths the gate skips, exit 1 (deny → fall through to
20
+ * gate) for everything else. TEMPLATE_VERSION 1.6.0 moved the decision
21
+ * out of an inlined `node -e "<js>"` one-liner, whose escaping was
22
+ * bash-specific and therefore could not take a platform `shell` pin.
21
23
  *
22
24
  * The previous `Bash` matcher (which whitelisted a fixed `peaks
23
25
  * <subcommand>` prefix) was removed in TEMPLATE_VERSION 1.2.0. The
@@ -31,6 +33,9 @@
31
33
  * consumer project's `.claude/settings.json` and which exits 0
32
34
  * silently for any command not guarded by a registered SOP gate.
33
35
  */
36
+ import { dirname, resolve } from 'node:path';
37
+ import { fileURLToPath } from 'node:url';
38
+ import { EXTERNAL_GATE_EXEMPT_ENV, hasExternalGateExemptions, resolveHookShell, resolveHookSpec } from '../skills/hooks-codegate-superpowers.js';
34
39
  export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
35
40
  /**
36
41
  * Informational version of the offline template shape. Bumped when the
@@ -55,14 +60,41 @@ export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
55
60
  * gate). The existing Write|Edit|MultiEdit matcher is
56
61
  * preserved. The new matcher's exit code is the load-bearing
57
62
  * signal: 0 = allow, 2 = block (with stderr BLOCKED reason).
63
+ * 1.4.0 — added the `peaks gate enforce` `Bash` PreToolUse entry. It
64
+ * lives here (machine-local, gitignored file) rather than in
65
+ * the committed `.claude/settings.json` because its `shell`
66
+ * is machine-specific: on Windows the default Git-Bash shell
67
+ * force-allocates a console window on every Bash tool call.
68
+ * This template is the second writer of that file, so it must
69
+ * emit the entry too — otherwise `peaks workspace init` would
70
+ * overwrite whatever `peaks hooks install` put there.
71
+ * 1.5.0 — pinned the same platform `shell` on the `peaks code
72
+ * gate-step-08` handler. It runs on the same `Bash` matcher,
73
+ * so leaving it un-pinned left the console-window defect in
74
+ * place for half of every Bash tool call.
75
+ * 1.6.0 — the `Write|Edit|MultiEdit` handler no longer inlines its
76
+ * JavaScript as `node -e "<js>"`. It invokes the shipped script
77
+ * `src/services/hooks/write-gate.js` instead, so the command
78
+ * string carries no shell-escaped payload at all and the handler
79
+ * can take the same platform `shell` pin as its siblings. The
80
+ * decision itself is a verbatim relocation — see that file.
81
+ * 1.7.0 — added the `env` block declaring Peaks' workspace tree exempt
82
+ * from a THIRD-PARTY PreToolUse fact-forcing gate
83
+ * (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
84
+ * on-disk file to declare those exemptions too, so a project
85
+ * installed by an earlier release refreshes once and converges.
58
86
  */
59
- export const TEMPLATE_VERSION = '1.3.0';
87
+ export const TEMPLATE_VERSION = '1.7.0';
60
88
  /**
61
- * Compare two serialized template strings for semantic equivalence.
89
+ * Compare two serialized template strings for semantic equivalence: does the
90
+ * on-disk file already declare everything the generated template declares?
62
91
  *
63
92
  * Returns `true` iff both strings parse to objects whose
64
93
  * `hooks.PreToolUse` arrays are structurally identical (same length;
65
- * each entry's `matcher`, `hooks[].type`, `hooks[].command` match).
94
+ * each entry's `matcher`, `hooks[].type`, `hooks[].command` match) AND the
95
+ * on-disk `env` already carries every exemption the template declares (extra
96
+ * on-disk keys and extra globs are allowed — a user may exempt other trees,
97
+ * and a requirement the file already exceeds must not re-trigger a write).
66
98
  *
67
99
  * Returns `false` on any `JSON.parse` error, shape mismatch, or
68
100
  * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
@@ -104,7 +136,12 @@ export function templateContentMatches(generated, onDisk) {
104
136
  return false;
105
137
  }
106
138
  }
107
- return true;
139
+ // A project installed by a release that predates a template-declared
140
+ // exemption still needs the refresh this comparator gates — otherwise the
141
+ // entry would only ever appear on a machine that re-ran `peaks hooks
142
+ // install`. `hasExternalGateExemptions` is the same predicate the installer
143
+ // uses, so the two writers cannot drift apart.
144
+ return hasExternalGateExemptions({ env: parsedOnDisk.env });
108
145
  }
109
146
  function isTemplateShape(value) {
110
147
  if (typeof value !== 'object' || value === null) {
@@ -124,70 +161,70 @@ function sameHooksArray(a, b) {
124
161
  for (let i = 0; i < a.length; i += 1) {
125
162
  const ha = a[i];
126
163
  const hb = b[i];
127
- if (ha.type !== hb.type || ha.command !== hb.command) {
164
+ // `shell` participates in the comparison: it is machine-specific (see
165
+ // `resolveHookShell`), so a file written on one platform must be
166
+ // recognized as drifted on the other instead of silently kept.
167
+ if (ha.type !== hb.type || ha.command !== hb.command || ha.shell !== hb.shell) {
128
168
  return false;
129
169
  }
130
170
  }
131
171
  return true;
132
172
  }
133
173
  /**
134
- * Wrap an inner JavaScript payload as a shell-evaluable `node -e "..."`
135
- * one-liner. The returned string is what Claude Code writes verbatim
136
- * into `.claude/settings.local.json` under the `command` field. Per
137
- * Node.js docs (https://nodejs.org/api/process.html#processargv), when
138
- * using `-e` there is no script-file slot, so `process.argv[1]` is the
139
- * first user-passed extra argument. This is consistent across Windows,
140
- * macOS, and Linux.
174
+ * This module's own directory — `<root>/src/services/workspace` in the
175
+ * source tree, `<root>/dist/services/workspace` in a build.
141
176
  *
142
- * Every `"` character in the inner JS must be JSON-escaped as `\\"`
143
- * so that the surrounding wrapper `node -e "..."` parses correctly:
144
- * the shell sees the escape and passes a literal `"` to Node. A
145
- * single missed escape closes the wrapper early and the entire hook
146
- * regresses to the bash-syntax-error class of bug.
177
+ * Anchored on the running module rather than on `process.argv[1]`: the
178
+ * same reason `daemon-supervisor.ts` documents — `argv[1]` is a different
179
+ * file in each way the CLI is entered (`bin/peaks.js`, `src/cli/index.ts`
180
+ * under tsx, `dist/cli/index.js` when invoked directly), whereas the
181
+ * module's own location is the one fact that is always true.
182
+ */
183
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
184
+ /**
185
+ * Absolute path of the shipped Write|Edit|MultiEdit gate script.
147
186
  *
148
- * @param js Inner JavaScript payload. Must be a single statement or a
149
- * sequence of statements joined with `;`. The wrapper does
150
- * not insert any `;` between the payload and the closing
151
- * `"` because Node accepts a trailing expression with `;`
152
- * already terminated by the payload itself.
187
+ * `write-gate.js` is a plain `.js` (not a compiled `.ts`) precisely so this
188
+ * single relative filename resolves in BOTH trees: `src/services/hooks/` for
189
+ * `tsx` / vitest, `dist/services/hooks/` for an installed consumer (copied by
190
+ * `scripts/copy-templates.mjs`; the `dist/**` `*.js` glob in
191
+ * `package.json#files` already ships it).
192
+ *
193
+ * Separators are normalized to `/` so the emitted command contains no
194
+ * backslash at all. That is what makes the handler shell-agnostic: bash
195
+ * reduces `\X` inside `"..."` and PowerShell escapes with a backtick, so any
196
+ * backslash in the string is one dialect's problem waiting to happen.
153
197
  */
154
- function wrapAsNodeOneLiner(js) {
155
- // Only `"` needs JSON-escaping: the wrapper uses double quotes, so an
156
- // unescaped inner `"` would close the wrapper prematurely. Backslashes
157
- // do NOT need escaping here — bash inside a `"..."` wrapper reduces
158
- // `\\` to `\`, so any `\X` in the inner JS reaches Node as `\X`,
159
- // which is what regex literals like `/\.peaks\//` need. Adding a
160
- // second `\\` → `\\` pass would double-escape backslashes and break
161
- // every regex literal the inner JS contains.
162
- const escaped = js.replace(/"/g, '\\"');
163
- return `node -e "${escaped}"`;
198
+ export function writeGateScriptPath() {
199
+ return resolve(MODULE_DIR, '..', 'hooks', 'write-gate.js').replaceAll('\\', '/');
164
200
  }
165
201
  /**
166
- * Build the Write|Edit|MultiEdit matcher command. The command reads
167
- * the candidate file path from argv[2] and exits 0 iff the path
168
- * contains `.peaks/_runtime/` or `.peaks/_runtime/<sessionId>/` (the change-id
169
- * segment is the next path component after `.peaks/`). All other
170
- * paths exit 1 so the gate fires normally.
202
+ * Build the Write|Edit|MultiEdit matcher command.
171
203
  *
172
- * The matcher is intentionally narrow: it only fires for tools that
173
- * take a `file_path` (Write/Edit/MultiEdit) and for the Bash
174
- * subcommand allow-list. It does NOT silently allow arbitrary paths
175
- * under `.peaks/_runtime/<sessionId>/` — only those matching the documented
176
- * pattern. Future slice work can broaden the allow-list if the
177
- * peaks-code workflow needs more paths.
204
+ * TEMPLATE_VERSION 1.6.0: `node "<script>"` with NO inline payload. The
205
+ * decision lives in `src/services/hooks/write-gate.js` and was relocated
206
+ * there verbatim. Because there is nothing left to escape, this handler is
207
+ * shell-dialect-independent and can carry the same platform `shell` pin as
208
+ * its Bash siblings (see `resolveHookShell`).
178
209
  */
179
210
  function buildWriteHookCommand() {
180
- // Path-matching: allow when the path contains `.peaks/_runtime/`
181
- // OR when the second `.peaks/` segment starts with anything that
182
- // looks like a change-id (kebab-case slug). Exit 0 for allow, exit
183
- // 1 for deny. The candidate path arrives on `process.argv[1]` per
184
- // Node.js argv layout under `-e` (cross-platform consistent).
185
- const js = 'const p=process.argv[1]||"";' +
186
- 'if(p.includes(".peaks/_runtime/"))process.exit(0);' +
187
- 'const m=p.match(/\\.peaks\\/([a-z0-9][a-z0-9.-]*)\\//);' +
188
- 'if(m&&m[1]&&m[1]!=="_runtime"&&m[1]!=="_dogfood"&&m[1]!=="_sub_agents"&&m[1]!=="memory"&&m[1]!=="sops"&&m[1]!=="retrospective"&&m[1]!=="project-scan"&&m[1]!=="perf-baseline")process.exit(0);' +
189
- 'process.exit(1)';
190
- return wrapAsNodeOneLiner(js);
211
+ return `node "${writeGateScriptPath()}"`;
212
+ }
213
+ /**
214
+ * TEMPLATE_VERSION 1.4.0 — the SOP gate-enforce handler, read from the same
215
+ * canonical hook spec `peaks hooks install` uses. Deriving it here (rather
216
+ * than re-typing the literal) is what keeps the two writers of this file
217
+ * byte-compatible: `templateContentMatches` compares the `command` string,
218
+ * so any drift would make every `peaks workspace init` rewrite the file and
219
+ * drop whatever `peaks hooks install` had merged in.
220
+ */
221
+ function buildGateEnforceHandler() {
222
+ const spec = resolveHookSpec('claude-code');
223
+ return {
224
+ type: 'command',
225
+ command: spec.hookEnforceCommand,
226
+ ...(spec.hookEnforceShell !== undefined ? { shell: spec.hookEnforceShell } : {})
227
+ };
191
228
  }
192
229
  /**
193
230
  * Build the full template object. The shape is the subset of Claude
@@ -202,7 +239,21 @@ function buildWriteHookCommand() {
202
239
  * `.claude/settings.json` (not `.claude/settings.local.json`).
203
240
  */
204
241
  export function buildClaudeSettingsLocalJson() {
242
+ // TEMPLATE_VERSION 1.6.0 — the write handler can now be shell-pinned for the
243
+ // same Windows reason as the two Bash handlers below: a shell-form command is
244
+ // executed by Git Bash / MSYS2, which force-allocates a console window on
245
+ // every matching tool call. It could NOT take the pin while its payload was
246
+ // inlined JavaScript, because PowerShell does not perform bash's backslash
247
+ // reduction and would have corrupted the payload. `undefined` on POSIX omits
248
+ // the key entirely.
249
+ const writeShell = resolveHookShell();
205
250
  return {
251
+ // Slice emit-gateguard-exemption — the third-party gate exemption. Peaks
252
+ // already bypasses its OWN fact-forcing gate for `.peaks/**` (the
253
+ // Write|Edit|MultiEdit handler below); this is the same intent declared in
254
+ // the currency an external PreToolUse gate reads. `peaks hooks install`
255
+ // merges the same row into this file, so the two writers agree.
256
+ env: { ...EXTERNAL_GATE_EXEMPT_ENV },
206
257
  hooks: {
207
258
  PreToolUse: [
208
259
  {
@@ -210,7 +261,8 @@ export function buildClaudeSettingsLocalJson() {
210
261
  hooks: [
211
262
  {
212
263
  type: 'command',
213
- command: buildWriteHookCommand()
264
+ command: buildWriteHookCommand(),
265
+ ...(writeShell !== undefined ? { shell: writeShell } : {})
214
266
  }
215
267
  ]
216
268
  },
@@ -223,12 +275,22 @@ export function buildClaudeSettingsLocalJson() {
223
275
  // 2 = block (stderr contains the BLOCKED: ... reason).
224
276
  // The existing Write|Edit|MultiEdit matcher is preserved.
225
277
  matcher: 'Bash',
226
- hooks: [
227
- {
228
- type: 'command',
229
- command: buildBashGateStep08Command()
230
- }
231
- ]
278
+ hooks: [buildBashGateStep08Handler()]
279
+ },
280
+ {
281
+ // TEMPLATE_VERSION 1.4.0 — SOP gate enforcement. Lives in this
282
+ // machine-local file (not the committed settings.json) because
283
+ // `shell` is machine-specific; see the version history above and
284
+ // `resolveHookShell`.
285
+ //
286
+ // It sits in its own matcher group on purpose: a group counts as
287
+ // peaks-managed only when EVERY handler in it carries a peaks
288
+ // sentinel, and the `peaks code gate-step-08` handler above does
289
+ // not. Sharing a group with it would make the whole group
290
+ // unmanaged, so `peaks hooks install` would append a second Bash
291
+ // group and the gate would run twice per Bash call.
292
+ matcher: 'Bash',
293
+ hooks: [buildGateEnforceHandler()]
232
294
  }
233
295
  ]
234
296
  }
@@ -244,11 +306,21 @@ export function buildClaudeSettingsLocalJson() {
244
306
  * re-running `peaks workspace init` on a project that already has the
245
307
  * Bash hook is a no-op (already-current).
246
308
  */
247
- function buildBashGateStep08Command() {
309
+ function buildBashGateStep08Handler() {
248
310
  // The hook receives the tool call on stdin. We ignore stdin and
249
311
  // delegate entirely to `peaks code gate-step-08`, which reads
250
312
  // .peaks/_runtime/<sessionId>/job-shape.json and last-prompt.txt.
251
313
  // `${CLAUDE_PROJECT_DIR}` resolves to the consumer project's root
252
314
  // (Claude Code's standard convention).
253
- return 'peaks code gate-step-08 --project "${CLAUDE_PROJECT_DIR}"';
315
+ //
316
+ // TEMPLATE_VERSION 1.5.0 — shell-pinned on Windows for the same reason
317
+ // as the gate-enforce handler below: this runs on the same `Bash`
318
+ // matcher, so a shell-form command is executed by Git Bash / MSYS2,
319
+ // which force-allocates a console window on every Bash tool call.
320
+ const shell = resolveHookShell();
321
+ return {
322
+ type: 'command',
323
+ command: 'peaks code gate-step-08 --project "${CLAUDE_PROJECT_DIR}"',
324
+ ...(shell !== undefined ? { shell } : {})
325
+ };
254
326
  }
@@ -9,10 +9,37 @@
9
9
  * the parent module and calls into this sibling. Function signatures
10
10
  * and behaviour are unchanged (verbatim move).
11
11
  */
12
- import { existsSync } from 'node:fs';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
13
  import { mkdir, writeFile } from 'node:fs/promises';
14
14
  import { join } from 'node:path';
15
+ import { withExternalGateExemptions } from '../skills/hooks-codegate-superpowers.js';
15
16
  import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME, templateContentMatches } from './claude-settings-template.js';
17
+ /** Read a file as text, or `undefined` when it cannot be read. */
18
+ function readTextIfPresent(filePath) {
19
+ try {
20
+ return readFileSync(filePath, 'utf8');
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /**
27
+ * The `env` object of a serialized settings file, or `undefined` when the file
28
+ * is malformed or has no `env` object. Tolerant on purpose: a bad on-disk file
29
+ * must not stop the materialization.
30
+ */
31
+ function readEnvObject(serialized) {
32
+ try {
33
+ const parsed = JSON.parse(serialized);
34
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
35
+ return undefined;
36
+ const env = parsed.env;
37
+ return typeof env === 'object' && env !== null && !Array.isArray(env) ? env : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
16
43
  /**
17
44
  * The peaks-managed snippet appended to the consumer project's
18
45
  * `.peaks/.gitignore` so the local-only settings file never lands
@@ -66,7 +93,15 @@ export async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks)
66
93
  const settingsRel = CLAUDE_SETTINGS_LOCAL_FILENAME;
67
94
  const settingsPath = join(projectRoot, settingsRel);
68
95
  const template = buildClaudeSettingsLocalJson();
69
- const serialized = JSON.stringify(template, null, 2) + '\n';
96
+ const fileExists = existsSync(settingsPath);
97
+ const existing = fileExists ? readTextIfPresent(settingsPath) : undefined;
98
+ // `.claude/settings.local.json` has a second writer: `peaks hooks install`
99
+ // unions the user's own exemption globs into `env`. Carry that value across
100
+ // the rewrite this function is about to do, or a refresh would silently
101
+ // drop someone else's exemptions. The template's own row is added on top, so
102
+ // the result is a union either way.
103
+ const onDiskEnv = existing === undefined ? undefined : readEnvObject(existing);
104
+ const serialized = JSON.stringify(withExternalGateExemptions(onDiskEnv === undefined ? template : { ...template, env: onDiskEnv }), null, 2) + '\n';
70
105
  // Always drop (or self-heal) a copy of the template under .peaks/
71
106
  // so the --no-claude-hooks recovery flow has a known source-of-truth
72
107
  // on disk. The file is gitignored by the snippet below.
@@ -84,23 +119,15 @@ export async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks)
84
119
  // hooks-settings-service applies the safety check for the Bash
85
120
  // gate-enforce path).
86
121
  await mkdir(join(projectRoot, '.claude'), { recursive: true });
87
- let action = 'written';
88
- if (existsSync(settingsPath)) {
89
- try {
90
- const { readFile } = await import('node:fs/promises');
91
- const existing = await readFile(settingsPath, 'utf8');
92
- if (existing === serialized) {
93
- action = 'already-current';
94
- }
95
- else {
96
- action = 'refreshed';
97
- }
98
- }
99
- catch {
100
- // Treat any read failure as "needs refresh" so the consumer
101
- // always ends up with a valid template on disk.
102
- action = 'refreshed';
103
- }
122
+ // An existing-but-unreadable file is treated as drifted, so the consumer
123
+ // always ends up with a valid template on disk.
124
+ let action = fileExists ? 'refreshed' : 'written';
125
+ // Structural comparison (not a byte comparison): `peaks hooks install` also
126
+ // writes this file, through a different serializer, so an equal hooks tree
127
+ // must be recognized as current or every init would rewrite the file and
128
+ // drop the installer's entries.
129
+ if (existing !== undefined && templateContentMatches(serialized, existing)) {
130
+ action = 'already-current';
104
131
  }
105
132
  if (action !== 'already-current') {
106
133
  await writeFile(settingsPath, serialized, 'utf8');
@@ -3,7 +3,31 @@ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { isDirectory } from 'peaks-loop-shared/fs';
5
5
  import { getSessionIdCanonical, setCurrentSessionBinding, setSessionMeta } from '../session/session-manager.js';
6
+ import { updateCallerBindingSessionId } from '../session/caller-binding-service.js';
7
+ import { resolveCallerProjection } from '../session/resolve-caller-id.js';
6
8
  import { normalizePath } from '../../shared/path-utils.js';
9
+ /**
10
+ * Slice 2026-09-10 (rid=rebind-must-update-caller-binding): repoint the
11
+ * calling process's own per-caller binding at the session id this init just
12
+ * bound. `getSessionIdCanonical` reads `callers/<callerId>.json` FIRST, so a
13
+ * binding left behind by an earlier session shadows every explicit rebind.
14
+ *
15
+ * Best-effort: an unresolvable callerId (`PEAKS_CALLER_NOT_RESOLVED`, e.g. a
16
+ * stock shell with no IDE adapter) means there is no binding file to
17
+ * repoint, and `session.json` alone answers both resolvers.
18
+ *
19
+ * @returns `true` when a binding file existed and was repointed.
20
+ */
21
+ function rebindCurrentCallerBinding(projectRoot, sessionId) {
22
+ let callerId;
23
+ try {
24
+ callerId = resolveCallerProjection({ projectRoot, env: process.env }).callerId;
25
+ }
26
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
27
+ return false;
28
+ }
29
+ return updateCallerBindingSessionId(projectRoot, callerId, sessionId);
30
+ }
7
31
  /**
8
32
  * Slice 2026-06-29-change-id-root-removal: list the immediate children of
9
33
  * `.peaks/` so the legacy sibling-dir guard can enumerate date-stamped
@@ -299,6 +323,15 @@ export async function initWorkspace(options) {
299
323
  // Either: existing session dir is empty (true leftover, no user data),
300
324
  // or the caller explicitly authorised a rebind. Overwrite.
301
325
  setCurrentSessionBinding(options.projectRoot, options.sessionId);
326
+ // Slice 2026-09-10 (rid=rebind-must-update-caller-binding): an
327
+ // explicit rebind must ALSO repoint this caller's per-caller binding.
328
+ // `getSessionIdCanonical` prefers `callers/<callerId>.json`, so leaving
329
+ // it untouched shadowed the rebind for every command resolving through
330
+ // it (session checkpoint / 24h-mode wrote into the stale session dir)
331
+ // while `getCurrentSessionId` (session.json) reported the new one.
332
+ // Only THIS caller's binding is repointed — a second caller keeps its
333
+ // own session by design.
334
+ rebindCurrentCallerBinding(options.projectRoot, options.sessionId);
302
335
  bound = true;
303
336
  }
304
337
  // Slice 2026-06-16-peaks-code-auto-scaffold (RD#7):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.36",
3
+ "version": "4.0.38",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -101,10 +101,10 @@
101
101
  "fzf": "^0.5.2",
102
102
  "yaml": "^2.9.0",
103
103
  "zod": "^4.4.3",
104
- "peaks-loop-internal-runtime": "0.0.21",
105
- "peaks-loop-shared-channel": "0.0.38",
106
- "peaks-loop-shared": "0.0.70",
107
- "peaks-loop-mut": "0.1.34"
104
+ "peaks-loop-internal-runtime": "0.0.23",
105
+ "peaks-loop-shared": "0.0.72",
106
+ "peaks-loop-mut": "0.1.36",
107
+ "peaks-loop-shared-channel": "0.0.40"
108
108
  },
109
109
  "devDependencies": {
110
110
  "@changesets/cli": "2.31.1",
@@ -60,6 +60,18 @@ const targets = [
60
60
  src: join(packageRoot, 'src/services/skillhub/migrations'),
61
61
  dest: join(packageRoot, 'dist/services/skillhub/migrations'),
62
62
  extensions: ['.sql']
63
+ },
64
+ {
65
+ // Slice c5-write-hook-exec-form: the Write|Edit|MultiEdit PreToolUse gate
66
+ // emitted by `peaks workspace init` is invoked as `node <path>`, and the
67
+ // path is resolved relative to this module — `dist/services/hooks/` in an
68
+ // installed consumer, `src/services/hooks/` under tsx. A plain `.js` asset
69
+ // (not a `.ts` compiled by tsc) is what lets ONE relative filename be valid
70
+ // in both trees. Without this copy the hook would be a broken path in every
71
+ // installed consumer while every test in this repo still passed.
72
+ src: join(packageRoot, 'src/services/hooks'),
73
+ dest: join(packageRoot, 'dist/services/hooks'),
74
+ extensions: ['.js']
63
75
  }
64
76
  ];
65
77
 
@@ -18,6 +18,26 @@ writeFileSync(
18
18
  `export const CLI_VERSION = ${JSON.stringify(version)};\n`,
19
19
  );
20
20
 
21
+ // Slice 2026-09-11 (runtime-version-lockstep) — sync RUNTIME_VERSION.
22
+ // `packages/peaks-loop-internal-runtime/src/index.ts` declares
23
+ // `RUNTIME_VERSION` under a comment stating it tracks the peaks-loop root
24
+ // version, but nothing wrote it: v4.0.37 was tagged with the constant still
25
+ // at 4.0.36 and publish.yml's gate-cli-version step aborted before npm
26
+ // publish. The literal is replaced in place, single-quoted — the exact
27
+ // shape the gate greps — because the file also holds the package's public
28
+ // exports and must never be regenerated wholesale. A literal we cannot
29
+ // find throws instead of no-op'ing: that silence is how the drift reached CI.
30
+ const runtimeIndexPath = resolve('packages/peaks-loop-internal-runtime/src/index.ts');
31
+ const runtimeIndex = readFileSync(runtimeIndexPath, 'utf8');
32
+ const runtimeDecl = /(export const RUNTIME_VERSION = ')[^']*(';)/;
33
+ if (!runtimeDecl.test(runtimeIndex)) {
34
+ throw new Error(`${runtimeIndexPath}: could not find "export const RUNTIME_VERSION = '...';"`);
35
+ }
36
+ const syncedRuntimeIndex = runtimeIndex.replace(runtimeDecl, `$1${version}$2`);
37
+ if (syncedRuntimeIndex !== runtimeIndex) {
38
+ writeFileSync(runtimeIndexPath, syncedRuntimeIndex);
39
+ }
40
+
21
41
  // 2026-07-23 follow-up (peaks-publish-stale fix, AC6): the shared
22
42
  // bump used to live here, gated on `PEAKS_AUTO_BUMP_SHARED === '1'`.
23
43
  // That gate was the Layer 2 root cause: publish.yml set the env on
@@ -235,6 +235,16 @@ triggered for this intent.
235
235
  5. LLM picks one recommendation (★ marker) with reasoning block.
236
236
  6. **Mandatory ⚠️ catch gate** — user explicitly acks / picks alt / rejects + reason.
237
237
 
238
+ **When the scan cannot be performed, the sub-step is SKIPPED — never fabricated.** The lookups behind
239
+ this scan are stubs in the current build: they return synthetic fragments marked internally as
240
+ synthetic. When the scan runs on a synthetic result it **refuses** — `ok:false`,
241
+ `BEST_PRACTICE_SCAN_SYNTHETIC_LOOKUP`, exit 1 — and prints no recommendation, no comparison table and
242
+ no ⚠️ catch gate, because there is nothing real for the user to judge. Record the sub-step as
243
+ **skipped with reason `synthetic-lookup`** and carry on; do not synthesise a recommendation to satisfy
244
+ the gate. `--intent <text>` is required (the business goal, not the project path) — omitting it is
245
+ `BEST_PRACTICE_SCAN_INTENT_REQUIRED`, also exit 1. A real scan renders the full table and the gate
246
+ normally; wiring the real Context7 / WebSearch lookup is a future slice.
247
+
238
248
  **Out of scope for this sub-step:**
239
249
  - ❌ No changes to RD dispatch Karpathy prose (Step 3 unchanged)
240
250
  - ❌ No changes to QA / SC / TXT skills
@@ -105,7 +105,7 @@ If the page redirects to a login challenge:
105
105
 
106
106
  ## Sensitive data sanitization
107
107
 
108
- Never persist any of the following in `.peaks/_runtime/<session-id>/**` artifacts:
108
+ **Default: never persist.** Never persist any of the following in `.peaks/_runtime/<session-id>/**` artifacts:
109
109
 
110
110
  - Login URLs, redirect URLs, OAuth callback URLs containing tokens or state.
111
111
  - Cookies, request or response headers, session tokens, storage state, QR payloads.
@@ -113,6 +113,15 @@ Never persist any of the following in `.peaks/_runtime/<session-id>/**` artifact
113
113
  - Raw browser state, browser traces.
114
114
  - Screenshots or logs containing PII, SSO challenge content, or MFA material.
115
115
 
116
+ **The one exception, and it is narrow.** `peaks web login --profile <name>` persists a Playwright `storageState.json` to `~/.peaks/web-profiles/<name>/`. It is allowed only when the user explicitly asks for a persistent login. An LLM must never choose it on its own initiative, and it is never part of a default workflow. That name at that path is the whole of the exception: **no other artifact may hold these values, anywhere** — not under `.peaks/_runtime/`, not elsewhere in the project tree, not elsewhere under the user's home. This file is written outside the project tree and outside git; that is a property of this one path, not a licence to persist these values wherever the project tree happens to end.
117
+
118
+ **Two named carve-outs, so the rule above can be read literally.**
119
+
120
+ 1. **The staging file.** The state is published atomically, which means writing it to a staging file **in the same profile directory** (`0600`) and renaming it into place. The staging name is **per-run** (`storageState.json.<pid>.staging`) so two concurrent logins cannot overwrite each other's in-flight bytes — the rule sanctions *a* staging file, and that is the only form it takes. It is sanctioned **only** while a publish is in flight: it must be deleted on every failure path, must never outlive the command that created it, and nothing may ever read it as a profile. A single non-atomic write is **not** an acceptable alternative — it truncates the previous session before writing the new one, so a failed write destroys a working login and the caller is told nothing happened.
121
+ 2. **The browser's own profile directory.** A headed Chromium writes its live session cookies to an **ephemeral profile directory the browser manages itself** (under the OS temp directory for the duration of the session, normally removed on close). This is not something a login flow can prevent and it is not what this rule is about — but it is a place those values are held, so it is named here rather than left implied. Launching a headed browser to log in is therefore **never zero-exposure**, and anyone weighing the exception above should weigh that too.
122
+
123
+ **The risk, stated plainly.** That file holds live session cookies and tokens for the sites that were logged into. Any process running as this user can read it. It does not expire with the browser session, and it survives until it is deleted. Deleting `~/.peaks/web-profiles/<name>/` removes what the tool stored — it does not revoke the session at the site, so log out there as well if the account matters. Only persist a login for an account that may safely stay logged in on this machine.
124
+
116
125
  Redact sensitive values before retention. Store evidence as sanitized observations (e.g., "user reached settings page; first 3 list items had a missing-image regression") rather than raw captures.
117
126
 
118
127
  ## Fallback when Playwright MCP is not installed