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