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
@@ -0,0 +1,173 @@
1
+ /**
2
+ * Where a captured session LANDS: the staging publish (user decision UD-7,
3
+ * 2026-09-10; `browser-workflow.md`'s staging carve-out).
4
+ *
5
+ * Split out of `web-login-profile.ts` in the S4 repair round 5, which pushed
6
+ * that file past the 800-line scan limit. The seam is the publish itself:
7
+ * everything here runs between "the session is in memory" and "the artifact on
8
+ * disk is the new one".
9
+ *
10
+ * WHY STAGING AT ALL: `writeFileSync`'s default flag `w` is `O_TRUNC`, so a
11
+ * plain overwrite empties `storageState.json` AT OPEN — before its first byte. A
12
+ * mid-write `ENOSPC`/`EIO` therefore destroyed a working login, while the
13
+ * failure message called it "unchanged". Staging the bytes beside the artifact
14
+ * and `renameSync`-ing them on (one filesystem operation within one directory)
15
+ * means the artifact is either the previous profile or the complete new one, so
16
+ * what the caller is told is a fact rather than an assertion.
17
+ *
18
+ * THE STAGING NAME IS PER-RUN: `<statePath>.<pid>.staging` (S4 repair R5). It
19
+ * used to be the fixed `<statePath>.staging`, which two `peaks web login` runs
20
+ * on the same profile shared — A's rename could install B's bytes while A
21
+ * reported A's counts, and B was then told the artifact "is unchanged" when it
22
+ * had in fact been replaced. The pid separates them: one login is one process
23
+ * and its publish is synchronous, so no other discriminator has to hold.
24
+ *
25
+ * `browser-workflow.md` sanctions a staging file under three conditions, all
26
+ * kept here: it lives in the profile directory, it is deleted on every failure
27
+ * path (and swept by the next run when a kill left one behind), and nothing ever
28
+ * READS it as a profile.
29
+ */
30
+ import { chmodSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
31
+ import { basename, dirname, join } from 'node:path';
32
+ import { getErrorMessage } from 'peaks-loop-shared/result';
33
+ /**
34
+ * The artifact's mode: owner-only, best-effort. `writeFileSync`'s `mode` applies
35
+ * at creation only, which is why the staging file is chmod'ed explicitly after
36
+ * the write, and why `renameSync` then carries that mode onto the artifact — so
37
+ * this is also what owner-onlys a RE-login over an existing file.
38
+ *
39
+ * WHAT THIS IS WORTH ON WINDOWS, PLAINLY: the `mode` is INERT there.
40
+ * `chmodSync(file, 0o600)` RETURNS WITHOUT THROWING while leaving the mode at
41
+ * 0666 [reproduced], so on Windows this code does not make the staging file or
42
+ * the artifact owner-only and no warning says so. The protection on this repo's
43
+ * first platform is the ACL of the user profile these files live in
44
+ * (`%USERPROFILE%`, not readable by other standard users), NOT this call. On
45
+ * POSIX the call is what it looks like.
46
+ */
47
+ const PROFILE_FILE_MODE = 0o600;
48
+ /** The suffix the carve-out names; every staging file this module handles ends in it. */
49
+ const STAGING_SUFFIX = '.staging';
50
+ /**
51
+ * THIS RUN's staging sibling: `<statePath>.<pid>.staging`, inside the profile
52
+ * directory, `0600`, and read by nothing (see the module docstring).
53
+ */
54
+ function stagingPathFor(statePath) {
55
+ return `${statePath}.${String(process.pid)}${STAGING_SUFFIX}`;
56
+ }
57
+ /**
58
+ * Publish the captured session ATOMICALLY (UD-7): stage the bytes beside the
59
+ * artifact, then `renameSync` them onto it.
60
+ *
61
+ * A rename within one directory is one filesystem operation, so
62
+ * `storageState.json` is either the previous profile or the complete new one —
63
+ * never a truncated half of either. That is what makes the failure message
64
+ * honest: a thrown write or a thrown rename leaves the artifact as it was, so
65
+ * "this run did not modify it" is a fact rather than an assertion.
66
+ *
67
+ * The staging file never survives this call: the `catch` unlinks it before
68
+ * rethrowing, and `runHeadedLogin`'s `finally` sweeps it on every path out.
69
+ * Nothing may ever READ the staging path; it exists only between these two
70
+ * calls.
71
+ */
72
+ export function publishState(statePath, snapshot, warnings) {
73
+ const stagingPath = stagingPathFor(statePath);
74
+ try {
75
+ writeFileSync(stagingPath, JSON.stringify(snapshot), { mode: PROFILE_FILE_MODE });
76
+ restrictToOwner(stagingPath, warnings);
77
+ renameSync(stagingPath, statePath);
78
+ }
79
+ catch (error) {
80
+ discardStaging(statePath, warnings);
81
+ throw error;
82
+ }
83
+ }
84
+ /**
85
+ * Delete this run's staging file, and sweep any a KILLED run left behind.
86
+ *
87
+ * Called on EVERY path out of `runHeadedLogin` — a thrown write, a failed
88
+ * rename, a timeout, a never-captured close, and after a successful publish
89
+ * (where the rename has already consumed the file, making this a no-op). The
90
+ * staging file holds live session cookies, and the carve-out allows it only
91
+ * while a publish is in flight: it must never outlive the command that created
92
+ * it.
93
+ */
94
+ export function discardStaging(statePath, warnings) {
95
+ unlinkStaging(stagingPathFor(statePath), warnings);
96
+ for (const stale of staleStagingPaths(statePath)) {
97
+ unlinkStaging(stale, warnings);
98
+ }
99
+ }
100
+ /** `rmSync` with `force`: a missing file is a no-op, and a failure is REPORTED. */
101
+ function unlinkStaging(path, warnings) {
102
+ try {
103
+ rmSync(path, { force: true });
104
+ }
105
+ catch (error) {
106
+ warnings.push(`could not remove the staging file ${path}: ${getErrorMessage(error)}`);
107
+ }
108
+ }
109
+ /**
110
+ * The staging files a run that was KILLED left in this profile directory.
111
+ *
112
+ * A process that is killed runs no `finally`, so its staging file — live
113
+ * cookies — survives it, and the next login on this profile is what clears it.
114
+ *
115
+ * ONLY files whose pid is GONE qualify. A concurrent login that is still alive
116
+ * owns its staging file until its own rename consumes it, and deleting that
117
+ * would make the rename fail and hand that run a failure message about an
118
+ * artifact it in fact replaced — the false invariant the per-run name exists to
119
+ * remove.
120
+ */
121
+ function staleStagingPaths(statePath) {
122
+ const directory = dirname(statePath);
123
+ const prefix = `${basename(statePath)}.`;
124
+ let entries;
125
+ try {
126
+ entries = readdirSync(directory);
127
+ }
128
+ catch {
129
+ // No profile directory at all: nothing can be stale inside it.
130
+ return [];
131
+ }
132
+ const stale = [];
133
+ for (const entry of entries) {
134
+ if (!entry.startsWith(prefix) || !entry.endsWith(STAGING_SUFFIX)) {
135
+ continue;
136
+ }
137
+ const pid = entry.slice(prefix.length, -STAGING_SUFFIX.length);
138
+ if (/^\d+$/.test(pid) && !pidIsAlive(Number(pid))) {
139
+ stale.push(join(directory, entry));
140
+ }
141
+ }
142
+ return stale;
143
+ }
144
+ /**
145
+ * Whether `pid` is still running. `process.kill(pid, 0)` sends no signal; ESRCH
146
+ * is the only answer that PROVES the process is gone, so anything else — a live
147
+ * pid, and equally a permission error on one this process may not signal —
148
+ * counts as alive. That is the safe direction: it can leave a stale file on
149
+ * disk, never delete a live run's.
150
+ */
151
+ function pidIsAlive(pid) {
152
+ try {
153
+ process.kill(pid, 0);
154
+ return true;
155
+ }
156
+ catch (error) {
157
+ return error.code !== 'ESRCH';
158
+ }
159
+ }
160
+ /**
161
+ * Owner-only the cookie jar, best-effort. A platform (or filesystem) that will
162
+ * not take the mode is REPORTED rather than swallowed — note that on Windows
163
+ * this call does not throw and does not restrict anything (see
164
+ * `PROFILE_FILE_MODE`), so the report covers only the paths that do fail.
165
+ */
166
+ function restrictToOwner(path, warnings) {
167
+ try {
168
+ chmodSync(path, PROFILE_FILE_MODE);
169
+ }
170
+ catch (error) {
171
+ warnings.push(`could not restrict the permissions of ${path}: ${getErrorMessage(error)}`);
172
+ }
173
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The `peaks web` daemon contract — on-disk (`daemon.json`) and on-the-wire
3
+ * (`POST /op`) in one place (slice S1, files 5/6).
4
+ *
5
+ * `parseDaemonInfo` validates both shape and `PROTOCOL_VERSION`, so a daemon
6
+ * left over from an older protocol is rejected (and therefore killed and
7
+ * respawned) rather than mis-called.
8
+ */
9
+ /** Bumped whenever `WebOpRequest` / `WebOpResponse` / `WebDaemonInfo` change shape. */
10
+ export declare const PROTOCOL_VERSION = 1;
11
+ /**
12
+ * The full `peaks web` verb surface. S1 implements the first six.
13
+ *
14
+ * `whoami` is the CLI's ownership proof: it answers with the daemon's OWN
15
+ * identity, behind the bearer check, so `peaks web stop` can tell "the daemon
16
+ * this record describes" from "some local listener that happens to answer 2xx".
17
+ */
18
+ export type WebOp = 'open' | 'text' | 'snap' | 'click' | 'shot' | 'metrics' | 'login' | 'install' | 'status' | 'stop' | 'whoami';
19
+ /** Contents of `web/daemon/daemon.json` — the only way to reach a live daemon. */
20
+ export interface WebDaemonInfo {
21
+ readonly protocolVersion: number;
22
+ readonly pid: number;
23
+ readonly port: number;
24
+ /** 32 random bytes, hex. Sent as `Authorization: Bearer <token>`. */
25
+ readonly token: string;
26
+ /** `peaks-loop` version that wrote the file. */
27
+ readonly version: string;
28
+ readonly projectRoot: string;
29
+ readonly sessionId: string;
30
+ readonly startedAt: string;
31
+ }
32
+ /** Body of `POST /op`. */
33
+ export interface WebOpRequest {
34
+ readonly op: WebOp;
35
+ readonly args: Readonly<Record<string, unknown>>;
36
+ }
37
+ /** Response of `POST /op`. */
38
+ export interface WebOpResponse<T = unknown> {
39
+ readonly ok: boolean;
40
+ readonly data: T | null;
41
+ readonly code: string | null;
42
+ readonly message: string | null;
43
+ readonly warnings: readonly string[];
44
+ readonly nextActions: readonly string[];
45
+ }
46
+ /**
47
+ * Parse and validate a `daemon.json` body. Returns `null` for malformed JSON,
48
+ * a wrong `protocolVersion`, or any missing/mistyped/out-of-range field — never
49
+ * throws.
50
+ *
51
+ * Every field is checked against how the caller will USE it, not just its type:
52
+ * `port` is bounded to the TCP range because it is interpolated into a URL, and
53
+ * `pid` is a positive integer because it is passed to `process.kill`. Ownership
54
+ * (`projectRoot` / `sessionId` vs the caller's) is deliberately NOT checked here
55
+ * — this function has no caller to compare against; `daemon-registry.readDaemonInfo`
56
+ * is where that comparison belongs.
57
+ */
58
+ export declare function parseDaemonInfo(raw: string): WebDaemonInfo | null;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The `peaks web` daemon contract — on-disk (`daemon.json`) and on-the-wire
3
+ * (`POST /op`) in one place (slice S1, files 5/6).
4
+ *
5
+ * `parseDaemonInfo` validates both shape and `PROTOCOL_VERSION`, so a daemon
6
+ * left over from an older protocol is rejected (and therefore killed and
7
+ * respawned) rather than mis-called.
8
+ */
9
+ /** Bumped whenever `WebOpRequest` / `WebOpResponse` / `WebDaemonInfo` change shape. */
10
+ export const PROTOCOL_VERSION = 1;
11
+ /**
12
+ * Parse and validate a `daemon.json` body. Returns `null` for malformed JSON,
13
+ * a wrong `protocolVersion`, or any missing/mistyped/out-of-range field — never
14
+ * throws.
15
+ *
16
+ * Every field is checked against how the caller will USE it, not just its type:
17
+ * `port` is bounded to the TCP range because it is interpolated into a URL, and
18
+ * `pid` is a positive integer because it is passed to `process.kill`. Ownership
19
+ * (`projectRoot` / `sessionId` vs the caller's) is deliberately NOT checked here
20
+ * — this function has no caller to compare against; `daemon-registry.readDaemonInfo`
21
+ * is where that comparison belongs.
22
+ */
23
+ export function parseDaemonInfo(raw) {
24
+ let parsed;
25
+ try {
26
+ parsed = JSON.parse(raw);
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
32
+ return null;
33
+ }
34
+ const { protocolVersion, pid, port, token, version, projectRoot, sessionId, startedAt } = parsed;
35
+ if (protocolVersion !== PROTOCOL_VERSION) {
36
+ return null;
37
+ }
38
+ if (!isPositiveInteger(pid) || !isTcpPort(port)) {
39
+ return null;
40
+ }
41
+ if (!isNonEmptyString(token) || !isNonEmptyString(version)) {
42
+ return null;
43
+ }
44
+ if (!isNonEmptyString(projectRoot) || !isNonEmptyString(sessionId) || !isNonEmptyString(startedAt)) {
45
+ return null;
46
+ }
47
+ return { protocolVersion, pid, port, token, version, projectRoot, sessionId, startedAt };
48
+ }
49
+ function isNonEmptyString(value) {
50
+ return typeof value === 'string' && value.length > 0;
51
+ }
52
+ function isPositiveInteger(value) {
53
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
54
+ }
55
+ /** 1..65535 — the range `fetch('http://127.0.0.1:<port>')` can actually mean. */
56
+ function isTcpPort(value) {
57
+ return isPositiveInteger(value) && value <= 65_535;
58
+ }
@@ -0,0 +1,33 @@
1
+ import { type BrowserProbe } from './web-install-service.js';
2
+ /**
3
+ * `live` = reachable, `orphaned` = alive but not answering, `stale` = the pid
4
+ * is gone. Only `live` may be reported as usable.
5
+ */
6
+ export type WebInstanceState = 'live' | 'orphaned' | 'stale';
7
+ export interface WebStatusInstance {
8
+ readonly pid: number;
9
+ readonly port: number;
10
+ readonly state: WebInstanceState;
11
+ readonly startedAt: string;
12
+ }
13
+ export interface WebStatusReport {
14
+ readonly projectRoot: string;
15
+ readonly sessionId: string;
16
+ /**
17
+ * At most one entry: there is one daemon per `(projectRoot, sessionId)`
18
+ * (design §10.2). It is a list because a leftover record and a live daemon
19
+ * can coexist until the record is reaped.
20
+ */
21
+ readonly instances: readonly WebStatusInstance[];
22
+ /**
23
+ * The same thing, unsugared: `instances[0]` or `null`. AC5's matrix reads
24
+ * `data.instance === null` for a session with no daemon, and `instances: []`
25
+ * is the list — one of the two has to be the caller-visible answer.
26
+ */
27
+ readonly instance: WebStatusInstance | null;
28
+ /** S3: the diagnosis path must be able to say WHY nothing will run. */
29
+ readonly disabled: boolean;
30
+ readonly browser: BrowserProbe;
31
+ }
32
+ /** Probe this session's daemon records. Never throws: a probe failure is a state, not an error. */
33
+ export declare function buildStatusReport(projectRoot: string, sessionId: string): Promise<WebStatusReport>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * `peaks web status` — which daemon instances exist for this session, and which
3
+ * of them are still real (slice S2, file 17).
4
+ *
5
+ * The three states are the three states of the loopback protocol (tech-doc
6
+ * §1.3): `live` (pid alive AND `/health` answers), `orphaned` (pid alive,
7
+ * `/health` dead — the daemon was SIGKILLed or wedged mid-startup, R3), `stale`
8
+ * (pid dead — a `daemon.json` left behind by a clean-free crash).
9
+ *
10
+ * This is a diagnosis path and nothing else: it opens no browser, downloads
11
+ * nothing, and never spawns. `peaks web stop` and `peaks web status` are the
12
+ * two verbs that keep working under S3's disable gate for exactly that reason
13
+ * (decision C2) — the gate is READ here (to report the flag) but never applied,
14
+ * and the browser probe it triggers is spawn- and download-free (R6).
15
+ *
16
+ * S3 extends the report with the browser-cache probe (`probeBrowserInstalled`),
17
+ * the `disabled` flag and the single `instance` view; the `instances` half here
18
+ * is S2's AC3/AC6 surface.
19
+ */
20
+ import { isProcessAlive, listSessionDaemons } from './daemon-registry.js';
21
+ import { WebDaemonClient } from './web-client.js';
22
+ import { isWebDisabled, probeBrowserInstalled } from './web-install-service.js';
23
+ /** Probe this session's daemon records. Never throws: a probe failure is a state, not an error. */
24
+ export async function buildStatusReport(projectRoot, sessionId) {
25
+ const instances = [];
26
+ for (const info of listSessionDaemons(projectRoot, sessionId)) {
27
+ const alive = isProcessAlive(info.pid);
28
+ // `/health` is probed only for a live pid: a dead pid's port may have been
29
+ // reused by an unrelated listener, and that must not read as our daemon
30
+ // answering.
31
+ const healthy = alive && (await new WebDaemonClient(info).health());
32
+ instances.push({
33
+ pid: info.pid,
34
+ port: info.port,
35
+ startedAt: info.startedAt,
36
+ state: !alive ? 'stale' : healthy ? 'live' : 'orphaned'
37
+ });
38
+ }
39
+ return {
40
+ projectRoot,
41
+ sessionId,
42
+ instances,
43
+ instance: instances[0] ?? null,
44
+ disabled: isWebDisabled(process.env),
45
+ browser: await probeBrowserInstalled()
46
+ };
47
+ }
@@ -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
@@ -55,14 +57,41 @@ export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.js
55
57
  * gate). The existing Write|Edit|MultiEdit matcher is
56
58
  * preserved. The new matcher's exit code is the load-bearing
57
59
  * signal: 0 = allow, 2 = block (with stderr BLOCKED reason).
60
+ * 1.4.0 — added the `peaks gate enforce` `Bash` PreToolUse entry. It
61
+ * lives here (machine-local, gitignored file) rather than in
62
+ * the committed `.claude/settings.json` because its `shell`
63
+ * is machine-specific: on Windows the default Git-Bash shell
64
+ * force-allocates a console window on every Bash tool call.
65
+ * This template is the second writer of that file, so it must
66
+ * emit the entry too — otherwise `peaks workspace init` would
67
+ * overwrite whatever `peaks hooks install` put there.
68
+ * 1.5.0 — pinned the same platform `shell` on the `peaks code
69
+ * gate-step-08` handler. It runs on the same `Bash` matcher,
70
+ * so leaving it un-pinned left the console-window defect in
71
+ * place for half of every Bash tool call.
72
+ * 1.6.0 — the `Write|Edit|MultiEdit` handler no longer inlines its
73
+ * JavaScript as `node -e "<js>"`. It invokes the shipped script
74
+ * `src/services/hooks/write-gate.js` instead, so the command
75
+ * string carries no shell-escaped payload at all and the handler
76
+ * can take the same platform `shell` pin as its siblings. The
77
+ * decision itself is a verbatim relocation — see that file.
78
+ * 1.7.0 — added the `env` block declaring Peaks' workspace tree exempt
79
+ * from a THIRD-PARTY PreToolUse fact-forcing gate
80
+ * (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
81
+ * on-disk file to declare those exemptions too, so a project
82
+ * installed by an earlier release refreshes once and converges.
58
83
  */
59
- export declare const TEMPLATE_VERSION = "1.3.0";
84
+ export declare const TEMPLATE_VERSION = "1.7.0";
60
85
  /**
61
- * Compare two serialized template strings for semantic equivalence.
86
+ * Compare two serialized template strings for semantic equivalence: does the
87
+ * on-disk file already declare everything the generated template declares?
62
88
  *
63
89
  * Returns `true` iff both strings parse to objects whose
64
90
  * `hooks.PreToolUse` arrays are structurally identical (same length;
65
- * each entry's `matcher`, `hooks[].type`, `hooks[].command` match).
91
+ * each entry's `matcher`, `hooks[].type`, `hooks[].command` match) AND the
92
+ * on-disk `env` already carries every exemption the template declares (extra
93
+ * on-disk keys and extra globs are allowed — a user may exempt other trees,
94
+ * and a requirement the file already exceeds must not re-trigger a write).
66
95
  *
67
96
  * Returns `false` on any `JSON.parse` error, shape mismatch, or
68
97
  * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
@@ -72,9 +101,25 @@ export declare const TEMPLATE_VERSION = "1.3.0";
72
101
  * refresh a stale `.peaks/.claude-settings-template.json` on disk.
73
102
  */
74
103
  export declare function templateContentMatches(generated: string, onDisk: string): boolean;
104
+ /**
105
+ * Absolute path of the shipped Write|Edit|MultiEdit gate script.
106
+ *
107
+ * `write-gate.js` is a plain `.js` (not a compiled `.ts`) precisely so this
108
+ * single relative filename resolves in BOTH trees: `src/services/hooks/` for
109
+ * `tsx` / vitest, `dist/services/hooks/` for an installed consumer (copied by
110
+ * `scripts/copy-templates.mjs`; the `dist/**` `*.js` glob in
111
+ * `package.json#files` already ships it).
112
+ *
113
+ * Separators are normalized to `/` so the emitted command contains no
114
+ * backslash at all. That is what makes the handler shell-agnostic: bash
115
+ * reduces `\X` inside `"..."` and PowerShell escapes with a backtick, so any
116
+ * backslash in the string is one dialect's problem waiting to happen.
117
+ */
118
+ export declare function writeGateScriptPath(): string;
75
119
  type ClaudeHookCommand = {
76
120
  type: 'command';
77
121
  command: string;
122
+ shell?: string;
78
123
  };
79
124
  type ClaudePreToolUseEntry = {
80
125
  matcher: string;
@@ -84,6 +129,13 @@ type ClaudeSettingsLocal = {
84
129
  hooks: {
85
130
  PreToolUse: ClaudePreToolUseEntry[];
86
131
  };
132
+ /**
133
+ * Exemptions declared to the third-party PreToolUse gate peaks does not own
134
+ * (`EXTERNAL_GATE_EXEMPT_ENV`). They belong in THIS file because it is
135
+ * machine-local and gitignored: a third-party variable name in the committed
136
+ * shared `settings.json` would be pushed to every consumer of the project.
137
+ */
138
+ env: Record<string, string>;
87
139
  };
88
140
  /**
89
141
  * Build the full template object. The shape is the subset of Claude