diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index c9a9f7f40..87f4df6e0 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -92,6 +92,17 @@ reviews:
Preserve unrelated editor tabs, dirty buffers, breakpoints, and processes.
Review cross-host assumptions against the cycle's declared support matrix.
+ - path: 'extension/src/**/{ownedNodeServerRun,vscodeNodeServerDebugger,launchNodeServer,confirmLaunchedGroup,reapNodeServerOrphans,signalProcessGroup,isProcessGroupGone,isGroupCertainlyGone}.ts'
+ instructions: |
+ Process signalling: every signal to a pid or a process group follows an
+ identity verdict (the launched leader's start time) taken within the same
+ bound as the signal. A signal-0 probe of a group proves only that some group
+ holds the id now, never that it is the launched one: a group id can be reused
+ between two probes. A signal sent after the operation that owns it has
+ returned (a close, a release, a sweep) is a defect, and so is a probe or
+ identity read that pushes a signal past its stated bound. Name the event
+ sequence that reaches each finding.
+
- path: 'extension/src/ui/**'
instructions: |
Follow PRODUCT_GOAL.md's design target and the approved design, as amended by the required reads and
diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml
index ae6b3662d..76512a694 100644
--- a/.github/workflows/package.yml
+++ b/.github/workflows/package.yml
@@ -27,7 +27,7 @@ jobs:
package_json_file: extension/package.json
- uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
with:
- node-version: 24.21.0
+ node-version-file: .node-version
cache: pnpm
cache-dependency-path: |
extension/pnpm-lock.yaml
diff --git a/.gstack/pregate.json b/.gstack/pregate.json
index b705c17ad..56693e496 100644
--- a/.gstack/pregate.json
+++ b/.gstack/pregate.json
@@ -36,6 +36,11 @@
".github/reader-dom/package-lock.json": [
"extension/scripts/tests/workflowPins.test.mjs"
],
+ "extension/scripts/gate.mjs": ["extension/scripts/tests/gate.test.mjs"],
+ "extension/scripts/ci-failures.mjs": [
+ "extension/scripts/tests/ciFailures.test.mjs"
+ ],
+ "extension/.node-version": ["extension/scripts/tests/gate.test.mjs"],
"extension/scripts/checkHealth.mjs": [
"extension/scripts/tests/health.test.mjs"
],
diff --git a/.node-version b/.node-version
index df6ae3370..ce62013c3 100644
--- a/.node-version
+++ b/.node-version
@@ -1 +1 @@
-24.21.0
+24.22.1
diff --git a/AGENTS.md b/AGENTS.md
index 30851e8f1..b48b629a8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -80,6 +80,7 @@ Driving agent owns state, cycle selection, completion claims; GStack supplies wo
- A large cycle may use up to three PRs (`cycle100-pt1`, `cycle100-pt2`, …), each passing reviews, CI and gate alone, merged in order; the Issue closes after the last.
- Codex automatic review off (no Codex cloud step). No `@coderabbitai ignore` in PR bodies (owner, 2026-10-05).
- Hosted CodeRabbit off from Cycle 0057 (owner, 2026-10-27: 「次のサイクルからGitHubのコード、Rabbitのレビューはトリガーさせないでください。」): `.coderabbit.yaml` keeps `reviews.auto_review.enabled: false` (CodeRabbit reads the PR branch's file); never post `@coderabbitai review`.
+- Outside review early (owner, 2026-10-28): `/ship` Step 9 pass 1 also runs Step 11's Codex structured review (`codex review --base main`) on the whole diff, its findings part of pass 1; a plan that changes process signalling, process identity or a stop/kill path also gets a Codex review before implementation. A fix-range pass (pass 2 on) under 300 changed lines dispatches the red team and the specialists whose area the range changes.
**Delivery parameters.**
@@ -143,6 +144,7 @@ Driving agent owns state, cycle selection, completion claims; GStack supplies wo
**CodeRabbit and OCR.**
- [.coderabbit.yaml](.coderabbit.yaml) = policy + exclusions: keep its guidelines current; validate with `coderabbit config validate .coderabbit.yaml`; pass it via `--config` beside extra context.
+ - CLI 0.9.1 (latest on 2026-10-28) exits 1 on every file with `unknown keyword: "x-undocumented"`, a defect of its own schema: record that line as the validation result; a review with `-c` still applies the file.
- Required: local CodeRabbit CLI, findings triage, CI, `open-code-review-delegate` (OCR picks files and rules, the host reviews; each eligible file reviewed or skipped with a reason; default exclusions reported apart; no independent external review).
- Review against the implementation base; record completion, source cut, covered files, findings.
- Hosted CodeRabbit is not triggered (owner, 2026-10-27; [Delivery](#delivery)): merge waits for no hosted review or approval. A hosted review that still appears is reported in the PR; its findings follow the cycle rule like the local review's. Run the local CodeRabbit CLI on the PR's final range before merge.
@@ -220,11 +222,13 @@ Standing rules, stated as rules (no decision log); delivery chain and ship limit
## Commands and checks
-Node and pnpm as pinned in the extension manifest and CI; run from the repo root.
+Node as `.node-version` pins it (mirrored in `extension/.node-version`, since a version manager's shim reads the nearest package's `engines` there) and pnpm as the extension's `packageManager` pins it; put that Node's own `bin` first in `PATH`; run from the repo root.
**Gate and focused runs.**
-- `pnpm --dir extension test` = build + Node's isolated runner (files parallel, tests in a file serial; never same-process concurrency globally: workspace cases mock shared prototypes and timers). Full gate before delivery. `pnpm --dir extension test:package` = package tests.
+- Exact-HEAD gate: `pnpm --dir extension gate`: the fifteen delivery checks in order (static checks, the suite, the five Reader DOM checks, package tests, measured health, the compaction-hook test), one log each under `artifacts/gate/
`; it refuses a Node or pnpm off the pins and a checkout with changes, and exits 0 only when every check passed on an unmoved HEAD.
+- `pnpm --dir extension test` = build + Node's isolated runner (files parallel, tests in a file serial; never same-process concurrency globally: workspace cases mock shared prototypes and timers). `pnpm --dir extension test:package` = package tests.
+- CI failures: `node extension/scripts/ci-failures.mjs ` (or `--run `) prints each failed job's failing tests with their first error line, also while the run goes on.
- Focused replay/workspace work: `pnpm --dir extension build` once, then `node --test extension/dist/tests/studyWorkspace*.test.js extension/dist/tests/playbackController.test.js` (every partition; `studyWorkspace.test.js` alone is one); reuse a current build.
- `studyWorkspaceHarness.ts` = fixtures only; each harness makes its own temp repo. Related race steps in one test; await each observable result before the next action. Keep real timeouts, production deadlines and assertions.
@@ -287,6 +291,7 @@ Node and pnpm as pinned in the extension manifest and CI; run from the repo root
## Native QA
+- Observing a native trial (state checks, screenshots, evidence stills): [docs/agents/native-trial-observation.md](docs/agents/native-trial-observation.md).
- UI changes: view the PNG again; compare real screenshots with its region at the normal desktop baseline; record differences and interaction.
- Motion needs recorded interaction + inspected frames; generated pixels or renderer assertions prove no native behavior.
diff --git a/docs/agents/native-trial-observation.md b/docs/agents/native-trial-observation.md
new file mode 100644
index 000000000..6ce4b5953
--- /dev/null
+++ b/docs/agents/native-trial-observation.md
@@ -0,0 +1,29 @@
+# Native trial observation
+
+Read before driving a native trial in the Cua Space (`local:codebook-qa`) or on a desktop. A `computer_screenshot` answers about 600,000 characters; in Cycles 0056 to 0058 screenshots were 85% of all tool output, and they forced most of the context compactions. Look with **text** first and spend **pictures** on visual evidence.
+
+## Text: check state
+
+Every step that only needs to know what is there (a window, a dialog, a label, a value, a process, a file) is checked with text:
+
+- **Windows and focus**: `computer_window_list` (filter with `app`), `computer_get_current_window`.
+- **Controls and labels**: `computer_get_accessibility_tree` with the window's `window_id` and a `max_depth`; it names buttons, dialog titles, picker entries and the chat's input text.
+- **Acting on a control**: `computer_accessibility_act` with the tree's `snapshot_id` and `element_id` (`press`, `focus`, `set_value`); it needs no coordinates, so no screenshot to find them.
+- **Processes, ports, files and logs in the Space**: `space_bash`, for example `lsof -nP -iTCP:4100 -sTCP:LISTEN`, `pgrep -fl 'Visual Studio Code'`, `osascript -e 'tell application "System Events" to get name of every window of process "Code"'`, or a `tail` of the file a step writes.
+
+After an action, verify its result the same way: the tree, the process or the file shows it.
+
+## Pictures: visual evidence
+
+A claim about what the learner sees (layout against the v9 PNG, rendering, a frame of motion) needs pixels. Capture a region in the Space and shrink it before reading it:
+
+```sh
+screencapture -x -R,,, /tmp/shot.png
+sips -s format jpeg -s formatOptions 70 -Z 1000 /tmp/shot.png --out /tmp/shot.jpg
+```
+
+Then `download` `/tmp/shot.jpg` and Read it.
+
+- **Evidence stills**: keep the full-size PNG as the evidence file (downloaded, SHA-256 on both sides, as AGENTS.md _Native QA_ says) and Read only the reduced copy.
+- **Coordinates**: use `computer_screenshot` when a click needs coordinates the accessibility tree cannot give (a canvas, a webview without accessible names); its pixel space is the one clicks use.
+- **Motion**: recordings and their frames follow AGENTS.md _Native QA_; Read reduced frames (`ffmpeg … -vf scale=1000:-1`).
diff --git a/extension/.node-version b/extension/.node-version
new file mode 100644
index 000000000..ce62013c3
--- /dev/null
+++ b/extension/.node-version
@@ -0,0 +1 @@
+24.22.1
diff --git a/extension/fallow-health-reference.json b/extension/fallow-health-reference.json
index ea9916b69..431dc08de 100644
--- a/extension/fallow-health-reference.json
+++ b/extension/fallow-health-reference.json
@@ -12,13 +12,13 @@
"maxCrap": 30
},
"producers": {
- "node": "24.21.0",
+ "node": "24.22.1",
"c8": "12.0.0",
"v8ToIstanbul": "9.3.0",
"fallow": "3.34.1"
},
"settings": {
- ".node-version": "73fb1b615e2043a933be1c0895cde4358036acc28d785692509b822aa53c761f",
+ ".node-version": "d9f318747a126ce2247661af67852b185def7789492eabfad7bba322be087c33",
"extension/.c8rc.json": "3b45447f2b721137e6f863dc3136c5aef26a14710dd6bdfb09756fcc5077ea6a",
"extension/.fallowrc.json": "de0d5f49a2c14a06a32de72184a95b2b90089318e3be6d434a212a2029fd0571"
},
diff --git a/extension/package.json b/extension/package.json
index bba722d2e..ce958fcfc 100644
--- a/extension/package.json
+++ b/extension/package.json
@@ -565,6 +565,7 @@
"format": "cd .. && prettier --write .",
"format:check": "cd .. && prettier --check .",
"health": "node scripts/checkHealth.mjs",
+ "gate": "node scripts/gate.mjs",
"health:static": "fallow health --complexity --max-crap inf --baseline-mode identity --fail-on-issues --baseline fallow-health-baseline.json",
"dupes": "fallow dupes --fail-on-issues --baseline fallow-dupes-baseline.json",
"dead-code": "fallow dead-code --fail-on-issues --baseline fallow-dead-code-baseline.json",
diff --git a/extension/scripts/checkHealth.mjs b/extension/scripts/checkHealth.mjs
index 140c52853..e29d6b35e 100644
--- a/extension/scripts/checkHealth.mjs
+++ b/extension/scripts/checkHealth.mjs
@@ -55,7 +55,7 @@ const TAP_SUMMARY_LINE = /^# (tests|pass|fail|cancelled|skipped|todo) (\d+)$/gm
/** How many names a refusal lists before "and N more". */
const SHOWN_NAME_LIMIT = 5
-/** Hashes a text file with CRLF read as LF, so a Windows checkout names the same bytes as the reference. @example await hashText('/repo/.node-version') // => '73fb1b61…' */
+/** Hashes a text file with CRLF read as LF, so a Windows checkout names the same bytes as the reference. @example await hashText('/repo/.node-version') // => 'd9f31874…' */
async function hashText(path) {
const text = (await readFile(path, 'utf8')).replaceAll('\r\n', '\n')
return createHash('sha256').update(text).digest('hex')
@@ -116,7 +116,7 @@ async function readReference() {
return reference
}
-/** Reads the version of every producer of the measured run as installed here. @example await readProducers() // => { node: '24.21.0', c8: '12.0.0', v8ToIstanbul: '9.3.0', fallow: '3.34.1' } */
+/** Reads the version of every producer of the measured run as installed here. @example await readProducers() // => { node: '24.22.1', c8: '12.0.0', v8ToIstanbul: '9.3.0', fallow: '3.34.1' } */
async function readProducers() {
const c8Manifest = requireFromExtension.resolve('c8/package.json')
const converterManifest = createRequire(c8Manifest).resolve(
@@ -196,7 +196,7 @@ function childEnvironment() {
* @param options - More `spawn` options; a `stdio` here replaces the default.
* @returns `{ code, signal, stdout }` once the child has exited; `stdout` holds its output only when `options.stdio` pipes it.
* @example
- * await runChild(process.execPath, ['--version'], { stdio: ['ignore', 'pipe', 2] }) // => { code: 0, signal: null, stdout: 'v24.21.0\n' }
+ * await runChild(process.execPath, ['--version'], { stdio: ['ignore', 'pipe', 2] }) // => { code: 0, signal: null, stdout: 'v24.22.1\n' }
*/
function runChild(command, arguments_, options = {}) {
return new Promise((resolve, reject) => {
diff --git a/extension/scripts/ci-failures.mjs b/extension/scripts/ci-failures.mjs
new file mode 100644
index 000000000..839020eeb
--- /dev/null
+++ b/extension/scripts/ci-failures.mjs
@@ -0,0 +1,158 @@
+// Prints the failing tests of a pull request's or a workflow run's failed CI jobs, also while the run is still going.
+//
+// node extension/scripts/ci-failures.mjs
+// node extension/scripts/ci-failures.mjs --run
+//
+// For each failed job: its name and URL, then each failing test of the runner's "failing tests" block with the first
+// error line under it; a failed job without that block prints its last lines instead. Needs an authenticated `gh`
+// (`GH_TOKEN` is passed through). Exit 0 after printing (also when nothing failed), 2 on a bad argument or a `gh` failure.
+import { execFileSync } from 'node:child_process'
+import { parseArgs } from 'node:util'
+
+import { isRunAsScript } from './utils/isRunAsScript.mjs'
+
+/** A GitHub Actions log line's timestamp prefix, as `gh api …/logs` writes it. */
+const TIMESTAMP_PREFIX = /^\d{4}-\d\d-\d\dT[\d:.]+Z /
+/** A terminal colour sequence, which the logs keep. */
+const COLOUR_SEQUENCE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g')
+/** How many of a failed job's last lines it prints when the log has no "failing tests" block. */
+const TAIL_LINES = 15
+
+/**
+ * Reads the failing tests out of one CI job's log: every `✖ ` of each `✖ failing tests:` block Node's spec
+ * reporter ends a failed run with, with the first error line under it.
+ * Why: a sharded job prints thousands of lines, and `gh run view --log-failed` prints nothing while the run is in
+ * progress; the names and their first error line are what a fix starts from. Called by {@link main} for each failed job.
+ * @param log - The job's log as `gh api …/actions/jobs//logs` answers it (timestamps and colours included).
+ * @returns `{ name, error }` per failing test, in log order, each name once; `error` is `undefined` when no error line follows.
+ * @example
+ * failedTestsOf('2026-10-28T00:00:00Z ✖ failing tests:\n2026-10-28T00:00:00Z ✖ adds (1.2ms)\n2026-10-28T00:00:00Z AssertionError [ERR_ASSERTION]: 1 !== 2')
+ * // => [{ name: 'adds', error: 'AssertionError [ERR_ASSERTION]: 1 !== 2' }]
+ */
+export function failedTestsOf(log) {
+ const lines = log
+ .split('\n')
+ .map((line) =>
+ line.replace(TIMESTAMP_PREFIX, '').replace(COLOUR_SEQUENCE, ''),
+ )
+ const failures = new Map()
+ let inBlock = false
+ let current
+ for (const line of lines) {
+ if (line.startsWith('✖ failing tests:')) {
+ inBlock = true
+ current = undefined
+ continue
+ }
+ // The block ends at the next summary or reporter line.
+ if (!inBlock || line.startsWith('ℹ ')) {
+ inBlock = false
+ continue
+ }
+ if (line.startsWith('✖ ')) {
+ const name = line.slice(2).replace(/ \([\d.]+m?s\)$/, '')
+ current = failures.get(name) ?? { name, error: undefined }
+ failures.set(name, current)
+ continue
+ }
+ // The first line naming an error under a test is its error line.
+ if (current && current.error === undefined && /Error\b/.test(line))
+ current.error = line.trim()
+ }
+ return [...failures.values()]
+}
+
+/** Runs `gh` and answers its stdout. @example gh(['api', 'user']) // => '{"login":…}' */
+function gh(args) {
+ return execFileSync('gh', args, {
+ encoding: 'utf8',
+ maxBuffer: 256 * 1024 * 1024,
+ })
+}
+
+/** Lists the workflow runs of a pull request's head commit. @example runsOfPullRequest(144) // => [7101, 7102] */
+function runsOfPullRequest(number) {
+ const head = gh([
+ 'pr',
+ 'view',
+ String(number),
+ '--json',
+ 'headRefOid',
+ '-q',
+ '.headRefOid',
+ ]).trim()
+ return JSON.parse(
+ gh(['run', 'list', '--commit', head, '--json', 'databaseId']),
+ ).map((run) => run.databaseId)
+}
+
+/**
+ * The command line: finds the failed jobs of the runs named, and prints each job's failing tests.
+ * @returns The exit code.
+ * @example main() // => 0
+ */
+function main() {
+ let parsed
+ try {
+ parsed = parseArgs({
+ options: { run: { type: 'string' } },
+ allowPositionals: true,
+ })
+ } catch (error) {
+ console.error(error.message)
+ return 2
+ }
+ const [pullRequest] = parsed.positionals
+ if ((pullRequest === undefined) === (parsed.values.run === undefined)) {
+ console.error('Name one pull request number, or one run with --run .')
+ return 2
+ }
+ try {
+ const repository = gh([
+ 'repo',
+ 'view',
+ '--json',
+ 'nameWithOwner',
+ '-q',
+ '.nameWithOwner',
+ ]).trim()
+ const runs =
+ parsed.values.run === undefined
+ ? runsOfPullRequest(pullRequest)
+ : [parsed.values.run]
+ let failedJobs = 0
+ for (const run of runs) {
+ const { jobs } = JSON.parse(
+ gh(['run', 'view', String(run), '--json', 'jobs']),
+ )
+ for (const job of jobs.filter((each) => each.conclusion === 'failure')) {
+ failedJobs += 1
+ console.log(`\n## ${job.name}\n${job.url}`)
+ // The logs keep the runners' colours, which `gh api` prints only when asked to; they are stripped before reading.
+ const log = gh([
+ 'api',
+ '--allow-escape-sequences',
+ `repos/${repository}/actions/jobs/${job.databaseId}/logs`,
+ ])
+ const failures = failedTestsOf(log)
+ // A job that failed outside the test runner (a refused health run, a build) shows its last lines.
+ if (failures.length === 0)
+ for (const line of log.split('\n').slice(-TAIL_LINES))
+ console.log(
+ line.replace(TIMESTAMP_PREFIX, '').replace(COLOUR_SEQUENCE, ''),
+ )
+ for (const failure of failures)
+ console.log(
+ `✖ ${failure.name}${failure.error ? `\n ${failure.error}` : ''}`,
+ )
+ }
+ }
+ if (failedJobs === 0) console.log('No failed job.')
+ return 0
+ } catch (error) {
+ console.error(`gh failed: ${error.message}`)
+ return 2
+ }
+}
+
+if (isRunAsScript(import.meta.url)) process.exitCode = main()
diff --git a/extension/scripts/gate.mjs b/extension/scripts/gate.mjs
new file mode 100644
index 000000000..e3cf96a45
--- /dev/null
+++ b/extension/scripts/gate.mjs
@@ -0,0 +1,377 @@
+// Runs the exact-HEAD gate behind `pnpm gate`: the fifteen checks a delivery passes before merge, in order, on a clean
+// committed tree, with the Node of `.node-version` and the pnpm of `extension/package.json`'s `packageManager`.
+//
+// pnpm --dir extension gate every check; logs under artifacts/gate//
+// pnpm --dir extension gate --out logs in
+// pnpm --dir extension gate --allow-dirty also on uncommitted changes (a trial run, not a gate)
+// pnpm --dir extension gate --silence-seconds end a check silent this long (default 300)
+//
+// Exit 0: every check passed and HEAD did not move. Exit 1: a check failed, or HEAD or the tree changed during the run.
+// Exit 2: refused before any check, because Node or pnpm differ from the pins, the checkout has changes, or an option is
+// unknown. Each check's output goes to its own log; stdout gets one line per check and the verdict. A check that prints
+// nothing for the silence bound has its whole process tree ended and counts as exit 124; SIGINT, SIGTERM or SIGHUP to
+// the gate ends the running check's tree and exits 130, 143 or 129.
+import { spawn, spawnSync } from 'node:child_process'
+import { createWriteStream } from 'node:fs'
+import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'
+import { delimiter, dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
+import { parseArgs } from 'node:util'
+
+import { createResettingTimer } from './utils/createResettingTimer.mjs'
+import { endChildrenOnSignal } from './utils/endChildrenOnSignal.mjs'
+import { endProcessTree } from './utils/endProcessTree.mjs'
+import { isRunAsScript } from './utils/isRunAsScript.mjs'
+import { listCheckoutChanges } from './utils/listCheckoutChanges.mjs'
+import { plainOutputEnvironment } from './utils/plainOutputEnvironment.mjs'
+
+const extension = fileURLToPath(new URL('../', import.meta.url))
+const repository = join(extension, '..')
+
+/** The five Reader DOM checks the `reader-dom.yml` workflow runs, each as `node extension/scripts/-qa.mjs verify `. */
+const READER_DOM_CHECKS = [
+ 'study-bootstrap',
+ 'wiki-reader-dom',
+ 'study-reading-pose',
+ 'understanding-focus',
+ 'repository-return',
+]
+
+/**
+ * The gate's checks in the order they run: a package script (`pnpm