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
@@ -1,26 +1,28 @@
1
1
  /**
2
- * Cross-platform `npx` resolver.
2
+ * Cross-platform `npm` / `npx` resolver.
3
3
  *
4
- * On Windows, `npm` installs `npx` as a `.cmd` shim; Node 22's
5
- * `child_process.spawnSync` refuses to invoke it unless `shell: true`
6
- * is set, and `shell: true` corrupts quoted `--package` arguments.
7
- * Rather than depend on shell quoting, this helper resolves the
8
- * npx script bundled with the user's `npm` install and invokes it
9
- * via `node <npx-cli.js>` with the same argv. macOS / Linux continue
10
- * to use the regular `npx` binary.
4
+ * On Windows, `npm` installs `npx` and `npm` as `.cmd` shims; Node's
5
+ * `child_process.spawnSync` refuses to invoke a `.cmd` unless `shell: true`
6
+ * is set, and `shell: true` concatenates (does not escape) the argv — it
7
+ * corrupts quoted `--package` arguments, splits any argument containing a
8
+ * space, and emits DEP0190 on every call. Rather than depend on shell
9
+ * quoting, these helpers resolve the CLI script bundled with the user's
10
+ * `npm` install and invoke it via `node <npm|npx>-cli.js` with the same
11
+ * argv. macOS / Linux continue to use the regular binaries.
11
12
  */
12
13
  import { existsSync } from 'node:fs';
13
14
  import { join } from 'node:path';
14
15
  import process from 'node:process';
