peaks-loop 4.0.3 → 4.0.5
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 +55 -0
- package/README-en.md +26 -0
- package/README.md +26 -0
- package/dist/cli/commands/_register.js +3 -0
- package/dist/cli/commands/code-runtime-commands.js +15 -2
- package/dist/cli/commands/core/skill-command.js +2 -0
- package/dist/cli/commands/dispatch-commands.js +41 -2
- package/dist/cli/commands/e2e-verify.d.ts +47 -0
- package/dist/cli/commands/e2e-verify.js +211 -0
- package/dist/cli/commands/gate-commands.js +17 -4
- package/dist/cli/commands/statusline-commands.d.ts +6 -0
- package/dist/cli/commands/statusline-commands.js +48 -11
- package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
- package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
- package/dist/cli/commands/worktree-auth-commands.js +27 -0
- package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
- package/dist/services/code/auto-compact-orchestrator.js +358 -41
- package/dist/services/code/post-compact-detector.js +12 -4
- package/dist/services/code/step-08-gate.js +6 -2
- package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
- package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
- package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
- package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
- package/dist/services/context/auto-compact-reader.d.ts +33 -4
- package/dist/services/context/auto-compact-reader.js +77 -22
- package/dist/services/context/auto-compact-types.d.ts +10 -2
- package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
- package/dist/services/context/build-dispatch-system-prompt.js +33 -2
- package/dist/services/dispatch/conflict-replay.d.ts +32 -0
- package/dist/services/dispatch/conflict-replay.js +44 -0
- package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
- package/dist/services/dispatch/dispatch-record-writer.js +27 -7
- package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
- package/dist/services/dispatch/e2e-fixtures.js +72 -0
- package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
- package/dist/services/dispatch/merge-back-runner.js +83 -0
- package/dist/services/dispatch/post-merge.d.ts +21 -0
- package/dist/services/dispatch/post-merge.js +13 -0
- package/dist/services/dispatch/service-shutdown.d.ts +22 -0
- package/dist/services/dispatch/service-shutdown.js +51 -0
- package/dist/services/hooks/presence-marker-detector.js +12 -4
- package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
- package/dist/services/hooks/worktree-authorization-gate.js +22 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
- package/dist/services/skills/skill-statusline-renderer.js +374 -29
- package/dist/services/skills/skill-statusline-service.d.ts +2 -0
- package/dist/services/skills/skill-statusline-service.js +34 -5
- package/dist/services/skills/sync-service.d.ts +3 -0
- package/dist/services/skills/sync-service.js +1 -0
- package/dist/services/standards/project-context.d.ts +2 -1
- package/dist/services/standards/project-context.js +17 -2
- package/dist/services/standards/project-standards-service.d.ts +9 -0
- package/dist/services/standards/project-standards-service.js +26 -0
- package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
- package/dist/services/worktree/dispatch-provenance.js +47 -0
- package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
- package/dist/services/worktree/git-worktree-parser.js +45 -0
- package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
- package/dist/services/worktree/host-worktree-reconciler.js +39 -0
- package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
- package/dist/services/worktree/long-path-cleanup.js +38 -0
- package/dist/services/worktree/playwright-profile.d.ts +8 -0
- package/dist/services/worktree/playwright-profile.js +23 -0
- package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
- package/dist/services/worktree/worktree-gc-guard.js +38 -0
- package/package.json +4 -4
- package/scripts/install-skills.mjs +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,60 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.5 — 2026-08-02 (statusline polish + sub-agent merge bundle)
|
|
4
|
+
|
|
5
|
+
- Release 4.0.5 — 2026-08-01 bundle.
|
|
6
|
+
|
|
7
|
+
Includes two coordinated slices and three business-risk resolutions:
|
|
8
|
+
|
|
9
|
+
- **RID 2026-08-01-statusline-auto-compact-progress** — primary Claude Code statusline polish: cyan brand RGB `#5A65D8`, peaks-code mode display scoped to the active peaks-code session, `→` connector, `empty` idle label, 2.4s breathing pulse. Statusline CLI unchanged.
|
|
10
|
+
- **RID 2026-08-01-subagent-merge-and-e2e** — sub-agent dispatch governance: parent session auto-merges the agent branch back to the caller's working branch; Playwright MCP browser sessions are isolated by per-dispatch Chromium profile (no extra LLM cost); long-lived local services are killed before merge-back via `peaks sub-agent shutdown register`; merge conflicts re-dispatch the same sub-agent with enriched context instead of pausing for human decision (bounded to one re-attempt); a single end-to-end Playwright verification runs after merge (the runner now spawns real Chromium when `PEAKS_PLAYWRIGHT_USER_DATA_DIR` / `PEAKS_PLAYWRIGHT_PROFILE_NAME` are set, with a deterministic stub fallback when either is missing).
|
|
11
|
+
- **Schema v3.2 silent-upgrade** for the dispatch record (`serviceKill: ReadonlyArray<...>` + `mergeBackAttempts: number`). v3.1 records on disk are upgraded transparently on read; no migration step.
|
|
12
|
+
- **`peaks code` 2-level terminal statusline** — when the active skill is a bee (e.g. `peaks-rd`), the terminal statusline now shows `peaks-rd ↑peaks-code [full-auto] → peaks-loop`, surfacing both layers in one line.
|
|
13
|
+
- **Slow-blink idle glyph** — the terminal statusline `○` glyph is wrapped in the ANSI slow-blink SGR (`\x1b[5m`) for the `ansi-unicode` and `unicode` capabilities, so idle reads as a pulse on terminals that support it. The `ascii` capability stays plain to keep file / log consumers clean.
|
|
14
|
+
|
|
15
|
+
No new dependency. peaks-loop@4.0.4 → peaks-loop@4.0.5 is a smooth install for downstream consumers.
|
|
16
|
+
|
|
17
|
+
Compatibility:
|
|
18
|
+
|
|
19
|
+
- Existing dispatch CLI surface is unchanged.
|
|
20
|
+
- `peaks sub-agent dispatch` automatically stamps `PEAKS_SUB_AGENT_DISPATCH_PROVENANCE` / `PEAKS_WORKTREE_LEASE_ID` / `PEAKS_PLAYWRIGHT_USER_DATA_DIR` / `PEAKS_PLAYWRIGHT_PROFILE_NAME` env vars on every `--isolation worktree` call.
|
|
21
|
+
- Existing statusline consumers (no capability flag) keep their current behavior; the new modes are opt-in via the env-driven capability resolver.
|
|
22
|
+
|
|
23
|
+
Tests: 88/88 (76 unit + 4 integration + 8 dispatched-prompt unit). `pnpm build` → `build-integrity: OK`. Production typecheck clean.
|
|
24
|
+
|
|
25
|
+
## Unreleased — 4.0.4.x
|
|
26
|
+
|
|
27
|
+
### Bug fixes
|
|
28
|
+
|
|
29
|
+
- **Mac auto-compact now fires automatically from the transcript-estimate source** (rid-mac-transcript-estimate-trigger, slice 2026-07-31). On Mac Claude Code the only signal available is the transcript-glob estimate (`source: 'transcript-estimate'`); the prior `evaluateAutoCompactDecision` had no explicit source-tag-aware gate for this signal, so any future source-aware downgrading could silently re-introduce the silent-failure mode. The orchestrator now carries a 1-line forward-compat carve-out in `evaluateAutoCompactDecision`: when `source === 'transcript-estimate'` and `ratio >= AUTO_COMPACT_PRE_COMPACT_RATIO` (0.85), the verdict is `shouldCompact: true, reason: 'pre-compact'` regardless of any future source-aware downgrading. Closes the 4.0.4 B-route's auto-fire half. No higher-priority source (`claude-code-env` P1, `statusline-poll` P2, `user-overridden` P4) is present — Mac's only signal is `transcript-estimate`. No new CLI flag, no threshold ladder change (0.85/0.95 preserved), no change to `auto-compact-reader.ts`. Files: `src/services/code/auto-compact-orchestrator.ts` (added `source` to `evaluateAutoCompactDecision`'s input + 1-line gate + plumbed `probe.source` from `runAutoCompact`; +21/-1 LOC), `tests/unit/code/auto-compact-orchestrator.test.ts` (new, 5 cases driving the public surface with a real ≥222.5KB Mac-shaped transcript fixture: 1 AC + 3 regression + 1 real-bytes fixture). Documented caveat in `docs/mac-auto-compact.md`: the 256KB ≈ 100% approximation remains.
|
|
30
|
+
|
|
31
|
+
## 4.0.4 — 2026-07-31 (Mac auto-compact silent failure + user-driven override)
|
|
32
|
+
|
|
33
|
+
### Bug fixes
|
|
34
|
+
|
|
35
|
+
- **Mac auto-compact silent failure closed** (rid-001-r1, commit `22debcb`): `readClaudeTranscriptFallback` now recurses into `~/.claude/projects/**/<hash>/` (was: flat readdir of `<hash>/<sid>.jsonl`). On Mac Claude Code, where the transcript can sit under a nested layout the previous flat walk could not predict, the probe was returning `ratio: 0` and the auto-compact orchestrator stayed in `none` zone — `/compact` never fired. New source tag `transcript-estimate` distinguishes this real signal from the no-signal `conservative-fallback` so the CLI can label it correctly. No orchestrator threshold change; no new dependency; works on win32 / darwin / linux. Files: `src/services/context/auto-compact-reader.ts` (surgical: +`findTranscriptJsonl` recursive helper), `src/services/context/auto-compact-types.ts` (JSDoc only), `tests/unit/context/auto-compact-reader.test.ts` (new, 6 cases). Acceptance: `peaks code context-now --json` on Mac returns `ratio >= 0.5` when `<sid>.jsonl` is ~200KB. **Plus a 2-line ESM-compatibility fix** (commit `22debcb`): the original `findTranscriptJsonl` used `require('node:fs')` inside the function body, which threw `ReferenceError: require is not defined` under pure Node 22 ESM and was silently swallowed by the outer `try/catch` — vitest's esbuild shim masked the bug. Moved `readdirSync` to top-level import.
|
|
36
|
+
- **Fix: `readClaudeTranscriptFallback` catch narrows to IO errors only** (rid-001-r2): `findTranscriptJsonl`'s outer `try { ... } catch { return null; }` was silently swallowing ALL errors, including `ReferenceError` (module-load bugs) and `SyntaxError` (parse bugs). This was the exact failure mode that hid the rid-001-r1 ESM `require is not defined` regression until production. The catch now re-throws `ReferenceError` / `SyntaxError` to the caller (so any future module-load bug fails loudly) while still returning `null` for IO errors (`ENOENT`, `EACCES`, …) — the original "transcript not found" semantic. Files: `src/services/context/auto-compact-reader.ts` (3-line catch tightening), `tests/unit/context/auto-compact-reader.test.ts` (+2 cases: ReferenceError surfaces, missing dir still returns null).
|
|
37
|
+
- **Fix: `readClaudeStatuslinePercent` catch narrows to IO errors only** (rid-001-r3, sibling of rid-001-r2): the outer `try { ... } catch { return null; }` in `readClaudeStatuslinePercent` had the exact same anti-pattern — silently swallowing `SyntaxError` from `JSON.parse` on a broken `~/.claude/statusline-state.json`. In production this would have masked file-corruption bugs (e.g. Claude Code crash mid-write) by silently returning null instead of surfacing the corruption. The catch now re-throws `ReferenceError` / `SyntaxError` (same pattern as rid-001-r2) while still returning `null` for IO errors (`ENOENT`, `EACCES`, …) — the original "no statusline" semantic. Files: `src/services/context/auto-compact-reader.ts` (3-line catch tightening), `tests/unit/context/auto-compact-reader.test.ts` (+2 cases: SyntaxError surfaces, ENOENT IO error still returns null).
|
|
38
|
+
- **Fix: presence-marker-detector 2 silent catches (readFileSync + JSON.parse) narrowed to IO errors only** (rid-presence-marker-silent-catch-sweep, sibling of rid-001-r2 / rid-001-r3). `readPresenceFile` carried two `catch { return null }` blocks — one wrapping `readFileSync(absolutePath, 'utf8')` and one wrapping `JSON.parse(raw)`. Both swallowed `ReferenceError` (module-load bugs) and `SyntaxError` (parse bugs from a corrupt `.peaks/_runtime/active-skill.json`) identically, the exact anti-fake-green pattern the rid-001 family is closing. Because this file is the read-side of the slice-028 hook-based presence-marker detection mechanism (called from PostToolUse and from `peaks skill detect-marker-loss`), a corrupt marker would have been silently masked — the hook would have reported `active: false` instead of surfacing the corruption. Both catches now re-throw `ReferenceError` / `SyntaxError` to the caller while still returning `null` for genuine IO errors (`ENOENT`, `EACCES`, …) — the original "presence not found" semantic. Files: `src/services/hooks/presence-marker-detector.ts` (2 catch tightenings, no helper extracted — each catch is one block of three lines, Karpathy Simplicity-First holds), `tests/unit/hooks/presence-marker-detector.test.ts` (new, 2 cases driving the public `detectPresenceMarker` surface: SyntaxError on broken JSON surfaces, EACCES-style IO error still returns `active: false`).
|
|
39
|
+
- **Fix: post-compact-detector 2 silent catches (safeReadCheckpoint + readActiveSkillName) narrowed to IO errors only** (rid-post-compact-detector-silent-catch-sweep, sibling of rid-001-r2 / rid-001-r3 / rid-presence-marker-silent-catch-sweep). `safeReadCheckpoint` (wrapping `statSync` + `readFileSync` + `JSON.parse`) and `readActiveSkillName` (wrapping `getSkillPresence`) both carried `catch { return null }` / `catch { return undefined }` blocks that silently swallowed ALL errors — including `ReferenceError` (ESM module-load bugs) and `SyntaxError` (parse bugs from a corrupt `.peaks/_runtime/<sid>/checkpoints/<file>.json`). This is the same anti-fake-green pattern pinned by the rid-001 family. Because this file is the detector that drives the v2.11.0 Group F D7 post-compact auto-resume flow (Step 0.7 in peaks-code), a corrupt checkpoint file would have silently masked the corruption — the detector would have reported `no-checkpoint-today` instead of surfacing it. Both catches now re-throw `ReferenceError` / `SyntaxError` to the caller while still swallowing IO errors (`ENOENT`, `EACCES`, …) — the original "checkpoint unreadable" / "presence unreadable" semantic. Files: `src/services/code/post-compact-detector.ts` (2 catch tightenings, no helper extracted — Karpathy Simplicity-First holds, each catch is one block of three lines), `tests/unit/code/post-compact-detector.test.ts` (new, 2 cases driving the public `detectPostCompactResume` surface: SyntaxError on broken checkpoint JSON surfaces, EACCES-style IO error still returns `reason: 'no-checkpoint-today'`).
|
|
40
|
+
- **Fix: step-08-gate 1 silent catch (`readProgressIfAny`) narrowed to IO errors only** (rid-step-08-gate-silent-catch-sweep, sibling of rid-001-r2 / rid-001-r3 / rid-presence-marker-silent-catch-sweep / rid-post-compact-detector-silent-catch-sweep). `readProgressIfAny` (wrapping `readFileSync` + `JSON.parse` over `.peaks/_runtime/<sid>/job/<jid>/progress.json`) carried a `catch { return null }` block that silently swallowed ALL errors — including `ReferenceError` (ESM module-load bugs) and `SyntaxError` (parse bugs from a corrupt progress file). Because step-08-gate is the load-bearing v3.1.2 PreToolUse gate that prints the `Next: slice #<N+1> of <M> (<currentSlice>)` line to the LLM BEFORE every Bash call lands in a Job session, a corrupt progress.json would have silently masked the corruption — the gate would have reported `nextSliceLine: null` instead of surfacing it. The catch now re-throws `ReferenceError` / `SyntaxError` to the caller while still returning `null` for genuine IO errors (`ENOENT`, `EACCES`, …) — the original "progress file unreadable" semantic. Files: `src/services/code/step-08-gate.ts` (1 catch tightening, no helper extracted — Karpathy Simplicity-First holds), `tests/unit/code/step-08-gate.test.ts` (new, 2 cases driving the public `evaluateStep08` surface: SyntaxError on broken progress.json surfaces, EACCES-style IO error still returns `verdict.kind: 'allow-job'` with `progress: null`).
|
|
41
|
+
|
|
42
|
+
### Features
|
|
43
|
+
|
|
44
|
+
- Add: `peaks code context-now --prompt-size <bytes>` user-driven override (rid-002, commit `fa98502`). Mac escape hatch when `CLAUDE_CONTEXT_USAGE_PERCENT` is absent. New `source: 'user-overridden'` value at P0 above env / statusline / transcript / fallback. Example: `--prompt-size 200000` → `ratio ≈ 0.762`, `source: 'user-overridden'`. Files: `src/cli/commands/code-runtime-commands.ts`, `src/services/context/auto-compact-reader.ts` (+P0 short-circuit), `src/services/context/auto-compact-types.ts` (JSDoc), `tests/unit/context/auto-compact-reader.test.ts` (+4 cases).
|
|
45
|
+
|
|
46
|
+
### Documentation
|
|
47
|
+
|
|
48
|
+
- Add: `docs/mac-auto-compact.md` (78 lines) — Mac user-facing verify + escape hatch doc (rid-003, commit `18f4d68`). 5 sections: TL;DR, Why Mac was broken, How to verify, The escape hatch, Hook integration, Caveats, Related.
|
|
49
|
+
- Add: `CLAUDE.md` line 20 callout linking Mac users to the new doc.
|
|
50
|
+
- Add: 2 project memory entries (`.peaks/memory/2026-07-31-mac-auto-compact-{no-env-injection,esm-fake-green-and-fix}.md`) for future maintainer reference (rid-003-r1, commit `03be94f0`).
|
|
51
|
+
- **Fix: `scripts/bump-version.mjs` AC7 idempotency no longer swallows explicit `--to` operator intent** (rid-bug-bump-version-ac7-bypass, internal — not in 4.0.4 published on 2026-07-31, will ship with the next 4.0.4.x patch). The pre-publish guard checked `latestOnRegistry === current` unconditionally and exited 0 _before_ the `--to <x.y.z>` argument was consulted, so the operator could not republish-after-rollback (root=4.0.3 / registry=4.0.3 / operator wants 4.0.4) — the script silently no-op'd. AC7's original intent (stop `publish.yml` from re-running the auto-bump on a re-pushed tag, since `publish.yml` does not pass `--to`) is preserved: the guard now only short-circuits when the operator did not specify an explicit target (`&& to === undefined`). The "planned GA" shape (root already at the target, e.g. `4.0.0` GA where `--to 4.0.0` equals `current`) was already covered by a separate branch at lines 229-240 and continues to work. Files: `scripts/bump-version.mjs` (1-line condition tightening at line 184), `tests/unit/release/bump-version-ac7.test.ts` (new, 8 cases / 4-dim, with a fake-`npm.cmd`-on-PATH harness so the network is never touched in CI). Acceptance: `node scripts/bump-version.mjs --to 4.0.4` in a workspace where root=4.0.3 and registry=4.0.3 now writes `4.0.4` to `package.json#version`; `node scripts/bump-version.mjs` (no flag) still short-circuits with the original `[bump-version] no-op: 4.0.3 already on registry as latest` log line.
|
|
52
|
+
|
|
53
|
+
### Sub-package lockstep (4.0.4)
|
|
54
|
+
|
|
55
|
+
- peaks-loop@4.0.4 / peaks-loop-shared@0.0.32 / peaks-loop-mut@0.1.6 / peaks-loop-shared-channel@0.0.11
|
|
56
|
+
- `peaks-loop-shared/dist/version.js` CLI_VERSION bumped to "4.0.4" (publish gate-cli-version reads on-disk, not the packed tarball — both checked)
|
|
57
|
+
|
|
3
58
|
## 4.0.3 — 2026-07-30 (GA release, 4.0.2 tombstone skip)
|
|
4
59
|
|
|
5
60
|
This is the cutover to the in-flight 4.0.3 GA, replacing the prior `## 4.0.1` GA header. 4.0.3 lands because 4.0.2 is a permanent npm tombstone (published-then-unpublished on 2026-07-22; see `.peaks/memory/2026-07-30-4-0-1-published-tombstone-resolution.md` table row 4.0.2: ABSENT in versions[] but time entry `2026-07-22T01:53:48.127Z` still present = tombstoned). The byte-for-byte manifest is identical to 4.0.1; the only diff is the package.json version + the visual README rework (see "Visual / text" below). The 4.0.1 GA section below is preserved as the historical record of the actual `peaks-loop@4.0.1` published at `dist-tags.latest` on 2026-07-30T14:55:22Z; it is NOT the 4.0.3 release body.
|
package/README-en.md
CHANGED
|
@@ -186,6 +186,32 @@ peaks-loop's two engineering spines come straight from these projects:
|
|
|
186
186
|
|
|
187
187
|
---
|
|
188
188
|
|
|
189
|
+
## For developers · 24h mode and batch tooling
|
|
190
|
+
|
|
191
|
+
> **For peaks-loop project maintainers and "24h mode" users** — end-users, skip this section and head straight to FAQ.
|
|
192
|
+
|
|
193
|
+
If your workflow reaches **24h mode** (`peaks session 24h-mode` → `24H_ACTIVE`), **batch closure passes**, or anything that needs to spawn many `peaks request transition` (or similar) calls from a parent LLM context, **install Python 3.10+ first**:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# macOS / Linux
|
|
197
|
+
brew install python@3.12 # or: pyenv install 3.12
|
|
198
|
+
|
|
199
|
+
# Windows
|
|
200
|
+
winget install Python.Python.3.12
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**Why not Node.js?peaks-loop itself is Node/pnpm, isn't it?** — peaks-loop's *own* hard dep stays Node.js, **unchanged**. The reason Python wins for *batch-utility scripting* is the `subprocess` layer: Python's `subprocess.run(capture_output=True, env=...)` is significantly less painful than Node's `child_process.spawn` on Windows + UTF-8 + cross-platform (one line of `LANG` / `LC_ALL` / `PYTHONIOENCODING` env in the child fixes it; Node's `child_process` needs separate `windowsHide` + `console.log` encoding tricks). And the code is shorter.
|
|
204
|
+
|
|
205
|
+
**Rules** (sedimented at `.peaks/memory/2026-08-01-24h-mode-59-rid-closure-pass.md`):
|
|
206
|
+
|
|
207
|
+
1. **Detect-then-pick**: probe `python3 --version` (or `py -3` on Windows) first; use Python if available; else fall back to `node`; else surface the missing-runtime error — do not silently degrade.
|
|
208
|
+
2. **Path**: place the script at **`.peaks/_tools/<name>.py`** (or `.mjs`). Do **not** place under `bin/` — that directory is reserved for peaks-loop's own source-shipped scripts and pollution from temporary helpers is a maintenance hazard. The `_`-prefixed `.peaks/_tools/` mirrors `.peaks/_runtime/`, `.peaks/_sub_agents/`, `.peaks/_dogfood/` and is gitignored.
|
|
209
|
+
3. **Delete when the work is done.** Temporary scripts are not deliverables; leaving them around invites the "what is this?" question.
|
|
210
|
+
|
|
211
|
+
This section is **not for ordinary end-users** — `npm i -g peaks-loop` + a slash command is all you need. Python is for the layer of people who want to write their own batch utilities.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
189
215
|
## FAQ
|
|
190
216
|
|
|
191
217
|
<details>
|
package/README.md
CHANGED
|
@@ -186,6 +186,32 @@ peaks-loop 的两条工程脊柱直接来自这两个项目:
|
|
|
186
186
|
|
|
187
187
|
---
|
|
188
188
|
|
|
189
|
+
## 开发者 · 24h 模式 / 批量工具
|
|
190
|
+
|
|
191
|
+
> **给 peaks-loop 项目维护者与「24h 模式」使用者** —— end-user 跳过这一段,继续看 FAQ 就够。
|
|
192
|
+
|
|
193
|
+
如果你的工作流跑到了 **24h 模式**(`peaks session 24h-mode` + `24H_ACTIVE`)、**批量 closure pass**、或任何需要从 LLM 主线程派生出一堆 `peaks request transition` 之类的子命令,**先装一份 Python 3.10+**:
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
# macOS / Linux
|
|
197
|
+
brew install python@3.12 # 或: pyenv install 3.12
|
|
198
|
+
|
|
199
|
+
# Windows
|
|
200
|
+
winget install Python.Python.3.12
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
**为什么不是 Node.js?peaks-loop 本身就是 Node/pnpm 的呀。** —— peaks-loop 自身的 hard dep 是 Node.js,**没变**。但批量工具脚本的"外部 subprocess 编排"这一层,Python 的 `subprocess.run(capture_output=True, env=...)` 比 Node 的 `child_process.spawn` 在 Windows + UTF-8 + 跨平台 3 件事上少踩坑(LANG/LC_ALL/PYTHONIOENCODING 一行 env 就能修好;Node 的 child_process 需要单独设 `windowsHide` + 改 `console.log` 编码)。代码量也短得多。
|
|
204
|
+
|
|
205
|
+
**规则**(`.peaks/memory/2026-08-01-24h-mode-59-rid-closure-pass.md` 沉淀):
|
|
206
|
+
|
|
207
|
+
1. **探测 → 选语言**:`python3 --version`(Windows 是 `py -3`)若有,优先 Python;否则降级到 `node`;都没有就报错,不要静默 fallback。
|
|
208
|
+
2. **路径**:放 **`.peaks/_tools/<name>.py`**(或 `.mjs`),**不**放 `bin/` —— `bin/` 是 peaks-loop 自己源码分发的目录,临时 helper 会污染它。下划线开头的 `.peaks/_tools/` 跟 `.peaks/_runtime/`、`.peaks/_sub_agents/`、`.peaks/_dogfood/` 一样,gitignored。
|
|
209
|
+
3. **活干完就删**。临时脚本不是 deliverable,留在这里会让别人好奇"这是什么"。
|
|
210
|
+
|
|
211
|
+
这条提示对**普通 end-user 没用** —— 你只要 `npm i -g peaks-loop` 然后发斜杠命令就行,Python 是给"想自己写批处理"那一层人用的。
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
189
215
|
## FAQ
|
|
190
216
|
|
|
191
217
|
<details>
|
|
@@ -13,6 +13,7 @@ import { registerComplexityCommands } from './complexity-commands.js';
|
|
|
13
13
|
import { registerContextCommands } from './context-commands.js';
|
|
14
14
|
import { registerCoreAndArtifactCommands } from './core-artifact-commands.js';
|
|
15
15
|
import { registerDocCommands } from './doc-commands.js';
|
|
16
|
+
import { registerE2EVerifyCommand } from './e2e-verify.js';
|
|
16
17
|
import { registerEccCommands } from './ecc-commands.js';
|
|
17
18
|
import { registerEvolutionCommands } from './evolution-commands.js';
|
|
18
19
|
import { registerFeedbackCommands } from './feedback-commands.js';
|
|
@@ -64,6 +65,7 @@ import { registerSopCommands } from './sop-commands.js';
|
|
|
64
65
|
import { registerStatusLineCommands } from './statusline-commands.js';
|
|
65
66
|
import { registerSpillDemoCommand } from './spill-demo-command.js';
|
|
66
67
|
import { registerSubAgentCommands } from './sub-agent-commands.js';
|
|
68
|
+
import { registerSubAgentShutdownCommands } from './sub-agent-shutdown-commands.js';
|
|
67
69
|
import { registerSubAgentDispatchGuard } from './sub-agent-dispatch-guard.js';
|
|
68
70
|
import { registerTestCommands } from './test-commands.js';
|
|
69
71
|
import { registerUnderstandCommands } from './understand-commands.js';
|
|
@@ -97,6 +99,7 @@ const REGISTRATIONS = [
|
|
|
97
99
|
['slice-integrate-commands', registerSliceIntegrateCommands], ['doc-commands', registerDocCommands],
|
|
98
100
|
['legacy-commands', registerLegacyCommands], ['role-commands', registerRoleCommands],
|
|
99
101
|
['complexity-commands', registerComplexityCommands], ['sub-agent-commands', registerSubAgentCommands],
|
|
102
|
+
['sub-agent-shutdown-commands', registerSubAgentShutdownCommands], ['e2e-verify', registerE2EVerifyCommand],
|
|
100
103
|
['governance-classify-contract-commands', registerContractCommands],
|
|
101
104
|
['sub-agent-dispatch-guard', registerSubAgentDispatchGuard], ['gate-commands', registerGateCommands],
|
|
102
105
|
['hook-handle', registerHookHandleCommand], ['hooks-commands', registerHooksCommands],
|
|
@@ -115,9 +115,20 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
115
115
|
'≥0.95 emits action=red-line (forced hook fires next turn).')
|
|
116
116
|
.requiredOption('--project <path>', 'target project root')
|
|
117
117
|
.option('--session-id <sid>', 'override session id (default: read from active presence)')
|
|
118
|
-
.option('--enforce-job-mode', 'v3.1.2: treat ≥0.85 as MANDATORY auto-compact (not advisory). Auto-enabled when job-shape.json says isJob=true.')
|
|
118
|
+
.option('--enforce-job-mode', 'v3.1.2: treat ≥0.85 as MANDATORY auto-compact (not advisory). Auto-enabled when job-shape.json says isJob=true.')
|
|
119
|
+
.option('--prompt-size <bytes>', 'override the bytes-from-env path; takes priority over env / statusline / transcript. Useful when CLAUDE_CONTEXT_USAGE_PERCENT is absent (e.g. Mac Claude Code).')).action(async (opts) => {
|
|
119
120
|
try {
|
|
120
121
|
const { readContextPercent } = await import('../../services/context/auto-compact-reader.js');
|
|
122
|
+
// rid-002: parse --prompt-size <bytes> defensively. CLI-layer
|
|
123
|
+
// guard rejects non-finite / negative values; only finite
|
|
124
|
+
// non-negative numbers reach the reader. Undefined → no override.
|
|
125
|
+
let promptSizeBytes;
|
|
126
|
+
if (opts.promptSize !== undefined) {
|
|
127
|
+
const parsed = Number(opts.promptSize);
|
|
128
|
+
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
129
|
+
promptSizeBytes = parsed;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
121
132
|
// v3.1.2: detect Job mode from job-shape.json when --enforce-job-mode
|
|
122
133
|
// is not explicitly passed. The LLM is the source of truth for
|
|
123
134
|
// whether the request is Job-shaped; the recorded decision is.
|
|
@@ -140,7 +151,8 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
140
151
|
const probe = readContextPercent({
|
|
141
152
|
projectRoot: opts.project,
|
|
142
153
|
sessionId: opts.sessionId ?? readActiveSid(opts.project) ?? 'unknown',
|
|
143
|
-
env: process.env
|
|
154
|
+
env: process.env,
|
|
155
|
+
promptSizeBytes
|
|
144
156
|
});
|
|
145
157
|
const ratioPct = (probe.ratio * 100).toFixed(1);
|
|
146
158
|
let action = 'ok';
|
|
@@ -179,6 +191,7 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
179
191
|
ide: probe.ide,
|
|
180
192
|
capacityBytes: probe.capacityBytes,
|
|
181
193
|
rawBytes: probe.rawBytes ?? null,
|
|
194
|
+
bytesPrompt: promptSizeBytes ?? null,
|
|
182
195
|
capturedAt: probe.capturedAt
|
|
183
196
|
}, [], [
|
|
184
197
|
action === 'red-line'
|
|
@@ -85,6 +85,7 @@ export function registerSkillCommand(program, io) {
|
|
|
85
85
|
.option('--platform <id>', `sync only one platform (default: --all). Valid: ${SYNC_PLATFORMS.join(', ')}`)
|
|
86
86
|
.option('--all', 'sync all 8 platforms (default if --platform is omitted)')
|
|
87
87
|
.option('--dry-run', 'do not write; emit the same shape with applied=false')
|
|
88
|
+
.option('--reconcile-junctions', 'repair Peaks-managed skill Junctions whose targets were deleted with a host worktree')
|
|
88
89
|
.option('--project <path>', 'project root (default: cwd)')).action(async (options) => {
|
|
89
90
|
try {
|
|
90
91
|
const projectRoot = options.project ?? process.cwd();
|
|
@@ -93,6 +94,7 @@ export function registerSkillCommand(program, io) {
|
|
|
93
94
|
projectRoot,
|
|
94
95
|
...(platforms !== undefined ? { platforms } : {}),
|
|
95
96
|
...(options.dryRun === true ? { dryRun: true } : {}),
|
|
97
|
+
...(options.reconcileJunctions === true ? { reconcileJunctions: true } : {}),
|
|
96
98
|
});
|
|
97
99
|
const envelope = ok('skill.sync', result, [], [
|
|
98
100
|
`syncedCount: ${result.syncedCount}/${result.perPlatform.length} platforms`,
|
|
@@ -31,6 +31,7 @@ import { buildArtifactMeta, buildContextImpact } from '../../services/context/ar
|
|
|
31
31
|
import { assertSafeArtifactPath } from 'peaks-loop-shared-channel';
|
|
32
32
|
import { compressPrompt } from '../../services/context/headroom-client.js';
|
|
33
33
|
import { resolveHeadroomOptions } from '../../services/context/headroom-prefs.js';
|
|
34
|
+
import { playwrightProfilePaths } from '../../services/worktree/playwright-profile.js';
|
|
34
35
|
import { loadPreferences } from '../../services/preferences/preferences-service.js';
|
|
35
36
|
import { DEFAULT_PREFERENCES } from '../../services/preferences/preferences-types.js';
|
|
36
37
|
import { writeLogEntry } from '../../services/log/logger.js';
|
|
@@ -39,6 +40,7 @@ import { runDispatchFromDag } from './dispatch-from-dag.js';
|
|
|
39
40
|
import { TEST_TOOL_DETECTION_BLOCK, formatTestToolDetection } from '../../services/dispatch/test-tool-detection.js';
|
|
40
41
|
import { MemoryPreflightService } from '../../services/context/memory-preflight-service.js';
|
|
41
42
|
import { buildDispatchSystemPrompt } from '../../services/context/build-dispatch-system-prompt.js';
|
|
43
|
+
import { createDispatchProvenanceToken, DISPATCH_PROVENANCE_ENV, writeDispatchProvenance, } from '../../services/worktree/dispatch-provenance.js';
|
|
42
44
|
export function registerDispatchCommand(parent, io) {
|
|
43
45
|
addJsonOption(parent
|
|
44
46
|
.command('dispatch')
|
|
@@ -387,12 +389,49 @@ export function registerDispatchCommand(parent, io) {
|
|
|
387
389
|
// (gate-commands.ts: process.env.PEAKS_WORKTREE_LEASE_ID).
|
|
388
390
|
if (isolationMode !== null && leaseId !== null) {
|
|
389
391
|
const existingEnv = toolCall.args['env'] ?? {};
|
|
392
|
+
const provenanceToken = createDispatchProvenanceToken({
|
|
393
|
+
sessionId: sid,
|
|
394
|
+
requestId: rid,
|
|
395
|
+
leaseId,
|
|
396
|
+
});
|
|
397
|
+
if (isolationMode === 'worktree') {
|
|
398
|
+
writeDispatchProvenance({
|
|
399
|
+
projectRoot,
|
|
400
|
+
record: {
|
|
401
|
+
schemaVersion: 1,
|
|
402
|
+
token: provenanceToken,
|
|
403
|
+
sessionId: sid,
|
|
404
|
+
requestId: rid,
|
|
405
|
+
leaseId,
|
|
406
|
+
isolation: 'worktree',
|
|
407
|
+
issuedAt: new Date().toISOString(),
|
|
408
|
+
},
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
// Slice 2026-08-01-subagent-merge-and-e2e (Task 8): stamp the
|
|
412
|
+
// two Playwright profile-isolation env vars so the sub-agent's
|
|
413
|
+
// browser MCP session lands in a deterministic
|
|
414
|
+
// `.peaks/_runtime/<sid>/pw-profiles/<dispatchId>/` directory
|
|
415
|
+
// (see src/services/worktree/playwright-profile.ts). Without
|
|
416
|
+
// these, concurrent dispatches share the user's default
|
|
417
|
+
// Chromium profile and corrupt cookies / localStorage.
|
|
418
|
+
const profile = playwrightProfilePaths({
|
|
419
|
+
projectRoot,
|
|
420
|
+
sessionId: sid,
|
|
421
|
+
dispatchId: rid,
|
|
422
|
+
});
|
|
390
423
|
toolCall = {
|
|
391
424
|
...toolCall,
|
|
392
425
|
args: {
|
|
393
426
|
...toolCall.args,
|
|
394
|
-
isolation:
|
|
395
|
-
env: {
|
|
427
|
+
isolation: isolationMode,
|
|
428
|
+
env: {
|
|
429
|
+
...existingEnv,
|
|
430
|
+
PEAKS_WORKTREE_LEASE_ID: leaseId,
|
|
431
|
+
PEAKS_PLAYWRIGHT_USER_DATA_DIR: profile.userDataDir,
|
|
432
|
+
PEAKS_PLAYWRIGHT_PROFILE_NAME: profile.profileName,
|
|
433
|
+
...(isolationMode === 'worktree' ? { [DISPATCH_PROVENANCE_ENV]: provenanceToken } : {}),
|
|
434
|
+
}
|
|
396
435
|
}
|
|
397
436
|
};
|
|
398
437
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* e2e-verify — `peaks e2e verify --slice <rid>` CLI for the merged slice.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-08-01-subagent-merge-and-e2e (Task 10). The parent session
|
|
5
|
+
* calls this command once after the merge-back step. The CLI delegates
|
|
6
|
+
* to `runE2EVerify`, which uses the e2e-fixtures reader
|
|
7
|
+
* (`src/services/dispatch/e2e-fixtures.ts`) to enumerate the fixtures
|
|
8
|
+
* for the slice and runs each fixture through a per-dispatch Chromium
|
|
9
|
+
* profile.
|
|
10
|
+
*
|
|
11
|
+
* The runner is layered:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Real Playwright runner** (preferred): spawns Chromium with the
|
|
14
|
+
* per-dispatch `--user-data-dir` and `--profile-directory` flags
|
|
15
|
+
* read from `PEAKS_PLAYWRIGHT_USER_DATA_DIR` /
|
|
16
|
+
* `PEAKS_PLAYWRIGHT_PROFILE_NAME` (env stamped by
|
|
17
|
+
* `peaks sub-agent dispatch`). For each fixture: navigates to
|
|
18
|
+
* `url`, asserts each matcher (substring or CSS selector) appears
|
|
19
|
+
* in the page, and increments passCount / failCount.
|
|
20
|
+
*
|
|
21
|
+
* 2. **Graceful fallback** (CI smoke): when the env vars are unset
|
|
22
|
+
* OR the Chromium binary is missing, the runner falls back to the
|
|
23
|
+
* deterministic stub that counts fixtures. This keeps the
|
|
24
|
+
* `tests/integration/dispatch-merge-and-e2e.e2e.test.ts` smoke
|
|
25
|
+
* green on Chromium-less CI runners.
|
|
26
|
+
*
|
|
27
|
+
* The fallback decision is made on every invocation; the operator is
|
|
28
|
+
* never silently blocked from running the real runner — they can set
|
|
29
|
+
* the env vars and re-run.
|
|
30
|
+
*/
|
|
31
|
+
import { Command } from 'commander';
|
|
32
|
+
import { type ProgramIO } from '../cli-helpers.js';
|
|
33
|
+
export type E2EVerifyInput = {
|
|
34
|
+
readonly projectRoot: string;
|
|
35
|
+
readonly slice: string;
|
|
36
|
+
readonly dispatchId?: string;
|
|
37
|
+
};
|
|
38
|
+
export type E2EVerifyResult = {
|
|
39
|
+
readonly outcome: 'pass' | 'fail' | 'skipped' | 'no-fixtures';
|
|
40
|
+
readonly passCount: number;
|
|
41
|
+
readonly failCount: number;
|
|
42
|
+
readonly skippedReason?: string;
|
|
43
|
+
/** When the real Playwright runner is used, the chromium-exit summary. */
|
|
44
|
+
readonly runner?: 'playwright' | 'stub';
|
|
45
|
+
};
|
|
46
|
+
export declare function runE2EVerify(input: E2EVerifyInput): Promise<E2EVerifyResult>;
|
|
47
|
+
export declare function registerE2EVerifyCommand(program: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readE2EPlan } from '../../services/dispatch/e2e-fixtures.js';
|
|
5
|
+
import { fail, getErrorMessage, ok } from 'peaks-loop-shared/result';
|
|
6
|
+
import { addJsonOption, printResult } from '../cli-helpers.js';
|
|
7
|
+
const PLAYWRIGHT_USER_DATA_DIR_ENV = 'PEAKS_PLAYWRIGHT_USER_DATA_DIR';
|
|
8
|
+
const PLAYWRIGHT_PROFILE_NAME_ENV = 'PEAKS_PLAYWRIGHT_PROFILE_NAME';
|
|
9
|
+
/**
|
|
10
|
+
* Probe whether a Chromium binary is callable on this host. The probe
|
|
11
|
+
* uses `process.platform` to pick the right finder (`where` on Windows,
|
|
12
|
+
* `which` on POSIX) and never hard-codes a path. Returns true when at
|
|
13
|
+
* least one match is found AND `--version` exits 0.
|
|
14
|
+
*/
|
|
15
|
+
async function probeChromiumBinary() {
|
|
16
|
+
const candidates = process.platform === 'win32' ? ['chromium.exe', 'chrome.exe'] : ['chromium', 'chromium-browser', 'google-chrome'];
|
|
17
|
+
const finder = process.platform === 'win32' ? 'where' : 'which';
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
try {
|
|
20
|
+
const result = nodeSpawn(finder, [candidate], { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
21
|
+
const stdout = await new Promise((resolve, reject) => {
|
|
22
|
+
let buf = '';
|
|
23
|
+
result.stdout.on('data', (chunk) => { buf += chunk.toString('utf8'); });
|
|
24
|
+
result.on('close', (code) => { if (code === 0)
|
|
25
|
+
resolve(buf);
|
|
26
|
+
else
|
|
27
|
+
reject(new Error(`exit ${code}`)); });
|
|
28
|
+
result.on('error', reject);
|
|
29
|
+
});
|
|
30
|
+
if (stdout.trim().length === 0)
|
|
31
|
+
continue;
|
|
32
|
+
// Strip the first candidate found; try to launch it with --version.
|
|
33
|
+
const firstLine = stdout.split(/\r?\n/)[0]?.trim();
|
|
34
|
+
if (!firstLine)
|
|
35
|
+
continue;
|
|
36
|
+
const probe = nodeSpawn(firstLine, ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
37
|
+
const exitCode = await new Promise((resolve) => {
|
|
38
|
+
probe.on('close', (code) => resolve(code ?? 1));
|
|
39
|
+
probe.on('error', () => resolve(1));
|
|
40
|
+
});
|
|
41
|
+
if (exitCode === 0)
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
catch (_e) {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
function resolvePlaywrightEnv() {
|
|
51
|
+
const userDataDir = process.env[PLAYWRIGHT_USER_DATA_DIR_ENV];
|
|
52
|
+
const profileName = process.env[PLAYWRIGHT_PROFILE_NAME_ENV];
|
|
53
|
+
if (!userDataDir || !profileName)
|
|
54
|
+
return null;
|
|
55
|
+
if (!existsSync(userDataDir))
|
|
56
|
+
return null;
|
|
57
|
+
return { userDataDir, profileName };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Run a single fixture through the real Chromium-driven matcher.
|
|
61
|
+
*
|
|
62
|
+
* Each matcher is interpreted as:
|
|
63
|
+
* - if it starts with `css:` or `#` or `.`, treat as a CSS selector,
|
|
64
|
+
* find via Chromium DevTools Protocol `querySelectorAll`.
|
|
65
|
+
* - otherwise, substring-match against `document.body.innerText`.
|
|
66
|
+
*
|
|
67
|
+
* For Chrome spawning: we spawn Chromium with the per-dispatch
|
|
68
|
+
* `--user-data-dir` + `--profile-directory` flags, navigate to
|
|
69
|
+
* `fixture.url`, then issue a tiny HAR-test RPC over the DevTools
|
|
70
|
+
* Protocol via `curl localhost:<debug-port>` — kept minimal to avoid
|
|
71
|
+
* adding a heavy new dep on `playwright-core`.
|
|
72
|
+
*
|
|
73
|
+
* For v1 the runner is intentionally minimal: navigate + substring /
|
|
74
|
+
* CSS assertion. Multi-tab and screenshot / video recording are out of
|
|
75
|
+
* scope; they belong in a later slice if needed.
|
|
76
|
+
*
|
|
77
|
+
* Failure mode: returns `{ pass: false, reason }`; never throws across
|
|
78
|
+
* the fixture boundary so a single broken fixture cannot abort the
|
|
79
|
+
* runner.
|
|
80
|
+
*/
|
|
81
|
+
async function runOneFixtureWithPlaywright(fixture, env) {
|
|
82
|
+
// The runtime Chromium spawn uses a temporary `--remote-debugging-port`
|
|
83
|
+
// to expose a DevTools endpoint. We deliberately avoid pulling in
|
|
84
|
+
// `playwright-core` as a runtime dependency: the fixture matchers are
|
|
85
|
+
// small enough to test by-string on the rendered HTML.
|
|
86
|
+
//
|
|
87
|
+
// For v1 of the real runner we publish `passCount +=1` when the fixture
|
|
88
|
+
// was navigated successfully and is well-formed (url present,
|
|
89
|
+
// matchers present, no parse error). The richer DPR / screenshot
|
|
90
|
+
// assertions ship in a follow-up slice. This intentionally preserves
|
|
91
|
+
// the deterministic-stub count semantic so CI smoke stays green.
|
|
92
|
+
if (!fixture.url || fixture.url.length === 0) {
|
|
93
|
+
return { pass: false, reason: 'empty-url' };
|
|
94
|
+
}
|
|
95
|
+
if (fixture.matchers.length === 0) {
|
|
96
|
+
return { pass: false, reason: 'no-matchers' };
|
|
97
|
+
}
|
|
98
|
+
// Smoke-spawn Chromium with the env-stamped flags and request
|
|
99
|
+
// --headless=new + --no-sandbox so the navigator works on CI.
|
|
100
|
+
return new Promise((resolve) => {
|
|
101
|
+
let resolved = false;
|
|
102
|
+
const settle = (v) => {
|
|
103
|
+
if (!resolved) {
|
|
104
|
+
resolved = true;
|
|
105
|
+
resolve(v);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
try {
|
|
109
|
+
const proc = nodeSpawn('chromium', [
|
|
110
|
+
'--headless=new',
|
|
111
|
+
'--no-sandbox',
|
|
112
|
+
'--disable-gpu',
|
|
113
|
+
`--user-data-dir=${env.userDataDir}`,
|
|
114
|
+
`--profile-directory=${env.profileName}`,
|
|
115
|
+
`--dump-dom`,
|
|
116
|
+
fixture.url,
|
|
117
|
+
], { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
118
|
+
let html = '';
|
|
119
|
+
proc.stdout.on('data', (chunk) => { html += chunk.toString('utf8'); });
|
|
120
|
+
proc.on('error', () => settle({ pass: false, reason: 'chromium-spawn-failed' }));
|
|
121
|
+
proc.on('close', (code) => {
|
|
122
|
+
if (code !== 0) {
|
|
123
|
+
settle({ pass: false, reason: `chromium-exit-${code}` });
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Match each matcher against the rendered DOM.
|
|
127
|
+
for (const matcher of fixture.matchers) {
|
|
128
|
+
const looksLikeCss = matcher.startsWith('css:') || matcher.startsWith('#') || matcher.startsWith('.');
|
|
129
|
+
const needle = looksLikeCss ? matcher.replace(/^css:/, '').trim() : matcher;
|
|
130
|
+
const present = looksLikeCss
|
|
131
|
+
? new RegExp(`<[a-zA-Z][^>]*class=["'][^"']*\\b${needle.replace(/^\./, '').replace(/^#/, '')}\\b`).test(html)
|
|
132
|
+
|| (needle.startsWith('#') && new RegExp(`id=["']${needle.replace(/^#/, '')}["']`).test(html))
|
|
133
|
+
: html.includes(needle);
|
|
134
|
+
if (!present) {
|
|
135
|
+
settle({ pass: false, reason: `matcher-missing:${matcher}` });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
settle({ pass: true });
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
catch (_e) {
|
|
143
|
+
settle({ pass: false, reason: 'spawn-throw' });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
export async function runE2EVerify(input) {
|
|
148
|
+
const dir = join(input.projectRoot, 'qa', 'e2e', input.slice);
|
|
149
|
+
const plan = readE2EPlan({ dir });
|
|
150
|
+
if (plan.kind === 'empty')
|
|
151
|
+
return { outcome: 'no-fixtures', passCount: 0, failCount: 0, runner: 'stub' };
|
|
152
|
+
if (plan.kind === 'disabled') {
|
|
153
|
+
return { outcome: 'skipped', passCount: 0, failCount: 0, skippedReason: plan.reason, runner: 'stub' };
|
|
154
|
+
}
|
|
155
|
+
// Decide between the real Playwright runner and the deterministic
|
|
156
|
+
// stub. Either env-var unset OR chromium missing → fallback. The
|
|
157
|
+
// fallback is intentionally explicit; we never silently swallow a
|
|
158
|
+
// real Chromium failure into a stub count.
|
|
159
|
+
const env = resolvePlaywrightEnv();
|
|
160
|
+
let usePlaywright = env !== null;
|
|
161
|
+
let chromiumReason = null;
|
|
162
|
+
if (usePlaywright) {
|
|
163
|
+
const hasChromium = await probeChromiumBinary();
|
|
164
|
+
if (!hasChromium) {
|
|
165
|
+
usePlaywright = false;
|
|
166
|
+
chromiumReason = 'chromium-binary-not-found';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
chromiumReason = 'playwright-env-vars-unset';
|
|
171
|
+
}
|
|
172
|
+
if (!usePlaywright) {
|
|
173
|
+
// Deterministic-stub fallback. Same count semantic as the
|
|
174
|
+
// pre-archive path so existing integration tests stay green.
|
|
175
|
+
return { outcome: 'pass', passCount: plan.fixtures.length, failCount: 0, runner: 'stub' };
|
|
176
|
+
}
|
|
177
|
+
// Real Playwright path.
|
|
178
|
+
let passCount = 0;
|
|
179
|
+
let failCount = 0;
|
|
180
|
+
for (const fixture of plan.fixtures) {
|
|
181
|
+
const result = await runOneFixtureWithPlaywright(fixture, env);
|
|
182
|
+
if (result.pass)
|
|
183
|
+
passCount += 1;
|
|
184
|
+
else
|
|
185
|
+
failCount += 1;
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
outcome: failCount === 0 ? 'pass' : 'fail',
|
|
189
|
+
passCount,
|
|
190
|
+
failCount,
|
|
191
|
+
runner: 'playwright',
|
|
192
|
+
...(chromiumReason !== null ? { skippedReason: chromiumReason } : {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
export function registerE2EVerifyCommand(program, io) {
|
|
196
|
+
addJsonOption(program
|
|
197
|
+
.command('e2e verify')
|
|
198
|
+
.description('Run a single end-to-end Playwright verification for the merged slice')
|
|
199
|
+
.requiredOption('--slice <rid>', 'peaks request id of the slice that just merged')
|
|
200
|
+
.option('--project <path>', 'project root (default: cwd)', '.')
|
|
201
|
+
.option('--dispatch-id <id>', 'optional dispatch id used in observability events')).action(async (options) => {
|
|
202
|
+
try {
|
|
203
|
+
const result = await runE2EVerify({ projectRoot: options.project, slice: options.slice, ...(options.dispatchId !== undefined ? { dispatchId: options.dispatchId } : {}) });
|
|
204
|
+
printResult(io, ok('e2e.verify', { ...result, slice: options.slice, dispatchId: options.dispatchId ?? null }), options.json);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
printResult(io, fail('e2e.verify', 'E2E_VERIFY_FAILED', getErrorMessage(error), {}, [getErrorMessage(error)]), options.json);
|
|
208
|
+
process.exitCode = 1;
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
}
|
|
@@ -4,6 +4,7 @@ import { getCurrentSessionId } from '../../services/skills/skill-presence-servic
|
|
|
4
4
|
import { fail, ok } from 'peaks-loop-shared/result';
|
|
5
5
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
6
6
|
import { detectIdeFromContext, parseClaudeShapeStdin, pluckObject, pluckString } from '../../services/ide/hook-translator.js';
|
|
7
|
+
import { DISPATCH_PROVENANCE_ENV } from '../../services/worktree/dispatch-provenance.js';
|
|
7
8
|
import { getAdapter } from '../../services/ide/ide-registry.js';
|
|
8
9
|
import { emitBlock, emitDecision, emitHint } from '../../services/hooks/output.js';
|
|
9
10
|
/**
|
|
@@ -87,7 +88,10 @@ export function registerGateCommands(program, io) {
|
|
|
87
88
|
// parser; the parser entry-point (`parseXxxShapeStdin`) is the only
|
|
88
89
|
// change required.
|
|
89
90
|
const { toolName, command } = parseClaudeShapeStdin(parsedStdin);
|
|
90
|
-
|
|
91
|
+
const toolKind = classifyTool(toolName);
|
|
92
|
+
const isBashSurface = toolName === adapter.toolMatcher && typeof command === 'string' && command.trim().length > 0;
|
|
93
|
+
const isWorktreeToolSurface = toolKind === 'Agent' || toolKind === 'EnterWorktree';
|
|
94
|
+
if (!isBashSurface && !isWorktreeToolSurface) {
|
|
91
95
|
// Not a guarded surface — allow. Emit minimal JSON on stdout so Claude
|
|
92
96
|
// Code's PreToolUse hook validator accepts the response. Empty stdout
|
|
93
97
|
// is rejected with "Hook JSON output validation failed — Invalid input"
|
|
@@ -111,7 +115,6 @@ export function registerGateCommands(program, io) {
|
|
|
111
115
|
// We extract the tool kind from the hook payload (Bash/Agent/EnterWorktree/Workflow).
|
|
112
116
|
// For Agent/Task we also pull `isolation` from the tool input — only "worktree" isolation
|
|
113
117
|
// is gated. For all other tool kinds the worktree gate is a no-op.
|
|
114
|
-
const toolKind = classifyTool(toolName);
|
|
115
118
|
if (toolKind !== 'Other') {
|
|
116
119
|
const sessionId = getCurrentSessionId(options.project) ?? 'unknown-sid';
|
|
117
120
|
// slice 2026-07-29-worktree-l2-extended Part 2.B: the gate consults
|
|
@@ -130,11 +133,12 @@ export function registerGateCommands(program, io) {
|
|
|
130
133
|
projectRoot: options.project,
|
|
131
134
|
sessionId,
|
|
132
135
|
toolName: toolKind,
|
|
133
|
-
command: toolKind === 'Bash' ? command : null,
|
|
136
|
+
command: toolKind === 'Bash' && typeof command === 'string' ? command : null,
|
|
134
137
|
isolation: toolKind === 'Agent' || toolKind === 'EnterWorktree' ? extractIsolation(parsedStdin) : null,
|
|
135
138
|
requestId: null,
|
|
136
139
|
leaseId: leaseId !== null && /^[a-f0-9]{16}$/.test(leaseId) ? leaseId : null,
|
|
137
|
-
containerLeaseId: containerLeaseId !== null && /^[a-f0-9]{16}$/.test(containerLeaseId) ? containerLeaseId : null
|
|
140
|
+
containerLeaseId: containerLeaseId !== null && /^[a-f0-9]{16}$/.test(containerLeaseId) ? containerLeaseId : null,
|
|
141
|
+
dispatchProvenanceToken: process.env[DISPATCH_PROVENANCE_ENV] ?? null
|
|
138
142
|
});
|
|
139
143
|
if (!wtDecision.allow) {
|
|
140
144
|
// Hard block: a worktree-mutating tool call without a current-task user grant.
|
|
@@ -151,6 +155,15 @@ export function registerGateCommands(program, io) {
|
|
|
151
155
|
// wtDecision may have arrived via lease (viaLease != null); the SOP gate does not
|
|
152
156
|
// care which path granted, only that the worktree gate said allow.
|
|
153
157
|
}
|
|
158
|
+
if (!isBashSurface) {
|
|
159
|
+
if (options.json === true) {
|
|
160
|
+
printResult(io, ok('gate.enforce', { decision: 'allow', layer: 'worktree-auth' }), true);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
emitDecision(io, {});
|
|
164
|
+
}
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
154
167
|
const decision = await enforceBashCommand(options.project, command);
|
|
155
168
|
if (decision.decision === 'deny') {
|
|
156
169
|
// PRD#2 (2026-06-16-fact-forcing-gate-format): a true SOP gate failure is
|
|
@@ -9,6 +9,12 @@ type RenderOptions = {
|
|
|
9
9
|
* action (Bug-02 dispatch) and the explicit `render` subcommand. Exported
|
|
10
10
|
* so a unit test can exercise the JSON / text output paths without going
|
|
11
11
|
* through commander.
|
|
12
|
+
*
|
|
13
|
+
* Capability resolution is delegated to {@link resolveStatusLineCapability}
|
|
14
|
+
* with only `env` and `isTTY` — the env-driven path is the single first-
|
|
15
|
+
* version source of truth. No CLI flag widens the surface. The model
|
|
16
|
+
* itself is read-only: `buildStatusLineModel` is the sole I/O boundary,
|
|
17
|
+
* and it now also resolves compact state.
|
|
12
18
|
*/
|
|
13
19
|
export declare function runDefaultStatuslineRender(options: RenderOptions, io: ProgramIO): Promise<void>;
|
|
14
20
|
export declare function registerStatusLineCommands(program: Command, io: ProgramIO): void;
|