peaks-loop 4.0.36 → 4.0.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/bin/peaks.js +71 -1
- package/dist/cli/cli-helpers.js +7 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
- package/dist/cli/commands/best-practice-scan-command.js +67 -9
- package/dist/cli/commands/code-runtime-commands.js +21 -5
- package/dist/cli/commands/hooks-commands.js +10 -1
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/web-commands.d.ts +28 -0
- package/dist/cli/commands/web-commands.js +327 -0
- package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
- package/dist/cli/commands/web-lifecycle-commands.js +321 -0
- package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
- package/dist/services/best-practice/scan-orchestrator.js +14 -5
- package/dist/services/code/orchestrator-can-do.js +27 -4
- package/dist/services/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/lint/detect-eslint.d.ts +2 -0
- package/dist/services/lint/detect-eslint.js +23 -9
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- package/dist/services/release/version-precheck-service.js +9 -2
- package/dist/services/scan/file-size-scan.d.ts +29 -0
- package/dist/services/scan/file-size-scan.js +63 -0
- package/dist/services/session/caller-binding-service.d.ts +24 -0
- package/dist/services/session/caller-binding-service.js +34 -0
- package/dist/services/session/getSessionDir.js +15 -10
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
- package/dist/services/skills/hooks-settings-service.d.ts +10 -0
- package/dist/services/skills/hooks-settings-service.js +152 -61
- package/dist/services/slice/slice-check-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +110 -50
- package/dist/services/slice/slice-check-types.d.ts +12 -7
- package/dist/services/slice/slice-check-types.js +8 -3
- package/dist/services/slice/slice-decompose-runners.js +24 -21
- package/dist/services/sop/sop-check-service.js +12 -1
- package/dist/services/web/bounded-output.d.ts +34 -0
- package/dist/services/web/bounded-output.js +68 -0
- package/dist/services/web/browser-acquire.d.ts +14 -0
- package/dist/services/web/browser-acquire.js +84 -0
- package/dist/services/web/browser-session-manager.d.ts +111 -0
- package/dist/services/web/browser-session-manager.js +413 -0
- package/dist/services/web/daemon-entry.d.ts +1 -0
- package/dist/services/web/daemon-entry.js +65 -0
- package/dist/services/web/daemon-registry.d.ts +42 -0
- package/dist/services/web/daemon-registry.js +164 -0
- package/dist/services/web/daemon-supervisor.d.ts +144 -0
- package/dist/services/web/daemon-supervisor.js +455 -0
- package/dist/services/web/playwright-loader.d.ts +89 -0
- package/dist/services/web/playwright-loader.js +253 -0
- package/dist/services/web/snapshot-pruner.d.ts +48 -0
- package/dist/services/web/snapshot-pruner.js +241 -0
- package/dist/services/web/untrusted-envelope.d.ts +27 -0
- package/dist/services/web/untrusted-envelope.js +44 -0
- package/dist/services/web/web-artifact-paths.d.ts +79 -0
- package/dist/services/web/web-artifact-paths.js +163 -0
- package/dist/services/web/web-client.d.ts +19 -0
- package/dist/services/web/web-client.js +55 -0
- package/dist/services/web/web-daemon-service.d.ts +38 -0
- package/dist/services/web/web-daemon-service.js +416 -0
- package/dist/services/web/web-fallback.d.ts +70 -0
- package/dist/services/web/web-fallback.js +121 -0
- package/dist/services/web/web-install-service.d.ts +91 -0
- package/dist/services/web/web-install-service.js +346 -0
- package/dist/services/web/web-login-profile.d.ts +89 -0
- package/dist/services/web/web-login-profile.js +612 -0
- package/dist/services/web/web-login-staging.d.ts +27 -0
- package/dist/services/web/web-login-staging.js +173 -0
- package/dist/services/web/web-protocol.d.ts +58 -0
- package/dist/services/web/web-protocol.js +58 -0
- package/dist/services/web/web-status-report.d.ts +33 -0
- package/dist/services/web/web-status-report.js +47 -0
- package/dist/services/workspace/claude-settings-template.d.ts +41 -5
- package/dist/services/workspace/claude-settings-template.js +116 -64
- package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
- package/dist/services/workspace/workspace-service.js +33 -0
- package/package.json +5 -5
- package/scripts/copy-templates.mjs +12 -0
- package/scripts/sync-version.mjs +20 -0
- package/skills/peaks-code/SKILL.md +10 -0
- 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
|
|
18
|
-
*
|
|
19
|
-
* 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,8 +57,26 @@ 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.
|
|
58
78
|
*/
|
|
59
|
-
export declare const TEMPLATE_VERSION = "1.
|
|
79
|
+
export declare const TEMPLATE_VERSION = "1.6.0";
|
|
60
80
|
/**
|
|
61
81
|
* Compare two serialized template strings for semantic equivalence.
|
|
62
82
|
*
|
|
@@ -72,9 +92,25 @@ export declare const TEMPLATE_VERSION = "1.3.0";
|
|
|
72
92
|
* refresh a stale `.peaks/.claude-settings-template.json` on disk.
|
|
73
93
|
*/
|
|
74
94
|
export declare function templateContentMatches(generated: string, onDisk: string): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Absolute path of the shipped Write|Edit|MultiEdit gate script.
|
|
97
|
+
*
|
|
98
|
+
* `write-gate.js` is a plain `.js` (not a compiled `.ts`) precisely so this
|
|
99
|
+
* single relative filename resolves in BOTH trees: `src/services/hooks/` for
|
|
100
|
+
* `tsx` / vitest, `dist/services/hooks/` for an installed consumer (copied by
|
|
101
|
+
* `scripts/copy-templates.mjs`; the `dist/**` `*.js` glob in
|
|
102
|
+
* `package.json#files` already ships it).
|
|
103
|
+
*
|
|
104
|
+
* Separators are normalized to `/` so the emitted command contains no
|
|
105
|
+
* backslash at all. That is what makes the handler shell-agnostic: bash
|
|
106
|
+
* reduces `\X` inside `"..."` and PowerShell escapes with a backtick, so any
|
|
107
|
+
* backslash in the string is one dialect's problem waiting to happen.
|
|
108
|
+
*/
|
|
109
|
+
export declare function writeGateScriptPath(): string;
|
|
75
110
|
type ClaudeHookCommand = {
|
|
76
111
|
type: 'command';
|
|
77
112
|
command: string;
|
|
113
|
+
shell?: string;
|
|
78
114
|
};
|
|
79
115
|
type ClaudePreToolUseEntry = {
|
|
80
116
|
matcher: string;
|