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,164 @@
1
+ /**
2
+ * File + lock IO for the `peaks web` daemon (slice S1, file 7).
3
+ *
4
+ * Isolated from the HTTP client so each is testable alone (tech-doc §2). Every
5
+ * WRITE goes through `assertUnder` first — the slice-wide guard against an
6
+ * artifact escaping `<root>/.peaks/_runtime/<sid>/web/` (AC1).
7
+ */
8
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
9
+ import { dirname } from 'node:path';
10
+ import { assertUnder, webDaemonDir, webDaemonInfoPath, webSpawnLockPath } from './web-artifact-paths.js';
11
+ import { parseDaemonInfo } from './web-protocol.js';
12
+ /** A spawn lock older than this is reclaimed even if its owner pid is alive. */
13
+ const SPAWN_LOCK_STALE_MS = 120_000;
14
+ /**
15
+ * `daemon.json` holds the 64-hex bearer token that is the ONLY lock on the
16
+ * loopback port — 127.0.0.1 is reachable by every local user, so the file mode
17
+ * is the boundary. 0644 would let any local account read the token and drive
18
+ * the browser (`open`/`text`/`snap`/`shot`) with it. The repo's other
19
+ * secret-bearing writers already use `0o600`
20
+ * (`src/services/ide/shared/atomic-json.ts`, `logger.ts`, `config-safety.ts`).
21
+ */
22
+ const DAEMON_FILE_MODE = 0o600;
23
+ /** The token's directory: readable only by its owner, same reasoning as above. */
24
+ export const DAEMON_DIR_MODE = 0o700;
25
+ export function writeDaemonInfo(projectRoot, sessionId, info) {
26
+ const target = webDaemonInfoPath(projectRoot, sessionId);
27
+ assertUnder(target, webDaemonDir(projectRoot, sessionId));
28
+ mkdirSync(dirname(target), { recursive: true, mode: DAEMON_DIR_MODE });
29
+ writeFileSync(target, JSON.stringify(info, null, 2), { encoding: 'utf8', mode: DAEMON_FILE_MODE });
30
+ }
31
+ /**
32
+ * Read + validate `daemon.json`. `null` when absent, malformed, a stale
33
+ * protocol, or — R6 — when the file claims to describe a DIFFERENT
34
+ * `(projectRoot, sessionId)` than the caller's.
35
+ *
36
+ * The ownership comparison belongs here rather than in `parseDaemonInfo`,
37
+ * because only the caller knows what the record is supposed to describe. A
38
+ * planted `daemon.json` naming someone else's session would otherwise redirect
39
+ * every op (page URL, selectors, bearer token) to a port of the attacker's
40
+ * choosing, and — via `stopDaemon` — aim `process.kill` at an unrelated pid.
41
+ */
42
+ export function readDaemonInfo(projectRoot, sessionId) {
43
+ const target = webDaemonInfoPath(projectRoot, sessionId);
44
+ if (!existsSync(target)) {
45
+ return null;
46
+ }
47
+ try {
48
+ const info = parseDaemonInfo(readFileSync(target, 'utf8'));
49
+ if (info === null || info.projectRoot !== projectRoot || info.sessionId !== sessionId) {
50
+ return null;
51
+ }
52
+ return info;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Remove `daemon.json`. Idempotent: a missing file is not an error. */
59
+ export function removeDaemonInfo(projectRoot, sessionId) {
60
+ const target = webDaemonInfoPath(projectRoot, sessionId);
61
+ if (!existsSync(target)) {
62
+ return;
63
+ }
64
+ try {
65
+ unlinkSync(target);
66
+ }
67
+ catch {
68
+ // Another process removed it between the probe and the unlink.
69
+ }
70
+ }
71
+ /**
72
+ * `process.kill(pid, 0)` is the cross-platform existence probe: it throws
73
+ * `ESRCH` when no such process exists and `EPERM` when one exists but belongs
74
+ * to another user and so may not be signalled. Only `ESRCH` means dead — an
75
+ * alive-but-unsignalable pid must not be reported as dead, or `stopDaemon`
76
+ * skips the kill and `acquireSpawnLock` reclaims a lock whose owner still
77
+ * holds it.
78
+ */
79
+ export function isProcessAlive(pid) {
80
+ if (!Number.isInteger(pid) || pid <= 0) {
81
+ return false;
82
+ }
83
+ try {
84
+ process.kill(pid, 0);
85
+ return true;
86
+ }
87
+ catch (error) {
88
+ return error.code === 'EPERM';
89
+ }
90
+ }
91
+ /**
92
+ * Take the cold-start lock (O_EXCL create). Returns `false` when another
93
+ * process holds a live lock, and reclaims a STALE one — owner pid dead, or
94
+ * older than `SPAWN_LOCK_STALE_MS` — before retrying once (tech-doc §1.4).
95
+ */
96
+ export function acquireSpawnLock(projectRoot, sessionId) {
97
+ const target = webSpawnLockPath(projectRoot, sessionId);
98
+ assertUnder(target, webDaemonDir(projectRoot, sessionId));
99
+ mkdirSync(dirname(target), { recursive: true, mode: DAEMON_DIR_MODE });
100
+ if (tryCreateSpawnLock(target)) {
101
+ return true;
102
+ }
103
+ const existing = readSpawnLock(target);
104
+ const ageMs = existing === null ? Number.NaN : Date.now() - Date.parse(existing.startedAt);
105
+ if (existing !== null && isProcessAlive(existing.pid) && ageMs <= SPAWN_LOCK_STALE_MS) {
106
+ return false;
107
+ }
108
+ try {
109
+ unlinkSync(target);
110
+ }
111
+ catch {
112
+ // Racers: whoever wins the unlink still has to win the O_EXCL create below.
113
+ }
114
+ return tryCreateSpawnLock(target);
115
+ }
116
+ /** Release the spawn lock, but only when this process is its owner. */
117
+ export function releaseSpawnLock(projectRoot, sessionId) {
118
+ const target = webSpawnLockPath(projectRoot, sessionId);
119
+ const existing = readSpawnLock(target);
120
+ if (existing !== null && existing.pid !== process.pid) {
121
+ return;
122
+ }
123
+ try {
124
+ unlinkSync(target);
125
+ }
126
+ catch {
127
+ // Already released (or never held).
128
+ }
129
+ }
130
+ /**
131
+ * The daemon instances for a session. There is one daemon per
132
+ * `(projectRoot, sessionId)` (design §10.2), so this list holds at most one
133
+ * entry; it is an array because the S2 status report folds it together with the
134
+ * lock and `/health` classification of each instance.
135
+ */
136
+ export function listSessionDaemons(projectRoot, sessionId) {
137
+ const info = readDaemonInfo(projectRoot, sessionId);
138
+ return info === null ? [] : [info];
139
+ }
140
+ function tryCreateSpawnLock(target) {
141
+ const body = { pid: process.pid, startedAt: new Date().toISOString() };
142
+ try {
143
+ writeFileSync(target, JSON.stringify(body), { flag: 'wx', encoding: 'utf8' });
144
+ return true;
145
+ }
146
+ catch {
147
+ // `EEXIST` (held) and any other write failure both mean "not acquired".
148
+ // The caller either waits for the holder or surfaces a readiness timeout.
149
+ return false;
150
+ }
151
+ }
152
+ function readSpawnLock(target) {
153
+ try {
154
+ const parsed = JSON.parse(readFileSync(target, 'utf8'));
155
+ const { pid, startedAt } = parsed;
156
+ if (typeof pid !== 'number' || typeof startedAt !== 'string') {
157
+ return null;
158
+ }
159
+ return { pid, startedAt };
160
+ }
161
+ catch {
162
+ return null;
163
+ }
164
+ }
@@ -0,0 +1,144 @@
1
+ import type { WebDaemonInfo } from './web-protocol.js';
2
+ export interface StopDaemonResult {
3
+ /** Daemons confirmed GONE at return — not merely asked to stop. */
4
+ readonly stopped: number;
5
+ /** Every pid we asked to stop (gracefully or by signal). */
6
+ readonly pids: readonly number[];
7
+ /** Alive but not answering `/health`: recorded, deliberately not signalled. */
8
+ readonly orphanedPids: readonly number[];
9
+ }
10
+ /**
11
+ * Absolute path of the daemon entry. The NAME is fixed by the tree we are
12
+ * running from, so the two supported modes each get a target that exists:
13
+ * `dist/services/web/daemon-entry.js` after `pnpm build`, and
14
+ * `src/services/web/daemon-entry.ts` for `pnpm dev` / the test suite.
15
+ */
16
+ export declare function daemonEntryPath(): string;
17
+ /**
18
+ * Absolute path of the peaks CLI entry — the file that owns
19
+ * `sub-agent shutdown register`.
20
+ *
21
+ * Explicit, never `process.argv[1]`: the caller of `registerWithParent` is the
22
+ * DAEMON, whose `argv[1]` is the daemon entry, so spawning it with CLI
23
+ * arguments boots a second daemon instead of registering anything (R8).
24
+ */
25
+ export declare function cliEntryPath(): string;
26
+ /**
27
+ * Arguments that make `node` run `entry`.
28
+ *
29
+ * A `.ts` entry needs the TypeScript loader, and it is attached with
30
+ * `--require preflight.cjs --import loader.mjs` — the exact flags `tsx` passes
31
+ * to its own child — rather than by running the `tsx` CLI.
32
+ *
33
+ * That distinction is not cosmetic. `windowsHide` applies to the process we
34
+ * spawn and to nothing it spawns in turn: launching `node tsx-cli.mjs entry.ts`
35
+ * makes tsx spawn a GRANDCHILD, and the grandchild has no `windowsHide` of ours
36
+ * — so on Windows every dev-mode `peaks web` cold start popped a console window
37
+ * the user could not close. Invoking the loader directly keeps it one process,
38
+ * so the flag we already pass covers everything.
39
+ *
40
+ * Exported so the race test's caller process is launched through the same
41
+ * shape as the daemon: two copies of these flags would drift.
42
+ *
43
+ * Both callers prepend `process.execPath` themselves, so this returns ONLY the
44
+ * interpreter flags and the entry.
45
+ */
46
+ export declare function interpreterArgs(entry: string): string[];
47
+ /**
48
+ * Whether this Node spells the ESM loader flag `--import`.
49
+ *
50
+ * It arrived in 20.6.0 (backported to 18.19.0); below that the spelling is
51
+ * `--loader`, and passing `--import` is a bad option that kills the daemon at
52
+ * startup — inside a `READY_TIMEOUT_MS = 20 s` wait whose only diagnosis is a
53
+ * line in `daemon.log`. `package.json` declares `engines.node >= 20.0.0`, which
54
+ * includes 20.0–20.5, so the flag cannot be unconditional. `tsx` gates its own
55
+ * child the same way and this mirrors it.
56
+ */
57
+ export declare function supportsImportFlag(nodeVersion?: string): boolean;
58
+ /**
59
+ * The exact command `spawnDaemon` hands to `spawn`, resolved without launching
60
+ * anything so a test can assert its shape.
61
+ *
62
+ * `process.execPath` directly — never `npx`, and therefore never the
63
+ * `cmd.exe /d /s /c node …` that npm's own `run-script` puts between us and the
64
+ * daemon (`@npmcli/run-script/lib/make-spawn-args.js` spawns with `shell: true`
65
+ * and no `windowsHide`). That layer was three processes per cold start instead
66
+ * of one, it re-parsed our `--require` / `--import` paths through a command
67
+ * string, it cost 2.8–7.1 s of the ~3 s cold start, and the two intermediate
68
+ * processes ignored `windowsHide` — the console-window incident, one layer down.
69
+ */
70
+ export declare function daemonSpawnCommand(): {
71
+ command: string;
72
+ args: string[];
73
+ };
74
+ /**
75
+ * Spawn the detached daemon. Both stdio pipes are redirected to
76
+ * `web/daemon/daemon.log` — a daemon that inherited our stdout, or wrote to
77
+ * `cwd`, would be the sneakiest way to break AC1 (tech-doc §7.2 rule 6).
78
+ */
79
+ export declare function spawnDaemon(projectRoot: string, sessionId: string): {
80
+ pid: number | undefined;
81
+ };
82
+ /**
83
+ * Return a healthy daemon for this `(projectRoot, sessionId)`, spawning one only
84
+ * when the session has none. A record whose pid is GONE is replaced (a stale
85
+ * file from a SIGKILLed parent, R3); a record whose pid is ALIVE is never
86
+ * replaced, even when `/health` does not answer — see `DaemonProbe`.
87
+ *
88
+ * Cold start is **double-checked locking**: the record, its pid liveness and
89
+ * `/health` are re-probed AFTER the lock is acquired, with the same oracle the
90
+ * pre-lock check used, and the lock is held across the spawn AND the readiness
91
+ * wait. Q10 lets the orchestrator and a sub-agent call
92
+ * `peaks web` at the same moment, and `spawnDaemon` returns as soon as `spawn()`
93
+ * does, so releasing the lock there left the whole ~20 s startup window
94
+ * unprotected: both callers would see no daemon, take the lock in turn, and
95
+ * launch a browser each — two processes per session in violation of Q8, one of
96
+ * them unreachable by `peaks web stop`. With the lock held, the loser waits for
97
+ * the winner's daemon instead of spawning its own.
98
+ */
99
+ export declare function ensureDaemon(projectRoot: string, sessionId: string): Promise<WebDaemonInfo>;
100
+ /**
101
+ * Stop this session's daemon and clear its records. Scoped to
102
+ * `(projectRoot, sessionId)` by construction — another worktree's daemon is
103
+ * untouched (design §10.2).
104
+ *
105
+ * Ownership is proven before asking the process to stop (R6/R7): a planted
106
+ * `daemon.json` naming an unrelated pid must not make `peaks web stop` kill a
107
+ * process the caller does not own, and on Windows `SIGTERM` is
108
+ * `TerminateProcess` with no chance to say "no". `/health` is NOT evidence —
109
+ * it is answered before the auth check, so any unrelated local listener that
110
+ * returns 2xx for a `GET` satisfies it. The proof is an AUTHENTICATED `/op`
111
+ * whose reply reports the very identity the record claims (`isOwnDaemon`): a
112
+ * recycled pid pointing at somebody else's dev server cannot produce it.
113
+ *
114
+ * The graceful path comes FIRST, and it is not a nicety: on Windows a signal
115
+ * cannot be handled, so signalling would terminate the daemon without running
116
+ * its teardown, leaving its chromium process behind — the exact false pass
117
+ * AC6 is written against. `/op {op:'stop'}` lets the daemon close every context,
118
+ * persist storage state, close the browser and exit on its own terms; the
119
+ * signal is only the fallback for a daemon that accepts the request and then
120
+ * fails to leave.
121
+ *
122
+ * An instance that is alive but cannot be proven ours is **not** signalled and
123
+ * **not** de-recorded. Its record is the only handle on a running process:
124
+ * clearing it would leave the daemon and its chromium detached with no verb
125
+ * able to reach them, and would let the next cold start spawn a second daemon
126
+ * beside a live one (Q8). It is reported in `orphanedPids`, and a daemon that
127
+ * survives its own stop request stays recorded too, so `status` and a second
128
+ * `stop` still see it.
129
+ */
130
+ export declare function stopDaemon(projectRoot: string, sessionId: string): Promise<StopDaemonResult>;
131
+ /**
132
+ * Best-effort registration with the existing sub-agent shutdown registry
133
+ * (Q7 / tech-doc §7.3).
134
+ *
135
+ * The entry point is `cliEntryPath()`, not `process.argv[1]`: this is called by
136
+ * the daemon, whose `argv[1]` is the daemon entry, so using it spawned a second
137
+ * daemon-entry with CLI arguments instead of registering anything (R8).
138
+ *
139
+ * Registration failing is non-fatal — the caller keeps running — but it must
140
+ * not be silent: the daemon's stderr is `web/daemon/daemon.log`
141
+ * (see `spawnDaemon`), so a spawn failure is written there. A detached child
142
+ * whose `error` event has no listener would otherwise crash the daemon.
143
+ */
144
+ export declare function registerWithParent(daemonPid: number, dispatchId: string): void;