diff --git a/.gitignore b/.gitignore
index 554689b42..d249b39fd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,3 +17,6 @@ dist/
.relayflow/
.relayflowd/
.relayflowd-*/
+
+# Legacy drive-local snapshots; the preparing launcher no longer executes them.
+.drive-gate/
diff --git a/ops/BACKLOG.md b/ops/BACKLOG.md
index 1a285abb1..e70f11b6b 100644
--- a/ops/BACKLOG.md
+++ b/ops/BACKLOG.md
@@ -238,6 +238,9 @@ complete. Recorded here so they are tracked rather than lost — found by tick
message.
- **F8b** — `validateKernelRetry` names an authoring rule as if the kernel
imposed it — a doc claim the kernel does not make.
+ Scope: `packages/sdk/src/compile.ts`. Use `validateAuthoringRetryDefaults`
+ for the authoring validator and its call site; remove the old identifier.
+ Verify: ["node", "--input-type=module", "-e", "import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; const source = readFileSync('packages/sdk/src/compile.ts', 'utf8'); assert(!source.includes('validateKernelRetry')); assert(source.includes('function validateAuthoringRetryDefaults(')); assert(source.includes('validateAuthoringRetryDefaults(step['));"]
- **F9** — `probeTrigger` catches every error with no classification, so a
broken probe environment is indistinguishable from a bad trigger.
- **F10** — small load-bearing boundary details a reader will trip over.
diff --git a/ops/DRIVE-LOCAL.md b/ops/DRIVE-LOCAL.md
new file mode 100644
index 000000000..48ff2967c
--- /dev/null
+++ b/ops/DRIVE-LOCAL.md
@@ -0,0 +1,87 @@
+# Local drive packages
+
+Launch from a trusted checkout with the SDK dependencies installed:
+
+```sh
+node scripts/run-drive-local.mjs
+```
+
+Run on the branch that should receive the diff, from a clean checkout. The
+flow leaves delivery to the operator. It does not commit or merge. The wrapper
+builds the SDK for the local launcher, pins the original HEAD and branch, and
+submits commands through the existing local launcher. The daemon journals those
+pins before implementation starts. Running the YAML template directly refuses
+the snapshot step because its pinned inputs are missing.
+
+`gate-snapshot` runs first. It extracts the package helper, verifier and SDK
+picker source with `git --no-replace-objects show
:`, then compiles
+that picker in a temporary directory outside the checkout. Its own extraction
+script also comes from that Git ref. Neither working-tree helper files nor
+ignored SDK dist supply gate inputs. The installed TypeScript compiler is a
+trusted toolchain dependency; no compiler package is copied into the temp dir.
+
+Selection records the same ref in the work package as `head`. Subsequent checks
+compare that field to the submitted pin, so changing package metadata cannot
+repin it after a commit. The ref supplies the gate-input integrity claim; there
+is no SHA256SUMS file beside the scripts. The launcher removes the temporary
+directory when its run returns.
+
+Selection uses the SDK backlog picker. A locally executable entry must name
+repository paths committed at HEAD in backticks and declare at least one acceptance
+command on an indented `Verify:` line. Each command is a JSON argv array,
+executed at the repository root with a two-minute bound. For example:
+
+```text
+- **Fix the value** Update `src/value.txt` to contain exactly "fixed".
+ Verify: ["node", "-e", "require('node:assert/strict').equal(require('node:fs').readFileSync('src/value.txt','utf8'),'fixed')"]
+```
+
+Multiple `Verify:` lines mean all commands must pass. Commands come from the
+backlog before implementation; the agent cannot substitute its own acceptance
+checks in package.json. Entries with no executable checks are skipped with
+`missing_executable_checks`, alongside the existing unbounded/stale scope
+reasons. The old F8b entry now carries a source assertion for its declared
+rename. That assertion also allows an already-completed package to pass
+without manufacturing another edit.
+
+The submitted scope step executes the helpers extracted from Git and refuses changes
+to its checkout sources and backlog. It checks the working tree and
+index against the selected HEAD, including untracked non-ignored files and
+both sides of renames. Scope uses exact paths or directory descendants, never
+string-prefix siblings. Backlog, verifier and gate changes fail even when a
+package names a containing directory. Symlink changes are refused. Ignored
+build/runtime artifacts are excluded from this Git diff boundary; it is not an
+OS filesystem sandbox. Moving HEAD or branch fails against the submitted pins,
+even if the agent updates the ignored package metadata to match.
+Pre-existing unresolved symlinks under directory scopes are refused: creating
+a file through a dangling symlink can write outside the checkout.
+
+Verification reconstructs the selected work from the unchanged backlog and
+compares its scope and commands to package.json. Each package check must pass
+before the SDK regression suite runs. An unchanged implementation whose DoD
+is unmet fails. HEAD/branch changes fail, and out-of-scope changes produced by
+an acceptance command fail too. A failed scope or verification step prevents
+the dependent report step from running.
+
+The scope command runs again after the SDK build and suite, before reporting.
+Reports include tracked changes against HEAD and non-ignored untracked paths.
+
+The execution contract has one owner for each kind of data:
+
+| Input or policy | Owner and validation |
+| --- | --- |
+| Gate inputs | Package helper, verifier, extraction script and picker source come from the pinned Git commit, with replacement objects disabled. The picker is built during `gate-snapshot` before selection or implementation. |
+| HEAD, branch, backlog hash | Preparing launcher pins the original commit/branch and the hash of its committed backlog in submitted commands. The package records the commit as `head`; no adjacent ref or checksum file is authority. |
+| Package metadata | Private atomic JSON artifact for the agent; scope, verify and report reconstruct its fields from the picker built from the pinned ref and the pinned backlog. It cannot redefine the original HEAD. |
+| Allowed paths and protected paths | `local-work-verification.mjs` extracted from the pinned ref; index and working tree checked separately against the original HEAD, including non-ignored untracked files. |
+| Acceptance argv | Parsed from the pinned backlog; every command must succeed, with scope rechecked after execution. |
+| Acceptance scripts and dependencies | **Open blocker:** arbitrary argv can load mutable source from implementation scope. |
+
+**A same-user agent can still write to the temporary execution directory.**
+This meets the narrower bar that gate inputs come from a pinned Git ref rather
+than files implementation edits. It does not make extracted runtime files
+immutable, isolate processes, or prevent arbitrary Git-storage tampering.
+
+The remaining acceptance-input decision is documented in
+`runtime-evidence/drive-threads-0909-decisions.md`. This flow is not ready for
+unattended use until those inputs have an explicit enforced ownership contract.
diff --git a/ops/RUNTIME-STATUS.md b/ops/RUNTIME-STATUS.md
index f78af0474..4b3256933 100644
--- a/ops/RUNTIME-STATUS.md
+++ b/ops/RUNTIME-STATUS.md
@@ -147,6 +147,10 @@ Its runtime behavior was not tested or inferred from a successful v2 check.
## Work package execution and delivery
Scaffold commit: `89f2f1d31f262420c2c5f613efa3f50c70153bfa`.
+The command and receipts below describe that historical scaffold. The current
+local drive uses `node scripts/run-drive-local.mjs` to capture gate inputs before
+submission; see [DRIVE-LOCAL.md](DRIVE-LOCAL.md) for its remaining acceptance-input
+blocker.
From a clean work branch at that commit, after the setup above:
```sh
diff --git a/ops/drive-local-flow.test.mjs b/ops/drive-local-flow.test.mjs
new file mode 100644
index 000000000..c39ed7fe5
--- /dev/null
+++ b/ops/drive-local-flow.test.mjs
@@ -0,0 +1,67 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { chmodSync, existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import test from 'node:test';
+import { fixture, packagePath } from './local-work-test-fixture.mjs';
+
+const quote = text => "'" + text.replaceAll("'", "'\\''") + "'";
+for (const scenario of ['outside edit', 'verifier edit', 'unchanged package', 'HEAD repin', 'forged snapshot']) {
+ test(`drive-local journals failure and blocks reporting for ${scenario}`, t => {
+ const f = fixture(t);
+ const wrapper = resolve('testdata/preflight/wrapper-session.mjs');
+ const cli = join(f.root, '.relayflow/agent.mjs');
+ f.put('.relayflow/agent.mjs', `#!/usr/bin/env node
+import { receiveWrapperRequest } from ${JSON.stringify(wrapper)};
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+const request = await receiveWrapperRequest();
+if (request) {
+ ${scenario === 'outside edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed');` : ''}
+ ${scenario === 'verifier edit' ? `writeFileSync(${JSON.stringify(join(f.root, 'ops/local-work-package.mjs'))}, 'process.exit(0)');` : ''}
+ ${scenario === 'HEAD repin' ? `
+ const git = (...args) => execFileSync('git', args, {cwd: ${JSON.stringify(f.root)}, encoding: 'utf8'}).trim();
+ writeFileSync(${JSON.stringify(join(f.root, 'outside.txt'))}, 'changed');
+ git('add', 'outside.txt');
+ git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test', '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside');
+ const path = ${JSON.stringify(join(f.root, packagePath))};
+ const pkg = JSON.parse(readFileSync(path, 'utf8'));
+ pkg.head = git('rev-parse', 'HEAD');
+ writeFileSync(path, JSON.stringify(pkg));` : ''}
+ ${scenario === 'forged snapshot' ? `
+ const directory = ${JSON.stringify(join(f.root, '.drive-gate'))};
+ mkdirSync(directory, {recursive: true});
+ writeFileSync(directory + '/local-work-package.mjs', 'process.exit(0);');
+ const sums = execFileSync('shasum', ['-a', '256', '.drive-gate/local-work-package.mjs'], {cwd: ${JSON.stringify(f.root)}});
+ writeFileSync(directory + '/SHA256SUMS', sums);` : ''}
+ console.log('DONE');
+}
+`);
+ // A scripted worker controls the edit while using the real worker protocol,
+ // stream pin, submitted flow commands, daemon and journal.
+ chmodSync(cli, 0o755);
+ const spec = structuredClone(f.preparedFlow);
+ for (const step of spec.steps) {
+ if (step.type === 'agent') step.cli = cli;
+ else step.command = `cd ${quote(f.root)}\n${step.command}`;
+ }
+ const path = join(f.root, '.relayflow/flow.json');
+ writeFileSync(path, JSON.stringify(spec));
+ const result = spawnSync(process.execPath, ['scripts/run-local-workflow.mjs', path], {
+ encoding: 'utf8', timeout: 20000,
+ });
+ assert.equal(result.error, undefined, result.stderr);
+ assert.equal(result.status, 1, result.stderr + result.stdout);
+ const dataDir = result.stdout.match(/^LOCAL_DATA_DIR=(.+)$/m)?.[1];
+ assert(dataDir, result.stderr);
+ t.after(() => rmSync(dataDir, { recursive: true, force: true }));
+ const journal = readFileSync(join(dataDir, 'journal.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
+ const failedStep = ['unchanged package', 'forged snapshot'].includes(scenario) ? 'verify' : 'scope';
+ const completion = journal.find(e => e.entry_type === 'step.completed' && e.step_id === failedStep);
+ assert.equal(completion?.payload.verification.verdict, 'fail', JSON.stringify(journal));
+ assert.equal(completion.payload.completionReason, 'retries_exhausted');
+ assert.equal(completion.payload.verification.detail, 'exit code was 1');
+ assert(!journal.some(e => e.entry_type === 'step.attempt.started' && e.step_id === 'report'));
+ assert(!existsSync(join(dataDir, 'relayflowd.sock')));
+ });
+}
diff --git a/ops/local-work-gate.mjs b/ops/local-work-gate.mjs
new file mode 100644
index 000000000..8400df52f
--- /dev/null
+++ b/ops/local-work-gate.mjs
@@ -0,0 +1,49 @@
+// Pin the Git ref before submission; gate-snapshot extracts only its Git objects.
+// The temporary execution directory is not a same-user filesystem sandbox.
+import assert from 'node:assert/strict';
+import { execFileSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { join, resolve } from 'node:path';
+import { tmpdir } from 'node:os';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+export const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
+export function prepareLocalDrive(flow, { root = process.cwd(),
+ compiler = fileURLToPath(new URL('../packages/sdk/node_modules/typescript/bin/tsc', import.meta.url)) } = {}) {
+ assert.equal(flow.name, 'drive-local', 'NOT_LOCAL_DRIVE');
+ const git = (...args) => execFileSync('git', ['--no-replace-objects', ...args], { cwd: root, encoding: 'utf8' });
+ const head = git('rev-parse', 'HEAD').trim();
+ const baseline = {
+ head,
+ branch: git('branch', '--show-current').trim(),
+ backlogSha256: createHash('sha256').update(git('show', `${head}:ops/BACKLOG.md`)).digest('hex'),
+ };
+ assert(baseline.branch && baseline.branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch');
+ const snapshot = git('show', `${head}:ops/local-work-snapshot.mjs`);
+ const directory = mkdtempSync(join(tmpdir(), 'drive-gate-'));
+ const dispose = () => rmSync(directory, { recursive: true, force: true });
+ try {
+ const picker = pathToFileURL(join(directory, 'dist/backlog-picker.js')).href;
+ const prefix = `GIT_NO_REPLACE_OBJECTS=1 DRIVE_GATE_BASELINE=${shellQuote(JSON.stringify(baseline))} ` +
+ `DRIVE_GATE_PICKER=${shellQuote(picker)} ${shellQuote(process.execPath)} ` +
+ shellQuote(join(directory, 'local-work-package.mjs'));
+ const commands = Object.fromEntries(['select', 'scope', 'verify', 'report']
+ .map(operation => [operation, `${prefix} ${operation}`]));
+ const prepared = structuredClone(flow);
+ const snapshotStep = prepared.steps.find(step => step.id === 'gate-snapshot');
+ assert.equal(snapshotStep?.command, 'node ops/local-work-snapshot.mjs', 'DRIVE_SNAPSHOT_CHANGED');
+ snapshotStep.command = `${shellQuote(process.execPath)} --input-type=module --eval ${shellQuote(snapshot)} ` +
+ `local-drive-snapshot ${shellQuote(head)} ${shellQuote(directory)} ${shellQuote(resolve(compiler))}`;
+ const operations = { select: 'select', 'initial-scope': 'scope', scope: 'scope',
+ verify: 'verify', 'final-scope': 'scope', report: 'report' };
+ for (const [id, operation] of Object.entries(operations)) {
+ const step = prepared.steps.find(candidate => candidate.id === id);
+ assert(step?.type === 'deterministic', `DRIVE_STEP_CHANGED: ${id}`);
+ const call = `node ops/local-work-package.mjs ${operation}`;
+ assert.equal(step.command.split(call).length, 2, `DRIVE_COMMAND_CHANGED: ${id}`);
+ step.command = step.command.replace(call, commands[operation]);
+ }
+ return { flow: prepared, commands, baseline, directory, dispose };
+ } catch (error) { dispose(); throw error; }
+}
diff --git a/ops/local-work-gate.test.mjs b/ops/local-work-gate.test.mjs
new file mode 100644
index 000000000..f2a06805f
--- /dev/null
+++ b/ops/local-work-gate.test.mjs
@@ -0,0 +1,55 @@
+import assert from 'node:assert/strict';
+import { spawnSync } from 'node:child_process';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import test from 'node:test';
+import { entry, fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs';
+
+test('committing outside scope and repinning metadata cannot replace the submitted HEAD', t => {
+ const f = fixture(t);
+ pass(f.run('select'));
+ f.put('outside.txt', 'committed outside scope');
+ f.git('add', 'outside.txt');
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test',
+ '-c', 'commit.gpgsign=false', 'commit', '-qm', 'outside scope');
+ const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8'));
+ pkg.head = f.git('rev-parse', 'HEAD').toString().trim();
+ f.put(packagePath, JSON.stringify(pkg));
+ for (const operation of ['scope', 'verify', 'report']) fail(f.run(operation), /HEAD_MOVED/);
+});
+
+test('forged legacy checkout snapshot and compiled picker cannot replace the Git-extracted judge', t => {
+ const f = fixture(t);
+ pass(f.run('select'));
+ for (const path of ['.drive-gate/local-work-package.mjs', '.drive-gate/local-work-verification.mjs',
+ '.drive-gate/backlog-picker.js', 'packages/sdk/dist/backlog-picker.js']) {
+ f.put(path, 'process.exit(0);\n');
+ }
+ pass(spawnSync('sh', ['-c', 'shasum -a 256 .drive-gate/*.mjs .drive-gate/*.js > .drive-gate/SHA256SUMS'],
+ { cwd: f.root, encoding: 'utf8' }));
+ pass(f.scope());
+ fail(f.run('verify'), /PACKAGE_CHECK_FAILED/);
+ f.put('src/value.txt', 'fixed');
+ pass(f.run('verify'));
+ pass(f.run('report'));
+});
+
+test('raw template refuses selection before implementation without captured gate inputs', t => {
+ const f = fixture(t);
+ const env = { ...process.env };
+ delete env.DRIVE_GATE_PICKER;
+ delete env.DRIVE_GATE_BASELINE;
+ fail(spawnSync(process.execPath, ['ops/local-work-package.mjs', 'select'],
+ { cwd: f.root, encoding: 'utf8', env }), /LOCAL_DRIVE_NOT_PREPARED/);
+});
+
+for (const title of ['Same title', 'Regex [a].* (b) + $']) {
+ test(`selection advances past rejected duplicate title: ${title}`, t => {
+ const f = fixture(t, entry(title, 'missing/value.txt') + entry(title));
+ const result = f.run('select');
+ pass(result);
+ assert(result.stdout.includes(`SKIPPED [stale_scope: missing/value.txt] ${title}`));
+ const pkg = JSON.parse(readFileSync(join(f.root, packagePath), 'utf8'));
+ assert.deepEqual(pkg.filesInScope, ['src/value.txt']);
+ });
+}
diff --git a/ops/local-work-git-input.test.mjs b/ops/local-work-git-input.test.mjs
new file mode 100644
index 000000000..ad14c834c
--- /dev/null
+++ b/ops/local-work-git-input.test.mjs
@@ -0,0 +1,72 @@
+import assert from 'node:assert/strict';
+import { existsSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import test from 'node:test';
+import { fixture, packagePath, pass, fail } from './local-work-test-fixture.mjs';
+
+test('preparation reads the committed gate instead of a working-tree replacement', t => {
+ const f = fixture(t);
+ const source = f.git('show', 'HEAD:ops/local-work-package.mjs').toString();
+ f.put('ops/local-work-package.mjs', source.replace('const packagePath', 'process.exit(0);\nconst packagePath'));
+ const selected = f.run('select');
+ pass(selected);
+ assert.match(selected.stdout, /SELECTED Fix value/);
+ assert.equal(readFileSync(join(f.gateDirectory, 'local-work-package.mjs'), 'utf8'), source);
+ assert.equal(JSON.parse(readFileSync(join(f.root, packagePath), 'utf8')).head,
+ f.git('rev-parse', 'HEAD').toString().trim());
+ fail(f.run('verify'), /OUT_OF_SCOPE/);
+ f.put('ops/local-work-package.mjs', f.git('show', 'HEAD:ops/local-work-package.mjs'));
+ f.put('src/value.txt', 'fixed');
+ pass(f.run('verify'));
+});
+
+test('gate-snapshot builds committed picker source outside the checkout', t => {
+ const f = fixture(t);
+ const original = f.git('show', 'HEAD:packages/sdk/src/backlog-picker.ts');
+ f.put('packages/sdk/src/backlog-picker.ts', 'this is not valid TypeScript');
+ const selected = f.run('select');
+ pass(selected);
+ assert.match(selected.stdout, /SELECTED Fix value/);
+ assert(!f.gateDirectory.startsWith(f.root));
+ assert.deepEqual(readFileSync(join(f.gateDirectory, 'backlog-picker.ts')), original);
+ assert(!existsSync(join(f.gateDirectory, 'SHA256SUMS')));
+ fail(f.scope(), /OUT_OF_SCOPE/);
+ f.put('packages/sdk/src/backlog-picker.ts', original);
+ f.put('src/value.txt', 'fixed');
+ pass(f.run('verify'));
+});
+
+test('a committed picker compile failure prevents package selection', t => {
+ const f = fixture(t);
+ f.put('packages/sdk/src/backlog-picker.ts', 'this is not valid TypeScript');
+ f.git('add', 'packages/sdk/src/backlog-picker.ts');
+ f.git('-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.test',
+ '-c', 'commit.gpgsign=false', 'commit', '-qm', 'broken picker source');
+ fail(f.run('select'), /error TS/);
+ assert(!existsSync(join(f.root, packagePath)));
+});
+
+test('an ignored compiled picker cannot become the input to selection', t => {
+ const f = fixture(t);
+ f.put('packages/sdk/dist/backlog-picker.js', 'process.exit(0);\n');
+ const selected = f.run('select');
+ pass(selected);
+ assert.match(selected.stdout, /SELECTED Fix value/);
+ fail(f.run('verify'), /PACKAGE_CHECK_FAILED/);
+ f.put('src/value.txt', 'fixed');
+ pass(f.run('verify'));
+});
+
+test('Git replacement objects cannot substitute gate source at the pinned ref', t => {
+ const f = fixture(t);
+ const original = f.git('rev-parse', 'HEAD:ops/local-work-package.mjs').toString().trim();
+ f.put('.relayflow/replacement.mjs', 'process.exit(0);\n');
+ const replacement = f.git('hash-object', '-w', '.relayflow/replacement.mjs').toString().trim();
+ f.git('replace', original, replacement);
+ const selected = f.run('select');
+ pass(selected);
+ assert.match(selected.stdout, /SELECTED Fix value/);
+ fail(f.run('verify'), /PACKAGE_CHECK_FAILED/);
+ f.put('src/value.txt', 'fixed');
+ pass(f.run('verify'));
+});
diff --git a/ops/local-work-package.mjs b/ops/local-work-package.mjs
index 00affa790..ecc3eeef1 100644
--- a/ops/local-work-package.mjs
+++ b/ops/local-work-package.mjs
@@ -1,71 +1,245 @@
-// The smallest useful local drive package: a mechanical change from BACKLOG.
+// Select one work package from ops/BACKLOG.md for a local drive tick.
+//
+// This used to hardcode a single item. The constants at the top named one file,
+// one old identifier and one new one, and `select` asserted that BACKLOG still
+// contained that exact entry. It proved a relayflow could drive a real change on
+// this checkout, which was the point at the time, but it could only ever drive
+// that one change — every later tick needed a human to rewrite the script first.
+//
+// Selection now comes from the SDK's backlog picker (gate 3, PR #20), which is
+// the same rule the cloud drive uses: the first top-level bullet whose title is
+// bold, validated for a title, files in scope, and a definition of done. Using
+// it here rather than a second implementation means the local loop and the cloud
+// loop cannot drift into disagreeing about what "the next work package" is.
+//
+// Implementation is no longer this script's job. A mechanical rewrite is the
+// only kind of change a deterministic step can make, and most backlog entries
+// are not mechanical. The flow now hands the package to an agent step, which is
+// what makes the loop general.
import assert from 'node:assert/strict';
-import { execFileSync } from 'node:child_process';
-import { createHash } from 'node:crypto';
-import { closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
+import { execFileSync, spawnSync } from 'node:child_process';
+import { createHash, randomUUID } from 'node:crypto';
+import {
+ closeSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync,
+} from 'node:fs';
import { dirname } from 'node:path';
-import { randomUUID } from 'node:crypto';
+import { checkScope, runChecks, verificationCommands } from './local-work-verification.mjs';
-const target = 'packages/sdk/src/compile.ts';
const packagePath = '.relayflow/drive-local/package.json';
-const oldName = 'validateKernelRetry';
-const newName = 'validateAuthoringRetryDefaults';
-const hash = text => createHash('sha256').update(text).digest('hex');
-const read = path => readFileSync(path, 'utf8');
-const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
-
-// A killed writer leaves the destination wholly old or wholly new. Flush the
-// replacement before rename and the containing directory before reporting it.
+const backlogPath = 'ops/BACKLOG.md';
+// Both values are embedded by the preparing launcher into the submitted command.
+// There is no fallback to an ignored artifact that implementation can replace.
+assert(process.env.DRIVE_GATE_PICKER && process.env.DRIVE_GATE_BASELINE,
+ 'LOCAL_DRIVE_NOT_PREPARED: run node scripts/run-drive-local.mjs');
+const sdkEntry = new URL(process.env.DRIVE_GATE_PICKER);
+const baseline = JSON.parse(process.env.DRIVE_GATE_BASELINE);
+
+const read = (p) => readFileSync(p, 'utf8');
+const hash = (t) => createHash('sha256').update(t).digest('hex');
+const git = (...a) => execFileSync('git', a, { encoding: 'utf8' }).trim();
+
+function assertBaseline() {
+ assert.equal(git('rev-parse', 'HEAD'), baseline.head, 'HEAD_MOVED');
+ assert.equal(git('branch', '--show-current'), baseline.branch, 'BRANCH_MOVED');
+ assert.equal(hash(read(backlogPath)), baseline.backlogSha256, 'BACKLOG_CHANGED');
+}
+
+// Only package metadata is written, always private (0600), never executable
+// source. Rename prevents partial JSON; file and directory fsync are required
+// before success. A directory fsync failure propagates even after rename.
function writeAtomically(path, contents) {
const temporary = `${path}.${randomUUID()}.tmp`;
+ mkdirSync(dirname(path), { recursive: true });
+ writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 });
+ const handle = openSync(temporary, 'r');
+ try { fsyncSync(handle); } finally { closeSync(handle); }
+ renameSync(temporary, path);
+ const directory = openSync(dirname(path), 'r');
+ try { fsyncSync(directory); } finally { closeSync(directory); }
+}
+
+async function loadPicker() {
try {
- const mode = path === target ? statSync(path).mode & 0o777 : 0o600;
- writeFileSync(temporary, contents, { flag: 'wx', mode, flush: true });
- renameSync(temporary, path);
- const directory = openSync(dirname(path), 'r');
- try { fsyncSync(directory); } finally { closeSync(directory); }
- } finally { rmSync(temporary, { force: true }); }
+ return await import(sdkEntry.href);
+ } catch (cause) {
+ // This is the picker built from Git by gate-snapshot, not SDK dist in the
+ // implementation checkout. Missing artifacts must never trigger a fallback.
+ throw new Error(
+ `GATE_PICKER_UNAVAILABLE: ${sdkEntry.pathname}; rerun through scripts/run-drive-local.mjs`,
+ { cause },
+ );
+ }
}
-switch (process.argv[2]) {
- case 'select': {
- const branch = git('branch', '--show-current');
- assert(branch && branch !== 'main', 'LOCAL_DRIVE_REFUSED: use a work branch');
- assert.equal(git('status', '--porcelain', '--', target), '', 'LOCAL_DRIVE_REFUSED: target has uncommitted edits');
- const entry = read('ops/BACKLOG.md').match(/^- \*\*F8b\*\*[^\n]*(?:\n [^\n]*)*/m)?.[0];
- assert(entry?.includes(oldName), 'BACKLOG_F8B_MISSING: expected the recorded work item');
- const source = read(target);
- assert.equal(source.split(oldName).length - 1, 2, 'PACKAGE_ALREADY_APPLIED_OR_CHANGED: expected declaration and call');
- const work = { id: 'F8b', entry, branch, target, before: hash(source), after: hash(source.replaceAll(oldName, newName)) };
- mkdirSync('.relayflow/drive-local', { recursive: true });
- writeAtomically(packagePath, JSON.stringify(work, null, 2) + '\n');
- assert.equal(JSON.parse(read(packagePath)).before, work.before);
- console.log(JSON.stringify(work));
- break;
+async function choose(markdown, { pathExists, log = true }) {
+ const { selectBacklogEntry, packageFromEntry, validateWorkPackage, renderWorkPackage } =
+ await loadPicker();
+ const skipped = [];
+ let entry = null;
+ let validation = null;
+
+ // Take the first entry this loop can actually bound. The picker returns one
+ // entry -- the first top-level bullet with a bold title -- so "next" is found
+ // by removing the one just rejected and asking it again, rather than writing
+ // a second parser that could disagree with it about what an entry is.
+ let commands;
+ while (markdown.length > 0) {
+ const candidateEntry = selectBacklogEntry(markdown);
+ if (!candidateEntry) break;
+
+ const candidate = packageFromEntry(candidateEntry);
+ const result = validateWorkPackage(candidate);
+ const scope = result.accepted ? result.work.files_in_scope : [];
+
+ // The picker emits ['.'] when an entry references code but names no path.
+ // That is deliberate on its side -- its comment calls it "honest breadth" --
+ // and it is a fair description of the entry. It is not usable as scope for
+ // an agent: "." is the whole repository, and an agent told its scope is
+ // everything has been told nothing. Skip rather than widen what an
+ // unattended tick may touch.
+ const unbounded = scope.length === 1 && scope[0] === '.';
+
+ // A path that no longer exists is not scope either. Backlog entries outlive
+ // the tree they were written against -- this repo moved `sdk/` to
+ // `packages/sdk/`, so entries naming `sdk/src/protocol.ts` still read as
+ // precise while pointing at nothing. An agent handed four missing files
+ // will either invent work or widen scope to find something, and both are
+ // failures the flow's instruction explicitly forbids. Skipping here means a
+ // rotted entry can never silently become an agent's instruction, and the
+ // skip line names the missing paths so the entry can be repaired.
+ const missing = unbounded ? [] : scope.filter((path) => !pathExists(path));
+ const checks = verificationCommands(candidateEntry.body);
+
+ if (result.accepted && !unbounded && missing.length === 0 && checks.length > 0) {
+ entry = candidateEntry;
+ validation = result;
+ commands = checks;
+ break;
+ }
+ skipped.push({
+ title: candidateEntry.title,
+ reason: !result.accepted
+ ? result.reason
+ : unbounded
+ ? 'unbounded_scope'
+ : missing.length > 0
+ ? `stale_scope: ${missing.join(', ')}`
+ : 'missing_executable_checks',
+ });
+ // Locate the bullet the SDK matched, never a mention of its title in an
+ // earlier entry's body. Consuming the full line guarantees forward progress.
+ const lines = markdown.split('\n');
+ const at = lines.findIndex(line => line.startsWith(`- **${candidateEntry.title}**`));
+ assert(at >= 0, 'BACKLOG_CURSOR_LOST');
+ markdown = lines.slice(at + 1).join('\n');
}
- case 'apply': {
- const work = JSON.parse(read(packagePath));
- assert.equal(git('branch', '--show-current'), work.branch, 'work branch changed');
- const before = read(target);
- // A retry after an interrupted write can observe the exact intended end state.
- if (hash(before) === work.after) { console.log('PACKAGE_ALREADY_APPLIED: F8b'); break; }
- assert.equal(hash(before), work.before, 'TARGET_CHANGED: refusing to overwrite intervening work');
- const after = before.replaceAll(oldName, newName);
- assert.notEqual(after, before);
- writeAtomically(target, after);
- assert.equal(hash(read(target)), work.after, 'MUTATION_NOT_PERSISTED');
- console.log(`PACKAGE_APPLIED: F8b ${work.before} -> ${work.after}`);
- console.log(git('diff', '--', target));
- break;
+
+ if (log) for (const s of skipped) console.log(`SKIPPED [${s.reason}] ${s.title.slice(0, 90)}`);
+ assert(
+ entry && validation?.accepted,
+ `NO_BOUNDED_WORK: ${skipped.length} entr(y|ies) considered, none named files ` +
+ `this loop can scope and verify. Add explicit paths and Verify: JSON argv to a BACKLOG entry.`,
+ );
+
+ return {
+ title: validation.work.title,
+ filesInScope: validation.work.files_in_scope,
+ definitionOfDone: validation.work.definition_of_done,
+ verificationCommands: commands,
+ brief: renderWorkPackage(entry),
+ };
+}
+
+async function select() {
+ assertBaseline();
+ // Restored guard. The generalization recorded the branch but stopped asserting
+ // it, so the loop would happily select work while sitting on `main` and let
+ // the agent edit the protected branch. `--show-current` prints nothing on a
+ // detached HEAD, which is equally not a work branch.
+ const branch = git('branch', '--show-current');
+ assert(
+ branch && branch !== 'main',
+ branch
+ ? `LOCAL_DRIVE_REFUSED: on '${branch}'; use a work branch, not main`
+ : 'LOCAL_DRIVE_REFUSED: detached HEAD is not a work branch; check out one',
+ );
+ const head = git('rev-parse', 'HEAD');
+ const markdown = read(backlogPath);
+ // Selection and the gate must share one baseline. `existsSync` accepts a path
+ // that exists only in the working tree, which selection then persists and
+ // checkScope immediately rejects -- its `git cat-file` lookup consults the
+ // selected commit, so an untracked path aborts the run on a package selection
+ // had already blessed. Committed-at-HEAD is the honest rule for both: scope is
+ // a claim about reviewable content, and an untracked path is not yet that.
+ const work = await choose(markdown, {
+ pathExists: path => spawnSync('git', ['cat-file', '-e', `${head}:${path.replace(/\/$/, '')}`],
+ { stdio: 'ignore' }).status === 0,
+ });
+ const pkg = {
+ selectedAt: new Date().toISOString(),
+ branch,
+ head,
+ backlogSha256: hash(markdown),
+ ...work,
+ };
+ writeAtomically(packagePath, `${JSON.stringify(pkg, null, 2)}\n`);
+ console.log(`SELECTED ${pkg.title}`);
+ console.log(` files in scope: ${pkg.filesInScope.join(', ')}`);
+ console.log(` definition of done: ${pkg.definitionOfDone.length} item(s)`);
+}
+
+async function verifiedPackage() {
+ assertBaseline();
+ const pkg = JSON.parse(read(packagePath));
+ assert.equal(pkg.head, baseline.head, 'PACKAGE_CHANGED: head');
+ assert.equal(pkg.branch, baseline.branch, 'PACKAGE_CHANGED: branch');
+ const markdown = read(backlogPath);
+ assert.equal(hash(markdown), pkg.backlogSha256, 'BACKLOG_CHANGED');
+ // Reconstruct from the unchanged backlog, so editing ignored package.json
+ // cannot widen scope or replace acceptance commands with `true`.
+ const selected = await choose(markdown, {
+ log: false,
+ // Consult the selected commit so deleting an in-scope file neither shifts
+ // selection nor resurrects an earlier entry with stale paths.
+ pathExists: path => spawnSync('git', ['cat-file', '-e', `${pkg.head}:${path.replace(/\/$/, '')}`],
+ { stdio: 'ignore' }).status === 0,
+ });
+ for (const key of Object.keys(selected)) {
+ assert.deepEqual(pkg[key], selected[key], `PACKAGE_CHANGED: ${key}`);
}
- case 'report': {
- const work = JSON.parse(read(packagePath));
- assert.equal(hash(read(target)), work.after, 'TARGET_CHANGED: expected the applied package');
- const diff = git('diff', '--', target);
- assert(diff.includes(`+function ${newName}(`), 'PACKAGE_DIFF_MISSING');
- console.log('PACKAGE_EXECUTED: F8b; delivery requires a branch commit and human-reviewed PR.');
- console.log(diff);
- break;
+ checkScope(pkg);
+ return pkg;
+}
+
+async function report() {
+ const pkg = await verifiedPackage();
+ // The package pins the HEAD it was selected against. Reporting a diff from a
+ // different commit would describe work this tick did not do.
+ const head = git('rev-parse', 'HEAD');
+ assert.equal(head, pkg.head, `HEAD_MOVED: selected at ${pkg.head}, now ${head}`);
+ const stat = [
+ git('diff', 'HEAD', '--stat'),
+ git('ls-files', '--others', '--exclude-standard'),
+ ].filter(Boolean).join('\n');
+ console.log(`REPORT ${pkg.title}`);
+ console.log(stat || ' (no working-tree changes)');
+ for (const item of pkg.definitionOfDone) console.log(` DoD: ${item}`);
+}
+
+try {
+ const command = process.argv[2];
+ if (command === 'select') await select();
+ else if (command === 'report') await report();
+ else if (command === 'scope') await verifiedPackage();
+ else if (command === 'verify') {
+ const pkg = await verifiedPackage();
+ runChecks(pkg);
+ await verifiedPackage();
+ } else {
+ console.error('usage: local-work-package.mjs