15
- function locateNpxCliScript() {
16
+ /** Locate `<npm install>/bin/<scriptFile>`, or `null` when not on disk. */
17
+ function locateNpmCliScript(scriptFile) {
16
18
  const candidates = process.platform === 'win32'
17
19
  ? [
18
- join(process.execPath, '..', '..', 'node_modules', 'npm', 'bin', 'npx-cli.js'),
19
- 'C:/nvm4w/nodejs/node_modules/npm/bin/npx-cli.js',
20
- 'C:/Program Files/nodejs/node_modules/npm/bin/npx-cli.js'
20
+ join(process.execPath, '..', '..', 'node_modules', 'npm', 'bin', scriptFile),
21
+ `C:/nvm4w/nodejs/node_modules/npm/bin/${scriptFile}`,
22
+ `C:/Program Files/nodejs/node_modules/npm/bin/${scriptFile}`
21
23
  ]
22
24
  : [
23
- join(process.execPath, '..', '..', 'lib', 'node_modules', 'npm', 'bin', 'npx-cli.js')
25
+ join(process.execPath, '..', '..', 'lib', 'node_modules', 'npm', 'bin', scriptFile)
24
26
  ];
25
27
  for (const candidate of candidates) {
26
28
  if (existsSync(candidate))
@@ -30,7 +32,7 @@ function locateNpxCliScript() {
30
32
  }
31
33
  export function resolveNpxInvocation(npxArgs) {
32
34
  if (process.platform === 'win32') {
33
- const cliScript = locateNpxCliScript();
35
+ const cliScript = locateNpmCliScript('npx-cli.js');
34
36
  if (cliScript !== null) {
35
37
  return {
36
38
  command: process.execPath,
@@ -45,3 +47,25 @@ export function resolveNpxInvocation(npxArgs) {
45
47
  baseEnv: process.env
46
48
  };
47
49
  }
50
+ /**
51
+ * `npm` sibling of `resolveNpxInvocation`, same shim bypass: `node
52
+ * <npm-cli.js> <args>`. A caller must NOT reach for bare `npm` + `shell: true`
53
+ * instead — the shim is the defect, not the justification.
54
+ */
55
+ export function resolveNpmInvocation(npmArgs) {
56
+ if (process.platform === 'win32') {
57
+ const cliScript = locateNpmCliScript('npm-cli.js');
58
+ if (cliScript !== null) {
59
+ return {
60
+ command: process.execPath,
61
+ args: [cliScript, ...npmArgs],
62
+ baseEnv: process.env
63
+ };
64
+ }
65
+ }
66
+ return {
67
+ command: 'npm',
68
+ args: npmArgs,
69
+ baseEnv: process.env
70
+ };
71
+ }
@@ -3,6 +3,7 @@
3
3
  * 8 supported languages to the corresponding `ocr review` filter.
4
4
  */
5
5
  import { spawnSync } from 'node:child_process';
6
+ import { resolveNpxInvocation } from './npx-resolver.js';
6
7
  export const OCR_18_PACKAGE = '@alibaba-group/open-code-review@1.8.9';
7
8
  export const OCR_18_LANGUAGES = [
8
9
  'python',
@@ -78,7 +79,12 @@ export function runOcr18(options) {
78
79
  timeout: options.timeoutMs ?? 60_000,
79
80
  maxBuffer: 32 * 1024 * 1024
80
81
  };
81
- const result = spawnSync('npx', args, spawnOptions);
82
+ // 2026-09-10: bare `spawnSync('npx', …)` cannot launch the Windows `npx.cmd`
83
+ // shim (ENOENT, no shell) — same fix and same helper as `detect-ocr-18.ts` /
84
+ // `detect-eslint.ts`. `shell: true` is NOT an option: it would concatenate the
85
+ // argv unescaped and split the `--package` flag.
86
+ const { command, args: npxArgs, baseEnv } = resolveNpxInvocation(args);
87
+ const result = spawnSync(command, npxArgs, { ...spawnOptions, env: baseEnv });
82
88
  const stdout = typeof result.stdout === 'string' ? result.stdout : '';
83
89
  const stderr = typeof result.stderr === 'string' ? result.stderr : '';
84
90
  if (result.error !== undefined && result.error !== null) {
@@ -87,7 +93,8 @@ export function runOcr18(options) {
87
93
  findings: [],
88
94
  summary: null,
89
95
  durationMs: Date.now() - start,
90
- rawOutput: stderr || stdout
96
+ // Never discard WHY the launch failed — stdout/stderr are both empty here.
97
+ rawOutput: stderr || stdout || result.error.message
91
98
  };
92
99
  }
93
100
  if (result.status !== 0) {
@@ -114,9 +114,16 @@ export function runRootVsShared(opts) {
114
114
  export function runTagCollision(opts) {
115
115
  const rootVersion = readRootVersion(opts.projectRoot);
116
116
  const tagName = `v${rootVersion}`;
117
- // Windows shell wrapping convention: 5 prior occurrences in tests/unit/release/.
117
+ // 2026-09-10: no shell. `git` is `git.exe` on Windows, so the wrapper bought
118
+ // nothing — and it actively broke this layer, because `projectRoot` is an
119
+ // ARGUMENT to git and a shell wraps the command line unescaped. On a project
120
+ // whose path contains a space the shell split it, git exited 128
121
+ // ("cannot change to '…'"), and the layer fell through to its
122
+ // "git tag --list exited with code 128; layer skipped" WARNING — so a real
123
+ // tag collision was reported as merely deferred. Reproduced on
124
+ // `…\Temp\peaks space demo` with tag v9.9.9 present: warning (should be
125
+ // blocker). It also emitted DEP0190 on every run, on every layer.
118
126
  const res = spawnSync('git', ['-C', opts.projectRoot, 'tag', '--list', tagName], {
119
- shell: process.platform === 'win32',
120
127
  encoding: 'utf8',
121
128
  timeout: 5_000
122
129
  });
@@ -1,4 +1,29 @@
1
1
  export declare const DEFAULT_FILE_SIZE_THRESHOLD = 800;
2
+ /**
3
+ * Paths exempt from the file-size cap. The cap is Karpathy's "Simplicity
4
+ * First": it exists to make a human *simplify* an over-long file. A path is
5
+ * therefore exempt exactly when no such simplification exists — which is two
6
+ * kinds of file, both listed below so the reason is visible next to the rule.
7
+ *
8
+ * Tool output (whole-cloth generated, nobody maintains it by hand):
9
+ * `.peaks/**` is Peaks-Loop's own state store and holds three derived indexes
10
+ * already over the cap (`memory/index.json`, `lint/baseline.json`,
11
+ * `retrospective/index.json`); exempting only `memory/` would leave the other
12
+ * two false positives intact. No source module lives there, and its largest
13
+ * hand-authored file is under 500 lines. Lockfiles are regenerated on every
14
+ * install.
15
+ *
16
+ * Append-only records: `CHANGELOG.md` is history, so its length is a function
17
+ * of how long the project has existed, not of anyone's design choices — the
18
+ * only way to "fix" a violation would be to delete the record. Left checked,
19
+ * this gate is reliably red on every release, precisely when it cannot be
20
+ * acted on, which trains people to ignore it. A nested changelog under
21
+ * `packages/` is the same kind of record.
22
+ *
23
+ * Declared once, here — do not add special-cases in the scan loop.
24
+ */
25
+ export declare const SIZE_CAP_EXEMPT_PATTERNS: readonly string[];
26
+ export declare function isSizeCapExempt(file: string): boolean;
2
27
  export type FileSizeViolation = {
3
28
  file: string;
4
29
  lines: number;
@@ -7,6 +32,10 @@ export type FileSizeScanResult = {
7
32
  ok: boolean;
8
33
  threshold: number;
9
34
  checkedFiles: number;
35
+ /** Paths skipped by SIZE_CAP_EXEMPT_PATTERNS (tool output + append-only
36
+ * records). Reported so the exemption is auditable rather than a silent
37
+ * skip. */
38
+ exemptFiles: string[];
10
39
  /** Files that appeared in `git diff` but no longer exist on disk (e.g.
11
40
  * deleted in the working tree). Pre-#015 the scan crashed on these via
12
41
  * ENOENT; now they are reported here as informational data. */
@@ -2,6 +2,63 @@ import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  export const DEFAULT_FILE_SIZE_THRESHOLD = 800;
5
+ /**
6
+ * Paths exempt from the file-size cap. The cap is Karpathy's "Simplicity
7
+ * First": it exists to make a human *simplify* an over-long file. A path is
8
+ * therefore exempt exactly when no such simplification exists — which is two
9
+ * kinds of file, both listed below so the reason is visible next to the rule.
10
+ *
11
+ * Tool output (whole-cloth generated, nobody maintains it by hand):
12
+ * `.peaks/**` is Peaks-Loop's own state store and holds three derived indexes
13
+ * already over the cap (`memory/index.json`, `lint/baseline.json`,
14
+ * `retrospective/index.json`); exempting only `memory/` would leave the other
15
+ * two false positives intact. No source module lives there, and its largest
16
+ * hand-authored file is under 500 lines. Lockfiles are regenerated on every
17
+ * install.
18
+ *
19
+ * Append-only records: `CHANGELOG.md` is history, so its length is a function
20
+ * of how long the project has existed, not of anyone's design choices — the
21
+ * only way to "fix" a violation would be to delete the record. Left checked,
22
+ * this gate is reliably red on every release, precisely when it cannot be
23
+ * acted on, which trains people to ignore it. A nested changelog under
24
+ * `packages/` is the same kind of record.
25
+ *
26
+ * Declared once, here — do not add special-cases in the scan loop.
27
+ */
28
+ export const SIZE_CAP_EXEMPT_PATTERNS = [
29
+ // tool output
30
+ '.peaks/**',
31
+ '**/pnpm-lock.yaml',
32
+ '**/package-lock.json',
33
+ '**/yarn.lock',
34
+ // append-only records
35
+ '**/CHANGELOG.md'
36
+ ];
37
+ /**
38
+ * Glob → RegExp for the shapes above only, in a single split pass (chained
39
+ * string replaces would re-expand the `.*` they had just produced). The
40
+ * directory-wildcard prefix matches zero directories, so a root-level
41
+ * lockfile still counts.
42
+ */
43
+ function exemptPatternToRegExp(pattern) {
44
+ const body = pattern
45
+ .split(/(\*\*\/|\*\*|\*)/)
46
+ .map((part) => {
47
+ if (part === '**/')
48
+ return '(?:.*/)?';
49
+ if (part === '**')
50
+ return '.*';
51
+ if (part === '*')
52
+ return '[^/]*';
53
+ return part.replace(/[.+^${}()|[\]\\]/g, '\\$&');
54
+ })
55
+ .join('');
56
+ return new RegExp(`^${body}$`);
57
+ }
58
+ export function isSizeCapExempt(file) {
59
+ const normalized = file.replace(/\\/g, '/');
60
+ return SIZE_CAP_EXEMPT_PATTERNS.some((pattern) => exemptPatternToRegExp(pattern).test(normalized));
61
+ }
5
62
  function getChangedFiles(projectRoot, baseRef) {
6
63
  try {
7
64
  // --diff-filter=AM keeps only Added + Modified entries. Deleted files
@@ -28,8 +85,13 @@ export function scanFileSize(options) {
28
85
  const files = getChangedFiles(options.projectRoot, baseRef);
29
86
  const violations = [];
30
87
  const deletedFiles = [];
88
+ const exemptFiles = [];
31
89
  let checkedFiles = 0;
32
90
  for (const file of files) {
91
+ if (isSizeCapExempt(file)) {
92
+ exemptFiles.push(file);
93
+ continue;
94
+ }
33
95
  const absolute = join(options.projectRoot, file);
34
96
  // Pre-#015: readFileSync threw ENOENT for files that appear in
35
97
  // `git diff --name-only` but no longer exist on disk (e.g. a refactor
@@ -62,6 +124,7 @@ export function scanFileSize(options) {
62
124
  ok: violations.length === 0,
63
125
  threshold,
64
126
  checkedFiles,
127
+ exemptFiles,
65
128
  deletedFiles,
66
129
  violations
67
130
  };
@@ -68,6 +68,30 @@ export declare function getCallerBinding(projectRoot: string, callerId: string):
68
68
  * documented 4.0.14 carry-forward micro-fix from QA's issue #1.
69
69
  */
70
70
  export declare function setCallerBinding(projectRoot: string, callerId: string, binding: CallerBinding): void;
71
+ /**
72
+ * Repoint an EXISTING per-caller binding at a new peak session id.
73
+ *
74
+ * Slice 2026-09-10 (rid=rebind-must-update-caller-binding): an explicit
75
+ * `peaks workspace init --session-id <X> --allow-session-rebind` rewrites
76
+ * the project-global `.peaks/_runtime/session.json`. Without this call the
77
+ * per-caller file keeps shadowing it for `getSessionIdCanonical`, so the
78
+ * rebind silently did not take for every command that resolves through
79
+ * that variant (`peaks session checkpoint`, `peaks session 24h-mode`, ...)
80
+ * while `getCurrentSessionId` reported the new session.
81
+ *
82
+ * Only the binding of the caller that performed the rebind is repointed —
83
+ * a second caller keeps its own session, which is the multi-caller
84
+ * isolation the per-caller design exists for.
85
+ *
86
+ * Every other field is preserved: `createdAt` is the session creation
87
+ * stamp the legacy `session.json` dual-write reuses, and `skill` / `mode`
88
+ * / `gate` are live presence state.
89
+ *
90
+ * @returns `true` when a binding existed and was repointed, `false` when
91
+ * the caller had no binding file (nothing was shadowing the rebind, and
92
+ * we do not create one speculatively).
93
+ */
94
+ export declare function updateCallerBindingSessionId(projectRoot: string, callerId: string, peakSessionId: string): boolean;
71
95
  /**
72
96
  * Enumerate the per-caller binding files under
73
97
  * `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
@@ -120,6 +120,40 @@ export function setCallerBinding(projectRoot, callerId, binding) {
120
120
  };
121
121
  atomicWriteJson(bindingPath, payload);
122
122
  }
123
+ /**
124
+ * Repoint an EXISTING per-caller binding at a new peak session id.
125
+ *
126
+ * Slice 2026-09-10 (rid=rebind-must-update-caller-binding): an explicit
127
+ * `peaks workspace init --session-id <X> --allow-session-rebind` rewrites
128
+ * the project-global `.peaks/_runtime/session.json`. Without this call the
129
+ * per-caller file keeps shadowing it for `getSessionIdCanonical`, so the
130
+ * rebind silently did not take for every command that resolves through
131
+ * that variant (`peaks session checkpoint`, `peaks session 24h-mode`, ...)
132
+ * while `getCurrentSessionId` reported the new session.
133
+ *
134
+ * Only the binding of the caller that performed the rebind is repointed —
135
+ * a second caller keeps its own session, which is the multi-caller
136
+ * isolation the per-caller design exists for.
137
+ *
138
+ * Every other field is preserved: `createdAt` is the session creation
139
+ * stamp the legacy `session.json` dual-write reuses, and `skill` / `mode`
140
+ * / `gate` are live presence state.
141
+ *
142
+ * @returns `true` when a binding existed and was repointed, `false` when
143
+ * the caller had no binding file (nothing was shadowing the rebind, and
144
+ * we do not create one speculatively).
145
+ */
146
+ export function updateCallerBindingSessionId(projectRoot, callerId, peakSessionId) {
147
+ const existing = getCallerBinding(projectRoot, callerId);
148
+ if (existing === null)
149
+ return false;
150
+ setCallerBinding(projectRoot, callerId, {
151
+ ...existing,
152
+ peakSessionId,
153
+ lastActivityAt: new Date().toISOString()
154
+ });
155
+ return true;
156
+ }
123
157
  /**
124
158
  * Enumerate the per-caller binding files under
125
159
  * `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
@@ -2,20 +2,25 @@
2
2
  * Canonical session-directory resolver.
3
3
  *
4
4
  * As of slice 2026-06-05-peaks-runtime-layer the per-session workspace
5
- * lives at `<root>/.peaks/_runtime/<sessionId>/` (NOT at the legacy
6
- * `<root>/.peaks/_runtime/<sessionId>/` location). All **write** paths MUST route
7
- * through this helper. The legacy top-level path is preserved as a
8
- * back-compat **read** fallback only (see
9
- * `src/services/artifacts/request-artifact-service.ts:662` etc.).
5
+ * lives at `<root>/.peaks/_runtime/<sessionId>/`, NOT at the legacy
6
+ * `<root>/.peaks/<sessionId>/` location. All **write** paths MUST route
7
+ * through this helper. The legacy top-level path survives only as a
8
+ * back-compat **read** fallback (see `legacySessionRoot` in
9
+ * `src/services/artifacts/artifact-prerequisites.ts` and `sessionOwnsSlice`
10
+ * in `src/services/sc/sc-service.ts`).
10
11
  *
11
- * The corresponding test in
12
12
  * `tests/unit/services/session/session-dir-canonical.test.ts` enforces
13
- * two invariants:
13
+ * three invariants:
14
14
  *
15
15
  * (a) `getSessionDir(root, sid)` returns `<root>/.peaks/_runtime/<sid>`.
16
- * (b) A static scan of `src/` flags any direct join of `.peaks` +
17
- * `sessionId` that does NOT route through this resolver. The
18
- * back-compat **read** sites are excluded by explicit allow-list.
16
+ * (b) A static scan of `src/**` flags any `join()` chain that names
17
+ * `.peaks` plus a session id without routing through this resolver,
18
+ * wherever the id sits in the chain. The back-compat **read** sites
19
+ * are exempted by an allow-list whose entries are asserted to be
20
+ * load-bearing (an inert entry fails the scan).
21
+ * (c) A static scan of every markdown file under `skills/` flags a
22
+ * legacy `.peaks/<sid>/...` artifact path that a sub-agent would
23
+ * follow verbatim.
19
24
  *
20
25
  * @param projectRoot - Absolute path to the project root.
21
26
  * @param sessionId - The session identifier (e.g. `2026-06-06-session-5b1095`).
@@ -15,7 +15,32 @@ interface ResolvedHookSpec {
15
15
  readonly hookEnforceSentinel: string;
16
16
  readonly hookEnforceMatcher: string;
17
17
  readonly hookEnforceEvent: string;
18
+ /**
19
+ * True when the gate-enforce entry must be materialized into the IDE's
20
+ * MACHINE-LOCAL settings file rather than the shared one (see
21
+ * `hookEnforceShell` below). Set for Claude Code, whose project-scope
22
+ * hooks have a gitignored per-machine sibling file.
23
+ */
24
+ readonly hookEnforceMachineLocal: boolean;
25
+ /**
26
+ * The `shell` field for the gate-enforce handler, or `undefined` to omit
27
+ * the key entirely and let the IDE use its documented default.
28
+ *
29
+ * Claude Code only. See `resolveHookShell` for why Windows needs this.
30
+ */
31
+ readonly hookEnforceShell: string | undefined;
18
32
  }
33
+ /**
34
+ * Claude Code runs a shell-form hook command through a shell that defaults
35
+ * to bash — which on Windows means Git Bash, and MSYS2's bash
36
+ * force-allocates its own console window. The result is a visible window on
37
+ * EVERY Bash tool call. The window is created by the spawner (Claude Code)
38
+ * before any peaks code runs, so `windowsHide` and any in-process hiding
39
+ * cannot help; pinning the hook's `shell` is the only lever the hook schema
40
+ * offers. The platform-neutral default (`undefined` → omit the key) is kept
41
+ * everywhere else.
42
+ */
43
+ export declare function resolveHookShell(platform?: NodeJS.Platform): string | undefined;
19
44
  export declare function resolveHookSpec(ide: IdeId): ResolvedHookSpec;
20
45
  /** A typed descriptor for a single peaks-managed hook entry. */
21
46
  export type PeaksHookEntry = {
@@ -23,6 +48,14 @@ export type PeaksHookEntry = {
23
48
  matcher: string;
24
49
  command: string;
25
50
  event: string;
51
+ /**
52
+ * When true the entry is written to the machine-local, gitignored settings
53
+ * file (`.claude/settings.local.json`) instead of the shared one, because
54
+ * the entry carries a machine-specific value (the `shell` field).
55
+ */
56
+ machineLocal?: boolean;
57
+ /** Optional `shell` field for the emitted handler. */
58
+ shell?: string;
26
59
  };
27
60
  /**
28
61
  * Slice 2026-08-06-codegate-vendor-neutral — code-gate hook entry.
@@ -62,4 +95,45 @@ export declare function resolveLegacySentinels(ide: IdeId): ReadonlyArray<string
62
95
  export declare const SUPERPOWERS_DENIED_SKILLS: ReadonlyArray<string>;
63
96
  export declare function formatSuperpowersDenyEntry(skillId: string): string;
64
97
  export declare const SUPERPOWERS_DENY_SENTINELS: ReadonlySet<string>;
98
+ /**
99
+ * Adapter table: a Peaks *concept* → the settings value an EXTERNAL,
100
+ * non-Peaks PreToolUse gate must read to honour it.
101
+ *
102
+ * The concept Peaks holds is "the `.peaks/**` workspace tree is not project
103
+ * source, so 'who imports this / what schema' carries no signal there".
104
+ * Peaks already enforces it for its own hooks: slice 2.0.1-bug3 materializes
105
+ * a `Write|Edit|MultiEdit` bypass in `.claude/settings.local.json` precisely
106
+ * so the first workspace write is never fact-gated. This table is that same
107
+ * intent declared to a gate Peaks does not own.
108
+ *
109
+ * The mapped key is read by the ECC plugin's `gateguard-fact-force` hook: a
110
+ * comma-separated glob list matched against the normalized (forward-slash,
111
+ * lowercased) path, where a match skips first-touch fact-forcing. The row is
112
+ * INERT when that plugin is absent — an env var nothing reads.
113
+ *
114
+ * The key below is the ONLY occurrence of that third-party name in the source
115
+ * tree (a test pins the count). Vendor-specific translation belongs in the
116
+ * adapter layer, next to `HOOK_COMMAND_BY_IDE` and `resolveHookShell` — never
117
+ * scattered through the installer. And it is emitted into a MACHINE-LOCAL
118
+ * settings file only (see `resolveHookTargets`): a third-party variable name in
119
+ * the COMMITTED shared settings would be pushed to every consumer of this repo.
120
+ */
121
+ export declare const EXTERNAL_GATE_EXEMPT_ENV: Readonly<Record<string, string>>;
122
+ /** True when every `EXTERNAL_GATE_EXEMPT_ENV` glob is already declared in `settings.env`. */
123
+ export declare function hasExternalGateExemptions(settings: Record<string, unknown>): boolean;
124
+ /**
125
+ * Union every `EXTERNAL_GATE_EXEMPT_ENV` row into `settings.env`. An existing
126
+ * value is EXTENDED, never replaced, so a user who exempted other trees keeps
127
+ * them; unrelated `env` keys are untouched. Rows already carrying our glob are
128
+ * left byte-identical (so a re-run cannot churn the file), and the input object
129
+ * is returned unchanged when there is nothing to add. Pure.
130
+ */
131
+ export declare function withExternalGateExemptions(settings: Record<string, unknown>): Record<string, unknown>;
132
+ /**
133
+ * Inverse of `withExternalGateExemptions`: remove exactly the globs this repo
134
+ * added, keeping any the user wrote. The key is deleted once it holds nothing
135
+ * of ours, and `env` itself is dropped when it becomes empty — so uninstall
136
+ * leaves no orphan field behind. Pure.
137
+ */
138
+ export declare function withoutExternalGateExemptions(settings: Record<string, unknown>): Record<string, unknown>;
65
139
  export {};
@@ -11,6 +11,19 @@ import { getAdapter } from '../ide/ide-registry.js';
11
11
  import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
12
12
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
13
13
  export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
14
+ /**
15
+ * Claude Code runs a shell-form hook command through a shell that defaults
16
+ * to bash — which on Windows means Git Bash, and MSYS2's bash
17
+ * force-allocates its own console window. The result is a visible window on
18
+ * EVERY Bash tool call. The window is created by the spawner (Claude Code)
19
+ * before any peaks code runs, so `windowsHide` and any in-process hiding
20
+ * cannot help; pinning the hook's `shell` is the only lever the hook schema
21
+ * offers. The platform-neutral default (`undefined` → omit the key) is kept
22
+ * everywhere else.
23
+ */
24
+ export function resolveHookShell(platform = process.platform) {
25
+ return platform === 'win32' ? 'powershell' : undefined;
26
+ }
14
27
  /**
15
28
  * Per-IDE hook command + sentinel. The default (Claude Code) uses the
16
29
  * legacy `peaks gate enforce` surface; Trae / Cursor / Codex (Cursor-style
@@ -42,11 +55,22 @@ export function resolveHookSpec(ide) {
42
55
  // silently writing a Claude-shaped entry to a non-Claude settings.json.
43
56
  throw new Error(`peaks hooks install: unsupported IDE '${ide}' (no HOOK_COMMAND_BY_IDE entry; add one to hooks-settings-service.ts)`);
44
57
  }
58
+ const isClaudeCode = ide === 'claude-code';
59
+ // Claude Code's gate hook must emit its structured decision as JSON:
60
+ // without `--json` the hook validator rejects the plain `{}` stdout with
61
+ // "Hook JSON output validation failed". See
62
+ // .peaks/memory/bash-pretooluse-hook-json-error-fix.md.
63
+ const jsonFlag = isClaudeCode ? ' --json' : '';
45
64
  return {
46
- hookEnforceCommand: `${spec.command} --project "\${${adapter.envVar}}"`,
65
+ hookEnforceCommand: `${spec.command} --project "\${${adapter.envVar}}"${jsonFlag}`,
47
66
  hookEnforceSentinel: spec.sentinel,
48
67
  hookEnforceMatcher: adapter.toolMatcher,
49
- hookEnforceEvent: adapter.hookEvent
68
+ hookEnforceEvent: adapter.hookEvent,
69
+ // Only Claude Code has the machine-local sibling settings file the
70
+ // routing depends on, and only Claude Code's hook schema accepts a
71
+ // `shell` key.
72
+ hookEnforceMachineLocal: isClaudeCode,
73
+ hookEnforceShell: isClaudeCode ? resolveHookShell() : undefined
50
74
  };
51
75
  }
52
76
  /**
@@ -64,7 +88,14 @@ export const HOOK_CODE_GATE_COMMAND = `peaks code-gate --json`;
64
88
  export function resolveHookEntries(ide, _skipProgress = false) {
65
89
  const spec = resolveHookSpec(ide);
66
90
  const entries = [
67
- { sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
91
+ {
92
+ sentinel: spec.hookEnforceSentinel,
93
+ matcher: spec.hookEnforceMatcher,
94
+ command: spec.hookEnforceCommand,
95
+ event: spec.hookEnforceEvent,
96
+ machineLocal: spec.hookEnforceMachineLocal,
97
+ ...(spec.hookEnforceShell !== undefined ? { shell: spec.hookEnforceShell } : {})
98
+ }
68
99
  ];
69
100
  if (ide === 'claude-code') {
70
101
  entries.push({
@@ -202,3 +233,98 @@ export function formatSuperpowersDenyEntry(skillId) {
202
233
  return `UseSkill(${skillId})`;
203
234
  }
204
235
  export const SUPERPOWERS_DENY_SENTINELS = new Set(SUPERPOWERS_DENIED_SKILLS.map(formatSuperpowersDenyEntry));
236
+ // --- External (third-party) PreToolUse gate exemptions ---------------------
237
+ /**
238
+ * Adapter table: a Peaks *concept* → the settings value an EXTERNAL,
239
+ * non-Peaks PreToolUse gate must read to honour it.
240
+ *
241
+ * The concept Peaks holds is "the `.peaks/**` workspace tree is not project
242
+ * source, so 'who imports this / what schema' carries no signal there".
243
+ * Peaks already enforces it for its own hooks: slice 2.0.1-bug3 materializes
244
+ * a `Write|Edit|MultiEdit` bypass in `.claude/settings.local.json` precisely
245
+ * so the first workspace write is never fact-gated. This table is that same
246
+ * intent declared to a gate Peaks does not own.
247
+ *
248
+ * The mapped key is read by the ECC plugin's `gateguard-fact-force` hook: a
249
+ * comma-separated glob list matched against the normalized (forward-slash,
250
+ * lowercased) path, where a match skips first-touch fact-forcing. The row is
251
+ * INERT when that plugin is absent — an env var nothing reads.
252
+ *
253
+ * The key below is the ONLY occurrence of that third-party name in the source
254
+ * tree (a test pins the count). Vendor-specific translation belongs in the
255
+ * adapter layer, next to `HOOK_COMMAND_BY_IDE` and `resolveHookShell` — never
256
+ * scattered through the installer. And it is emitted into a MACHINE-LOCAL
257
+ * settings file only (see `resolveHookTargets`): a third-party variable name in
258
+ * the COMMITTED shared settings would be pushed to every consumer of this repo.
259
+ */
260
+ export const EXTERNAL_GATE_EXEMPT_ENV = Object.freeze({
261
+ // peaks' `.peaks/**` workspace tree is not project source → skip fact-forcing
262
+ GATEGUARD_EXEMPT_GLOBS: '.peaks/**'
263
+ });
264
+ function isPlainObject(value) {
265
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
266
+ }
267
+ /** Split a comma-separated glob list, dropping blanks and surrounding space. */
268
+ function splitGlobList(value) {
269
+ return value.split(',').map((glob) => glob.trim()).filter((glob) => glob.length > 0);
270
+ }
271
+ /** True when every `EXTERNAL_GATE_EXEMPT_ENV` glob is already declared in `settings.env`. */
272
+ export function hasExternalGateExemptions(settings) {
273
+ const env = isPlainObject(settings.env) ? settings.env : {};
274
+ return Object.entries(EXTERNAL_GATE_EXEMPT_ENV).every(([key, glob]) => {
275
+ const current = env[key];
276
+ return typeof current === 'string' && splitGlobList(current).includes(glob);
277
+ });
278
+ }
279
+ /**
280
+ * Union every `EXTERNAL_GATE_EXEMPT_ENV` row into `settings.env`. An existing
281
+ * value is EXTENDED, never replaced, so a user who exempted other trees keeps
282
+ * them; unrelated `env` keys are untouched. Rows already carrying our glob are
283
+ * left byte-identical (so a re-run cannot churn the file), and the input object
284
+ * is returned unchanged when there is nothing to add. Pure.
285
+ */
286
+ export function withExternalGateExemptions(settings) {
287
+ const env = isPlainObject(settings.env) ? { ...settings.env } : {};
288
+ let changed = false;
289
+ for (const [key, glob] of Object.entries(EXTERNAL_GATE_EXEMPT_ENV)) {
290
+ const current = typeof env[key] === 'string' ? env[key] : '';
291
+ const existing = splitGlobList(current);
292
+ if (existing.includes(glob))
293
+ continue;
294
+ env[key] = [...existing, glob].join(',');
295
+ changed = true;
296
+ }
297
+ return changed ? { ...settings, env } : settings;
298
+ }
299
+ /**
300
+ * Inverse of `withExternalGateExemptions`: remove exactly the globs this repo
301
+ * added, keeping any the user wrote. The key is deleted once it holds nothing
302
+ * of ours, and `env` itself is dropped when it becomes empty — so uninstall
303
+ * leaves no orphan field behind. Pure.
304
+ */
305
+ export function withoutExternalGateExemptions(settings) {
306
+ if (!isPlainObject(settings.env))
307
+ return settings;
308
+ const env = { ...settings.env };
309
+ let changed = false;
310
+ for (const [key, glob] of Object.entries(EXTERNAL_GATE_EXEMPT_ENV)) {
311
+ const current = env[key];
312
+ if (typeof current !== 'string')
313
+ continue;
314
+ const kept = splitGlobList(current).filter((entry) => entry !== glob);
315
+ changed = true;
316
+ if (kept.length > 0) {
317
+ env[key] = kept.join(',');
318
+ }
319
+ else {
320
+ delete env[key];
321
+ }
322
+ }
323
+ if (!changed)
324
+ return settings;
325
+ if (Object.keys(env).length === 0) {
326
+ const { env: _omit, ...rest } = settings;
327
+ return rest;
328
+ }
329
+ return { ...settings, env };
330
+ }