From 29c5f99b8966e812d3f700c68d4f6d71cef1f4f1 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:55:10 -0700 Subject: [PATCH 01/14] feat: archive-run pins the plan and spec to the recorded versions --- scripts/_pin.py | 208 +++++++++++++++++++++++++++++++++++++ scripts/archive-run | 101 ++++++++++++++++-- tests/archive-pin.test.mjs | 198 +++++++++++++++++++++++++++++++++++ 3 files changed, 498 insertions(+), 9 deletions(-) create mode 100644 scripts/_pin.py create mode 100644 tests/archive-pin.test.mjs diff --git a/scripts/_pin.py b/scripts/_pin.py new file mode 100644 index 0000000..92a3fed --- /dev/null +++ b/scripts/_pin.py @@ -0,0 +1,208 @@ +"""Pin an archived plan or spec to the version a launch recorded (archive-run). + +Import with: + sys.dont_write_bytecode = True + sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) + import _pin + +recorded_inputs(ledger_dir) reads every setup run_started event of +/_run.jsonl, in file order, as {"head", "plan_sha256", +"spec_sha256"}; a field that is missing or not a string is None. A missing +or unreadable file gives [], and a line that is not a JSON object is skipped. + +pin(kind, live, archived, recorded, git_cmd, repo_root) decides one file +(kind "plan" or "spec"). live is the file's live path (it need not exist; +None when there is none), archived the path of its copy in the run folder, +recorded what recorded_inputs gave, git_cmd the git command for the run's +repo (["git", "-C", root] in git mode, ["git", "--git-dir", git_dir] in +shadow mode) and repo_root the project root the live path is relative to. +It returns {"action", "source", "state", "warning"}: + action "keep" (leave the archived copy as it is), "write" (write + source over the archived copy, or as a new one) or "none" + (nothing to archive); + source the bytes to write with "write", else None; + state "kept" (the archived copy has the recorded sha), "pinned" (a new + copy with the recorded sha), "replaced" (an archived copy that + did not have it is replaced by one that does), or None (not + pinned: no recorded sha, or no source has it); + warning None, or {"code": "_unpinned", "detail": ...} when a sha is + recorded but no source has it; the detail names the sha and what + was kept or copied. +The rules (spec 4.1): + 1. The recorded sha is the last event's _sha256; none: rule 5. + 2. An archived copy with that sha is kept, whatever the live file holds. + 3. Otherwise the first source with that sha is written. Sources, in order: + the live file; then for each event's head, from the last back (each + head once), 'git show :' and 'git cat-file --filters + :' (the bytes a checkout writes, so a CRLF checkout + matches). is the live path relative to repo_root as git stores + it at that head: the name from 'git ls-tree -r --name-only -z ' + equal to it, else one equal to it compared without case. A live path + outside repo_root has no git source. + 4. No source has the sha: an archived copy is kept; without one the live + file is copied (nothing when it is missing). Either way: the warning. + 5. No recorded sha: an archived copy is kept (never replaced); without one + the live file is copied, as before pinning. +sha256 is taken over the bytes as they are. A git call that fails (a head +that is gone, a path git does not hold at it) is a source that does not +match. Git runs with _audit_git.git_env(), so the caller's GIT_DIR and +similar variables cannot point it at another repo. Nothing here writes. +""" +import hashlib +import json +import os +import subprocess +import sys + +sys.dont_write_bytecode = True +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +import _audit_git # noqa: E402 + +KINDS = ("plan", "spec") + + +def recorded_inputs(ledger_dir): + """[{"head", "plan_sha256", "spec_sha256"}] of the setup run_started + events in /_run.jsonl, in file order.""" + try: + with open(os.path.join(ledger_dir, "_run.jsonl"), "rb") as f: + lines = f.read().splitlines() + except OSError: + return [] + out = [] + for line in lines: + try: + event = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + continue + if not isinstance(event, dict) or event.get("event") != "run_started" or event.get("phase") != "setup": + continue + out.append({key: event.get(key) if isinstance(event.get(key), str) else None + for key in ("head", "plan_sha256", "spec_sha256")}) + return out + + +def sha256(data): + return hashlib.sha256(data).hexdigest() + + +def read_bytes(path): + """The bytes of the file at path; None when it is not a readable file.""" + if not path or not os.path.isfile(path): + return None + try: + with open(path, "rb") as f: + return f.read() + except OSError: + return None + + +def git_bytes(git_cmd, args): + """The stdout bytes of git_cmd + args; None when git fails.""" + try: + res = subprocess.run(list(git_cmd) + list(args), env=_audit_git.git_env(), stdin=subprocess.DEVNULL, + capture_output=True) + except (OSError, subprocess.SubprocessError): + return None + return res.stdout if res.returncode == 0 else None + + +def repo_path(live, repo_root): + """The live path relative to repo_root, '/'-separated; None when it is + outside the root (or on another drive).""" + if not live or not repo_root: + return None + candidates = [(os.path.abspath(live), os.path.abspath(repo_root)), + (os.path.realpath(live), os.path.realpath(repo_root))] + for path, root in candidates: + try: + rel = os.path.relpath(path, root) + except ValueError: + continue + rel = rel.replace(os.sep, "/") + if rel != "." and rel != ".." and not rel.startswith("../") and not os.path.isabs(rel): + return rel + return None + + +def stored_path(git_cmd, head, rel): + """The path git stores at head for rel: the equal name, else the first + equal without case; None when git holds neither.""" + out = git_bytes(git_cmd, ["ls-tree", "-r", "--name-only", "-z", head]) + if out is None: + return None + names = [n.decode("utf-8", "surrogateescape") for n in out.split(b"\0") if n] + if rel in names: + return rel + folded = rel.casefold() + for name in names: + if name.casefold() == folded: + return name + return None + + +def heads_from_last(recorded): + """The recorded heads, from the last event back, each once.""" + out = [] + for event in reversed(recorded): + head = event.get("head") + if isinstance(head, str) and head and not head.startswith("-") and head not in out: + out.append(head) + return out + + +def git_sources(git_cmd, recorded, rel): + """Yields the bytes of each git source of rel, in rule 3's order.""" + for head in heads_from_last(recorded): + path = stored_path(git_cmd, head, rel) + if path is None: + continue + spec = head + ":" + path + for args in (["show", spec], ["cat-file", "--filters", spec]): + data = git_bytes(git_cmd, args) + if data is not None: + yield data + + +def result(action, source=None, state=None, warning=None): + return {"action": action, "source": source, "state": state, "warning": warning} + + +def pin(kind, live, archived, recorded, git_cmd, repo_root): + """What to do with the archived copy of the plan or spec (see the + module docstring).""" + if kind not in KINDS: + raise ValueError("kind must be plan or spec: " + repr(kind)) + want = recorded[-1].get(kind + "_sha256") if recorded else None + want = want.lower() if isinstance(want, str) and want else None + have = read_bytes(archived) + live_bytes = read_bytes(live) + if want is None: + # Rule 5: nothing to pin against. + if have is not None: + return result("keep") + if live_bytes is not None: + return result("write", live_bytes) + return result("none") + if have is not None and sha256(have) == want: + return result("keep", state="kept") + state = "replaced" if have is not None else "pinned" + if live_bytes is not None and sha256(live_bytes) == want: + return result("write", live_bytes, state) + rel = repo_path(live, repo_root) + if rel is not None: + for data in git_sources(git_cmd, recorded, rel): + if sha256(data) == want: + return result("write", data, state) + # Rule 4: no source has the recorded sha. + name = os.path.basename(archived) + detail = "no source of the " + kind + " " + name + " has the recorded sha256 " + want + "; " + if have is not None: + return result("keep", warning={"code": kind + "_unpinned", + "detail": detail + "kept the archived copy (sha256 " + sha256(have) + ")"}) + if live_bytes is not None: + return result("write", live_bytes, warning={ + "code": kind + "_unpinned", + "detail": detail + "copied the live file (sha256 " + sha256(live_bytes) + ")"}) + return result("none", warning={"code": kind + "_unpinned", + "detail": detail + "nothing archived (no live file)"}) diff --git a/scripts/archive-run b/scripts/archive-run index b00cd47..3c7422c 100755 --- a/scripts/archive-run +++ b/scripts/archive-run @@ -14,7 +14,7 @@ The run goes to //, which holds: the manifest; ..ledger/ repo.ledger_dir, copied whole (checks/ too); ..report.json from beside the manifest, when present; - the plan and spec files when present, by their own names; + the plan and spec files by their own names, pinned (below); wf_/ and wf_.json each TRANSCRIPT_DIR and its workflow result file (found as run-report finds it, _shell.result_paths), when present. @@ -36,6 +36,21 @@ Copying is idempotent: a file missing from the folder is added, a file whose bytes differ is replaced (each through a temp file and a rename), and a file already in the archive is never deleted, even when its source is gone. +The plan and spec (manifest.plan and manifest.spec; a spec of null is +skipped) are pinned instead (_pin.pin, spec 4.1): an archived copy is only +ever the version the latest setup run_started event of the ledger +(repo.ledger_dir) recorded in plan_sha256 / spec_sha256. An archived copy +with that sha is kept, even when the live file differs. Otherwise the first +source with that sha is written: the live file, then the file's blob at each +recorded setup head from the latest back, as git stores it and with the +checkout filters (git cat-file --filters, so a CRLF checkout matches), the +path looked up in git without case. Git runs as git -C repo.root, or with +--git-dir repo.git_dir in shadow mode. When no source has the sha, an +archived copy is kept and, without one, the live file is copied, with a +_unpinned warning naming the recorded sha. A ledger with no recorded +sha (before plan_sha256) never replaces an archived copy; without one the +live file is copied. + The index entry (_history.ENTRY_KEYS) is rebuilt from the folder after copying: launches the number of wf_ dirs in the folder; @@ -59,8 +74,14 @@ The whole read index, choose folder, copy and write index step runs inside _history.index_lock, so archive-runs at the same time lose no entry. Prints one JSON line: - {"folder": "", "added": [rel paths], "updated": [rel paths]} -with paths relative to the folder, '/'-separated. + {"folder": "", "added": [rel paths], "updated": [rel paths], + "pinned": [names], "kept": [names], "replaced": [names], + "warnings": [{"code": "", "detail": ""}]} +with paths relative to the folder, '/'-separated. pinned names a plan or +spec newly archived with its recorded sha, kept one whose archived copy +already had it, replaced one whose archived copy lacked it and was replaced +by one that has it; a file with no recorded sha, or none that matched, is in +none of the three. warnings holds plan_unpinned and spec_unpinned. Exit 0 on success; 2 on a usage error, an unreadable manifest, transcript dir, ledger dir or index, a failed copy, or the lock not acquired; 3 when @@ -102,6 +123,7 @@ import time sys.dont_write_bytecode = True sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import _history # noqa: E402 +import _pin # noqa: E402 import _shell # noqa: E402 UNAVAILABLE = "unavailable" @@ -267,6 +289,46 @@ def copy_file(src, dst, rel, added, updated): raise +def write_bytes(dst, data, rel, added, updated): + """Write data to dst unless dst already holds the same bytes.""" + if os.path.lexists(dst): + if os.path.isfile(dst): + with open(dst, "rb") as f: + if f.read() == data: + return + updated.append(rel) + else: + added.append(rel) + folder = os.path.dirname(dst) + os.makedirs(folder, exist_ok=True) + fd, tmp = tempfile.mkstemp(prefix="." + os.path.basename(dst) + ".", suffix=".tmp", dir=folder) + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(tmp, dst) + except BaseException: + try: + os.remove(tmp) + except OSError: + pass + raise + + +def same_source(a, b): + """Whether two source paths are one file (by path when one is missing).""" + if os.path.exists(a) and os.path.exists(b): + return os.path.samefile(a, b) + return os.path.normcase(os.path.abspath(a)) == os.path.normcase(os.path.abspath(b)) + + +def git_command(repo): + """The git command for the run's repo: the shadow git dir in shadow + mode, else the repo root.""" + if repo.get("mode") == "shadow" and isinstance(repo.get("git_dir"), str) and repo["git_dir"]: + return ["git", "--git-dir", _shell.native_path(repo["git_dir"])] + return ["git", "-C", _shell.native_path(repo["root"])] + + def copy_tree(src, dst, rel, added, updated): """Copy every file under src to dst; nothing in dst is removed.""" os.makedirs(dst, exist_ok=True) @@ -442,24 +504,31 @@ def archive(manifest_path, manifest, dirs, hdir): report = os.path.join(os.path.dirname(manifest_path), report_name) if os.path.isfile(report): sources.append((report, report_name)) - for key in ("plan", "spec"): + pins = [] # (kind, live path, name in the folder): pinned, not copied + for key in _pin.KINDS: p = manifest.get(key) if isinstance(p, str) and p: p = _shell.native_path(p) - if os.path.isfile(p): - sources.append((p, os.path.basename(p))) + pins.append((key, p, os.path.basename(p))) # Two sources with one name would leave only the second in the folder. names = {} - for src, rel in sources: + for src, rel in sources + [(p, rel) for _k, p, rel in pins]: other = names.get(rel.lower()) if other is None: names[rel.lower()] = src - elif not os.path.samefile(other, src): + elif not same_source(other, src): raise ArchiveError("two files would be archived as " + rel + ": " + other + " and " + src + "; give the plan and spec names that differ from each other and from" " the manifest and report") sources = [(src, rel) for i, (src, rel) in enumerate(sources) if all(r.lower() != rel.lower() for _s, r in sources[:i])] + pins = [(kind, p, rel) for i, (kind, p, rel) in enumerate(pins) + if all(r.lower() != rel.lower() for _s, r in sources) + and all(r.lower() != rel.lower() for _k, _p, r in pins[:i])] + # The recorded versions come from the ledger being archived. + recorded = _pin.recorded_inputs(ledger_src) + git_cmd = git_command(repo) + root = _shell.native_path(repo["root"]) results = [result_file(d) for d in dirs] with _history.index_lock(hdir): @@ -478,6 +547,17 @@ def archive(manifest_path, manifest, dirs, hdir): os.makedirs(folder, exist_ok=True) for src, rel in sources: copy_file(src, os.path.join(folder, rel), rel, added, updated) + states = {"pinned": [], "kept": [], "replaced": []} + warnings = [] + for kind, live, rel in pins: + dst = os.path.join(folder, rel) + decision = _pin.pin(kind, live, dst, recorded, git_cmd, root) + if decision["action"] == "write": + write_bytes(dst, decision["source"], rel, added, updated) + if decision["state"]: + states[decision["state"]].append(rel) + if decision["warning"]: + warnings.append(decision["warning"]) ledger_name = pname + "." + run_id + ".ledger" copy_tree(ledger_src, os.path.join(folder, ledger_name), ledger_name, added, updated) for d, r in zip(dirs, results): @@ -491,7 +571,10 @@ def archive(manifest_path, manifest, dirs, hdir): index = dict(index) index["runs"] = runs _history.write_index(hdir, index) - return {"folder": hdir.rstrip("/\\") + "/" + name, "added": added, "updated": updated} + out = {"folder": hdir.rstrip("/\\") + "/" + name, "added": added, "updated": updated} + out.update(states) + out["warnings"] = warnings + return out def first_match(folder, patterns): diff --git a/tests/archive-pin.test.mjs b/tests/archive-pin.test.mjs new file mode 100644 index 0000000..8edec37 --- /dev/null +++ b/tests/archive-pin.test.mjs @@ -0,0 +1,198 @@ +// scripts/archive-run pins the plan and spec (spec 4.1): an archived copy is +// only ever the version the latest setup run_started event recorded, found in +// the live file or in git at a recorded setup head. Each test builds its own +// git repo, run and history dir and passes --history-dir. + +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; +import { join } from 'node:path'; +import { SKILL_DIR } from './harness.mjs'; +import { mergeEnv, tempDir } from './platform.mjs'; + +const SCRIPT = join(SKILL_DIR, 'scripts', 'archive-run'); +const TMP = tempDir('pl-archive-pin-'); +after(() => rmSync(TMP, { recursive: true, force: true })); + +const DAY1 = Date.UTC(2026, 9, 10, 7, 0, 0); +const IDENTITY = ['-c', 'user.name=noderaven', '-c', 'user.email=130021665+noderaven@users.noreply.github.com', + '-c', 'commit.gpgsign=false']; + +function cleanEnv() { + const env = mergeEnv(process.env); + for (const key of Object.keys(env)) { + if (['PL_HISTORY_DIR', 'CLAUDE_CONFIG_DIR'].includes(key.toUpperCase())) delete env[key]; + } + return env; +} + +let counter = 0; +function freshDir(name) { + counter += 1; + const dir = join(TMP, `${name}-${counter}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeJson(path, value) { + writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); +} + +function sha(bytes) { + return createHash('sha256').update(bytes).digest('hex'); +} + +function git(dir, ...args) { + const res = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', env: cleanEnv() }); + assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`); + return res.stdout.trim(); +} + +// A launch dir /s/wf_a with one transcript and its result file. +function addLaunch(root) { + const dir = join(root, 's', 'wf_a'); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'agent-a.jsonl'), + JSON.stringify({ type: 'assistant', timestamp: new Date(DAY1).toISOString() }) + '\n'); + writeJson(`${dir}.json`, { runId: 'wf_a', startTime: DAY1, result: { status: 'complete' } }); + return dir; +} + +// A git repo /proj holding docs/plan.md and docs/spec.md (committed), +// a run /run/p.lanes.json whose plan and spec point there, and a ledger +// whose setup run_started event records the head and the working-tree shas. +// crlf the repo checks text out with CRLF (autocrlf and .gitattributes); +// planPath the plan path the manifest names, relative to the repo; +// spec false for a manifest spec of null; +// shas false for setup events without plan_sha256 and spec_sha256. +function makeRun({ crlf = false, planPath = 'docs/plan.md', spec = true, shas = true } = {}) { + const root = freshDir('case'); + const proj = join(root, 'proj'); + const run = join(root, 'run'); + const ledger = join(run, 'p.x1.ledger'); + mkdirSync(join(proj, 'docs'), { recursive: true }); + mkdirSync(ledger, { recursive: true }); + git(proj, 'init', '-q'); + git(proj, 'config', 'core.autocrlf', crlf ? 'true' : 'false'); + const eol = crlf ? '\r\n' : '\n'; + if (crlf) writeFileSync(join(proj, '.gitattributes'), '* text=auto eol=crlf\n'); + writeFileSync(join(proj, 'docs', 'plan.md'), `# Plan${eol}committed${eol}`); + writeFileSync(join(proj, 'docs', 'spec.md'), `# Spec${eol}committed${eol}`); + git(proj, 'add', '-A'); + git(proj, ...IDENTITY, 'commit', '-q', '-m', 'docs'); + const head = git(proj, 'rev-parse', 'HEAD'); + const plan = join(proj, ...planPath.split('/')); + const specFile = join(proj, 'docs', 'spec.md'); + const planBytes = readFileSync(join(proj, 'docs', 'plan.md')); + const specBytes = readFileSync(specFile); + const manifest = join(run, 'p.lanes.json'); + writeJson(manifest, { version: 1, run_id: 'x1', plan, spec: spec ? specFile : null, + repo: { mode: 'git', root: proj, ledger_dir: ledger } }); + const event = { task: '_run', event: 'run_started', phase: 'setup', head }; + if (shas) { + event.plan_sha256 = sha(planBytes); + event.spec_sha256 = spec ? sha(specBytes) : null; + } + writeFileSync(join(ledger, '_run.jsonl'), JSON.stringify(event) + '\n'); + const wf = addLaunch(root); + return { root, proj, run, ledger, manifest, plan, spec: specFile, wf, head, planBytes, specBytes, + hist: join(root, 'hist'), folder: join(root, 'hist', '2026-10-10-x1') }; +} + +// Replaces the recorded events of the ledger with one setup event. +function recordEvent(r, fields) { + writeFileSync(join(r.ledger, '_run.jsonl'), + JSON.stringify({ task: '_run', event: 'run_started', phase: 'setup', head: r.head, ...fields }) + '\n'); +} + +function archive(r) { + const res = spawnSync('python3', [SCRIPT, r.manifest, r.wf, '--history-dir', r.hist], { encoding: 'utf8', env: cleanEnv() }); + assert.equal(res.status, 0, res.stderr); + return JSON.parse(res.stdout); +} + +test('a rerun after the live plan changed keeps the pinned copy', () => { + const r = makeRun(); + const first = archive(r); + assert.deepEqual(first.pinned.sort(), ['plan.md', 'spec.md']); + assert.deepEqual(first.warnings, []); + writeFileSync(r.plan, '# Plan\nedited after the run\n'); + const out = archive(r); + assert.ok(readFileSync(join(r.folder, 'plan.md')).equals(r.planBytes), 'the archived plan is the committed one'); + assert.ok(out.kept.includes('plan.md'), JSON.stringify(out)); + assert.ok(!out.updated.includes('plan.md')); + assert.deepEqual(out.replaced, []); + assert.deepEqual(out.warnings, []); +}); + +test('an archived spec that differs from the recorded sha is replaced from git at the setup head', () => { + const r = makeRun(); + archive(r); + writeFileSync(join(r.folder, 'spec.md'), '# a wrong archived spec\n'); + writeFileSync(r.spec, '# Spec\nedited after the run\n'); + const out = archive(r); + assert.equal(sha(readFileSync(join(r.folder, 'spec.md'))), sha(r.specBytes)); + assert.deepEqual(out.replaced, ['spec.md']); + assert.ok(out.updated.includes('spec.md')); + assert.deepEqual(out.warnings, []); +}); + +test('a CRLF checkout still pins through --filters', () => { + const r = makeRun({ crlf: true }); + assert.ok(r.planBytes.includes('\r\n'), 'the working tree holds CRLF'); + const stored = spawnSync('git', ['-C', r.proj, 'show', `${r.head}:docs/plan.md`], { env: cleanEnv() }).stdout; + assert.ok(!stored.includes('\r\n'), 'git stores LF'); + writeFileSync(r.plan, '# Plan\r\nedited after the run\r\n'); + const out = archive(r); + assert.equal(sha(readFileSync(join(r.folder, 'plan.md'))), sha(r.planBytes)); + assert.ok(out.pinned.includes('plan.md'), JSON.stringify(out)); + assert.deepEqual(out.warnings, []); +}); + +test('a path stored in another case still pins', { skip: process.platform === 'linux' && 'a case-sensitive filesystem' }, () => { + const r = makeRun({ planPath: 'DOCS/Plan.md' }); + writeFileSync(r.plan, '# Plan\nedited after the run\n'); + const out = archive(r); + assert.equal(sha(readFileSync(join(r.folder, 'Plan.md'))), sha(r.planBytes)); + assert.ok(out.pinned.includes('Plan.md'), JSON.stringify(out)); + assert.deepEqual(out.warnings, []); +}); + +test('no matching source keeps the copy and warns', () => { + const r = makeRun(); + const nowhere = sha(Buffer.from('a spec no source holds\n')); + recordEvent(r, { plan_sha256: sha(r.planBytes), spec_sha256: nowhere }); + // With no archived copy the live spec is copied, with the warning. + const first = archive(r); + assert.ok(first.added.includes('spec.md')); + assert.deepEqual(first.warnings.map((w) => w.code), ['spec_unpinned']); + assert.ok(readFileSync(join(r.folder, 'spec.md')).equals(r.specBytes)); + writeFileSync(r.spec, '# Spec\nedited after the run\n'); + const out = archive(r); + assert.equal(out.warnings.length, 1, JSON.stringify(out.warnings)); + assert.equal(out.warnings[0].code, 'spec_unpinned'); + assert.ok(out.warnings[0].detail.includes(nowhere), out.warnings[0].detail); + assert.ok(readFileSync(join(r.folder, 'spec.md')).equals(r.specBytes), 'the archived spec is unchanged'); + assert.ok(!out.updated.includes('spec.md')); + for (const key of ['pinned', 'kept', 'replaced']) assert.ok(!out[key].includes('spec.md'), key); +}); + +test('a ledger without plan_sha256 never replaces an archived plan', () => { + const r = makeRun({ shas: false }); + archive(r); + writeFileSync(r.plan, '# Plan\nedited after the run\n'); + const out = archive(r); + assert.ok(readFileSync(join(r.folder, 'plan.md')).equals(r.planBytes), 'the archived plan stays'); + assert.ok(!out.updated.includes('plan.md')); + assert.deepEqual(out.warnings, []); +}); + +test('a spec of null pins nothing', () => { + const r = makeRun({ spec: false }); + const out = archive(r); + assert.ok(!existsSync(join(r.folder, 'spec.md')), 'no spec is archived'); + assert.deepEqual(out.pinned, ['plan.md']); + assert.deepEqual(out.warnings, []); +}); From 2d8fbb4603eccd8715dc7b10b95c9325a2f951f6 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:55:45 -0700 Subject: [PATCH 02/14] feat: run-checks start phase and recorded setup; reuse-checks --- scripts/reuse-checks | 194 ++++++++++++++++++++++++++ scripts/run-checks | 58 +++++--- tests/reuse-checks.test.mjs | 239 ++++++++++++++++++++++++++++++++ tests/run-checks-phase.test.mjs | 47 ++++++- 4 files changed, 518 insertions(+), 20 deletions(-) create mode 100755 scripts/reuse-checks create mode 100644 tests/reuse-checks.test.mjs diff --git a/scripts/reuse-checks b/scripts/reuse-checks new file mode 100755 index 0000000..8aa72a2 --- /dev/null +++ b/scripts/reuse-checks @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +"""Decide whether a resumed integration can reuse a passing check. + +usage: reuse-checks LEDGER_DIR --name NAME --head SHA --checkout DIR + [--setup COMMAND ...] --cmd GROUP COMMAND [--cmd ...] + +It reads the run-checks evidence files /checks/..json +(n a decimal number; other files, logs and lock sidecars included, are not +read), newest first: highest n, compared as a number, so integrate.10.json +comes before integrate.9.json. The first file that passes every rule below +is reused: + - phase is "integrate"; + - head is SHA, and there is no head_after (no command moved HEAD); + - checkout is DIR, both compared after os.path.normcase(os.path.realpath()) + (DIR may be given in any form _shell.native_path takes: C:\\x, /c/x); + - ok and clean are true, and every result's exit is 0; + - tracked_before and untracked_before are empty lists (the checkout was + clean when the check began); + - tree_changed is false (null, a fingerprint that could not be taken, is + not reused); + - the results' (group, command) pairs equal the --cmd list, in order; + - setup_commands (run-checks --record-setup) equals the --setup list, in + order. Evidence without setup_commands (written before 1.6.8, or without + --record-setup) is never reused, even with no --setup. +A file that cannot be read, is not UTF-8 JSON, is not a JSON object, or is an +error record (it has an "error" key) is skipped. + +Prints one JSON line: + {"reuse": true, "file": } exit 0 + {"reuse": false, "why": } exit 1 +The why names the newest file and the first rule it fails (for a skipped +file, why it was skipped), or says there is no ..json at all. NAME +is a safe file name, as await-checks reserve takes it +(^[A-Za-z0-9_][A-Za-z0-9._-]*$); SHA is 7 to 64 lowercase hex digits, and +must equal the evidence head exactly. + +Exit 2 on a usage error (an unknown or repeated flag, a missing --name, +--head, --checkout or --cmd, an empty value, an unsafe NAME, a SHA that is not +hex, or a LEDGER_DIR that is not a directory); it prints the message on +stderr and nothing on stdout. Nothing is written. +""" + +import json +import os +import re +import sys + +sys.dont_write_bytecode = True +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +import _shell # noqa: E402 + +USAGE = ("usage: reuse-checks LEDGER_DIR --name NAME --head SHA --checkout DIR " + "[--setup COMMAND ...] --cmd GROUP COMMAND [--cmd GROUP COMMAND ...]") +NAME = re.compile(r"[A-Za-z0-9_][A-Za-z0-9._-]*") +SHA = re.compile(r"[0-9a-f]{7,64}") +PHASE = "integrate" + + +def fail(code, message): + print(f"reuse-checks: {message}", file=sys.stderr) + sys.exit(code) + + +def parse(argv): + """{ledger, name, head, checkout, setup, cmds} from argv; a usage error + exits 2.""" + if not argv or not argv[0] or argv[0].startswith("-"): + fail(2, USAGE) + args = {"ledger": _shell.native_path(argv[0]), "name": None, "head": None, "checkout": None, + "setup": [], "cmds": []} + rest = argv[1:] + while rest: + flag = rest[0] + if flag == "--cmd" and len(rest) >= 3 and rest[1] and rest[2]: + args["cmds"].append((rest[1], rest[2])) + rest = rest[3:] + elif flag == "--setup" and len(rest) >= 2 and rest[1]: + args["setup"].append(rest[1]) + rest = rest[2:] + elif flag in ("--name", "--head", "--checkout") and len(rest) >= 2 and rest[1] \ + and args[flag[2:]] is None: + args[flag[2:]] = rest[1] + rest = rest[2:] + else: + fail(2, USAGE) + if args["name"] is None or args["head"] is None or args["checkout"] is None or not args["cmds"]: + fail(2, USAGE) + if not NAME.fullmatch(args["name"]): + fail(2, f"unsafe name {args['name']!r}: use letters, digits, '.', '_' and '-', not first '.' or '-'") + if not SHA.fullmatch(args["head"]): + fail(2, f"--head {args['head']!r} is not a sha (7 to 64 lowercase hex digits)") + if not os.path.isdir(args["ledger"]): + fail(2, f"{args['ledger']!r} is not a directory") + args["checkout"] = same_path_key(args["checkout"]) + return args + + +def same_path_key(path): + """path in the form two spellings of one directory share.""" + return os.path.normcase(os.path.realpath(_shell.native_path(path))) + + +def candidates(ledger, name): + """[(n, path)] of checks/..json, highest n first.""" + checks = os.path.join(ledger, "checks") + pattern = re.compile(re.escape(name) + r"\.([0-9]+)\.json") + try: + entries = os.listdir(checks) + except OSError: + return [] + found = [] + for entry in entries: + match = pattern.fullmatch(entry) + if match and os.path.isfile(os.path.join(checks, entry)): + found.append((int(match.group(1)), os.path.join(checks, entry))) + return sorted(found, key=lambda item: item[0], reverse=True) + + +def load(path): + """(evidence, None) for a usable evidence object, else (None, why).""" + try: + with open(path, "rb") as f: + data = json.loads(f.read().decode("utf-8")) + except OSError as e: + return None, f"cannot be read ({e.strerror or e})" + except (UnicodeDecodeError, ValueError): + return None, "is not JSON" + if not isinstance(data, dict): + return None, "is not a JSON object" + if "error" in data: + return None, f"is an error record: {data['error']}" + return data, None + + +def mismatch(ev, args): + """The first rule ev fails, as a phrase, or None when it can be reused.""" + if ev.get("phase") != PHASE: + return f"phase is {ev.get('phase')!r}, not {PHASE!r}" + if ev.get("head") != args["head"]: + return f"head is {ev.get('head')!r}, not {args['head']!r}" + if "head_after" in ev: + return f"head_after is present ({ev['head_after']!r}): a command moved HEAD" + checkout = ev.get("checkout") + if not isinstance(checkout, str) or not checkout or same_path_key(checkout) != args["checkout"]: + return f"checkout is {checkout!r}, not the given checkout" + if ev.get("ok") is not True: + return f"ok is {ev.get('ok')!r}, not true" + if ev.get("clean") is not True: + return f"clean is {ev.get('clean')!r}, not true" + results = ev.get("results") + if not isinstance(results, list) or not all(isinstance(r, dict) for r in results): + return "results is not a list of objects" + for r in results: + if r.get("exit") != 0 or isinstance(r.get("exit"), bool): + return f"{r.get('group')!r} ({r.get('command')!r}) has exit {r.get('exit')!r}, not 0" + for key in ("tracked_before", "untracked_before"): + if ev.get(key) != []: + return f"{key} is {ev.get(key)!r}, not empty" + if ev.get("tree_changed") is not False: + return f"tree_changed is {ev.get('tree_changed')!r}, not false" + pairs = [(r.get("group"), r.get("command")) for r in results] + if pairs != args["cmds"]: + return f"the commands {pairs!r} differ from the given commands {args['cmds']!r}" + if "setup_commands" not in ev: + return "it has no setup_commands (recorded without --record-setup)" + if ev["setup_commands"] != args["setup"]: + return f"the setup commands {ev['setup_commands']!r} differ from the given setup {args['setup']!r}" + return None + + +def decide(args): + """{reuse, file} or {reuse, why}.""" + why = None + for _, path in candidates(args["ledger"], args["name"]): + ev, skipped = load(path) + reason = skipped if ev is None else mismatch(ev, args) + if reason is None: + return {"reuse": True, "file": _shell.native_path(os.path.abspath(path))} + if why is None: + why = f"{os.path.basename(path)}: {reason}" + if why is None: + why = f"no checks/{args['name']}..json in {args['ledger']}" + return {"reuse": False, "why": why} + + +def main(argv): + _shell.setup_io() + result = decide(parse(argv)) + print(json.dumps(result)) + sys.exit(0 if result["reuse"] else 1) + + +if __name__ == "__main__": + main(sys.argv[1:]) diff --git a/scripts/run-checks b/scripts/run-checks index 825f5be..35253eb 100755 --- a/scripts/run-checks +++ b/scripts/run-checks @@ -2,13 +2,24 @@ """Run a checkout's project commands one by one and keep every exit status. usage: run-checks DIR [--out FILE] [--root ROOT] [--descends-from SHA] - [--lock-held] [--phase NAME] --cmd GROUP COMMAND [--cmd ...] - ---phase NAME (prelude, lane, join, integrate, post-integrate, final-fix or -verify, else a usage error) sets PL_PHASE=NAME for every command, the busy -retry included, so a project's check script can tell the run's phases apart -(a join unit runs in a lane worktree from 1.6.5); without --phase an inherited -PL_PHASE is removed. The JSON gains "phase": NAME, or null without it. + [--lock-held] [--phase NAME] [--record-setup COMMAND ...] + --cmd GROUP COMMAND [--cmd ...] + +--phase NAME (prelude, lane, join, integrate, post-integrate, final-fix, +verify or start, else a usage error) sets PL_PHASE=NAME for every command, the +busy retry included, so a project's check script can tell the run's phases +apart (a join unit runs in a lane worktree from 1.6.5); without --phase an +inherited PL_PHASE is removed. The start phase is the start check's (the +failing verify commands rerun at the run's start point, in its own +worktree): it sets PL_PHASE=verify, since it reproduces verify's +environment. The JSON gains "phase": NAME (start for the start phase), or +null without it. + +--record-setup COMMAND (repeatable, a non-empty COMMAND, else a usage error) +records the setup commands the caller ran before this call: the JSON gains +"setup_commands": [COMMAND, ...] in the order given; without the flag the key +is absent. They are recorded only, never run. reuse-checks compares them, so +a passing check is reused only after the same setup. Each COMMAND runs with bash -c in DIR (the bash _shell.find_bash resolves: Git Bash on Windows, never the WSL launcher), in the given order, and every @@ -151,9 +162,12 @@ BUSY_EXIT = 75 # sysexits EX_TEMPFAIL: busy or temporary, retried once BUSY_WAIT_S = 60 SHA = re.compile(r"^[0-9a-f]{7,40}$") USAGE = ("usage: run-checks DIR [--out FILE] [--root ROOT] [--descends-from SHA] [--lock-held] " - "[--phase NAME] --cmd GROUP COMMAND [--cmd GROUP COMMAND ...]") + "[--phase NAME] [--record-setup COMMAND ...] --cmd GROUP COMMAND [--cmd GROUP COMMAND ...]") # The run phases a check can belong to (--phase, PL_PHASE). -PHASES = ("prelude", "lane", "join", "integrate", "post-integrate", "final-fix", "verify") +PHASES = ("prelude", "lane", "join", "integrate", "post-integrate", "final-fix", "verify", "start") +# The PL_PHASE a phase sets when it is not the phase's own name: the start +# check reproduces verify's environment. +PHASE_ENV = {"start": "verify"} # Where fail writes its error record: FILE once it is validated, unless # --lock-held says the caller writes FILE's records; None before that. @@ -187,11 +201,14 @@ def parse(argv): directory, rest = _shell.native_path(argv[0]), argv[1:] out = root = descends = phase = None lock_held = False - cmds = [] + cmds, setup = [], None while rest: if rest[0] == "--cmd" and len(rest) >= 3 and rest[1] and rest[2]: cmds.append((rest[1], rest[2])) rest = rest[3:] + elif rest[0] == "--record-setup" and len(rest) >= 2 and rest[1]: + setup = (setup or []) + [rest[1]] + rest = rest[2:] elif rest[0] == "--lock-held": lock_held = True rest = rest[1:] @@ -209,7 +226,7 @@ def parse(argv): rest = rest[2:] else: fail(2, USAGE) - return directory, out, root, cmds, descends, lock_held, phase + return directory, out, root, cmds, descends, lock_held, phase, setup def head_of(directory): @@ -388,11 +405,11 @@ def busy_wait_s(): def command_env(phase): - """The commands' environment: ENV with PL_PHASE=phase, or without - PL_PHASE when phase is None.""" + """The commands' environment: ENV with PL_PHASE=phase (verify for the + start phase, PHASE_ENV), or without PL_PHASE when phase is None.""" env = {k: v for k, v in ENV.items() if k != "PL_PHASE"} if phase is not None: - env["PL_PHASE"] = phase + env["PL_PHASE"] = PHASE_ENV.get(phase, phase) return env @@ -407,7 +424,7 @@ def run_command(command, directory, f, env=None): def main(argv): global _record _shell.setup_io() - directory, out, root, cmds, descends, lock_held, phase = parse(argv) + directory, out, root, cmds, descends, lock_held, phase, setup = parse(argv) if out is not None and root is not None and (os.path.islink(out) or not _shell.contained(out, root)): fail(3, f"output path {out!r} is outside {root!r}") with contextlib.ExitStack() as stack: @@ -426,7 +443,7 @@ def main(argv): except OSError as e: fail(3, f"cannot lock {out!r}: {e}") try: - run(directory, out, root, cmds, descends, phase) + run(directory, out, root, cmds, descends, phase, setup) except _shell.BashNotFound as e: fail(3, str(e)) except Exception as e: @@ -434,8 +451,9 @@ def main(argv): raise -def run(directory, out, root, cmds, descends=None, phase=None): - """The checks themselves; main has validated out against root.""" +def run(directory, out, root, cmds, descends=None, phase=None, setup=None): + """The checks themselves; main has validated out against root. setup, the + --record-setup commands (None without the flag), is recorded, never run.""" env = command_env(phase) if cmds: # Found before anything runs: a missing bash fails no check halfway. @@ -460,6 +478,8 @@ def run(directory, out, root, cmds, descends=None, phase=None): "tracked_before": tracked_before, "tracked_after": tracked_before, "untracked_before": untracked_lines(before), "untracked_after": untracked_lines(before), "tree_changed": False, "descends_from": lineage, "phase": phase} + if setup is not None: + report["setup_commands"] = setup emit(report, out) fingerprint_before = fingerprint(directory) results = [] @@ -499,6 +519,8 @@ def run(directory, out, root, cmds, descends=None, phase=None): report["head_after"] = after if lineage is not None: report["descends_from"] = lineage + if setup is not None: + report["setup_commands"] = setup emit(report, out) diff --git a/tests/reuse-checks.test.mjs b/tests/reuse-checks.test.mjs new file mode 100644 index 0000000..1d61d02 --- /dev/null +++ b/tests/reuse-checks.test.mjs @@ -0,0 +1,239 @@ +// scripts/reuse-checks (1.6.8 design 4.7): a resumed integration reuses a +// passing check of the same head, checkout, commands and setup commands. Each +// test writes its evidence files by hand into a fresh ledger directory and +// runs the real script. + +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { SKILL_DIR } from './harness.mjs'; +import { tempDir } from './platform.mjs'; + +const SCRIPT = join(SKILL_DIR, 'scripts', 'reuse-checks'); +const TMP = realpathSync(tempDir('pl-reuse-checks-')); +after(() => rmSync(TMP, { recursive: true, force: true })); + +const HEAD = 'a'.repeat(40); +const CHECKOUT = join(TMP, 'feature checkout').replace(/\\/g, '/'); +const OTHER = join(TMP, 'other checkout').replace(/\\/g, '/'); +mkdirSync(CHECKOUT, { recursive: true }); +mkdirSync(OTHER, { recursive: true }); +const SETUP = ['npm ci']; +const CMDS = [['test', 'npm test'], ['lint', 'npm run lint']]; + +// A passing integrate evidence object; a key in over set to undefined is +// removed. +function evidence(over = {}) { + const results = CMDS.map(([group, command], i) => ({ + group, command, exit: 0, started: '2026-10-17T07:00:00.000Z', ended: '2026-10-17T07:00:01.000Z', + duration_s: 1, log: `${CHECKOUT}/integrate.1.${i + 1}.log`, tail: '', + })); + const ev = { + checkout: CHECKOUT, branch: 'feature', head: HEAD, results, ok: true, clean: true, + tracked_before: [], tracked_after: [], untracked_before: [], untracked_after: [], + tree_changed: false, phase: 'integrate', setup_commands: SETUP, ...over, + }; + for (const key of Object.keys(over)) if (over[key] === undefined) delete ev[key]; + return ev; +} + +let serial = 0; +// A ledger directory whose checks/ holds files: {name: object, raw string or +// Buffer}. +function ledger(files) { + serial += 1; + const dir = join(TMP, `ledger ${serial}`); + mkdirSync(join(dir, 'checks'), { recursive: true }); + for (const [name, body] of Object.entries(files)) { + const raw = typeof body === 'string' || Buffer.isBuffer(body) ? body : `${JSON.stringify(body)}\n`; + writeFileSync(join(dir, 'checks', name), raw); + } + return dir; +} + +function reuse(dir, { name = 'integrate', head = HEAD, checkout = CHECKOUT, setup = SETUP, cmds = CMDS } = {}) { + const argv = [SCRIPT, dir, '--name', name, '--head', head, '--checkout', checkout]; + for (const command of setup) argv.push('--setup', command); + for (const [group, command] of cmds) argv.push('--cmd', group, command); + return run(argv); +} + +function run(argv) { + const res = spawnSync('python3', ['-B', ...argv], { encoding: 'utf8' }); + let json = null; + try { json = JSON.parse(res.stdout); } catch { /* left null */ } + return { status: res.status, json, stdout: res.stdout, stderr: res.stderr }; +} + +function assertReused(r, file) { + assert.equal(r.status, 0, `${r.stdout} ${r.stderr}`); + assert.equal(r.json.reuse, true, r.stdout); + assert.deepEqual(Object.keys(r.json).sort(), ['file', 'reuse']); + assert.equal(realpathSync(r.json.file), realpathSync(file)); +} + +function assertNotReused(r, pattern) { + assert.equal(r.status, 1, `${r.stdout} ${r.stderr}`); + assert.equal(r.json.reuse, false, r.stdout); + assert.deepEqual(Object.keys(r.json).sort(), ['reuse', 'why']); + assert.equal(typeof r.json.why, 'string'); + assert.match(r.json.why, pattern); +} + +test('a matching file is reused', () => { + const dir = ledger({ 'integrate.1.json': evidence() }); + assertReused(reuse(dir), join(dir, 'checks', 'integrate.1.json')); +}); + +test('the checkout matches in another spelling', () => { + const dir = ledger({ 'integrate.1.json': evidence() }); + const spelling = process.platform === 'win32' ? CHECKOUT.replace(/\//g, '\\').toUpperCase() : `${CHECKOUT}/.`; + assertReused(reuse(dir, { checkout: spelling }), join(dir, 'checks', 'integrate.1.json')); +}); + +test('no setup commands on either side is a match', () => { + const dir = ledger({ 'integrate.1.json': evidence({ setup_commands: [] }) }); + assertReused(reuse(dir, { setup: [] }), join(dir, 'checks', 'integrate.1.json')); +}); + +test('other head', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence() }), { head: 'b'.repeat(40) }), /head/); +}); + +test('head_after present', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ head_after: 'c'.repeat(40) }) })), /head_after/); +}); + +test('other checkout', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence() }), { checkout: OTHER }), /checkout/); +}); + +test('a failed command', () => { + const failed = evidence(); + failed.results[1].exit = 1; + assertNotReused(reuse(ledger({ 'integrate.1.json': failed })), /exit/); + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ ok: false }) })), /ok/); +}); + +test('clean false', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ clean: false }) })), /clean/); +}); + +test('non-empty tracked_before', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ tracked_before: [' M a.txt'] }) })), /tracked_before/); +}); + +test('non-empty untracked_before', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ untracked_before: ['?? b.txt'] }) })), + /untracked_before/); +}); + +test('tree_changed true', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ tree_changed: true }) })), /tree_changed/); + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ tree_changed: null }) })), /tree_changed/); +}); + +test('other commands', () => { + const dir = ledger({ 'integrate.1.json': evidence() }); + assertNotReused(reuse(dir, { cmds: [['test', 'npm test -- --all'], ['lint', 'npm run lint']] }), /commands/); + assertNotReused(reuse(dir, { cmds: [['test', 'npm test']] }), /commands/); + assertNotReused(reuse(dir, { cmds: [['unit', 'npm test'], ['lint', 'npm run lint']] }), /commands/); +}); + +test('other order', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence() }), { cmds: [CMDS[1], CMDS[0]] }), /commands/); +}); + +test('other setup', () => { + const dir = ledger({ 'integrate.1.json': evidence() }); + assertNotReused(reuse(dir, { setup: ['npm install'] }), /setup/); + assertNotReused(reuse(dir, { setup: [] }), /setup/); + assertNotReused(reuse(dir, { setup: ['npm ci', 'npm run build'] }), /setup/); +}); + +test('no setup_commands', () => { + // Evidence from 1.6.7 has no setup_commands and is never reused. + const dir = ledger({ 'integrate.1.json': evidence({ setup_commands: undefined }) }); + assertNotReused(reuse(dir), /setup_commands/); + assertNotReused(reuse(dir, { setup: [] }), /setup_commands/); +}); + +test('phase not integrate', () => { + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ phase: 'verify' }) })), /phase/); + assertNotReused(reuse(ledger({ 'integrate.1.json': evidence({ phase: null }) })), /phase/); +}); + +test('integrate.10.json beats integrate.9.json', () => { + const dir = ledger({ 'integrate.9.json': evidence(), 'integrate.10.json': evidence() }); + assertReused(reuse(dir), join(dir, 'checks', 'integrate.10.json')); +}); + +test('the highest matching file wins over a newer one that does not match', () => { + const dir = ledger({ 'integrate.2.json': evidence(), 'integrate.3.json': evidence({ head: 'b'.repeat(40) }) }); + assertReused(reuse(dir), join(dir, 'checks', 'integrate.2.json')); +}); + +test('only files of the given name count', () => { + const dir = ledger({ + 'start.1.json': evidence(), 'integrate.json': evidence(), 'integrate.x.json': evidence(), + 'integrate.1.1.log': 'log\n', 'integrate-1.json': evidence(), + }); + assertNotReused(reuse(dir), /integrate/); +}); + +test('a malformed file and an error record are skipped', () => { + const dir = ledger({ + 'integrate.3.json': evidence(), + 'integrate.4.json': '{"results": [', + 'integrate.5.json': { results: [], ok: false, error: 'not a checkout' }, + 'integrate.6.json': [evidence()], + 'integrate.7.json': '', + 'integrate.8.json': Buffer.from([0xff, 0xfe, 0x00]), + }); + assertReused(reuse(dir), join(dir, 'checks', 'integrate.3.json')); + const bad = ledger({ + 'integrate.1.json': '{', + 'integrate.2.json': { results: [], ok: false, error: 'internal error' }, + }); + const r = reuse(bad); + assertNotReused(r, /integrate\.2\.json/); + assert.match(r.json.why, /error/); +}); + +test('no evidence at all', () => { + assertNotReused(reuse(ledger({})), /integrate/); + serial += 1; + const bare = join(TMP, `ledger ${serial}`); + mkdirSync(bare, { recursive: true }); + assertNotReused(reuse(bare), /integrate/); +}); + +test('usage errors exit 2', () => { + const dir = ledger({ 'integrate.1.json': evidence() }); + const ok = ['--name', 'integrate', '--head', HEAD, '--checkout', CHECKOUT, '--cmd', 'test', 'npm test']; + const cases = [ + [], + ['--name', 'integrate'], + [dir], + [dir, '--head', HEAD, '--checkout', CHECKOUT, '--cmd', 'test', 'npm test'], + [dir, '--name', 'integrate', '--checkout', CHECKOUT, '--cmd', 'test', 'npm test'], + [dir, '--name', 'integrate', '--head', HEAD, '--cmd', 'test', 'npm test'], + [dir, '--name', 'integrate', '--head', HEAD, '--checkout', CHECKOUT], + [dir, ...ok, '--cmd', 'lint'], + [dir, ...ok, '--cmd', '', 'npm test'], + [dir, ...ok, '--setup'], + [dir, ...ok, '--bogus'], + [dir, ...ok, '--name', 'again'], + [dir, '--name', '../integrate', '--head', HEAD, '--checkout', CHECKOUT, '--cmd', 'test', 'npm test'], + [dir, '--name', 'integrate', '--head', 'not-a-sha', '--checkout', CHECKOUT, '--cmd', 'test', 'npm test'], + [join(TMP, 'no such ledger'), ...ok], + ]; + for (const args of cases) { + const r = run([SCRIPT, ...args]); + assert.equal(r.status, 2, `${args.map((a) => basename(a)).join(' ')}: ${r.stdout} ${r.stderr}`); + assert.equal(r.stdout, '', `${args.join(' ')}: a usage error prints nothing on stdout`); + assert.match(r.stderr, /usage|reuse-checks/); + } +}); diff --git a/tests/run-checks-phase.test.mjs b/tests/run-checks-phase.test.mjs index 9937808..7295d7b 100644 --- a/tests/run-checks-phase.test.mjs +++ b/tests/run-checks-phase.test.mjs @@ -1,6 +1,8 @@ // scripts/run-checks --phase (1.6.5 design 4.6): every command, first attempt // and busy retry alike, runs with PL_PHASE set to the phase; without --phase // an inherited PL_PHASE is removed. task-checks passes its --phase through. +// The start phase (1.6.8 design 4.4) runs with PL_PHASE=verify, and +// --record-setup (4.7) records setup commands without running them. // Real scripts, real git repos. import { test, after } from 'node:test'; @@ -93,14 +95,55 @@ test('an unknown phase is a usage error', () => { }); test('every phase name is accepted', () => { - for (const phase of ['prelude', 'lane', 'join', 'integrate', 'post-integrate', 'final-fix', 'verify']) { + for (const phase of ['prelude', 'lane', 'join', 'integrate', 'post-integrate', 'final-fix', 'verify', 'start']) { const { dir, record } = fresh(); const r = runChecks(dir, ['--phase', phase], [['test', recorder(record)]]); assert.equal(r.status, 0, `${phase}: ${r.stderr}`); - assert.deepEqual(recorded(record), [phase]); + // The start check reproduces verify's environment. + assert.deepEqual(recorded(record), [phase === 'start' ? 'verify' : phase]); + assert.equal(JSON.parse(r.stdout).phase, phase); } }); +test('--phase start runs with PL_PHASE=verify and records start', () => { + const { dir, record } = fresh(); + const r = runChecks(dir, ['--phase', 'start'], [['test', recorder(record, [75, 0])], ['lint', recorder(record)]], + { PL_PHASE: 'lane' }); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(recorded(record), ['verify', 'verify', 'verify']); + assert.equal(JSON.parse(r.stdout).phase, 'start'); +}); + +test('--record-setup is recorded and not run', () => { + const { dir, record } = fresh(); + const made = join(TMP, `setup${serial}.txt`).replace(/\\/g, '/'); + const setup = [`node -e 'require("fs").writeFileSync(${JSON.stringify(made)}, "x")'`, 'npm ci']; + const out = join(TMP, `evidence${serial}.json`); + const r = runChecks(dir, ['--out', out, '--record-setup', setup[0], '--phase', 'integrate', '--record-setup', setup[1]], + [['test', recorder(record)]]); + assert.equal(r.status, 0, r.stderr); + assert.equal(existsSync(made), false, 'a recorded setup command must not run'); + assert.deepEqual(JSON.parse(r.stdout).setup_commands, setup); + assert.deepEqual(JSON.parse(readFileSync(out, 'utf8')).setup_commands, setup); + assert.deepEqual(recorded(record), ['integrate']); +}); + +test('without --record-setup there is no setup_commands key', () => { + const { dir, record } = fresh(); + const r = runChecks(dir, [], [['test', recorder(record)]]); + assert.equal(r.status, 0, r.stderr); + assert.equal('setup_commands' in JSON.parse(r.stdout), false); +}); + +test('--record-setup without a command is a usage error', () => { + const { dir, record } = fresh(); + for (const extra of [['--record-setup'], ['--record-setup', '']]) { + const r = runChecks(dir, extra, [['test', recorder(record)]]); + assert.equal(r.status, 2, `${extra.join(' ')}: ${r.stderr}`); + } + assert.equal(existsSync(record), false); +}); + test('task-checks passes --phase through', () => { const { dir, record } = fresh(); const ledger = join(TMP, `ledger${serial}`); From 87ffddd1f9776871d4d606998c878b9a5dc78d98 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:02:31 -0700 Subject: [PATCH 03/14] feat: the dry run lists widening and the start check --- run.workflow.js | 47 +++++++++++++++++++++++++++---- src/plan.js | 47 +++++++++++++++++++++++++++---- tests/budget.test.mjs | 10 +++++++ tests/dryrun.test.mjs | 65 +++++++++++++++++++++++++++++++++++++++---- tests/run.test.mjs | 3 +- 5 files changed, 156 insertions(+), 16 deletions(-) diff --git a/run.workflow.js b/run.workflow.js index 6bbdbfe..25277b2 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -912,7 +912,8 @@ function tierSettings(tier) { // once, whenever a project or lane test, lint or build command exists // (finalChecks): after the final fix and before the final re-review (which // gets its result), or after the lenses when they found nothing; it is -// listed in that place; e2e_recheck and +// listed in that place. The start check (Sonnet) is listed beside it as the +// upper bound: it runs only after a failing verify. e2e_recheck and // post_integrate_recheck are listed as the upper bound: they run only when a // later commit made the earlier result stale. Retries, adjudications, // escalations, conflict resolution, and post-integrate fixes are not @@ -968,7 +969,10 @@ function planAgents(m) { ? ['final_review_combined'] : ['final_review_sp', 'final_review_security', 'final_review_correctness']; for (const role of [...lenses, 'final_fix']) add('Final review', null, null, role, standard); - if (finalChecks(m).length > 0) add('Verify', null, null, 'verify', sonnetHigh); + if (finalChecks(m).length > 0) { + add('Verify', null, null, 'verify', sonnetHigh); + add('Verify', null, null, 'start_check', sonnetHigh); + } add('Final review', null, null, 'final_re_review', standard); if (m.hooks.e2e) add('Verify', null, null, 'e2e_recheck', sonnetHigh); if (!lite && m.hooks.post_integrate) add('Verify', null, null, 'post_integrate_recheck', standard); @@ -1161,13 +1165,46 @@ function longCommandWarnings(lengths) { + " shorten the project's check commands, for example with a wrapper script"]; } +// The widen paths that make a unit's task checks wider (1.6.8 spec 4.5): +// [{file, widen_path}], one per declared file in declared order, with the +// first widen path that covers it. A widen path ending in '/' covers the paths +// under it; any other covers the equal path and the paths under it (the dry +// run cannot tell whether it names a directory). A declared entry ending in +// '/' counts when it contains a widen path. Paths compare after turning '\' +// into '/'; the declared and widen strings are reported as written. +function unitWidening(files, widen) { + const slash = (p) => p.split('\\').join('/'); + const covers = (w, f) => (w.endsWith('/') ? f.startsWith(w) : f === w || f.startsWith(`${w}/`)) + || (f.endsWith('/') && w.startsWith(f)); + const out = []; + const seen = new Set(); + for (const file of files) { + const f = slash(file); + if (seen.has(f)) continue; + seen.add(f); + const hit = widen.find((w) => covers(slash(w), f)); + if (hit !== undefined) out.push({ file, widen_path: hit }); + } + return out; +} + // The dry run's checks summary (spec 4.9). Units are the units with work, -// built as planAgents builds them. +// built as planAgents builds them. widening lists each unit with a +// task_checks entry whose declared files a widen path covers (a unit without +// one checks its lane commands whatever it changes), in unit order. function checksSummary(m) { + const widen = Array.isArray(m.widen_paths) ? m.widen_paths : []; const withEntry = []; const withoutEntry = []; + const widening = []; for (const { unit, laneId } of unitsWithWork(m)) { - (taskChecksEntry(m, unit, laneId) === null ? withoutEntry : withEntry).push(unit.id); + if (taskChecksEntry(m, unit, laneId) === null) { + withoutEntry.push(unit.id); + continue; + } + withEntry.push(unit.id); + const paths = unitWidening(unit.files, widen); + if (paths.length > 0) widening.push({ unit: unit.id, paths }); } const project = (group, command) => commandGroup(m, null, group).includes(command); const added = finalChecks(m).filter((c) => !project(c.group, c.command)).map((c) => ({ @@ -1175,7 +1212,6 @@ function checksSummary(m) { group: c.group, command: c.command, })); - const widen = Array.isArray(m.widen_paths) ? m.widen_paths : []; const hasEntries = Boolean(m.task_checks) && Object.keys(m.task_checks).length > 0; return { final_count: finalChecks(m).length, @@ -1189,6 +1225,7 @@ function checksSummary(m) { ...(hasEntries && widen.length === 0 ? ['changes to shared files will not widen task checks'] : []), ...longCommandWarnings(checkCommandLengths(m)), ], + widening, }; } diff --git a/src/plan.js b/src/plan.js index 0ad3539..26ea860 100644 --- a/src/plan.js +++ b/src/plan.js @@ -80,7 +80,8 @@ function tierSettings(tier) { // once, whenever a project or lane test, lint or build command exists // (finalChecks): after the final fix and before the final re-review (which // gets its result), or after the lenses when they found nothing; it is -// listed in that place; e2e_recheck and +// listed in that place. The start check (Sonnet) is listed beside it as the +// upper bound: it runs only after a failing verify. e2e_recheck and // post_integrate_recheck are listed as the upper bound: they run only when a // later commit made the earlier result stale. Retries, adjudications, // escalations, conflict resolution, and post-integrate fixes are not @@ -136,7 +137,10 @@ function planAgents(m) { ? ['final_review_combined'] : ['final_review_sp', 'final_review_security', 'final_review_correctness']; for (const role of [...lenses, 'final_fix']) add('Final review', null, null, role, standard); - if (finalChecks(m).length > 0) add('Verify', null, null, 'verify', sonnetHigh); + if (finalChecks(m).length > 0) { + add('Verify', null, null, 'verify', sonnetHigh); + add('Verify', null, null, 'start_check', sonnetHigh); + } add('Final review', null, null, 'final_re_review', standard); if (m.hooks.e2e) add('Verify', null, null, 'e2e_recheck', sonnetHigh); if (!lite && m.hooks.post_integrate) add('Verify', null, null, 'post_integrate_recheck', standard); @@ -329,13 +333,46 @@ function longCommandWarnings(lengths) { + " shorten the project's check commands, for example with a wrapper script"]; } +// The widen paths that make a unit's task checks wider (1.6.8 spec 4.5): +// [{file, widen_path}], one per declared file in declared order, with the +// first widen path that covers it. A widen path ending in '/' covers the paths +// under it; any other covers the equal path and the paths under it (the dry +// run cannot tell whether it names a directory). A declared entry ending in +// '/' counts when it contains a widen path. Paths compare after turning '\' +// into '/'; the declared and widen strings are reported as written. +function unitWidening(files, widen) { + const slash = (p) => p.split('\\').join('/'); + const covers = (w, f) => (w.endsWith('/') ? f.startsWith(w) : f === w || f.startsWith(`${w}/`)) + || (f.endsWith('/') && w.startsWith(f)); + const out = []; + const seen = new Set(); + for (const file of files) { + const f = slash(file); + if (seen.has(f)) continue; + seen.add(f); + const hit = widen.find((w) => covers(slash(w), f)); + if (hit !== undefined) out.push({ file, widen_path: hit }); + } + return out; +} + // The dry run's checks summary (spec 4.9). Units are the units with work, -// built as planAgents builds them. +// built as planAgents builds them. widening lists each unit with a +// task_checks entry whose declared files a widen path covers (a unit without +// one checks its lane commands whatever it changes), in unit order. function checksSummary(m) { + const widen = Array.isArray(m.widen_paths) ? m.widen_paths : []; const withEntry = []; const withoutEntry = []; + const widening = []; for (const { unit, laneId } of unitsWithWork(m)) { - (taskChecksEntry(m, unit, laneId) === null ? withoutEntry : withEntry).push(unit.id); + if (taskChecksEntry(m, unit, laneId) === null) { + withoutEntry.push(unit.id); + continue; + } + withEntry.push(unit.id); + const paths = unitWidening(unit.files, widen); + if (paths.length > 0) widening.push({ unit: unit.id, paths }); } const project = (group, command) => commandGroup(m, null, group).includes(command); const added = finalChecks(m).filter((c) => !project(c.group, c.command)).map((c) => ({ @@ -343,7 +380,6 @@ function checksSummary(m) { group: c.group, command: c.command, })); - const widen = Array.isArray(m.widen_paths) ? m.widen_paths : []; const hasEntries = Boolean(m.task_checks) && Object.keys(m.task_checks).length > 0; return { final_count: finalChecks(m).length, @@ -357,5 +393,6 @@ function checksSummary(m) { ...(hasEntries && widen.length === 0 ? ['changes to shared files will not widen task checks'] : []), ...longCommandWarnings(checkCommandLengths(m)), ], + widening, }; } diff --git a/tests/budget.test.mjs b/tests/budget.test.mjs index d696a79..977271d 100644 --- a/tests/budget.test.mjs +++ b/tests/budget.test.mjs @@ -179,6 +179,14 @@ test('labelTasks maps an agent label to its tasks: one id, a batch range, or non assert.deepEqual(labelTasks(m, 'T1-T3 review'), ['T1', 'T2', 'T3']); assert.deepEqual(labelTasks(m, 'final review sp'), []); assert.deepEqual(labelTasks(m, 'run adjudicate'), []); + assert.deepEqual(labelTasks(m, 'start check'), []); + assert.deepEqual(labelTasks(m, 'start check retry'), []); +}); + +test('the default max_agents estimate counts the start check once, right after verify', () => { + const roles = planAgents(manifest()).map((a) => a.role); + assert.equal(roles.filter((r) => r === 'start_check').length, 1); + assert.equal(roles.indexOf('start_check'), roles.indexOf('verify') + 1); }); // ---- Agent type ---- @@ -192,6 +200,8 @@ test('agentTypeFor leaves the e2e and post-integrate rechecks on the default age assert.equal(agentTypeFor(m, label), null, label); } assert.equal(agentTypeFor(m, 'verify'), 'parallel-lanes-worker'); + assert.equal(agentTypeFor(m, 'start check'), 'parallel-lanes-worker'); + assert.equal(agentTypeFor(m, 'start check retry'), 'parallel-lanes-worker'); }); test('agentTypeFor names the agent type for every label but the hook agents', () => { diff --git a/tests/dryrun.test.mjs b/tests/dryrun.test.mjs index c466f53..2b11faf 100644 --- a/tests/dryrun.test.mjs +++ b/tests/dryrun.test.mjs @@ -121,6 +121,7 @@ test('full plan lists every role in run order with phase, lane and task', () => ['Final review', null, null, 'final_review_correctness'], ['Final review', null, null, 'final_fix'], ['Verify', null, null, 'verify'], + ['Verify', null, null, 'start_check'], ['Final review', null, null, 'final_re_review'], ['Verify', null, null, 'e2e_recheck'], ['Verify', null, null, 'post_integrate_recheck'], @@ -169,7 +170,7 @@ test('a sonnet task implementer runs at sonnet/high and its reviewer at opus/hig test('integrate and e2e run at sonnet/high; every other agent but a light implementer at opus/high', () => { const m = manifest({ hooks: { post_integrate: 'check', e2e: 'run' } }); m.join[0].tier = 'light'; - const sonnet = new Set(['integrate', 'e2e', 'verify', 'e2e_recheck']); + const sonnet = new Set(['integrate', 'e2e', 'verify', 'start_check', 'e2e_recheck']); for (const a of planAgents(m)) { const want = sonnet.has(a.role) || (a.task === 'T5' && a.role === 'implement') ? ['sonnet', 'high'] : ['opus', 'high']; @@ -190,6 +191,17 @@ test('no verify agent when the project has no test, lint or build command', () = assert.deepEqual(planAgents(m).filter((a) => a.phase === 'Verify'), []); }); +test('the planned agents list a start check after verify', () => { + const agents = planAgents(manifest()); + const at = agents.findIndex((a) => a.role === 'verify'); + assert.deepEqual(agents[at + 1], { + phase: 'Verify', lane: null, task: null, role: 'start_check', model: 'sonnet', effort: 'high', + }); + assert.equal(agents.filter((a) => a.role === 'start_check').length, 1); + const none = manifest({ commands: { setup: [], test: [], lint: [], build: [] } }); + assert.deepEqual(planAgents(none).filter((a) => a.role === 'start_check'), []); +}); + test('a lite manifest whose only check is a lane override lists a verify agent', async () => { const m = liteManifest({ commands: { setup: [], test: [], lint: [], build: [] }, @@ -216,6 +228,7 @@ test('lite plan: no pre-flight, integrate or post-integrate; one combined final ['Final review', null, null, 'final_review_combined', 'opus'], ['Final review', null, null, 'final_fix', 'opus'], ['Verify', null, null, 'verify', 'sonnet'], + ['Verify', null, null, 'start_check', 'sonnet'], ['Final review', null, null, 'final_re_review', 'opus'], ['Verify', null, null, 'e2e_recheck', 'sonnet'], ]); @@ -342,8 +355,9 @@ async function parityRun(m) { const settings = (list) => list.map((a) => `${a.model}/${a.effort}`).sort(); +// The start check runs only after a failing verify; these runs pass verify. async function assertParity(m) { - const planned = planAgents(m); + const planned = planAgents(m).filter((a) => a.role !== 'start_check'); const { result, calls } = await parityRun(m); assert.equal(result.status, 'complete', JSON.stringify(result)); assert.deepEqual(result.final.fixed.map((f) => f.issue), ['one issue']); @@ -385,17 +399,19 @@ test('the dry run lists verify before the final re-review', async () => { const m = manifest({ hooks: { post_integrate: 'check contracts', e2e: 'run e2e' } }); const { result } = await dryRun(m); const roles = result.agents.map((a) => a.role); - assert.deepEqual(roles.slice(-5), ['final_fix', 'verify', 'final_re_review', 'e2e_recheck', 'post_integrate_recheck']); + assert.deepEqual(roles.slice(-6), + ['final_fix', 'verify', 'start_check', 'final_re_review', 'e2e_recheck', 'post_integrate_recheck']); assert.equal(result.agents.length, planAgents(m).length); - assert.equal(result.agents.length, 22, 'the count is unchanged'); + assert.equal(result.agents.length, 23, 'the count adds the start check'); }); // ---- 1.6.0 check policy (spec 4.1, 4.9) ---- const { - taskChecksEntry, finalChecks, finalSetup, expectedChecks, repairLimit, repairReserve, repairFixMessage, + taskChecksEntry, finalChecks, finalSetup, expectedChecks, repairLimit, repairReserve, repairFixMessage, checksSummary, } = await loadHelpers([ 'taskChecksEntry', 'finalChecks', 'finalSetup', 'expectedChecks', 'repairLimit', 'repairReserve', 'repairFixMessage', + 'checksSummary', ]); function policyManifest(overrides = {}) { @@ -419,9 +435,48 @@ test('the dry run reports a checks summary', async () => { repair_limit: 2, repairs_spent: 0, warnings: [], + widening: [], }); }); +test('the summary lists units that widen', async () => { + const m = manifest({ + prelude: [task('T1', ['tools/a.py'])], + lanes: [ + { id: 'alpha', name: 'Lane alpha', tasks: [task('T2', ['src/x.js', 'shared/b.py']), task('T3', ['shared'])] }, + { id: 'beta', name: 'Lane beta', tasks: [task('T4', ['pkg/']), task('T6', ['src/none.js'])] }, + ], + join: [task('T5', ['tools/c.py'])], + task_checks: { + T1: { test: ['node --test t1'] }, + T2: { test: ['node --test t2'] }, + T3: { test: ['node --test t3'] }, + T4: { test: ['node --test t4'] }, + T6: { test: ['node --test t6'] }, + }, + widen_paths: ['tools/', 'shared', 'pkg/sub/'], + }); + const { result } = await dryRun(m); + assert.deepEqual(result.errors, []); + assert.deepEqual(result.checks.widening, [ + { unit: 'T1', paths: [{ file: 'tools/a.py', widen_path: 'tools/' }] }, + { unit: 'T2', paths: [{ file: 'shared/b.py', widen_path: 'shared' }] }, + { unit: 'T3', paths: [{ file: 'shared', widen_path: 'shared' }] }, + { unit: 'T4', paths: [{ file: 'pkg/', widen_path: 'pkg/sub/' }] }, + ]); +}); + +test('widening matches a batch unit by every task file, after turning backslashes into slashes', () => { + const m = batchedManifest({ + task_checks: { T3: { test: ['node --test t3'] } }, + widen_paths: ['src\\a2.js', 'src/a.js/', 'sr'], + }); + m.lanes[0].tasks[1].files = ['src/a2.js', 'docs/']; + assert.deepEqual(checksSummary(m).widening, [ + { unit: 'T2-T3', paths: [{ file: 'src/a2.js', widen_path: 'src\\a2.js' }] }, + ]); +}); + test('the summary warns when task_checks has entries and widen_paths is empty', async () => { const m = policyManifest(); delete m.widen_paths; diff --git a/tests/run.test.mjs b/tests/run.test.mjs index 975cf78..5dde551 100644 --- a/tests/run.test.mjs +++ b/tests/run.test.mjs @@ -230,7 +230,8 @@ test('happy path: complete, phases in order, report filled in', async () => { assert.equal(result.e2e.checked_sha, H('f1')); assert.deepEqual(result.acceptance, { status: 'accepted', delivered_sha: H('f1'), reasons: [], warnings: [] }); assert.equal(result.agents_spawned, calls.length); - assert.equal(result.agents_spawned, planAgents(m).length, 'no fix rounds: matches the dry-run plan'); + // The start check runs only after a failing verify. + assert.equal(result.agents_spawned, planAgents(m).filter((a) => a.role !== 'start_check').length, 'no fix rounds: matches the dry-run plan without the start check'); assert.ok(logs.includes(`parallel-lanes: launching run run-1: 2 lanes, ${planAgents(m).length} agents`), JSON.stringify(logs)); assert.ok(!logs.some((l) => l.includes('resuming'))); From e05ad09c4be4e9ee101e0cd5a902c0ff2b69bdba Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:04:30 -0700 Subject: [PATCH 04/14] feat: pre-flight permits reach task-checks and the ledger --- scripts/ledger | 120 ++++++++++++++++++++++++++++++------ scripts/task-checks | 68 ++++++++++++++------ tests/ledger-carry.test.mjs | 87 +++++++++++++++++++++++++- tests/recovery.test.mjs | 20 ++++++ tests/task-checks.test.mjs | 77 +++++++++++++++++++++++ 5 files changed, 333 insertions(+), 39 deletions(-) diff --git a/scripts/ledger b/scripts/ledger index b0a4e9f..e86f336 100755 --- a/scripts/ledger +++ b/scripts/ledger @@ -66,8 +66,15 @@ exit 3 with nothing written. A reader skips a last line that has no newline yet and is not JSON: an append in progress. A ruling may carry 'by' (implementer, reviewer, adjudicator, preflight; none: a task agent before 1.6.3, read as an implementer), 'scope' (lane, run), -'overrides' and 'files' (lists of strings), 'pf' (PF-n) and 'wf'. The values -are checked, not whether an agent told the truth. +'overrides' and 'files' (lists of strings), 'pf' (PF-n), 'wf' and (1.6.8) +'permits' (a list of {task, files}: task a non-empty string, files a list of +non-empty strings, no other keys; else the error "'permits' must be a list +of {task, files}"). The values are checked, not whether an agent told the +truth. Implementer and reviewer rulings permit nothing; an adjudicator +ruling in force permits its 'files' (adjudicator_permits); a pre-flight +ruling permits what its `permits` names for its task (preflight_permits; +its 'files' permit nothing). task-checks, undeclared and status --manifest +count a permitted path as declared. rulings prints the rulings in force, one per line: ' [, , ] ', then ' (overrides )' (an id that resolves to no ruling @@ -83,7 +90,11 @@ or for a wf id from /projects/*/*/workflows/ .json) and appends, once per wf: each of result.preflight.rulings as a ruling by preflight with scope run and pf PF- (the adjudication at preflight.adjudicated is skipped: the adjudicator recorded it; a result -without that field and with conflicts has it last); a minors event; and a +without that field and with conflicts has it last), with 'permits': the +entries of result.preflight.permits whose 'ruling' is its 0-based index, as +{task, files} in the result's order (none: no 'permits'; an entry without a +non-empty task, a non-empty files list of non-empty strings and an integer +ruling is left out); a minors event; and a carried event binding every ruling whose overrides name a PF-n no earlier carried event bound. Launches are carried in launch order: with the ledger's doctor-.json files (ledger conditions) naming each launch's setup event, @@ -172,7 +183,9 @@ transitively, in run order. undeclared (with --manifest; else {}): {task: [{path, owner, relation}]} for each task with a recorded range and a non-empty files list whose range changes a path (git diff --name-only --no-renames base head) that is not in its files, nor in the files of a task with the same range -(a batch); paths compare normalized and case-insensitively, non-ASCII ones +(a batch), nor permitted to one of those tasks by a ruling in force in DIR +(an adjudicator's files or a pre-flight ruling's permits, as task-checks +reads them); paths compare normalized and case-insensitively, non-ASCII ones unquoted. owner is the first other task whose files list the path (null when none does) and relation when that task runs: earlier, alongside or later. minors: {"open": [ids], "covered": [task ids]} from the latest minors event @@ -393,9 +406,19 @@ def ruling_fields_error(entry): return "'pf' must look like PF-1" if "wf" in entry and not is_text(entry["wf"]): return "'wf' must be a non-empty string" + if "permits" in entry and not is_permits(entry["permits"]): + return "'permits' must be a list of {task, files}" return None +def is_permits(v): + """A pre-flight ruling's permits (1.6.8): [{task: non-empty string, files: + list of non-empty strings}], those two keys only.""" + return isinstance(v, list) and all( + isinstance(p, dict) and set(p) == {"task", "files"} and is_text(p["task"]) and is_text_list(p["files"]) + for p in v) + + def is_count(v, least): return isinstance(v, int) and not isinstance(v, bool) and v >= least @@ -630,7 +653,8 @@ def read_events_at(directory): return events -def summarize(events, plan_text=None, plan_given=False, spec_sha=None, plan_sha=None, manifest=None): +def summarize(events, plan_text=None, plan_given=False, spec_sha=None, plan_sha=None, manifest=None, + ledger_dir=None): done, reviewed_by, blocked, start_points = {}, {}, {}, {} last_ruling, unblocked, deferred = {}, {}, {} review_event = {} @@ -789,7 +813,7 @@ def summarize(events, plan_text=None, plan_given=False, spec_sha=None, plan_sha= undeclared = {} if manifest is not None: - undeclared = annotated_undeclared(manifest, ranges) + undeclared = annotated_undeclared(manifest, ranges, ledger_dir=ledger_dir) return { "done": list(done), @@ -980,8 +1004,9 @@ def adjudicator_permits(directory, task_ids, declared=()): (none of _run's without a readable owner map) and that sits in the folder of a declared file, or in a declared directory itself (_folder; folders compare by case). A permit names files only. Implementer, reviewer - and pre-flight rulings permit nothing. task-checks counts these paths as - declared.""" + and pre-flight rulings' files permit nothing (a pre-flight ruling's + permits are preflight_permits'). task-checks and undeclared_files count + these paths as declared.""" shown, _marked, overridden_by = ruling_view(directory) try: owned = set(_owners.load(owners_path(directory))["files"]) @@ -1005,6 +1030,30 @@ def adjudicator_permits(directory, task_ids, declared=()): return out +def preflight_permits(directory, task_ids): + """[(path, ruling id)]: the files the pre-flight rulings in force (by + preflight, not overridden) permit to one of task_ids through their + 'permits' (1.6.8, written by carry-launch from the engine's kept + permits). A permit names files only: an entry naming a directory, an + absolute path or one that leaves the project permits nothing. + task-checks and undeclared_files count these paths as declared.""" + shown, _marked, overridden_by = ruling_view(directory) + out = [] + for r in shown: + e = r["event"] + if r["role"] != "preflight" or r["id"] in overridden_by: + continue + for permit in e.get("permits") or []: + if not isinstance(permit, dict) or permit.get("task") not in task_ids: + continue + for path in permit.get("files") or []: + if not isinstance(path, str) or _owners.path_key(path) in (None, ".") \ + or path.endswith(("/", "\\")): + continue + out.append((path, r["id"])) + return out + + def _folder(path, as_file=False): """The folder a declared path names, compared by case: a directory entry ('src/') is its own folder, a file's is its parent; '' is the @@ -1078,7 +1127,11 @@ def path_key(path): return norm.lower() -def undeclared_files(m, ranges): +def undeclared_files(m, ranges, ledger_dir=None): + """{task: [path]}: the paths each task's range changes outside its Files + list. With ledger_dir, a path the rulings in that ledger permit to the + task's unit (adjudicator_permits, preflight_permits) counts as declared; + without it no permits apply.""" git = repo_git(m) tasks = manifest_tasks(m) out = {} @@ -1089,9 +1142,15 @@ def undeclared_files(m, ranges): continue # A batch shares one range: the files of every task on it are declared. allowed = set() + unit_ids, declared = [], [] for other in tasks: if ranges.get(other["id"]) == rng: + unit_ids.append(other["id"]) + declared.extend(other.get("files") or []) allowed.update(k for k in map(path_key, other.get("files") or []) if k is not None) + if ledger_dir is not None: + found = adjudicator_permits(ledger_dir, unit_ids, declared) + preflight_permits(ledger_dir, unit_ids) + allowed.update(k for k in (_owners.path_key(p) for p, _ in found) if k is not None) res = git("-c", "core.quotePath=false", "diff", "--name-only", "--no-renames", rng["base"], rng["head"]) if res.returncode != 0: continue @@ -1122,13 +1181,14 @@ def overrides_list(directory): for r in records if resolved[r["id"]]] -def annotated_undeclared(manifest, ranges, owners=None): +def annotated_undeclared(manifest, ranges, owners=None, ledger_dir=None): """undeclared_files with each path's owner: {task: [{path, owner, relation}]}, owner the first other task listing the path (None when no - task does) and relation earlier, alongside or later (None likewise).""" + task does) and relation earlier, alongside or later (None likewise). + ledger_dir goes to undeclared_files (the ledger whose permits apply).""" owners = owners or _owners.build(manifest) out = {} - for tid, paths in undeclared_files(manifest, ranges).items(): + for tid, paths in undeclared_files(manifest, ranges, ledger_dir).items(): rows = [] for p in paths: owner, rel = _owners.first_owner(owners, [tid], p) @@ -1206,7 +1266,8 @@ def undeclared(directory): list, from the owner map and the ledger's ranges.""" data = load_owners(directory) result, _ = summarize(read_events(directory)) - print(json.dumps(annotated_undeclared(manifest_from_owners(data), result["ranges"], data))) + print(json.dumps(annotated_undeclared(manifest_from_owners(data), result["ranges"], data, + ledger_dir=directory))) def status(directory, plan=None, spec=None, manifest_path=None): @@ -1216,7 +1277,7 @@ def status(directory, plan=None, spec=None, manifest_path=None): events = [entry for _, _, entry in events_at] result, _ = summarize(events, plan_text, plan is not None, file_sha256(spec) if spec is not None else None, - file_sha256(plan) if plan is not None else None, manifest) + file_sha256(plan) if plan is not None else None, manifest, ledger_dir=directory) result.update(join_facts(events_at)) snap = latest_minors(events) result["minors"] = {"open": [m.get("id") for m in snap["open"]] if snap else [], @@ -1315,10 +1376,29 @@ def setup_window(directory, n): return starts[n - 1], (starts[n] if n < len(starts) else float("inf")) +def launch_permits(permits): + """{ruling index: [{task, files}]} from a result's preflight.permits (the + engine's kept permits, 1.6.8): each well-formed entry ({task: non-empty + string, files: non-empty list of non-empty strings, ruling: integer >= + 0}) under its ruling's 0-based index, in the result's order; the rest + are left out.""" + out = {} + for p in permits if isinstance(permits, list) else []: + if not isinstance(p, dict): + continue + task, files, ruling = p.get("task"), p.get("files"), p.get("ruling") + if not (is_text(task) and is_text_list(files) and files + and isinstance(ruling, int) and not isinstance(ruling, bool) and ruling >= 0): + continue + out.setdefault(ruling, []).append({"task": task, "files": list(files)}) + return out + + def carry_launch(directory, source): """Record what a launch knew that the ledger does not: pre-flight's own - rulings (by preflight, PF-n), the open task minors, and which PF-n - overrides recorded during the launch name. Once per wf id.""" + rulings (by preflight, PF-n, each with the permits the engine kept for + it), the open task minors, and which PF-n overrides recorded during the + launch name. Once per wf id.""" if not os.path.isdir(directory): fail(3, f"no ledger dir {directory!r}") path = find_launch_file(source) @@ -1354,6 +1434,7 @@ def carry_launch(directory, source): # conflicts only in autonomous runs and pushed the ruling last. With no # adjudicator ruling in _run (a supervised stop) there was none. adjudicated = len(texts) - 1 + permits_of = launch_permits(pre.get("permits")) pf = {} entries = [] run_rulings = len([r for r in ruling_records(directory) if r["stem"] == "_run"]) @@ -1363,8 +1444,11 @@ def carry_launch(directory, source): if adj_ids: pf[key] = adj_ids[-1] continue - entries.append({"task": "_run", "event": "ruling", "text": text, "by": "preflight", - "scope": "run", "pf": key, "wf": wf}) + entry = {"task": "_run", "event": "ruling", "text": text, "by": "preflight", + "scope": "run", "pf": key, "wf": wf} + if permits_of.get(i): + entry["permits"] = permits_of[i] + entries.append(entry) pf[key] = f"_run-R{run_rulings + len(entries)}" written = len(entries) bound_before = {(b.get("file"), b.get("line")) for c in carried_events(directory) diff --git a/scripts/task-checks b/scripts/task-checks index b32294f..85ae19c 100755 --- a/scripts/task-checks +++ b/scripts/task-checks @@ -4,7 +4,7 @@ usage: task-checks DIR --base SHA --ledger LEDGER_DIR --unit UNIT_ID --lane LANE --task ID [--task ID ...] [--declared PATH ... | --declared-from-owners N:HHHHHHHH] - [--widen PATH ...] [--project-test CMD ...] + [--widen PATH ...] [--permit PATH=PF-N ...] [--project-test CMD ...] [--entry TASK_ID --targeted GROUP CMD [--targeted GROUP CMD ...]] --lane-cmd GROUP CMD [...] (--wide-cmd GROUP CMD [...] | --wide-as-lane) [--force-full REASON] [--evidence PATH] [--phase NAME] @@ -62,16 +62,29 @@ case-insensitive filesystem a case mismatch only widens. A --declared or --widen value that is absolute or leaves the project after normalizing is a usage error. -Permitted paths: a changed path outside the Files list that an adjudicator -ruling in force permits this unit (its 'files', file paths only: an entry -naming a directory permits nothing; filed under one of the --task ids, or -under _run for a path that neither it nor a folder above it is named by a -task's Files list in LEDGER_DIR/owners.json, in the folder of a --declared -file or in a declared directory itself, folders compared by case) counts as declared and is listed in policy.permitted as -{path, ruling}. Implementer, reviewer and pre-flight rulings permit nothing. -The ledger script's adjudicator_permits finds them. When the ledger cannot -be read none are permitted; when the owner map cannot be read, none of -_run's are. +Permitted paths: a changed path outside the Files list counts as declared +and is listed in policy.permitted as {path, ruling} when it is permitted +this unit by, in this order: + - a --permit PATH=PF-N (1.6.8, repeatable): this launch's pre-flight ruling + PF-N (N from 1, no leading zero) permits PATH, a file, to one of the + unit's tasks; the engine passes one per kept file. PATH goes through the + --declared rules ('\\' becomes '/', normalized); a directory (ending in + '/', or the project root), an absolute path, one that leaves the project, + or a value without '=PF-N' is a usage error. ruling is 'PF-N'; + - an adjudicator ruling in force (its 'files', file paths only: an entry + naming a directory permits nothing; filed under one of the --task ids, + or under _run for a path that neither it nor a folder above it is named + by a task's Files list in LEDGER_DIR/owners.json, in the folder of a + --declared file or in a declared directory itself, folders compared by + case), found by the ledger script's adjudicator_permits; + - an earlier launch's pre-flight ruling in force whose 'permits' (written + by ledger carry-launch) names the file for one of the --task ids, found + by the ledger script's preflight_permits. +ruling is the ledger's ruling id for the last two. Implementer and reviewer +rulings permit nothing; a pre-flight ruling permits what its `permits` names +for its task. When the ledger cannot be read only --permit paths are +permitted; when the owner map cannot be read, none of _run's adjudicator +rulings are. The chosen list runs through run-checks (DIR --out FILE --root LEDGER_DIR --lock-held). FILE, the result file, is: @@ -159,7 +172,7 @@ PRINT_PATHS = 50 PRINT_DETAIL = 2000 USAGE = ("usage: task-checks DIR --base SHA --ledger LEDGER_DIR --unit UNIT_ID --lane LANE " "--task ID [...] [--declared PATH ... | --declared-from-owners N:HHHHHHHH] " - "[--widen PATH ...] [--project-test CMD ...] " + "[--widen PATH ...] [--permit PATH=PF-N ...] [--project-test CMD ...] " "[--entry TASK_ID --targeted GROUP CMD ...] --lane-cmd GROUP CMD [...] " "(--wide-cmd GROUP CMD [...] | --wide-as-lane) [--force-full REASON] [--evidence PATH] " "[--phase NAME]") @@ -169,7 +182,9 @@ SINGLE = {"--base": "base", "--ledger": "ledger", "--unit": "unit", "--lane": "l "--entry": "entry", "--force-full": "forced", "--evidence": "evidence", "--phase": "phase", "--declared-from-owners": "owners_fp"} REPEATED = {"--task": "tasks", "--declared": "declared", "--widen": "widen", - "--project-test": "project_tests"} + "--permit": "permit", "--project-test": "project_tests"} +# A --permit's ruling: this launch's pre-flight ruling, numbered from 1. +PF_ID = re.compile(r"PF-[1-9][0-9]*") PAIRS = {"--targeted": "targeted", "--lane-cmd": "lane_cmds", "--wide-cmd": "wide_cmds"} # Options that take no value. FLAGS = {"--wide-as-lane": "wide_as_lane"} @@ -216,7 +231,7 @@ def parse(argv): that is not an option is skipped.""" args = {"dir": None, "base": None, "ledger": None, "unit": None, "lane": None, "entry": None, "forced": None, "evidence": None, "phase": None, "owners_fp": None, "wide_as_lane": False, - "tasks": [], "declared": [], "widen": [], "project_tests": [], + "tasks": [], "declared": [], "widen": [], "permit": [], "project_tests": [], "targeted": [], "lane_cmds": [], "wide_cmds": []} errors = [] rest = argv @@ -311,6 +326,17 @@ def validate(args): fail(2, f"{flag} {value!r} is not a project-relative path") keys.append(k) args[key] = keys + permits = [] + for value in args["permit"]: + path, eq, ruling = value.rpartition("=") + if not eq or not PF_ID.fullmatch(ruling): + fail(2, f"--permit {value!r} is not PATH=PF-N") + k = entry_key(path) + # A permit names a file: a directory would cover every path under it. + if k is None or k[1] or k[0] == ".": + fail(2, f"--permit {value!r} does not name a project-relative file") + permits.append((k, ruling)) + args["permit"] = permits args["dir"] = _shell.native_path(args["dir"]) args["ledger"] = _shell.native_path(args["ledger"]) # A --widen entry naming an existing directory covers it without the '/': @@ -377,9 +403,13 @@ def covered(path, entries): def permits(args): - """[((path, is_dir), ruling id)]: the paths the adjudicator rulings in - force permit this unit (ledger's adjudicator_permits, one helper for both - scripts); [] when the ledger cannot be read, so the strict rule holds.""" + """[((path, is_dir), ruling id)]: the --permit paths, then the paths the + adjudicator rulings and the carried pre-flight permits in force permit + this unit (ledger's adjudicator_permits and preflight_permits, one helper + for both scripts, from one load of the ledger script); only the --permit + paths when the ledger cannot be read, so the strict rule holds for the + rest.""" + out = list(args["permit"]) try: loader = importlib.machinery.SourceFileLoader("_pl_ledger", os.path.join(HERE, "ledger")) spec = importlib.util.spec_from_loader("_pl_ledger", loader) @@ -387,9 +417,9 @@ def permits(args): with contextlib.redirect_stderr(io.StringIO()): loader.exec_module(ledger) found = ledger.adjudicator_permits(args["ledger"], args["tasks"], args["declared_raw"]) + found += ledger.preflight_permits(args["ledger"], args["tasks"]) except (Exception, SystemExit): # noqa: BLE001 - any failure keeps the strict rule - return [] - out = [] + return out for path, ruling in found: key = entry_key(path) if isinstance(path, str) else None # A permit covers its own path only, never a directory's contents. diff --git a/tests/ledger-carry.test.mjs b/tests/ledger-carry.test.mjs index cac1b14..6daef50 100644 --- a/tests/ledger-carry.test.mjs +++ b/tests/ledger-carry.test.mjs @@ -113,14 +113,14 @@ const minor = (id, task, issue = `issue ${id}`) => ({ id, severity: 'minor', fil // A Claude config dir holding one session's workflow result file, as the // Workflow tool writes it; returns {config, transcripts} (the transcript dir). -function launchFile(config, wf, { rulings = [], adjudicated = null, tasks = {}, final = null, args = {} } = {}) { +function launchFile(config, wf, { rulings = [], adjudicated = null, tasks = {}, final = null, args = {}, permits } = {}) { const session = join(config, 'projects', 'C--work', 'sess-1'); mkdirSync(join(session, 'workflows'), { recursive: true }); const transcripts = join(session, 'subagents', 'workflows', wf); mkdirSync(transcripts, { recursive: true }); const result = { status: 'complete', run_id: 'run-1', tasks, - preflight: { conflicts: [], rulings, undeclared: [], schedule: [], adjudicated }, + preflight: { conflicts: [], rulings, undeclared: [], schedule: [], adjudicated, ...(permits ? { permits } : {}) }, final, }; writeFileSync(join(session, 'workflows', `${wf}.json`), JSON.stringify({ runId: wf, args, result })); @@ -460,3 +460,86 @@ test("a batch's minors are carried once, under the batch's first task", () => { assert.deepEqual(statusOf(dir).minors, { open: ['T2-T3-1', 'T2-T3-2'], covered: ['T2', 'T3'] }); assert.ok(ledger('minors', dir).stdout.includes('[T2-T3-1] T2 '), 'listed under the first task'); }); + +// ---- 1.6.8 (spec 4.6): pre-flight permits carried through the ledger ---- + +const runEvents = (dir) => readFileSync(join(dir, '_run.jsonl'), 'utf8').trim().split('\n').map((l) => JSON.parse(l)); + +test('carry-launch records permits on the pre-flight ruling', () => { + const dir = freshDir(); + const config = freshDir('config'); + const t = launchFile(config, 'wf_permit-001', { + rulings: ['a', 'b'], permits: [{ task: 'T1', files: ['x.py'], ruling: 1 }], + }); + const out = carry(dir, t); + assert.equal(out.code, 0, out.stderr); + const rulings = runEvents(dir).filter((e) => e.event === 'ruling'); + const byPf = Object.fromEntries(rulings.map((e) => [e.pf, e])); + assert.deepEqual(byPf['PF-2'].permits, [{ task: 'T1', files: ['x.py'] }]); + assert.ok(!('permits' in byPf['PF-1']), JSON.stringify(byPf['PF-1'])); + // The ledger reads the carried permit back for T1 only. + const res = withLedgerModule('import json\nprint(json.dumps([L.preflight_permits(sys.argv[2], ["T1"]), ' + + 'L.preflight_permits(sys.argv[2], ["T2"])]))\n', [dir]); + assert.equal(res.status, 0, res.stderr); + assert.deepEqual(JSON.parse(res.stdout), [[['x.py', '_run-R2']], []]); +}); + +test('carry-launch drops malformed permits and those of the adjudication or no ruling', () => { + const dir = freshDir(); + const config = freshDir('config'); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'adjudicator', text: 'Ruling: ADJ - w - c' }); + const t = launchFile(config, 'wf_permit-002', { + rulings: ['a', 'ADJ', 'c'], adjudicated: 1, + permits: [ + { task: 'T1', files: ['x.py'], ruling: 0 }, { task: 'T2', files: ['y.py', 'z.py'], ruling: 0 }, + { task: 'T3', files: ['adj.py'], ruling: 1 }, { task: 'T4', files: ['far.py'], ruling: 7 }, + { task: '', files: ['a.py'], ruling: 2 }, { task: 'T5', files: 'a.py', ruling: 2 }, + { task: 'T6', files: [], ruling: 2 }, { task: 'T7', files: ['a.py'], ruling: true }, + { task: 'T8', files: ['a.py'], ruling: '2' }, 'junk', + ], + }); + const out = carry(dir, t); + assert.equal(out.code, 0, out.stderr); + const rulings = runEvents(dir).filter((e) => e.by === 'preflight'); + assert.deepEqual(rulings.map((e) => [e.pf, e.permits]), [ + ['PF-1', [{ task: 'T1', files: ['x.py'] }, { task: 'T2', files: ['y.py', 'z.py'] }]], + ['PF-3', undefined], + ]); +}); + +test('adjudicator_permits still ignores pre-flight rulings', () => { + const dir = freshDir(); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'preflight', pf: 'PF-1', text: 'P', + files: ['x.py'], permits: [{ task: 'T1', files: ['x.py'] }] }); + appendOk(dir, 'alpha', { task: 'T1', event: 'ruling', by: 'preflight', text: 'Q', files: ['y.py'] }); + const res = withLedgerModule('import json\nprint(json.dumps([L.adjudicator_permits(sys.argv[2], ["T1"], ["x.py"]), ' + + 'L.preflight_permits(sys.argv[2], ["T1"])]))\n', [dir]); + assert.equal(res.status, 0, res.stderr); + assert.deepEqual(JSON.parse(res.stdout), [[], [['x.py', '_run-R1']]]); +}); + +test('preflight_permits skips an overridden ruling and directory entries', () => { + const dir = freshDir(); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'preflight', pf: 'PF-1', text: 'P', + permits: [{ task: 'T1', files: ['a.py', 'src/', 'src\\', '.', '../up.py', '/abs.py'] }] }); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'preflight', pf: 'PF-2', text: 'Q', + permits: [{ task: 'T1', files: ['b.py'] }] }); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'adjudicator', text: 'NO', overrides: ['_run-R2'] }); + const res = withLedgerModule('import json\nprint(json.dumps(L.preflight_permits(sys.argv[2], ["T1"])))\n', [dir]); + assert.equal(res.status, 0, res.stderr); + assert.deepEqual(JSON.parse(res.stdout), [['a.py', '_run-R1']]); +}); + +test('a malformed permits field is refused', () => { + const dir = freshDir(); + for (const bad of ['x.py', ['x.py'], [{ task: 'T1' }], [{ files: ['x.py'] }], [{ task: '', files: ['x.py'] }], + [{ task: 'T1', files: 'x.py' }], [{ task: 'T1', files: [''] }], [{ task: 7, files: ['x.py'] }], + [{ task: 'T1', files: ['x.py'], ruling: 0 }], {}]) { + const res = ledger('append', dir, '_run', JSON.stringify({ task: '_run', event: 'ruling', by: 'preflight', text: 'P', permits: bad })); + assert.notEqual(res.code, 0, JSON.stringify(bad)); + assert.ok(res.stderr.includes("'permits' must be a list of {task, files}"), `${JSON.stringify(bad)}: ${res.stderr}`); + } + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'preflight', text: 'P', permits: [] }); + appendOk(dir, '_run', { task: '_run', event: 'ruling', by: 'preflight', text: 'P', + permits: [{ task: 'T1', files: ['x.py', 'y.py'] }, { task: 'T2', files: ['z.py'] }] }); +}); diff --git a/tests/recovery.test.mjs b/tests/recovery.test.mjs index a4694a5..cfe6080 100644 --- a/tests/recovery.test.mjs +++ b/tests/recovery.test.mjs @@ -305,6 +305,26 @@ test("a batch's shared range counts every batch task's files, in status and in l assert.deepEqual(ok(py('ledger', 'undeclared', c.ledger)), status.undeclared); }); +test('a permitted path is not undeclared', () => { + // 1.6.8 (spec 4.6): a carried pre-flight permit and an adjudicator's both count as declared. + const c = newCase(); + withManifest(c, [ptask('T1', { files: ['a.txt'] }), ptask('T2', { files: ['x.txt'] })]); + for (const name of ['a.txt', 'b.txt', 'c.txt', 'd.txt', 'e.txt']) write(join(c.repo, name), `${name}\n`); + git(c.repo, 'add', '.'); + git(c.repo, 'commit', '-q', '-m', 'T1'); + finish(c, c.start, '--task', 'T1'); + const ruling = (lane, entry) => ok(py('ledger', 'append', c.ledger, lane, JSON.stringify({ event: 'ruling', ...entry }))); + ruling('_run', { task: '_run', by: 'preflight', pf: 'PF-1', text: 'P', permits: [{ task: 'T1', files: ['b.txt'] }] }); + ruling('prelude', { task: 'T1', by: 'adjudicator', text: 'A', files: ['C.txt'] }); + // Not permits for T1: another task's pre-flight permit, and an implementer's files. + ruling('_run', { task: '_run', by: 'preflight', pf: 'PF-2', text: 'Q', permits: [{ task: 'T2', files: ['d.txt'] }] }); + ruling('prelude', { task: 'T1', by: 'implementer', text: 'I', files: ['e.txt'] }); + ok(py('ledger', 'write-owners', c.ledger, c.manifest)); + const expected = { T1: ['d.txt', 'e.txt'].map((path) => ({ path, owner: null, relation: null })) }; + assert.deepEqual(ok(py('ledger', 'undeclared', c.ledger)), expected); + assert.deepEqual(ok(py('ledger', 'status', c.ledger, '--manifest', c.manifest)).undeclared, expected); +}); + test('run_ended events add up to the budget spent across launches', () => { const c = newCase(); append(c, { task: '_run', event: 'run_ended', status: 'stopped', agents: 5, rulings: 2 }); diff --git a/tests/task-checks.test.mjs b/tests/task-checks.test.mjs index 3465c09..38c0e48 100644 --- a/tests/task-checks.test.mjs +++ b/tests/task-checks.test.mjs @@ -287,6 +287,83 @@ test('a malformed owner map, and a backslash or mixed-case permit of an owned fi } }); +// 1.6.8 (spec 4.6): a pre-flight permit, passed by the engine as --permit or carried in the ledger. +test('a --permit path counts as declared', () => { + const r = repo({ 'src/a.js': '1\n' }); + writeFileSync(join(r.dir, 'src/a.js'), '2\n'); + mkdirSync(join(r.dir, 'tools')); + writeFileSync(join(r.dir, 'tools/x.py'), 'new\n'); + const { status, json } = run(r, { args: ['--declared', 'src/a.js', '--permit', 'tools/x.py=PF-2'] }); + assert.equal(status, 0); + assert.equal(json.policy.selected, 'targeted'); + assert.deepEqual(json.policy.reasons, []); + assert.deepEqual(json.policy.permitted, [{ path: 'tools/x.py', ruling: 'PF-2' }]); + // A permit for another file leaves this one outside the Files list. + const other = run(r, { args: ['--declared', 'src/a.js', '--permit', 'tools/y.py=PF-2'] }); + assert.deepEqual(codes(other.json), ['outside_files']); + assert.deepEqual(other.json.policy.permitted, []); +}); + +test('a --permit with backslashes is normalised', () => { + const r = repo({ 'src/a.js': '1\n' }); + writeFileSync(join(r.dir, 'src/a.js'), '2\n'); + mkdirSync(join(r.dir, 'tools')); + writeFileSync(join(r.dir, 'tools/x.py'), 'new\n'); + const { json } = run(r, { args: ['--declared', 'src/a.js', '--permit', 'tools\\x.py=PF-1'] }); + assert.equal(json.policy.selected, 'targeted'); + assert.deepEqual(json.policy.permitted, [{ path: 'tools/x.py', ruling: 'PF-1' }]); +}); + +test('--permit naming a directory, an absolute path or .. exits 2', () => { + const r = repo({ 'src/a.js': '1\n' }); + for (const bad of ['tools/=PF-1', 'tools\\=PF-1', '.=PF-1', './=PF-1', '/abs/x.py=PF-1', 'C:/x.py=PF-1', + '../x.py=PF-1', 'a/../../x.py=PF-1', '..=PF-1']) { + const res = run(r, { args: ['--declared', 'src/a.js', '--permit', bad] }); + assert.equal(res.status, 2, `${bad}: ${res.stderr}`); + assert.equal(res.json, null, bad); + } +}); + +test('--permit without =PF-n exits 2', () => { + const r = repo({ 'src/a.js': '1\n' }); + for (const bad of ['tools/x.py', 'tools/x.py=', 'tools/x.py=PF-0', 'tools/x.py=PF-01', 'tools/x.py=pf-1', + 'tools/x.py=_run-R1', '=PF-1', 'tools/x.py=PF-1 ']) { + const res = run(r, { args: ['--declared', 'src/a.js', '--permit', bad] }); + assert.equal(res.status, 2, `${bad}: ${res.stderr}`); + assert.equal(res.json, null, bad); + } +}); + +test('a carried pre-flight permit counts as declared', () => { + const r = repo({ 'src/a.js': '1\n' }); + writeFileSync(join(r.dir, 'src/a.js'), '2\n'); + mkdirSync(join(r.dir, 'tools')); + writeFileSync(join(r.dir, 'tools/x.py'), 'new\n'); + const pre = (permits, extra = {}) => ({ event: 'ruling', task: '_run', by: 'preflight', pf: 'PF-1', scope: 'run', + text: 'Ruling: T1 may change tools/x.py - w - c', permits, ...extra }); + const opts = { tasks: ['T1'], args: ['--declared', 'src/a.js'] }; + ledgerFile(r, '_run', [pre([{ task: 'T1', files: ['tools/x.py'] }])]); + const { status, json } = run(r, opts); + assert.equal(status, 0); + assert.equal(json.policy.selected, 'targeted'); + assert.deepEqual(json.policy.permitted, [{ path: 'tools/x.py', ruling: '_run-R1' }]); + const widens = (why) => { + const res = run(r, opts); + assert.deepEqual(codes(res.json), ['outside_files'], why); + assert.deepEqual(res.json.policy.permitted, [], why); + }; + // A permit for another task, a directory, a ruling an adjudicator overrode, or one not by pre-flight: none. + ledgerFile(r, '_run', [pre([{ task: 'T2', files: ['tools/x.py'] }])]); + widens('another task'); + ledgerFile(r, '_run', [pre([{ task: 'T1', files: ['tools/'] }])]); + widens('a directory'); + ledgerFile(r, '_run', [pre([{ task: 'T1', files: ['tools/x.py'] }]), + { event: 'ruling', task: '_run', by: 'adjudicator', text: 'Ruling: no - w - c', overrides: ['_run-R1'] }]); + widens('overridden'); + ledgerFile(r, '_run', [pre([{ task: 'T1', files: ['tools/x.py'] }], { by: 'implementer' })]); + widens('an implementer ruling'); +}); + test('an untracked file outside the Files list widens', () => { const r = repo({ 'src/a.js': '1\n' }); writeFileSync(join(r.dir, 'new.js'), 'x\n'); From a8365463f13ec9cd13aaed90fe0ae1157d6c7d6a Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:13:53 -0700 Subject: [PATCH 05/14] feat: pre-flight rulings can permit files --- run.workflow.js | 151 ++++++++++++++++++++++++++-- src/phases.js | 104 ++++++++++++++++++- src/prompts-phase.js | 20 ++++ src/prompts-task.js | 27 ++++- tests/carry-prompts.test.mjs | 7 ++ tests/check-policy-prompts.test.mjs | 25 +++++ tests/run.test.mjs | 151 +++++++++++++++++++++++++++- 7 files changed, 471 insertions(+), 14 deletions(-) diff --git a/run.workflow.js b/run.workflow.js index 25277b2..6945919 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1531,7 +1531,9 @@ function commandsText(m, laneId, dir = null, name = 'checks', phase = null) { // null when the ledger lane has no test, lint or build command. 1.6.6 (spec // 4.5): the Files list goes as its fingerprint (task-checks reads the list // from owners.json), and a wide list equal to the lane list as --wide-as-lane, -// so the command does not grow with the Files list. +// so the command does not grow with the Files list. 1.6.8 (spec 4.6): one +// --permit '=PF-' per file of a kept pre-flight permit of the unit's +// tasks. function taskChecksCommand(m, task, where, base) { const lane = laneChecks(m, where.lane); if (lane.length === 0) return null; @@ -1540,12 +1542,20 @@ function taskChecksCommand(m, task, where, base) { const entry = taskChecksEntry(m, task, where.lane); const declared = declaredFiles(task); const wide = wideChecks(m, where.lane); + const permits = []; + for (const p of unitPermits(m, task)) { + for (const f of p.files) { + const value = `${f}=PF-${p.ruling + 1}`; + if (!permits.includes(value)) permits.push(value); + } + } return [ `cd ${q(where.dir)} && ${pythonCommand(m)} ${q(`${m.skill_dir}/scripts/task-checks`)}`, q(where.dir), '--base', q(base), '--ledger', q(m.repo.ledger_dir), '--unit', q(task.id), '--evidence', resultPath(), '--lane', q(where.lane), '--phase', q(checksPhase(where.lane)), ...unitTasks(task).flatMap((t) => ['--task', q(t.id)]), ...(declared.length === 0 ? [] : ['--declared-from-owners', q(declaredFingerprint(declared))]), + ...permits.flatMap((v) => ['--permit', q(v)]), ...(Array.isArray(m.widen_paths) ? m.widen_paths : []).flatMap((p) => ['--widen', q(p)]), ...commandGroup(m, null, 'test').flatMap((c) => ['--project-test', q(c)]), ...(entry === null ? [] : ['--entry', q(entry.id), ...pairs('--targeted', entry.checks)]), @@ -1675,6 +1685,20 @@ function runRulingsLines(m) { return ['Rulings already made for this run (binding):', ...rulings.map((r, i) => `- PF-${i + 1}: ${r}`)]; } +// The pre-flight permits the run kept for the unit's tasks (m.run_permits, +// set only by the workflow, spec 4.6): [{task, files, ruling}], ruling the +// 0-based index of a run ruling, so its id is PF-. +function unitPermits(m, task) { + const ids = unitTasks(task).map((t) => t.id); + return (Array.isArray(m.run_permits) ? m.run_permits : []).filter((p) => ids.includes(p.task)); +} + +// One prompt line per kept permit of the unit's tasks; [] when there is none. +function runPermitsLines(m, task) { + return unitPermits(m, task).map((p) => + `PF-${p.ruling + 1} permits ${p.task} to change ${p.files.join(', ')}: changing them does not widen your checks.`); +} + // The message a task fix round commits with: 'fix: address review findings // for Task ', or for a batch 'for Batch -'. function fixCommitMessage(task) { @@ -1748,6 +1772,7 @@ function taskContext(m, task, where, guidance = null, by = 'implementer') { checkoutRules(where.dir, where.branch), ...briefs, ...runRulingsLines(m), + ...runPermitsLines(m, task), ...runNotes.map((n) => `A note decided on the user's behalf for this ${unitNoun(task)} (follow it where it settles a question): ${n}`), ...amendments.map((a) => (isBatch(task) @@ -2420,6 +2445,21 @@ function preflightSchema() { rulings: { type: 'array', items: { type: 'string' } }, undeclared: dependencies(), code_deps: dependencies(), + // Optional (spec 4.6): files a ruling lets a task change outside its + // Files list; ruling is the 0-based index in rulings. The run reads a + // result without it as []. + permits: { + type: 'array', + items: { + type: 'object', + properties: { + task: { type: 'string' }, + files: { type: 'array', items: { type: 'string' } }, + ruling: { type: 'integer' }, + }, + required: ['task', 'files', 'ruling'], + }, + }, }, required: ['conflicts', 'rulings', 'undeclared', 'code_deps'], }; @@ -2590,6 +2630,7 @@ function relaunchPreflightLines(m) { `Built tasks (done in an earlier launch; never rule on their code): ${done.length > 0 ? done.join(', ') : '(none)'}`, 'Earlier rulings bind; read them with (a read-only command):', ` ${ledgerReadCommand(m, featureDir(m), 'rulings')}`, + 'Permits an earlier launch recorded are carried; they need no new ruling.', 'Check only the tasks still to run and their dependence on built work.', 'A matter an earlier ruling settled is not a conflict and gets no new ruling, unless the plan or the spec changed', `since that ruling; then report it as a conflict. ${changed}`, @@ -2633,6 +2674,10 @@ function preflightPrompt(m, waves = null) { 'Report serious problems (implementers would build the wrong thing, or check 1 or 2 fails) as conflicts,', 'one sentence each naming the tasks and plan or spec sections. Settle minor ambiguities yourself and report', 'each as a ruling in the form "Ruling: decision - why - cost if wrong".', + 'When a ruling has a task change a file outside its Files list, also return it in permits as', + '{task, files, ruling}: the task id, the project-relative files (files, not folders) and the ruling\'s', + '0-based index in rulings. A permitted file does not widen that task\'s checks. The run drops a permit', + 'for a file that a task able to run at the same time declares or is also permitted.', '', phaseRules(m), ].join('\n'); @@ -5494,6 +5539,89 @@ function preflightUndeclared(m, entries) { return { kept, dropped }; } +// Pre-flight's permits (spec 4.6), judged file by file: {kept: [{task, files, +// ruling}], dropped: [{task, file, why}]}. A permit lets a task change files +// outside its Files list under rulings[ruling] (0-based). A file is kept when +// the task is a task of this launch (not done and reviewed), ruling indexes +// rulings, the file is project-relative (no '..', no trailing '/'), no task +// that can run at the same time declares it (the file or a folder holding +// it, compared without case), and no permit kept before it in pre-flight's +// order gives it to such a task. Two tasks can run at the same time when they +// are in different lanes, or are join tasks of different units of one wave of +// waves (the join's waves after wave_moves; null when the join runs one unit +// at a time). Prelude tasks never run beside another task. +function preflightPermits(m, permits, rulings, waves) { + const all = [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]; + const runnable = new Set(all.filter((t) => taskState(m, t.id) !== 'skip').map((t) => t.id)); + const laneOf = new Map(); + for (const l of m.lanes) for (const t of l.tasks) laneOf.set(t.id, l.id); + const waveOf = new Map(); + if (Array.isArray(waves)) { + for (const u of joinUnits(m)) { + const wave = waves.findIndex((ids) => ids.includes(u.id)); + if (wave >= 0) for (const t of unitTasks(u)) waveOf.set(t.id, { wave, unit: u.id }); + } + } + const alongside = (a, b) => { + if (laneOf.has(a) && laneOf.has(b)) return laneOf.get(a) !== laneOf.get(b); + const wa = waveOf.get(a); + const wb = waveOf.get(b); + return Boolean(wa && wb && wa.wave === wb.wave && wa.unit !== wb.unit); + }; + const relative = (f) => typeof f === 'string' && normalizePath(f) !== null && !f.split('/').includes('..') + && !f.endsWith('/'); + const key = (f) => normalizePath(f).toLowerCase(); + const covers = (declared, k) => { + const d = normalizePath(declared); + return d !== null && (k === d.toLowerCase() || k.startsWith(`${d.toLowerCase()}/`)); + }; + const kept = []; + const dropped = []; + const given = []; + for (const p of Array.isArray(permits) ? permits : []) { + const shaped = p !== null && typeof p === 'object' && !Array.isArray(p); + const taskId = shaped && typeof p.task === 'string' ? p.task : null; + const listed = shaped && Array.isArray(p.files); + const files = listed ? p.files : [shaped && p.files !== undefined ? p.files : null]; + const drop = (file, why) => dropped.push({ task: taskId, file, why }); + if (taskId === null || !runnable.has(taskId)) { + for (const f of files) drop(f, 'not a task of this launch'); + continue; + } + if (!Number.isInteger(p.ruling) || p.ruling < 0 || p.ruling >= rulings.length) { + for (const f of files) drop(f, 'no such ruling'); + continue; + } + if (!listed) { + drop(files[0], 'not a project-relative file'); + continue; + } + const mine = []; + for (const f of files) { + if (!relative(f)) { + drop(f, 'not a project-relative file'); + continue; + } + const k = key(f); + if (mine.some((x) => key(x) === k)) continue; + const owner = all.find((t) => alongside(taskId, t.id) && (t.files || []).some((d) => covers(d, k))); + if (owner) { + drop(f, `declared by ${owner.id}, which can run at the same time`); + continue; + } + const other = given.find((g) => g.key === k && alongside(taskId, g.task)); + if (other) { + drop(f, `also permitted to ${other.task}, which can run at the same time`); + continue; + } + given.push({ task: taskId, key: k }); + mine.push(f); + } + if (mine.length > 0) kept.push({ task: taskId, files: mine, ruling: p.ruling }); + } + return { kept, dropped }; +} + // A copy of the manifest with consumes_extra: {: [, ...]} // from the kept undeclared entries, producers in entry order without // duplicates. startCommand and the adjudicator's task-brief command pass @@ -5580,9 +5708,9 @@ async function runAll(manifest, io) { const autonomous = effectiveAutonomy(m) === 'autonomous'; const hasChecks = finalChecks(m).length > 0; - // Run rulings come from this run's pre-flight only, never from the - // manifest file. - m = { ...m, run_rulings: [] }; + // Run rulings and permits come from this run's pre-flight only, never from + // the manifest file. + m = { ...m, run_rulings: [], run_permits: [] }; const tasks = {}; const deferredBefore = new Set(m.deferred || []); for (const t of [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]) { @@ -5791,10 +5919,21 @@ async function runAll(manifest, io) { + `${mv.producer ? ` (it needs ${mv.producer})` : ''}`); } } + // Permits (spec 4.6) are judged against the waves after the moves; a + // result without permits has none. + const permits = preflightPermits(m, pre.permits, pre.rulings, + shown.mode === 'waves' ? wavePlan(m, waveEdges).waves : null); + preflight.permits = permits.kept; + preflight.permits_dropped = permits.dropped; + for (const d of permits.dropped) { + io.log(`parallel-lanes: pre-flight: dropped the permit of ${d.task} for ${d.file} (${d.why})`); + } m = withConsumesExtra(m, kept); // Pre-flight's rulings bind every task and final reviewer of this run - // (taskContext and the final review prompts show m.run_rulings). - m = { ...m, run_rulings: [...pre.rulings] }; + // (taskContext and the final review prompts show m.run_rulings); its + // kept permits reach the task-checks call and the prompt of their tasks + // (m.run_permits). + m = { ...m, run_rulings: [...pre.rulings], run_permits: permits.kept }; if (pre.conflicts.length > 0) { // Supervised keeps Plan 1 (stop and wait); autonomous adjudicates (C1). if (!autonomous) return report('preflight_conflicts'); diff --git a/src/phases.js b/src/phases.js index 486a7e2..b64c572 100644 --- a/src/phases.js +++ b/src/phases.js @@ -392,6 +392,89 @@ function preflightUndeclared(m, entries) { return { kept, dropped }; } +// Pre-flight's permits (spec 4.6), judged file by file: {kept: [{task, files, +// ruling}], dropped: [{task, file, why}]}. A permit lets a task change files +// outside its Files list under rulings[ruling] (0-based). A file is kept when +// the task is a task of this launch (not done and reviewed), ruling indexes +// rulings, the file is project-relative (no '..', no trailing '/'), no task +// that can run at the same time declares it (the file or a folder holding +// it, compared without case), and no permit kept before it in pre-flight's +// order gives it to such a task. Two tasks can run at the same time when they +// are in different lanes, or are join tasks of different units of one wave of +// waves (the join's waves after wave_moves; null when the join runs one unit +// at a time). Prelude tasks never run beside another task. +function preflightPermits(m, permits, rulings, waves) { + const all = [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]; + const runnable = new Set(all.filter((t) => taskState(m, t.id) !== 'skip').map((t) => t.id)); + const laneOf = new Map(); + for (const l of m.lanes) for (const t of l.tasks) laneOf.set(t.id, l.id); + const waveOf = new Map(); + if (Array.isArray(waves)) { + for (const u of joinUnits(m)) { + const wave = waves.findIndex((ids) => ids.includes(u.id)); + if (wave >= 0) for (const t of unitTasks(u)) waveOf.set(t.id, { wave, unit: u.id }); + } + } + const alongside = (a, b) => { + if (laneOf.has(a) && laneOf.has(b)) return laneOf.get(a) !== laneOf.get(b); + const wa = waveOf.get(a); + const wb = waveOf.get(b); + return Boolean(wa && wb && wa.wave === wb.wave && wa.unit !== wb.unit); + }; + const relative = (f) => typeof f === 'string' && normalizePath(f) !== null && !f.split('/').includes('..') + && !f.endsWith('/'); + const key = (f) => normalizePath(f).toLowerCase(); + const covers = (declared, k) => { + const d = normalizePath(declared); + return d !== null && (k === d.toLowerCase() || k.startsWith(`${d.toLowerCase()}/`)); + }; + const kept = []; + const dropped = []; + const given = []; + for (const p of Array.isArray(permits) ? permits : []) { + const shaped = p !== null && typeof p === 'object' && !Array.isArray(p); + const taskId = shaped && typeof p.task === 'string' ? p.task : null; + const listed = shaped && Array.isArray(p.files); + const files = listed ? p.files : [shaped && p.files !== undefined ? p.files : null]; + const drop = (file, why) => dropped.push({ task: taskId, file, why }); + if (taskId === null || !runnable.has(taskId)) { + for (const f of files) drop(f, 'not a task of this launch'); + continue; + } + if (!Number.isInteger(p.ruling) || p.ruling < 0 || p.ruling >= rulings.length) { + for (const f of files) drop(f, 'no such ruling'); + continue; + } + if (!listed) { + drop(files[0], 'not a project-relative file'); + continue; + } + const mine = []; + for (const f of files) { + if (!relative(f)) { + drop(f, 'not a project-relative file'); + continue; + } + const k = key(f); + if (mine.some((x) => key(x) === k)) continue; + const owner = all.find((t) => alongside(taskId, t.id) && (t.files || []).some((d) => covers(d, k))); + if (owner) { + drop(f, `declared by ${owner.id}, which can run at the same time`); + continue; + } + const other = given.find((g) => g.key === k && alongside(taskId, g.task)); + if (other) { + drop(f, `also permitted to ${other.task}, which can run at the same time`); + continue; + } + given.push({ task: taskId, key: k }); + mine.push(f); + } + if (mine.length > 0) kept.push({ task: taskId, files: mine, ruling: p.ruling }); + } + return { kept, dropped }; +} + // A copy of the manifest with consumes_extra: {: [, ...]} // from the kept undeclared entries, producers in entry order without // duplicates. startCommand and the adjudicator's task-brief command pass @@ -478,9 +561,9 @@ async function runAll(manifest, io) { const autonomous = effectiveAutonomy(m) === 'autonomous'; const hasChecks = finalChecks(m).length > 0; - // Run rulings come from this run's pre-flight only, never from the - // manifest file. - m = { ...m, run_rulings: [] }; + // Run rulings and permits come from this run's pre-flight only, never from + // the manifest file. + m = { ...m, run_rulings: [], run_permits: [] }; const tasks = {}; const deferredBefore = new Set(m.deferred || []); for (const t of [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]) { @@ -689,10 +772,21 @@ async function runAll(manifest, io) { + `${mv.producer ? ` (it needs ${mv.producer})` : ''}`); } } + // Permits (spec 4.6) are judged against the waves after the moves; a + // result without permits has none. + const permits = preflightPermits(m, pre.permits, pre.rulings, + shown.mode === 'waves' ? wavePlan(m, waveEdges).waves : null); + preflight.permits = permits.kept; + preflight.permits_dropped = permits.dropped; + for (const d of permits.dropped) { + io.log(`parallel-lanes: pre-flight: dropped the permit of ${d.task} for ${d.file} (${d.why})`); + } m = withConsumesExtra(m, kept); // Pre-flight's rulings bind every task and final reviewer of this run - // (taskContext and the final review prompts show m.run_rulings). - m = { ...m, run_rulings: [...pre.rulings] }; + // (taskContext and the final review prompts show m.run_rulings); its + // kept permits reach the task-checks call and the prompt of their tasks + // (m.run_permits). + m = { ...m, run_rulings: [...pre.rulings], run_permits: permits.kept }; if (pre.conflicts.length > 0) { // Supervised keeps Plan 1 (stop and wait); autonomous adjudicates (C1). if (!autonomous) return report('preflight_conflicts'); diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 68c1a4f..98ad007 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -90,6 +90,21 @@ function preflightSchema() { rulings: { type: 'array', items: { type: 'string' } }, undeclared: dependencies(), code_deps: dependencies(), + // Optional (spec 4.6): files a ruling lets a task change outside its + // Files list; ruling is the 0-based index in rulings. The run reads a + // result without it as []. + permits: { + type: 'array', + items: { + type: 'object', + properties: { + task: { type: 'string' }, + files: { type: 'array', items: { type: 'string' } }, + ruling: { type: 'integer' }, + }, + required: ['task', 'files', 'ruling'], + }, + }, }, required: ['conflicts', 'rulings', 'undeclared', 'code_deps'], }; @@ -260,6 +275,7 @@ function relaunchPreflightLines(m) { `Built tasks (done in an earlier launch; never rule on their code): ${done.length > 0 ? done.join(', ') : '(none)'}`, 'Earlier rulings bind; read them with (a read-only command):', ` ${ledgerReadCommand(m, featureDir(m), 'rulings')}`, + 'Permits an earlier launch recorded are carried; they need no new ruling.', 'Check only the tasks still to run and their dependence on built work.', 'A matter an earlier ruling settled is not a conflict and gets no new ruling, unless the plan or the spec changed', `since that ruling; then report it as a conflict. ${changed}`, @@ -303,6 +319,10 @@ function preflightPrompt(m, waves = null) { 'Report serious problems (implementers would build the wrong thing, or check 1 or 2 fails) as conflicts,', 'one sentence each naming the tasks and plan or spec sections. Settle minor ambiguities yourself and report', 'each as a ruling in the form "Ruling: decision - why - cost if wrong".', + 'When a ruling has a task change a file outside its Files list, also return it in permits as', + '{task, files, ruling}: the task id, the project-relative files (files, not folders) and the ruling\'s', + '0-based index in rulings. A permitted file does not widen that task\'s checks. The run drops a permit', + 'for a file that a task able to run at the same time declares or is also permitted.', '', phaseRules(m), ].join('\n'); diff --git a/src/prompts-task.js b/src/prompts-task.js index 8dd24ec..fb6c408 100644 --- a/src/prompts-task.js +++ b/src/prompts-task.js @@ -300,7 +300,9 @@ function commandsText(m, laneId, dir = null, name = 'checks', phase = null) { // null when the ledger lane has no test, lint or build command. 1.6.6 (spec // 4.5): the Files list goes as its fingerprint (task-checks reads the list // from owners.json), and a wide list equal to the lane list as --wide-as-lane, -// so the command does not grow with the Files list. +// so the command does not grow with the Files list. 1.6.8 (spec 4.6): one +// --permit '=PF-' per file of a kept pre-flight permit of the unit's +// tasks. function taskChecksCommand(m, task, where, base) { const lane = laneChecks(m, where.lane); if (lane.length === 0) return null; @@ -309,12 +311,20 @@ function taskChecksCommand(m, task, where, base) { const entry = taskChecksEntry(m, task, where.lane); const declared = declaredFiles(task); const wide = wideChecks(m, where.lane); + const permits = []; + for (const p of unitPermits(m, task)) { + for (const f of p.files) { + const value = `${f}=PF-${p.ruling + 1}`; + if (!permits.includes(value)) permits.push(value); + } + } return [ `cd ${q(where.dir)} && ${pythonCommand(m)} ${q(`${m.skill_dir}/scripts/task-checks`)}`, q(where.dir), '--base', q(base), '--ledger', q(m.repo.ledger_dir), '--unit', q(task.id), '--evidence', resultPath(), '--lane', q(where.lane), '--phase', q(checksPhase(where.lane)), ...unitTasks(task).flatMap((t) => ['--task', q(t.id)]), ...(declared.length === 0 ? [] : ['--declared-from-owners', q(declaredFingerprint(declared))]), + ...permits.flatMap((v) => ['--permit', q(v)]), ...(Array.isArray(m.widen_paths) ? m.widen_paths : []).flatMap((p) => ['--widen', q(p)]), ...commandGroup(m, null, 'test').flatMap((c) => ['--project-test', q(c)]), ...(entry === null ? [] : ['--entry', q(entry.id), ...pairs('--targeted', entry.checks)]), @@ -444,6 +454,20 @@ function runRulingsLines(m) { return ['Rulings already made for this run (binding):', ...rulings.map((r, i) => `- PF-${i + 1}: ${r}`)]; } +// The pre-flight permits the run kept for the unit's tasks (m.run_permits, +// set only by the workflow, spec 4.6): [{task, files, ruling}], ruling the +// 0-based index of a run ruling, so its id is PF-. +function unitPermits(m, task) { + const ids = unitTasks(task).map((t) => t.id); + return (Array.isArray(m.run_permits) ? m.run_permits : []).filter((p) => ids.includes(p.task)); +} + +// One prompt line per kept permit of the unit's tasks; [] when there is none. +function runPermitsLines(m, task) { + return unitPermits(m, task).map((p) => + `PF-${p.ruling + 1} permits ${p.task} to change ${p.files.join(', ')}: changing them does not widen your checks.`); +} + // The message a task fix round commits with: 'fix: address review findings // for Task ', or for a batch 'for Batch -'. function fixCommitMessage(task) { @@ -517,6 +541,7 @@ function taskContext(m, task, where, guidance = null, by = 'implementer') { checkoutRules(where.dir, where.branch), ...briefs, ...runRulingsLines(m), + ...runPermitsLines(m, task), ...runNotes.map((n) => `A note decided on the user's behalf for this ${unitNoun(task)} (follow it where it settles a question): ${n}`), ...amendments.map((a) => (isBatch(task) diff --git a/tests/carry-prompts.test.mjs b/tests/carry-prompts.test.mjs index 992d510..7a818de 100644 --- a/tests/carry-prompts.test.mjs +++ b/tests/carry-prompts.test.mjs @@ -122,6 +122,13 @@ test("a relaunch's pre-flight checks only the tasks still to run, bound by the e assert.ok(unknown.includes('Whether the plan or the spec changed since the last launch is not known.'), unknown); }); +test("a relaunch's pre-flight is told the earlier permits are carried", () => { + const carried = 'Permits an earlier launch recorded are carried; they need no new ruling.'; + assert.ok(!preflightPrompt(manifest()).includes(carried)); + const p = preflightPrompt(manifest({ done: ['T1'], reviewed: ['T1'] })); + assert.ok(p.includes(`${RULINGS_CMD}\n${carried}\n`), p); +}); + test("a relaunch with carried minors but no task done still gets the earlier rulings in pre-flight", () => { const m = manifest({ carried_minors: { open: [], covered: [] } }); const p = preflightPrompt(m); diff --git a/tests/check-policy-prompts.test.mjs b/tests/check-policy-prompts.test.mjs index 7cb2f05..89d6772 100644 --- a/tests/check-policy-prompts.test.mjs +++ b/tests/check-policy-prompts.test.mjs @@ -110,6 +110,31 @@ test('a batch unit uses its last task\'s entry and passes every --task and the f assert.ok(!cmd.includes('--declared '), cmd); }); +test('task-checks gets no --permit without permits, and one per kept file of the unit\'s tasks', () => { + const plain = manifest(); + const where = laneWhere(plain, lane(plain, 'alpha')); + const p = implementPrompt(plain, t(plain, 'T2'), where, H('b')); + assert.ok(!p.includes('--permit') && !p.includes('does not widen your checks'), p); + const m = manifest({ run_permits: [ + { task: 'T3', files: ['tools/a.py', 'tools/b.py'], ruling: 0 }, + { task: 'T4', files: ['tools/c.py'], ruling: 1 }, + { task: 'T2', files: ['tools/d.py'], ruling: 2 }, + ] }); + const solo = taskChecksCommand(m, t(m, 'T2'), where, H('b')); + assert.ok(solo.includes("--permit 'tools/d.py=PF-3'"), solo); + assert.ok(!solo.includes('tools/a.py') && !solo.includes('tools/c.py'), solo); + const unit = batchUnit([t(m, 'T2'), t(m, 'T3')]); + const cmd = taskChecksCommand(m, unit, where, H('b')); + for (const part of ["--permit 'tools/a.py=PF-1'", "--permit 'tools/b.py=PF-1'", "--permit 'tools/d.py=PF-3'"]) { + assert.ok(cmd.includes(part), part); + } + assert.ok(!cmd.includes('tools/c.py'), cmd); + const text = implementPrompt(m, unit, where, H('b')); + assert.ok(text.includes('PF-1 permits T3 to change tools/a.py, tools/b.py: changing them does not widen your checks.'), text); + assert.ok(text.includes('PF-3 permits T2 to change tools/d.py: changing them does not widen your checks.'), text); + assert.ok(!text.includes('permits T4'), text); +}); + test('prelude and join tasks get project-wide --wide-cmd lists', () => { const m = manifest({ lane_commands: { alpha: { test: ['npm test -- alpha'] } } }); for (const [id, ledgerLane] of [['T1', 'prelude'], ['T5', 'join']]) { diff --git a/tests/run.test.mjs b/tests/run.test.mjs index 5dde551..077c017 100644 --- a/tests/run.test.mjs +++ b/tests/run.test.mjs @@ -214,8 +214,10 @@ test('happy path: complete, phases in order, report filled in', async () => { assert.deepEqual(t.commits, [bases[id], H(`${id}-h`)]); } assert.deepEqual(result.stopped_lanes, []); - assert.deepEqual(result.preflight, - { conflicts: [], rulings: ['Ruling: x - y - z'], undeclared: [], schedule: [], adjudicated: null }); + assert.deepEqual(result.preflight, { + conflicts: [], rulings: ['Ruling: x - y - z'], undeclared: [], schedule: [], adjudicated: null, permits: [], + permits_dropped: [], + }); assert.equal(result.integrate.status, 'done'); assert.equal(result.integrate.post_integrate.status, 'done'); assert.deepEqual(result.e2e.items.map((i) => i.result), ['PASS']); @@ -1742,6 +1744,151 @@ test('the pre-flight prompt sends code dependencies to code_deps and its schema assert.deepEqual(s.properties.code_deps, s.properties.undeclared); }); +// ---- pre-flight permits (spec 4.6) ---- + +const { preflightPermits } = await loadHelpers(['preflightPermits']); +const permit = (taskId, files, ruling = 0) => ({ task: taskId, files, ruling }); +const ONE_RULING = ['Ruling: a - b - c']; + +test('preflightPermits keeps a permit of a runnable task for a file no task alongside declares', () => { + const m = manifest(); + const out = preflightPermits(m, [permit('T2', ['tools/x.py', 'tools/y.py'])], ONE_RULING, null); + assert.deepEqual(out, { kept: [permit('T2', ['tools/x.py', 'tools/y.py'])], dropped: [] }); +}); + +test('preflightPermits takes a missing or malformed permits list as none', () => { + for (const given of [undefined, null, 'x', {}]) { + assert.deepEqual(preflightPermits(manifest(), given, ONE_RULING, null), { kept: [], dropped: [] }, String(given)); + } + // An entry that is not an object, or whose files is not a list, is dropped whole. + const out = preflightPermits(manifest(), ['x', permit('T2', 'tools/x.py')], ONE_RULING, null); + assert.deepEqual(out, { kept: [], dropped: [ + { task: null, file: null, why: 'not a task of this launch' }, + { task: 'T2', file: 'tools/x.py', why: 'not a project-relative file' }, + ] }); +}); + +test('preflightPermits drops a permit of a task that is not in this launch', () => { + const m = manifest({ done: ['T1', 'T3'], reviewed: ['T1'], backfill: backfillFor(['T1', 'T3']) }); + const out = preflightPermits(m, [permit('T9', ['a.py']), permit('T1', ['b.py']), permit('T3', ['c.py'])], + ONE_RULING, null); + assert.deepEqual(out.kept, [permit('T3', ['c.py'])], 'a done task still to review keeps its permit'); + assert.deepEqual(out.dropped, [ + { task: 'T9', file: 'a.py', why: 'not a task of this launch' }, + { task: 'T1', file: 'b.py', why: 'not a task of this launch' }, + ]); +}); + +test('preflightPermits drops a file that is not project-relative', () => { + const bad = ['/abs/x.py', 'C:/x.py', '../x.py', 'a/../b.py', 'tools/', '', 'a\\b.py', 7]; + const out = preflightPermits(manifest(), [permit('T2', ['tools/ok.py', ...bad])], ONE_RULING, null); + assert.deepEqual(out.kept, [permit('T2', ['tools/ok.py'])]); + assert.deepEqual(out.dropped, bad.map((file) => ({ task: 'T2', file, why: 'not a project-relative file' }))); +}); + +test('preflightPermits drops a file a task of another lane declares, in any case or under a declared folder', () => { + const m = manifest({ + lanes: [ + { id: 'alpha', name: 'Lane alpha', tasks: [task('T2'), task('T3')] }, + { id: 'beta', name: 'Lane beta', tasks: [task('T4', { files: ['src/T4.js', 'tools/'] })] }, + ], + }); + const out = preflightPermits(m, [ + permit('T2', ['SRC/t4.js', 'tools/x.py', 'src/T3.js']), + permit('T1', ['src/T2.js']), + permit('T5', ['src/T4.js']), + ], ONE_RULING, null); + assert.deepEqual(out.kept, [ + permit('T2', ['src/T3.js']), + permit('T1', ['src/T2.js']), + permit('T5', ['src/T4.js']), + ], 'a task of the same lane, the prelude and a serial join run at other times'); + assert.deepEqual(out.dropped, [ + { task: 'T2', file: 'SRC/t4.js', why: 'declared by T4, which can run at the same time' }, + { task: 'T2', file: 'tools/x.py', why: 'declared by T4, which can run at the same time' }, + ]); +}); + +test('preflightPermits keeps the first of two tasks in different lanes given the same undeclared file', () => { + const out = preflightPermits(manifest(), [ + permit('T2', ['tools/x.py']), + permit('T4', ['tools/x.py', 'tools/y.py']), + permit('T3', ['tools/x.py']), + ], ONE_RULING, null); + assert.deepEqual(out.kept, [ + permit('T2', ['tools/x.py']), + permit('T4', ['tools/y.py']), + permit('T3', ['tools/x.py']), + ], 'T3 is in the lane of T2: they never run at the same time'); + assert.deepEqual(out.dropped, [{ task: 'T4', file: 'tools/x.py', why: 'also permitted to T2, which can run at the same time' }]); +}); + +test('preflightPermits checks a join task against the other units of its wave after a wave move', () => { + const m = manifest({ join: [task('J1'), task('J2'), task('J3')] }); + // J3 moved from wave 1 to wave 2, beside J2. + const waves = [['J1'], ['J2', 'J3']]; + const permits = [ + permit('J3', ['src/J2.js', 'src/J1.js', 'tools/x.py']), + permit('J2', ['tools/x.py']), + permit('J1', ['tools/x.py']), + ]; + const out = preflightPermits(m, permits, ONE_RULING, waves); + assert.deepEqual(out.kept, [permit('J3', ['src/J1.js', 'tools/x.py']), permit('J1', ['tools/x.py'])]); + assert.deepEqual(out.dropped, [ + { task: 'J3', file: 'src/J2.js', why: 'declared by J2, which can run at the same time' }, + { task: 'J2', file: 'tools/x.py', why: 'also permitted to J3, which can run at the same time' }, + ]); + // Without waves the join runs one task at a time. + assert.deepEqual(preflightPermits(m, permits, ONE_RULING, null).kept, permits); +}); + +test('preflightPermits drops a permit whose ruling is not an index into the rulings', () => { + const out = preflightPermits(manifest(), [ + permit('T2', ['a.py'], 1), permit('T2', ['b.py'], -1), permit('T2', ['c.py'], '0'), permit('T2', ['d.py'], 0.5), + permit('T2', ['e.py'], 0), + ], ONE_RULING, null); + assert.deepEqual(out.kept, [permit('T2', ['e.py'], 0)]); + assert.deepEqual(out.dropped, ['a.py', 'b.py', 'c.py', 'd.py'].map((file) => ({ task: 'T2', file, why: 'no such ruling' }))); +}); + +test('a kept permit reaches task-checks and the implementer', async () => { + const rulings = ['Ruling: x - y - z', 'Ruling: T2 may change tools/x.py - it owns the tool - none']; + const script = { + ...phaseScript({ 'pre-flight': [{ conflicts: [], rulings, undeclared: [], code_deps: [], permits: [ + permit('T2', ['tools/x.py'], 1), permit('T4', ['src/T2.js'], 1), + ] }] }), + ...taskScript(ALL), + }; + const { result, calls, logs } = await run(manifest(), script); + assert.equal(result.status, 'complete', JSON.stringify(result)); + assert.deepEqual(result.preflight.permits, [permit('T2', ['tools/x.py'], 1)]); + assert.deepEqual(result.preflight.permits_dropped, + [{ task: 'T4', file: 'src/T2.js', why: 'declared by T2, which can run at the same time' }]); + assert.ok(logs.includes('parallel-lanes: pre-flight: dropped the permit of T4 for src/T2.js' + + ' (declared by T2, which can run at the same time)'), JSON.stringify(logs)); + const line = 'PF-2 permits T2 to change tools/x.py: changing them does not widen your checks.'; + const t2 = calls.find((c) => c.label === 'T2 implement').prompt; + assert.ok(t2.includes("--permit 'tools/x.py=PF-2'"), t2); + assert.ok(t2.includes(line), t2); + for (const label of ['T3 implement', 'T4 implement']) { + const p = calls.find((c) => c.label === label).prompt; + assert.ok(!p.includes('--permit') && !p.includes('permits T'), label); + } +}); + +test('the pre-flight prompt asks for permits with a ruling that adds a file; the schema does not require them', () => { + const p = preflightPrompt(manifest()).replace(/\s+/g, ' '); + assert.ok(p.includes('When a ruling has a task change a file outside its Files list, also return it in permits as' + + ' {task, files, ruling}'), p); + const s = preflightSchema(); + assert.ok(!s.required.includes('permits')); + const item = s.properties.permits.items; + assert.deepEqual(item.required, ['task', 'files', 'ruling']); + assert.equal(item.properties.task.type, 'string'); + assert.deepEqual(item.properties.files, { type: 'array', items: { type: 'string' } }); + assert.equal(item.properties.ruling.type, 'integer'); +}); + test('autonomous integrate conflict: sonnet aborts, an opus resolver runs, the opus rerun reviews it', async () => { const script = { ...phaseScript({ From dcd86fb5944529d061363d5c4635b6b72fb50071 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:14:02 -0700 Subject: [PATCH 06/14] feat: archive-run counts launches and takes --ledger and --notes --- scripts/_history.py | 5 +- scripts/_launches.py | 68 ++++++++ scripts/archive-run | 258 +++++++++++++++++++++++----- tests/archive-launches.test.mjs | 287 ++++++++++++++++++++++++++++++++ tests/archive-run.test.mjs | 7 +- 5 files changed, 580 insertions(+), 45 deletions(-) create mode 100644 scripts/_launches.py create mode 100644 tests/archive-launches.test.mjs diff --git a/scripts/_history.py b/scripts/_history.py index cf69628..6afa564 100644 --- a/scripts/_history.py +++ b/scripts/_history.py @@ -59,13 +59,14 @@ # The keys of an index entry. folder is relative to the history dir; project # is repo.root, or the shadow's project root; platform is sys.platform; -# first_launch and last_launch are ISO 8601 UTC. +# first_launch and last_launch are ISO 8601 UTC; launches_recorded is the +# launch count the run's ledger recorded (_launches), or "unavailable". ENTRY_KEYS = ( "run_id", "folder", "ledger_dir", "project", "plan", "engine_version", "machine", "platform", "first_launch", "last_launch", - "launches", "status", "acceptance", + "launches", "launches_recorded", "status", "acceptance", ) # How often index_lock retries while another process holds the lock. diff --git a/scripts/_launches.py b/scripts/_launches.py new file mode 100644 index 0000000..5972e1a --- /dev/null +++ b/scripts/_launches.py @@ -0,0 +1,68 @@ +"""The launches a run's ledger recorded (archive-run and the audit, spec 4.2). + +Import with: + sys.dont_write_bytecode = True + sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) + import _launches + +recorded_launches(ledger_dir) gives {"count": int, "named": [wf ids]}: + named the sorted distinct wf ids the ledger names: the 'wf' of every + carried event in /_run.jsonl and the of every + doctor-.json file in ledger_dir (1.6.3 and later); + count the larger of the number of run_ended events in _run.jsonl (one + per launch the session recorded, relaunches included, never a dry + run) and len(named) (a launch whose session died has no + run_ended). +Setup run_started events are not counted: a resume may run setup twice in +one launch. A missing or unreadable file counts as none, and a line that is +not a JSON object is skipped. Nothing here writes. +""" +import glob +import json +import os + +_DOCTOR_PREFIX = "doctor-" +_DOCTOR_SUFFIX = ".json" + + +def _events(ledger_dir): + try: + with open(os.path.join(ledger_dir, "_run.jsonl"), "rb") as f: + lines = f.read().splitlines() + except OSError: + return [] + out = [] + for line in lines: + try: + event = json.loads(line.decode("utf-8")) + except (UnicodeDecodeError, ValueError): + continue + if isinstance(event, dict): + out.append(event) + return out + + +def _doctor_ids(ledger_dir): + out = [] + pattern = os.path.join(glob.escape(ledger_dir), _DOCTOR_PREFIX + "*" + _DOCTOR_SUFFIX) + for path in glob.glob(pattern): + name = os.path.basename(path) + wf = name[len(_DOCTOR_PREFIX):-len(_DOCTOR_SUFFIX)] + if wf and os.path.isfile(path): + out.append(wf) + return out + + +def recorded_launches(ledger_dir): + """{"count": int, "named": [wf ids sorted]} for the ledger in ledger_dir.""" + ended = 0 + named = set() + for event in _events(ledger_dir): + kind = event.get("event") + if kind == "run_ended": + ended += 1 + elif kind == "carried" and isinstance(event.get("wf"), str) and event["wf"]: + named.add(event["wf"]) + named.update(_doctor_ids(ledger_dir)) + named = sorted(named) + return {"count": max(ended, len(named)), "named": named} diff --git a/scripts/archive-run b/scripts/archive-run index 3c7422c..a6388f2 100755 --- a/scripts/archive-run +++ b/scripts/archive-run @@ -2,9 +2,14 @@ """Copy a run into the run history and record it in index.json. usage: - python3 archive-run MANIFEST TRANSCRIPT_DIR... [--history-dir DIR] + python3 archive-run MANIFEST [TRANSCRIPT_DIR...] [--ledger DIR] + [--notes FILE...] [--history-dir DIR] python3 archive-run --reindex [--history-dir DIR] +A call needs at least one TRANSCRIPT_DIR, or --notes for a run already in +index.json (a notes-only call). --notes takes every following argument up to +the next -- option. + The history dir is _history.history_dir(DIR): --history-dir, else PL_HISTORY_DIR, else /parallel-lanes/history. A history dir inside the project (repo.root: the repo in git mode, the @@ -12,15 +17,36 @@ project folder in shadow mode) is refused, and nothing is written. The run goes to //, which holds: the manifest; - ..ledger/ repo.ledger_dir, copied whole (checks/ too); + ..ledger/ the ledger, copied whole (checks/ too): + --ledger DIR when given, else + repo.ledger_dir; ..report.json from beside the manifest, when present; the plan and spec files by their own names, pinned (below); + derive.json and setup.json from the manifest's directory, when present + and that directory is the run-owned + / + parallel-lanes/runs/ (compared + after realpath and normcase); a plans + folder is shared between runs, so nothing + generic is taken from it; + notes/ each --notes FILE under its basename; wf_/ and wf_.json each TRANSCRIPT_DIR and its workflow result file (found as run-report finds it, _shell.result_paths), when present. Two different files that would get one name (the plan and spec with one -file name, or one named like the manifest or the report; compared without -case) are refused with exit 2 before anything is written. +file name, or one named like the manifest, the report, derive.json or +setup.json; two notes with one basename; compared without case) are refused +with exit 2 before anything is written. + +--ledger DIR reads the ledger from DIR instead of repo.ledger_dir, for a run +whose live ledger is gone: archive-run ... --ledger . DIR must hold _run.jsonl (else exit 2). +The run is still found in the index by its key, so it keeps its folder, and +a ledger copied onto itself is left as it is. Without --ledger a call with +transcript dirs needs repo.ledger_dir to be a directory. A notes-only call +copies the ledger only when its source exists. The ledger the call reads +(for pinning and for counting launches) is --ledger, else repo.ledger_dir +when it exists, else the folder's archived ledger. is the manifest file name minus .lanes.json (minus its extension when it does not end in .lanes.json). Every TRANSCRIPT_DIR must be a directory named wf_, as the Workflow tool names them. @@ -38,8 +64,8 @@ already in the archive is never deleted, even when its source is gone. The plan and spec (manifest.plan and manifest.spec; a spec of null is skipped) are pinned instead (_pin.pin, spec 4.1): an archived copy is only -ever the version the latest setup run_started event of the ledger -(repo.ledger_dir) recorded in plan_sha256 / spec_sha256. An archived copy +ever the version the latest setup run_started event of the ledger the call +reads (above) recorded in plan_sha256 / spec_sha256. An archived copy with that sha is kept, even when the live file differs. Otherwise the first source with that sha is written: the live file, then the file's blob at each recorded setup head from the latest back, as git stores it and with the @@ -54,6 +80,12 @@ live file is copied. The index entry (_history.ENTRY_KEYS) is rebuilt from the folder after copying: launches the number of wf_ dirs in the folder; + launches_recorded + the launches the ledger the call reads recorded + (_launches.recorded_launches: the larger of its run_ended + count and the number of distinct wf ids its carried events + and doctor-.json files name); "unavailable" without a + ledger; first_launch, last_launch the earliest and latest launch time (ISO 8601 UTC). A launch's time is the startTime of its wf_.json, else the @@ -81,18 +113,25 @@ with paths relative to the folder, '/'-separated. pinned names a plan or spec newly archived with its recorded sha, kept one whose archived copy already had it, replaced one whose archived copy lacked it and was replaced by one that has it; a file with no recorded sha, or none that matched, is in -none of the three. warnings holds plan_unpinned and spec_unpinned. +none of the three. warnings holds plan_unpinned and spec_unpinned, and +launches_missing when the folder holds fewer wf_ dirs than +launches_recorded (spec 4.2): its detail gives both counts and the wf ids +the ledger names that have no dir ("unknown" when none are named). Exit 0 on success; 2 on a usage error, an unreadable manifest, transcript -dir, ledger dir or index, a failed copy, or the lock not acquired; 3 when -the history dir is inside the project (nothing written). +dir, note, ledger dir (a --ledger DIR without _run.jsonl included) or index, +two notes with one basename, a notes-only call for a run not in the index, a +failed copy, or the lock not acquired; 3 when the history dir is inside the +project (nothing written). --reindex rebuilds index.json from the folders already in the history dir (runs archived by hand, imported, or by an older release), under _history.index_lock. Every folder that holds a *.lanes.json is one run, read as archive-run reads its own folders: the report is the first of *.report.json, *.run-report.json and report.json; launches and dates come -from the wf_ dirs and their wf_.json startTime values. A value that +from the wf_ dirs and their wf_.json startTime values; +launches_recorded from the folder's archived ledger, +..ledger, else its one *.ledger dir. A value that cannot be read is "unavailable". When no launch has a time, first_launch and last_launch are the old index entry's, else "unavailable" (the date is never made up). A folder keeps its name; an old index entry of the folder lends its @@ -123,12 +162,17 @@ import time sys.dont_write_bytecode = True sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import _history # noqa: E402 +import _launches # noqa: E402 import _pin # noqa: E402 import _shell # noqa: E402 UNAVAILABLE = "unavailable" -USAGE = ("usage: archive-run MANIFEST TRANSCRIPT_DIR... [--history-dir DIR]\n" +USAGE = ("usage: archive-run MANIFEST [TRANSCRIPT_DIR...] [--ledger DIR] [--notes FILE...]" + " [--history-dir DIR]\n" " archive-run --reindex [--history-dir DIR]") +# The files a run-owned manifest directory also holds, archived by name. +_RUN_OWNED = ("derive.json", "setup.json") +NOTES = "notes" # The report names a run folder may hold, in the order reindex tries them. _REPORT_PATTERNS = ("*.report.json", "*.run-report.json", "report.json") # A run_id names the run folder, so it must be one plain file name. @@ -377,10 +421,46 @@ def stored(p): return _history.norm_path(p) if isinstance(p, str) and p else UNAVAILABLE -def build_entry(old, folder, name, manifest, report_name, reindex=False): +def archived_ledger(folder, manifest_name, run_id): + """The archived ledger dir in folder: ..ledger, else + the folder's one *.ledger dir; None.""" + path = os.path.join(folder, plan_name(manifest_name) + "." + run_id + ".ledger") + if os.path.isdir(path): + return path + found = [p for p in glob.glob(os.path.join(glob.escape(folder), "*.ledger")) if os.path.isdir(p)] + return found[0] if len(found) == 1 else None + + +def recorded_in(ledger_dir): + """_launches.recorded_launches of ledger_dir; None without a ledger.""" + if not ledger_dir or not os.path.isdir(ledger_dir): + return None + return _launches.recorded_launches(ledger_dir) + + +def launches_warning(recorded, folder): + """The launches_missing warning when folder holds fewer wf_ dirs than + the ledger recorded; None.""" + if recorded is None: + return None + archived = [n for _t, n in launches_in(folder)] + if len(archived) >= recorded["count"]: + return None + missing = [wf for wf in recorded["named"] if wf not in set(archived)] + unknown = recorded["count"] - len(archived) - len(missing) + parts = list(missing) + if unknown > 0 and missing: + parts.append(str(unknown) + " unknown") + detail = (str(len(archived)) + " archived, " + str(recorded["count"]) + " recorded in the ledger; missing: " + + (", ".join(parts) if parts else "unknown")) + return {"code": "launches_missing", "detail": detail} + + +def build_entry(old, folder, name, manifest, report_name, reindex=False, recorded=None): """The index entry of the run in folder. archive-run dates a run without any launch time now; reindex keeps the old entry's first_launch and - last_launch then, and says "unavailable" when it has none.""" + last_launch then, and says "unavailable" when it has none. recorded is + what recorded_in gave for the run's ledger (None: "unavailable").""" run_id, ledger_dir = _history.run_key(manifest) launches = launches_in(folder) times = [t for t, _ in launches if t is not None] @@ -418,6 +498,7 @@ def build_entry(old, folder, name, manifest, report_name, reindex=False): "first_launch": first, "last_launch": last, "launches": len(launches), + "launches_recorded": recorded["count"] if recorded is not None else UNAVAILABLE, "status": status, "acceptance": acceptance, }) @@ -425,31 +506,46 @@ def build_entry(old, folder, name, manifest, report_name, reindex=False): def parse_args(args): - """(positional args, --history-dir value or None, --reindex given).""" - pos, hdir, reindex = [], None, False + """{"pos": positional args, "history_dir": --history-dir or None, + "reindex": --reindex given, "ledger": --ledger or None, "notes": the + --notes files ([] without --notes)}. --notes takes every following + argument up to the next -- option.""" + opts = {"pos": [], "history_dir": None, "reindex": False, "ledger": None, "notes": []} + notes_given = False i = 0 while i < len(args): a = args[i] if a == "--reindex": - reindex = True + opts["reindex"] = True i += 1 continue - if a == "--history-dir": - if i + 1 >= len(args) or not args[i + 1]: - raise ArchiveError("--history-dir needs a directory") - hdir = args[i + 1] + if a in ("--history-dir", "--ledger"): + if i + 1 >= len(args) or not args[i + 1] or args[i + 1].startswith("--"): + raise ArchiveError(a + " needs a directory") + opts["history_dir" if a == "--history-dir" else "ledger"] = args[i + 1] i += 2 continue + if a == "--notes": + notes_given = True + i += 1 + start = i + while i < len(args) and not args[i].startswith("--"): + i += 1 + if i == start: + raise ArchiveError("--notes needs at least one file") + opts["notes"].extend(args[start:i]) + continue if a.startswith("--"): raise ArchiveError("unknown option " + a) - pos.append(a) + opts["pos"].append(a) i += 1 - if reindex: - if pos: - raise ArchiveError("--reindex takes no manifest or transcript dir") - elif len(pos) < 2: - raise ArchiveError("a manifest and at least one transcript dir are needed") - return pos, hdir, reindex + if opts["reindex"]: + if opts["pos"] or opts["ledger"] is not None or notes_given: + raise ArchiveError("--reindex takes no manifest, transcript dir, --ledger or --notes") + elif not opts["pos"] or (len(opts["pos"]) < 2 and not notes_given): + raise ArchiveError("a manifest and at least one transcript dir are needed," + " or --notes for a run already archived") + return opts def load_manifest(path): @@ -486,15 +582,62 @@ def transcript_dirs(paths): return dirs -def archive(manifest_path, manifest, dirs, hdir): - """Copy the run into hdir and update index.json; the output object.""" +def note_files(paths): + """[(abs path, basename)] for the --notes files; the same file given + twice is one note, two files with one basename (compared without case) + are refused.""" + out = [] + for p in paths: + f = os.path.abspath(p) + if not os.path.isfile(f): + raise ArchiveError("not a file: " + p + " (given with --notes)") + name = os.path.basename(f) + other = next((o for o, n in out if n.lower() == name.lower()), None) + if other is None: + out.append((f, name)) + elif not same_source(other, f): + raise ArchiveError("two notes would be archived as " + NOTES + "/" + name + ": " + other + + " and " + f + "; give them names that differ") + return out + + +def ledger_arg(path): + """The --ledger dir as an absolute path; it must hold _run.jsonl.""" + d = os.path.abspath(path) + if not os.path.isfile(os.path.join(d, "_run.jsonl")): + raise ArchiveError("--ledger " + path + " is not a ledger (it has no _run.jsonl)") + return d + + +def run_owned_dir(run_id): + """/parallel-lanes/runs/.""" + config = _shell._getenv(os.environ, "CLAUDE_CONFIG_DIR") or os.path.join(_history._home(os.environ), ".claude") + return os.path.join(_shell.native_path(config), "parallel-lanes", "runs", run_id) + + +def same_dir(a, b): + """Whether two paths name one directory (after realpath and normcase).""" + return os.path.normcase(os.path.realpath(a)) == os.path.normcase(os.path.realpath(b)) + + +def archive(manifest_path, manifest, dirs, hdir, ledger=None, notes=()): + """Copy the run into hdir and update index.json; the output object. + ledger is the --ledger dir or None; notes what note_files gave. With no + dirs the run must already be in the index.""" run_id, ledger_key = _history.run_key(manifest) repo = manifest["repo"] - ledger_src = _shell.native_path(repo["ledger_dir"]) - if not os.path.isdir(ledger_src): - raise ArchiveError("the ledger dir is not a directory: " + ledger_src) + if ledger is not None: + ledger_src = ledger + else: + ledger_src = _shell.native_path(repo["ledger_dir"]) + if not os.path.isdir(ledger_src): + if dirs: + raise ArchiveError("the ledger dir is not a directory: " + ledger_src + + " (pass --ledger DIR for a run whose live ledger is gone)") + # A notes-only call copies the ledger only when its source exists. + ledger_src = None # A copy into a folder under its own source would copy itself again. - for src in [ledger_src] + dirs: + for src in ([ledger_src] if ledger_src else []) + dirs: if _history.inside(hdir, src): raise ArchiveError("the history dir " + hdir + " is inside " + src + ", which it would copy") pname = plan_name(manifest_path) @@ -504,6 +647,14 @@ def archive(manifest_path, manifest, dirs, hdir): report = os.path.join(os.path.dirname(manifest_path), report_name) if os.path.isfile(report): sources.append((report, report_name)) + # A plans folder is shared between runs: only a run-owned one lends its + # derive.json and setup.json. + manifest_dir = os.path.dirname(manifest_path) + if same_dir(manifest_dir, run_owned_dir(run_id)): + for fname in _RUN_OWNED: + path = os.path.join(manifest_dir, fname) + if os.path.isfile(path): + sources.append((path, fname)) pins = [] # (kind, live path, name in the folder): pinned, not copied for key in _pin.KINDS: p = manifest.get(key) @@ -525,8 +676,6 @@ def archive(manifest_path, manifest, dirs, hdir): pins = [(kind, p, rel) for i, (kind, p, rel) in enumerate(pins) if all(r.lower() != rel.lower() for _s, r in sources) and all(r.lower() != rel.lower() for _k, _p, r in pins[:i])] - # The recorded versions come from the ledger being archived. - recorded = _pin.recorded_inputs(ledger_src) git_cmd = git_command(repo) root = _shell.native_path(repo["root"]) results = [result_file(d) for d in dirs] @@ -538,15 +687,28 @@ def archive(manifest_path, manifest, dirs, hdir): name = old.get("folder") if not isinstance(name, str) or not _SAFE_NAME.match(name): raise ArchiveError("the index entry of " + run_id + " has a bad folder: " + json.dumps(name)) + elif not dirs: + raise ArchiveError("the run " + run_id + " is not in the index of " + hdir + + "; a call without transcript dirs only adds notes to a run already archived") else: times = [launch_time(d, r) for d, r in zip(dirs, results)] times = [t for t in times if t is not None] name = choose_folder(hdir, index, run_id, ledger_key, min(times) if times else time.time()) folder = os.path.join(hdir, name) + ledger_name = pname + "." + run_id + ".ledger" + # The ledger this call reads: the one being archived, else (a + # notes-only call whose source is gone) the folder's archived one. + ledger_read = ledger_src if ledger_src is not None \ + else archived_ledger(folder, os.path.basename(manifest_path), run_id) + # The recorded versions come from the ledger being archived. + recorded = _pin.recorded_inputs(ledger_read) if ledger_read else [] added, updated = [], [] os.makedirs(folder, exist_ok=True) for src, rel in sources: copy_file(src, os.path.join(folder, rel), rel, added, updated) + for src, note in notes: + rel = NOTES + "/" + note + copy_file(src, os.path.join(folder, NOTES, note), rel, added, updated) states = {"pinned": [], "kept": [], "replaced": []} warnings = [] for kind, live, rel in pins: @@ -558,14 +720,20 @@ def archive(manifest_path, manifest, dirs, hdir): states[decision["state"]].append(rel) if decision["warning"]: warnings.append(decision["warning"]) - ledger_name = pname + "." + run_id + ".ledger" - copy_tree(ledger_src, os.path.join(folder, ledger_name), ledger_name, added, updated) + ledger_dst = os.path.join(folder, ledger_name) + # A ledger copied onto itself (--ledger at the archived one) is a no-op. + if ledger_src is not None and not (os.path.isdir(ledger_dst) and same_dir(ledger_src, ledger_dst)): + copy_tree(ledger_src, ledger_dst, ledger_name, added, updated) for d, r in zip(dirs, results): wf = os.path.basename(d) copy_tree(d, os.path.join(folder, wf), wf, added, updated) if r: copy_file(r, os.path.join(folder, wf + ".json"), wf + ".json", added, updated) - entry = build_entry(old or {}, folder, name, manifest, report_name) + launches = recorded_in(ledger_read) + warning = launches_warning(launches, folder) + if warning: + warnings.append(warning) + entry = build_entry(old or {}, folder, name, manifest, report_name, recorded=launches) runs = [e for e in index.get("runs", []) if e is not old] runs.append(entry) index = dict(index) @@ -609,7 +777,10 @@ def reindex_folder(hdir, name, old_index): if entry.get("folder") == name and _history.find_run({"runs": [entry]}, run_id, ledger_dir) is not None: old = entry break - entry = build_entry(old, folder, name, manifest, first_match(folder, _REPORT_PATTERNS), reindex=True) + recorded = recorded_in(archived_ledger(folder, manifests[0], run_id)) \ + if _SAFE_NAME.match(run_id) else None + entry = build_entry(old, folder, name, manifest, first_match(folder, _REPORT_PATTERNS), reindex=True, + recorded=recorded) # The machine running reindex is not the one that ran the run. for key in ("machine", "platform"): entry[key] = old[key] if isinstance(old.get(key), str) and old[key] else UNAVAILABLE @@ -650,12 +821,13 @@ def reindex(hdir): def main(argv): _shell.setup_io() try: - pos, override, do_reindex = parse_args(argv[1:]) + opts = parse_args(argv[1:]) except ArchiveError as e: print("archive-run: " + str(e), file=sys.stderr) print(USAGE, file=sys.stderr) return 2 - if do_reindex: + pos, override = opts["pos"], opts["history_dir"] + if opts["reindex"]: try: out = reindex(_history.history_dir(override)) except (ArchiveError, _history.HistoryError, _shell.BashNotFound) as e: @@ -669,6 +841,8 @@ def main(argv): try: manifest_path = os.path.abspath(_shell.native_path(pos[0])) dirs = transcript_dirs([_shell.native_path(p) for p in pos[1:]]) + notes = note_files([_shell.native_path(p) for p in opts["notes"]]) + ledger = ledger_arg(_shell.native_path(opts["ledger"])) if opts["ledger"] is not None else None manifest = load_manifest(manifest_path) hdir = _history.history_dir(override) root = _shell.native_path(manifest["repo"]["root"]) @@ -680,7 +854,7 @@ def main(argv): + root + " (set PL_HISTORY_DIR or pass --history-dir)", file=sys.stderr) return 3 try: - out = archive(manifest_path, manifest, dirs, hdir) + out = archive(manifest_path, manifest, dirs, hdir, ledger=ledger, notes=notes) except (ArchiveError, _history.HistoryError, _shell.BashNotFound) as e: print("archive-run: " + str(e), file=sys.stderr) return 2 diff --git a/tests/archive-launches.test.mjs b/tests/archive-launches.test.mjs new file mode 100644 index 0000000..5965a12 --- /dev/null +++ b/tests/archive-launches.test.mjs @@ -0,0 +1,287 @@ +// scripts/archive-run counts the launches the ledger recorded and warns when +// fewer are archived (spec 4.2), takes --ledger and --notes, and archives +// derive.json and setup.json from a run-owned folder (spec 4.3). Each test +// builds its own temp run and history dir and passes --history-dir. + +import { test, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { SKILL_DIR } from './harness.mjs'; +import { mergeEnv, tempDir } from './platform.mjs'; + +const SCRIPT = join(SKILL_DIR, 'scripts', 'archive-run'); +const TMP = tempDir('pl-archive-launches-'); +after(() => rmSync(TMP, { recursive: true, force: true })); + +const DAY1 = Date.UTC(2026, 9, 10, 7, 0, 0); +const DAY2 = DAY1 + 24 * 3600 * 1000; + +// The environment without the history variables a developer may have set, +// plus extra. +function cleanEnv(extra = {}) { + const env = mergeEnv(process.env); + for (const key of Object.keys(env)) { + if (['PL_HISTORY_DIR', 'CLAUDE_CONFIG_DIR'].includes(key.toUpperCase())) delete env[key]; + } + return { ...env, ...extra }; +} + +let counter = 0; +function freshDir(name) { + counter += 1; + const dir = join(TMP, `${name}-${counter}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function writeJson(path, value) { + writeFileSync(path, JSON.stringify(value, null, 2) + '\n'); +} + +function jsonl(events) { + return events.map((e) => JSON.stringify(e)).join('\n') + '\n'; +} + +// A launch dir // with one transcript and its result file. +function addLaunch(root, session, id, startMs) { + const dir = join(root, session, id); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, 'agent-a.jsonl'), + JSON.stringify({ type: 'assistant', timestamp: new Date(startMs).toISOString() }) + '\n'); + writeJson(`${dir}.json`, { runId: id, startTime: startMs, result: { status: 'complete' } }); + return dir; +} + +// A run: /p.lanes.json with its ledger, plan and spec beside it, the +// project at /proj and one launch /s/wf_a. events is the ledger's +// _run.jsonl. +function makeRun({ events = [{ task: '_run', event: 'run_ended', status: 'complete', agents: 1, rulings: 0 }], + runDir = null, runId = 'x1' } = {}) { + const root = freshDir('case'); + const run = runDir || join(root, 'run'); + const proj = join(root, 'proj'); + const ledger = join(run, `p.${runId}.ledger`); + mkdirSync(ledger, { recursive: true }); + mkdirSync(proj, { recursive: true }); + const manifest = join(run, 'p.lanes.json'); + const plan = join(run, 'p.md'); + writeJson(manifest, { version: 1, run_id: runId, plan, spec: null, repo: { mode: 'git', root: proj, ledger_dir: ledger } }); + writeFileSync(join(ledger, '_run.jsonl'), jsonl(events)); + writeFileSync(plan, '# Plan\n'); + const wfA = addLaunch(root, 's', 'wf_a', DAY1); + return { root, run, proj, ledger, manifest, plan, wfA, hist: join(root, 'hist'), runId }; +} + +function archive(args, hist, env = {}) { + const res = spawnSync('python3', ['-B', SCRIPT, ...args, '--history-dir', hist], { encoding: 'utf8', env: cleanEnv(env) }); + return { code: res.status, out: res.status === 0 ? JSON.parse(res.stdout) : null, stderr: res.stderr, stdout: res.stdout }; +} + +function archiveOk(args, hist, env = {}) { + const res = archive(args, hist, env); + assert.equal(res.code, 0, res.stderr); + return res.out; +} + +function readIndex(hist) { + return JSON.parse(readFileSync(join(hist, 'index.json'), 'utf8')); +} + +const ENDED = { task: '_run', event: 'run_ended', status: 'stopped', agents: 3, rulings: 0 }; + +test('two run_ended and one wf dir warn launches_missing', () => { + const r = makeRun({ events: [ENDED, ENDED] }); + const out = archiveOk([r.manifest, r.wfA], r.hist); + const warn = out.warnings.filter((w) => w.code === 'launches_missing'); + assert.equal(warn.length, 1, JSON.stringify(out.warnings)); + assert.match(warn[0].detail, /1 archived/); + assert.match(warn[0].detail, /2 recorded/); + const entry = readIndex(r.hist).runs[0]; + assert.equal(entry.launches, 1); + assert.equal(entry.launches_recorded, 2); +}); + +test('no warning when every recorded launch is archived', () => { + const r = makeRun({ events: [ENDED] }); + const out = archiveOk([r.manifest, r.wfA], r.hist); + assert.deepEqual(out.warnings.filter((w) => w.code === 'launches_missing'), []); + assert.equal(readIndex(r.hist).runs[0].launches_recorded, 1); +}); + +test('named wf ids appear in the warning and raise the count', () => { + const r = makeRun({ + events: [ + ENDED, + { task: '_run', event: 'carried', wf: 'wf_a', pf: {}, bound: [] }, + { task: '_run', event: 'carried', wf: 'wf_b', pf: {}, bound: [] }, + ], + }); + const out = archiveOk([r.manifest, r.wfA], r.hist); + const warn = out.warnings.find((w) => w.code === 'launches_missing'); + assert.ok(warn, JSON.stringify(out.warnings)); + assert.match(warn.detail, /2 recorded/); + assert.match(warn.detail, /wf_b/); + assert.doesNotMatch(warn.detail, /wf_a/); + assert.equal(readIndex(r.hist).runs[0].launches_recorded, 2); +}); + +test('a doctor file names a launch', () => { + const r = makeRun({ events: [ENDED] }); + writeJson(join(r.ledger, 'doctor-wf_a.json'), { launch: { wf: 'wf_a' } }); + writeJson(join(r.ledger, 'doctor-wf_c.json'), { launch: { wf: 'wf_c' } }); + const out = archiveOk([r.manifest, r.wfA], r.hist); + const warn = out.warnings.find((w) => w.code === 'launches_missing'); + assert.ok(warn, JSON.stringify(out.warnings)); + assert.match(warn.detail, /wf_c/); + assert.doesNotMatch(warn.detail, /wf_a/); + assert.equal(readIndex(r.hist).runs[0].launches_recorded, 2); +}); + +test('reindex writes launches_recorded', () => { + const r = makeRun({ events: [ENDED, ENDED, ENDED] }); + archiveOk([r.manifest, r.wfA], r.hist); + // An index entry without the key, as an older release wrote it. + const index = readIndex(r.hist); + delete index.runs[0].launches_recorded; + writeJson(join(r.hist, 'index.json'), index); + const res = spawnSync('python3', ['-B', SCRIPT, '--reindex', '--history-dir', r.hist], { encoding: 'utf8', env: cleanEnv() }); + assert.equal(res.status, 0, res.stderr); + assert.equal(readIndex(r.hist).runs[0].launches_recorded, 3); + // A folder with no ledger says unavailable. + rmSync(join(r.hist, '2026-10-10-x1', 'p.x1.ledger'), { recursive: true, force: true }); + const again = spawnSync('python3', ['-B', SCRIPT, '--reindex', '--history-dir', r.hist], { encoding: 'utf8', env: cleanEnv() }); + assert.equal(again.status, 0, again.stderr); + assert.equal(readIndex(r.hist).runs[0].launches_recorded, 'unavailable'); +}); + +test('--ledger archives a run whose live ledger is gone into its existing folder', () => { + const r = makeRun({ events: [ENDED, ENDED] }); + const first = archiveOk([r.manifest, r.wfA], r.hist); + rmSync(r.ledger, { recursive: true, force: true }); + const folder = join(r.hist, '2026-10-10-x1'); + const archivedLedger = join(folder, 'p.x1.ledger'); + const archivedManifest = join(folder, 'p.lanes.json'); + // Without --ledger the live ledger is needed. + const wfB = addLaunch(r.root, 's2', 'wf_b', DAY2); + assert.equal(archive([archivedManifest, wfB], r.hist).code, 2); + const out = archiveOk([archivedManifest, wfB, '--ledger', archivedLedger], r.hist); + assert.equal(out.folder, first.folder); + assert.ok(out.added.includes('wf_b/agent-a.jsonl'), JSON.stringify(out.added)); + assert.ok(out.added.includes('wf_b.json')); + // Copying the ledger onto itself changes nothing. + assert.ok(!out.added.some((rel) => rel.startsWith('p.x1.ledger/'))); + assert.ok(!out.updated.some((rel) => rel.startsWith('p.x1.ledger/'))); + assert.deepEqual(out.warnings.filter((w) => w.code === 'launches_missing'), []); + const index = readIndex(r.hist); + assert.equal(index.runs.length, 1); + assert.equal(index.runs[0].launches, 2); + assert.equal(index.runs[0].launches_recorded, 2); +}); + +test('--ledger at a folder without _run.jsonl exits 2', () => { + const r = makeRun(); + const notLedger = freshDir('not-ledger'); + const res = archive([r.manifest, r.wfA, '--ledger', notLedger], r.hist); + assert.equal(res.code, 2, res.stderr); + assert.match(res.stderr, /_run\.jsonl/); + assert.ok(!existsSync(r.hist), 'nothing is written'); + assert.equal(archive([r.manifest, r.wfA, '--ledger'], r.hist).code, 2); +}); + +test('--notes copies into notes/', () => { + const r = makeRun(); + const dir = join(r.root, 'my notes'); + mkdirSync(dir); + const note = join(dir, 'review one.md'); + writeFileSync(note, 'first\n'); + const out = archiveOk([r.manifest, r.wfA, '--notes', note], r.hist); + assert.ok(out.added.includes('notes/review one.md'), JSON.stringify(out.added)); + const archived = join(r.hist, '2026-10-10-x1', 'notes', 'review one.md'); + assert.equal(readFileSync(archived, 'utf8'), 'first\n'); + writeFileSync(note, 'second\n'); + const again = archiveOk([r.manifest, r.wfA, '--notes', note], r.hist); + assert.deepEqual(again.updated, ['notes/review one.md']); + assert.equal(readFileSync(archived, 'utf8'), 'second\n'); +}); + +test('--notes takes the arguments up to the next option', () => { + const r = makeRun(); + const a = join(r.root, 'a.md'); + const b = join(r.root, 'b.txt'); + writeFileSync(a, 'a\n'); + writeFileSync(b, 'b\n'); + const out = archiveOk([r.manifest, r.wfA, '--notes', a, b], r.hist); + assert.ok(out.added.includes('notes/a.md')); + assert.ok(out.added.includes('notes/b.txt')); + assert.equal(archive([r.manifest, r.wfA, '--notes'], r.hist).code, 2); + assert.equal(archive([r.manifest, r.wfA, '--notes', join(r.root, 'missing.md')], r.hist).code, 2); +}); + +test('two notes with one basename exit 2 and write nothing', () => { + const r = makeRun(); + for (const sub of ['one', 'two']) { + mkdirSync(join(r.root, sub)); + writeFileSync(join(r.root, sub, 'n.md'), sub + '\n'); + } + const res = archive([r.manifest, r.wfA, '--notes', join(r.root, 'one', 'n.md'), join(r.root, 'two', 'n.md')], r.hist); + assert.equal(res.code, 2, res.stderr); + assert.match(res.stderr, /n\.md/); + assert.ok(!existsSync(r.hist), 'nothing is written'); +}); + +test('notes alone for an indexed run', () => { + const r = makeRun(); + const note = join(r.root, 'x.md'); + writeFileSync(note, 'note\n'); + // Not in the index yet: exit 2, nothing written. + const refused = archive([r.manifest, '--notes', note], r.hist); + assert.equal(refused.code, 2, refused.stderr); + assert.ok(!existsSync(join(r.hist, '2026-10-10-x1'))); + // A manifest alone, without --notes, is a usage error. + assert.equal(archive([r.manifest], r.hist).code, 2); + archiveOk([r.manifest, r.wfA], r.hist); + const out = archiveOk([r.manifest, '--notes', note], r.hist); + assert.deepEqual(out.added, ['notes/x.md']); + assert.ok(!out.added.some((rel) => rel.startsWith('wf_'))); + const entry = readIndex(r.hist).runs[0]; + assert.equal(entry.launches, 1); + assert.equal(entry.launches_recorded, 1); +}); + +test('notes alone with the live ledger gone uses the archived ledger', () => { + const r = makeRun({ events: [ENDED, ENDED] }); + archiveOk([r.manifest, r.wfA], r.hist); + rmSync(r.ledger, { recursive: true, force: true }); + const note = join(r.root, 'x.md'); + writeFileSync(note, 'note\n'); + const out = archiveOk([r.manifest, '--notes', note], r.hist); + assert.deepEqual(out.added, ['notes/x.md']); + const warn = out.warnings.find((w) => w.code === 'launches_missing'); + assert.ok(warn, JSON.stringify(out.warnings)); + assert.equal(readIndex(r.hist).runs[0].launches_recorded, 2); + assert.ok(existsSync(join(r.hist, '2026-10-10-x1', 'p.x1.ledger', '_run.jsonl')), 'the archived ledger is kept'); +}); + +test('derive.json and setup.json come from a run-owned folder only', () => { + const config = join(freshDir('config'), 'claude config'); + const owned = join(config, 'parallel-lanes', 'runs', 'x1'); + mkdirSync(owned, { recursive: true }); + const r = makeRun({ runDir: owned }); + writeJson(join(owned, 'derive.json'), { lanes: [] }); + writeJson(join(owned, 'setup.json'), { ok: true }); + const out = archiveOk([r.manifest, r.wfA], r.hist, { CLAUDE_CONFIG_DIR: config }); + assert.ok(out.added.includes('derive.json'), JSON.stringify(out.added)); + assert.ok(out.added.includes('setup.json')); + const folder = join(r.hist, '2026-10-10-x1'); + assert.ok(readFileSync(join(folder, 'derive.json')).equals(readFileSync(join(owned, 'derive.json')))); + // The same files beside a manifest in another folder are not archived. + const other = makeRun(); + writeJson(join(other.run, 'derive.json'), { lanes: [] }); + writeJson(join(other.run, 'setup.json'), { ok: true }); + const out2 = archiveOk([other.manifest, other.wfA], other.hist, { CLAUDE_CONFIG_DIR: config }); + assert.ok(!out2.added.includes('derive.json'), JSON.stringify(out2.added)); + assert.ok(!out2.added.includes('setup.json')); + assert.ok(!readdirSync(join(other.hist, '2026-10-10-x1')).includes('derive.json')); +}); diff --git a/tests/archive-run.test.mjs b/tests/archive-run.test.mjs index 4b05890..822c7d4 100644 --- a/tests/archive-run.test.mjs +++ b/tests/archive-run.test.mjs @@ -262,8 +262,13 @@ test('engine version unavailable without a report', () => { test('usage errors and unreadable inputs exit 2', () => { const r = makeRun(); - assert.equal(archive(r.manifest, [], r.hist).code, 2); + // A manifest alone needs --notes (for a run already archived). + const alone = archive(r.manifest, [], r.hist); + assert.equal(alone.code, 2); + assert.match(alone.stderr, /at least one transcript dir are needed, or --notes for a run already archived/); + assert.match(alone.stderr, /--ledger DIR/); assert.equal(archive(join(r.run, 'missing.lanes.json'), [r.wf1], r.hist).code, 2); + assert.equal(archive(r.manifest, [r.wf1, '--bogus'], r.hist).code, 2); assert.equal(archive(r.manifest, [join(r.root, 's', 'wf_9')], r.hist).code, 2); // An unreadable index is reported, not replaced. mkdirSync(r.hist, { recursive: true }); From dd8b9f00be1d855d26c4c1db5c00cb3cdc18648f Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:17:05 -0700 Subject: [PATCH 07/14] feat: cleanup removes the run's start worktree --- scripts/cleanup | 118 +++++++++++++++++++++++++++++++++++------ tests/cleanup.test.mjs | 93 +++++++++++++++++++++++++++++++- 2 files changed, 195 insertions(+), 16 deletions(-) diff --git a/scripts/cleanup b/scripts/cleanup index 33a4667..81ea0a4 100755 --- a/scripts/cleanup +++ b/scripts/cleanup @@ -33,12 +33,19 @@ Preconditions (otherwise it refuses, exit 3, and changes nothing): Run-owned items: - /lane- for each manifest lane, on branch pl--; - in shadow mode, /feature on repo.branch; -- the branches pl-- and repo.branch. +- the branches pl-- and repo.branch; +- /start, the start check's worktree (it holds no commits), + when git worktree list --porcelain shows it detached, its common dir is + the repo's, and its HEAD is the run's start sha: manifest + start_points.prelude when it has one, else the head of the first setup + run_started event in /_run.jsonl. Its plan item has + branch null. A worktree path counts only when git worktree list --porcelain shows it with that branch and its git rev-parse --git-common-dir resolves to the repo's. -Anything else directly under worktree_root is listed in foreign with reason -"not owned by this run" and left in place. refs/parallel-lanes// refs -are always kept. +Anything else directly under worktree_root (the start path on another sha or +on a branch, or with no start sha known, included) is listed in foreign with +reason "not owned by this run" and left in place. refs/parallel-lanes// +refs are always kept. The restore point is a new folder /cleanup//, made with os.mkdir; when it exists, -2, -3, ... are tried, so an existing @@ -48,7 +55,8 @@ replaces the UTC timestamp (for tests). It holds, written in this order: under refs/parallel-lanes// that exists; - run.bundle: git bundle create of those refs, checked with git bundle verify; - restore.md: for each ref, git fetch : (the bundle's - absolute path), and a git worktree add line for each worktree to be removed. + absolute path), and a git worktree add line for each worktree to be removed + (git worktree add --detach for the start worktree). It is written last, so a folder without it is not a usable restore point. In shadow mode every repo command uses --git-dir=; in git mode -C . @@ -68,7 +76,12 @@ cannot be removed safely is kept and named with the reason: worktree list: it is still a run-owned worktree on its branch, it is not locked, its HEAD is still the planned head, and it is still clean. Otherwise it is kept with "changed after the plan (); run - cleanup again", and so is its planned branch; + cleanup again", and so is its planned branch. + The start worktree is the exception: it is removed even with uncommitted + or untracked files, with git worktree remove --force (it holds no + commits, and a failed start check can leave its files behind). It is + kept when locked, and right before its removal it is checked again: + still detached, of this repo, unlocked and at the start sha; 2. lane branches the plan marks remove, with git branch -d (git mode only), each only while its tip is still the planned sha; otherwise it is kept with "moved after the plan; run cleanup again", or named with "deleted @@ -93,7 +106,8 @@ cannot be removed safely is kept and named with the reason: Shadow mode removes the lane worktrees and /feature, and keeps every branch and the shadow repo. Foreign items under worktree_root and refs/parallel-lanes// refs are always kept. It never uses ---force or branch -D, never deletes a remote branch, never makes a merge +--force (except git worktree remove --force for the start worktree) or +branch -D, never deletes a remote branch, never makes a merge commit, resets or rebases, and never touches the manifest, the ledger or the history folder (other than writing the restore point). @@ -104,7 +118,8 @@ Output: the plan, as text or (--json) one JSON object: "feature_steps": [...], "refs": [{"ref", "sha", "action", "reason"}], "foreign": [{"path", "reason"}], "restore_point": } A worktree's head is its git rev-parse HEAD when the plan was made (null -when it cannot be read; the worktree is then kept). +when it cannot be read; the worktree is then kept); its branch is null for +the start worktree. With --yes the object also has "removed" and "kept": lists of {"kind", ..., "reason"}, where kind is worktree (with path, branch), branch (branch, sha), ref (ref, sha), path (a foreign item under @@ -212,6 +227,7 @@ class Repo: try: self.root = _shell.native_path(repo["root"]) self.worktree_root = _shell.native_path(repo["worktree_root"]).rstrip("/\\") + self.ledger_dir = _shell.native_path(repo["ledger_dir"]) self.git_dir = _shell.native_path(repo["git_dir"]) if self.mode == "shadow" else None except _shell.BashNotFound as e: raise Failure(2, str(e)) @@ -235,16 +251,19 @@ class Repo: return res.stdout.strip() if res.returncode == 0 else None def worktrees(self): - """git worktree list --porcelain as [{"path", "branch", "locked"}].""" + """git worktree list --porcelain as [{"path", "branch", "detached", + "locked"}].""" found = [] for block in self.out("worktree", "list", "--porcelain").split("\n\n"): lines = block.splitlines() if not lines or not lines[0].startswith("worktree "): continue - entry = {"path": lines[0][len("worktree "):], "branch": None, "locked": False} + entry = {"path": lines[0][len("worktree "):], "branch": None, "detached": False, "locked": False} for line in lines[1:]: if line.startswith("branch refs/heads/"): entry["branch"] = line[len("branch refs/heads/"):] + elif line == "detached": + entry["detached"] = True elif line == "locked" or line.startswith("locked "): entry["locked"] = True found.append(entry) @@ -363,6 +382,55 @@ def owned_worktree(repo, listing, path, branch): return entry +def start_sha(repo, m): + """The run's start sha: manifest start_points.prelude, else the head of + the first setup run_started event in the ledger's _run.jsonl; None when + neither is known.""" + points = m.get("start_points") + prelude = points.get("prelude") if isinstance(points, dict) else None + if isinstance(prelude, str) and prelude: + return prelude + try: + with open(os.path.join(repo.ledger_dir, "_run.jsonl"), encoding="utf-8") as f: + lines = f.read().splitlines() + except (OSError, UnicodeDecodeError): + return None + for line in lines: + try: + event = json.loads(line) + except ValueError: + continue + if (isinstance(event, dict) and event.get("event") == "run_started" and event.get("phase") == "setup" + and isinstance(event.get("head"), str) and event["head"]): + return event["head"] + return None + + +def owned_start_worktree(repo, listing, path, sha): + """The worktree list entry when path is a worktree of this repo detached + at sha (the run's start worktree), else None.""" + if sha is None or os.path.islink(path) or not os.path.isdir(path): + return None + entry = next((e for e in listing if same_place(e["path"], path)), None) + if entry is None or not entry["detached"] or entry["branch"] is not None: + return None + res = run_git(["rev-parse", "--path-format=absolute", "--git-common-dir"], ["-C", path]) + if res.returncode != 0 or not same_place(res.stdout.strip(), repo.common_dir): + return None + head, _ = worktree_head(path) + return entry if head == sha else None + + +def start_item(entry, path, sha): + """The plan item of the run's start worktree: removed with --force, even + with uncommitted or untracked files, unless it is locked.""" + item = {"path": path, "branch": None, "head": sha} + if entry["locked"]: + return dict(item, action="keep", reason="the worktree is locked") + return dict(item, action="remove", reason="the run's start worktree, detached at the start sha " + + sha[:12] + "; removed with git worktree remove --force") + + def worktree_head(path): """(sha, None) for the worktree's HEAD, or (None, the git error).""" res = run_git(["rev-parse", "--verify", "HEAD"], ["-C", path]) @@ -397,6 +465,12 @@ def recheck_worktree(repo, w): listing = repo.worktrees() except Failure as e: return changed(str(e)) + if w["branch"] is None: + # The start worktree: removed with --force, so no clean check. + entry = owned_start_worktree(repo, listing, w["path"], w["head"]) + if entry is None: + return changed("it is no longer a worktree of this repo detached at " + w["head"][:12]) + return changed("it is locked now") if entry["locked"] else None entry = owned_worktree(repo, listing, w["path"], w["branch"]) if entry is None: return changed("it is no longer a worktree of this repo on " + w["branch"]) @@ -496,6 +570,12 @@ def make_plan(repo, m, run_folder): if entry is not None: worktrees.append(worktree_item(repo, entry, slash(path), branch)) owned_paths.append(path) + # The start check's worktree, detached at the run's start sha. + start, start_at = repo.worktree_root + "/start", start_sha(repo, m) + entry = owned_start_worktree(repo, listing, start, start_at) + if entry is not None: + worktrees.append(start_item(entry, slash(start), start_at)) + owned_paths.append(start) foreign = [] if os.path.isdir(repo.worktree_root): for name in sorted(os.listdir(repo.worktree_root)): @@ -596,7 +676,10 @@ def write_restore_point(repo, plan, run_folder): lines.append("# no run refs existed, so there is nothing to restore") lines += ["```", "", "## Worktrees", "", "Recreate a removed worktree once its branch is back:", "", "```bash"] removed = [w for w in plan["worktrees"] if w["action"] == "remove"] - lines += [git + " worktree add " + shlex.quote(w["path"]) + " " + shlex.quote(w["branch"]) for w in removed] + lines += [git + " worktree add " + shlex.quote(w["path"]) + " " + shlex.quote(w["branch"]) + if w["branch"] is not None else + git + " worktree add --detach " + shlex.quote(w["path"]) + " " + shlex.quote(w["head"]) + for w in removed] if not removed: lines.append("# no worktree is removed") lines += ["```", ""] @@ -715,10 +798,15 @@ def remove_owned(repo, m, plan): note(False, entry, reason) still_out[w["branch"]] = "its worktree " + w["path"] + " " + reason continue - res = repo.git("-c", "status.showUntrackedFiles=all", "worktree", "remove", w["path"]) + if w["branch"] is None: + # The start worktree holds no commits: the one forced removal. + res = repo.git("worktree", "remove", "--force", w["path"]) + else: + res = repo.git("-c", "status.showUntrackedFiles=all", "worktree", "remove", w["path"]) if res.returncode != 0: note(False, entry, "git worktree remove refused: " + git_error(res)) - still_out[w["branch"]] = "it is checked out in the kept worktree " + w["path"] + if w["branch"] is not None: + still_out[w["branch"]] = "it is checked out in the kept worktree " + w["path"] else: note(True, entry, w["reason"]) for f in plan["foreign"]: @@ -773,7 +861,7 @@ def remove_owned(repo, m, plan): def item_name(item): if item["kind"] == "worktree": - return item["path"] + " (" + item["branch"] + ")" + return item["path"] + " (" + (item["branch"] or "detached") + ")" if item["kind"] in ("branch", "ref"): return item[item["kind"]] + " " + item["sha"][:12] return item["path"] @@ -793,7 +881,7 @@ def render_done(plan): def render(plan): out = ["cleanup plan for run " + plan["run_id"] + " (" + plan["mode"] + " mode)", "run folder: " + plan["run_folder"], "", "worktrees:"] - out += [" " + w["action"] + " " + w["path"] + " (" + w["branch"] + "): " + w["reason"] + out += [" " + w["action"] + " " + w["path"] + " (" + (w["branch"] or "detached") + "): " + w["reason"] for w in plan["worktrees"]] or [" (no run-owned worktree)"] out += ["", "branches:"] out += [" " + b["action"] + " " + b["branch"] + " " + b["sha"][:12] + ": " + b["reason"] diff --git a/tests/cleanup.test.mjs b/tests/cleanup.test.mjs index e542d72..60ac64b 100644 --- a/tests/cleanup.test.mjs +++ b/tests/cleanup.test.mjs @@ -103,7 +103,7 @@ function newCase({ archived = true } = {}) { const runFolder = join(history, folder); mkdirSync(runFolder, { recursive: true }); writeIndex(history, manifest, archived ? folder : null); - return { root, project, origin, worktreeRoot, history, active, manifest, runFolder, lanes, abandoned }; + return { root, project, origin, worktreeRoot, ledgerDir, history, active, manifest, runFolder, lanes, abandoned }; } // index.json in history: one entry for the manifest's run (keyed with @@ -908,3 +908,94 @@ test('another run\'s unit branch is not claimed', () => { const plan = cleanupOk(c); assert.ok(!plan.branches.some((b) => b.branch === 'pl-r2-u-J1'), JSON.stringify(plan.branches)); }); + +// ---- 1.6.8: the start check's worktree (/start) ---- + +// Record the run's start sha as setup does: a setup run_started event in +// the ledger's _run.jsonl. Returns the sha (main, where the run started). +function recordStart(c) { + const start = sha(c, 'main'); + const event = { task: '_run', event: 'run_started', phase: 'setup', head: start, ts: '2026-10-10T10:00:00Z' }; + writeFileSync(join(c.ledgerDir, '_run.jsonl'), JSON.stringify(event) + '\n'); + return start; +} + +// A detached worktree at /start on ref, as the start check adds it. +function addStart(c, ref) { + const dir = join(c.worktreeRoot, 'start'); + git(c.project, 'worktree', 'add', '-q', '--detach', dir, ref); + return dir; +} + +test('cleanup: a detached start worktree at the start sha is removed even with untracked files', () => { + const c = newCase(); + const start = recordStart(c); + const dir = addStart(c, start); + writeFileSync(join(dir, 'left-behind.log'), 'a start check left this\n'); + writeFileSync(join(dir, 'README.md'), 'changed by a setup command\n'); + const plan = cleanupOk(c); + const wt = plan.worktrees.find((w) => samePath(w.path, dir)); + assert.ok(wt, JSON.stringify(plan.worktrees)); + assert.equal(wt.branch, null); + assert.equal(wt.head, start); + assert.equal(wt.action, 'remove', JSON.stringify(wt)); + assert.match(wt.reason, /--force/); + assert.equal(plan.foreign.length, 0, JSON.stringify(plan.foreign)); + const text = sh('python3', [CLEANUP, c.manifest, '--history-dir', c.history], { env: { PL_ACTIVE_DIR: c.active } }); + assert.equal(text.code, 0, text.stderr); + assert.match(text.stdout, /remove {2}.*start \(detached\)/, text.stdout); + + const out = cleanupOk(c, ['--yes']); + assert.ok(!existsSync(dir), 'the start worktree is gone'); + assert.ok(!hasWorktree(c.project, dir), 'the start path is not a worktree any more'); + assert.ok(findItem(out.removed, 'worktree', (i) => samePath(i.path, dir)), JSON.stringify(out.removed)); + // Nothing else was left, so the worktree root goes too. + assert.ok(!existsSync(c.worktreeRoot)); +}); + +test('cleanup: a start worktree on another sha stays foreign', () => { + const c = newCase(); + recordStart(c); + const dir = addStart(c, 'feature/x'); + const plan = cleanupOk(c); + assert.ok(!plan.worktrees.some((w) => samePath(w.path, dir)), JSON.stringify(plan.worktrees)); + const foreign = plan.foreign.find((f) => samePath(f.path, dir)); + assert.ok(foreign, JSON.stringify(plan.foreign)); + assert.equal(foreign.reason, 'not owned by this run'); + const out = cleanupOk(c, ['--yes']); + assert.ok(existsSync(dir)); + assert.ok(hasWorktree(c.project, dir)); + assert.ok(findItem(out.kept, 'path', (i) => samePath(i.path, dir)), JSON.stringify(out.kept)); +}); + +test('cleanup: a start worktree on a branch at the start sha stays foreign', () => { + const c = newCase(); + const start = recordStart(c); + const dir = join(c.worktreeRoot, 'start'); + git(c.project, 'worktree', 'add', '-q', '-b', 'mine', dir, start); + const plan = cleanupOk(c); + assert.ok(!plan.worktrees.some((w) => samePath(w.path, dir)), JSON.stringify(plan.worktrees)); + assert.ok(plan.foreign.some((f) => samePath(f.path, dir)), JSON.stringify(plan.foreign)); +}); + +test('cleanup: restore.md names the start worktree', () => { + const c = newCase(); + // The manifest's start_points.prelude wins over the ledger. + const start = sha(c, 'main'); + const m = JSON.parse(readFileSync(c.manifest, 'utf8')); + m.start_points = { prelude: start }; + writeFileSync(c.manifest, JSON.stringify(m, null, 2)); + const dir = addStart(c, start); + const out = cleanupOk(c, ['--yes']); + assert.ok(!existsSync(dir)); + const restore = readFileSync(join(restorePoint(c, out), 'restore.md'), 'utf8'); + const line = restore.split('\n').find((l) => /^git .* worktree add --detach /.test(l)); + assert.ok(line, restore); + assert.ok(line.endsWith(` ${start}`), line); + assert.ok(line.includes('start'), line); + // The line brings the worktree back, detached at the start sha. + const res = sh(BASH, ['-c', line], { cwd: c.root }); + assert.equal(res.code, 0, res.stderr); + assert.ok(hasWorktree(c.project, dir)); + assert.equal(git(dir, 'rev-parse', 'HEAD'), start); +}); From 9e1929e0816e4c42032453e2c24badbaae8adba2 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:22:42 -0700 Subject: [PATCH 08/14] feat: the audit reports launches the archive is missing --- scripts/_audit.py | 18 +++++++++++++++-- scripts/_audit_run.py | 32 +++++++++++++++++++++++++++++- tests/audit-run.test.mjs | 42 ++++++++++++++++++++++++++++++++++++++++ tests/audit.test.mjs | 14 ++++++++++++++ 4 files changed, 103 insertions(+), 3 deletions(-) diff --git a/scripts/_audit.py b/scripts/_audit.py index 5491c2d..3ed99ab 100644 --- a/scripts/_audit.py +++ b/scripts/_audit.py @@ -189,9 +189,12 @@ def _counts(audit): open_work = run["open_work"] final = open_work.get("final") findings = final["value"]["findings"] if _is_ref(final) and isinstance(final.get("value"), dict) else [] + recorded = run["launches"].get("recorded") + recorded = recorded.get("value") if _is_ref(recorded) and isinstance(recorded.get("value"), dict) else None return { "verdict": run["acceptance"]["verdict"].get("value"), "launches": len(launches), + "recorded": recorded["recorded"] if recorded is not None else None, "latest": run["launches"].get("latest"), "order": run["launches"].get("order"), "tasks": len(tasks), @@ -211,10 +214,13 @@ def _counts(audit): def _summary_lines(audit): c = _counts(audit) lines = ["audit of run %s: verdict %s" % (audit.get("run_id") or "unknown", _scalar(c["verdict"]))] + count = "%d" % c["launches"] + if c["recorded"] is not None and c["recorded"] > c["launches"]: + count = "%d archived, %d recorded" % (c["launches"], c["recorded"]) if c["latest"] is not None: - lines.append("launches: %d, latest %s" % (c["launches"], c["latest"])) + lines.append("launches: %s, latest %s" % (count, c["latest"])) else: - lines.append("launches: %d, latest unknown (%s)" % (c["launches"], c["order"])) + lines.append("launches: %s, latest unknown (%s)" % (count, c["order"])) lines.append("tasks: %d; committed %d, reviewed %d, verified %d, accepted %d" % (c["tasks"], c["committed"], c["reviewed"], c["verified"], c["accepted"])) if c["outside"] is None: @@ -297,6 +303,14 @@ def _md_launches(launches, out): out += _quote("reason", e["reason"], " ") for gap in e.get("layout") or []: out.append(_ref("layout", gap, " ")) + recorded = launches.get("recorded") + if _is_ref(recorded): + value = recorded.get("value") + text = ("%d recorded, %d archived" % (value["recorded"], value["archived"]) + if isinstance(value, dict) else None) + out.append(_ref("launches the ledger records", recorded, value=text)) + for gap in launches.get("missing") or []: + out.append(_ref("missing launch", gap)) out.append("") diff --git a/scripts/_audit_run.py b/scripts/_audit_run.py index 862f292..a3945f9 100644 --- a/scripts/_audit_run.py +++ b/scripts/_audit_run.py @@ -17,6 +17,13 @@ "verify_head", "accepted_run", "order_known"}) are the run facts the per-task sections take. +launches also compares the launches the archived ledger records +(_launches.recorded_launches) with the ones archived: recorded is a ref of +{"recorded", "archived", "named"} (unavailable without a ledger), and +missing holds one missing ref per recorded launch the archive lacks when it +records more (the named wf ids with no launch here, then None per unnamed +one); equal counts give none. + Run-level facts come from the latest launch (_audit_core.latest_launch); when the launch order is unknown they are unavailable, never taken from an arbitrary launch. @@ -59,8 +66,10 @@ sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) import _audit_core as c # noqa: E402 import _autopsy # noqa: E402 +import _launches as launch_counts # noqa: E402 (the name _launches is the section below) SCRIPTS = os.path.dirname(os.path.realpath(__file__)) +MISSING_LAUNCH = "the ledger records this launch; its transcripts are not archived" GATES = ("match", "not_required", "missing", "stale", "mismatch", "invalid", "unavailable", "checker_error") # What check-verify itself prints; checker_error is audit's own. @@ -416,6 +425,26 @@ def _launches(run, pairs): return c.ref(entries, "present", sources) +def _recorded(run): + """(the recorded ref, the missing refs): the launches the archived ledger + records (_launches.recorded_launches) against the launches archived here. + When it records more, each one missing is a gap: the named wf ids with no + launch here first, then None for each unnamed one.""" + if not run.get("ledger"): + return c.ref(None, "unavailable", [], "no ledger in the archive"), [] + recorded = launch_counts.recorded_launches(os.path.join(run["folder"], run["ledger"])) + sources = [c.src(run["ledger"], None)] + archived = set(l["wf"] for l in run.get("launches") or []) + count, named = recorded["count"], recorded["named"] + value = {"recorded": count, "archived": len(archived), "named": named} + missing = [] + if count > len(archived): + absent = [wf for wf in named if wf not in archived] + absent += [None] * max(0, count - len(archived) - len(absent)) + missing = [c.ref(wf, "missing", sources, MISSING_LAUNCH) for wf in absent] + return c.ref(value, "present", sources), missing + + # ---- code outside recorded task ranges ---- def _version(run, launch): @@ -915,9 +944,10 @@ def run_sections(run, git, head=None, check_verify=run_check_verify, git_note=No "order_known": order_known, } want = expected_checks(manifest) + recorded, missing = _recorded(run) return { "launches": {"latest": launch["wf"] if launch is not None else None, "order": why, - "entries": _launches(run, pairs)}, + "entries": _launches(run, pairs), "recorded": recorded, "missing": missing}, "code_outside_ranges": _code_outside(run, git, git_note, launch, why, pairs), "checks": _checks(run, launch, why, gate, evidence, rule, want), "acceptance": _acceptance(run, launch, why, facts, gate, evidence, rule, accepted_events, pairs), diff --git a/tests/audit-run.test.mjs b/tests/audit-run.test.mjs index eef5877..628d6f7 100644 --- a/tests/audit-run.test.mjs +++ b/tests/audit-run.test.mjs @@ -417,6 +417,48 @@ test('launches list layout gaps; no launch is unavailable', () => { assert.equal(none.facts.verdict, 'unavailable'); }); +const ENDED = { task: '_run', event: 'run_ended', status: 'complete', agents: 3, rulings: 0 }; +const MISSING_NOTE = 'the ledger records this launch; its transcripts are not archived'; +const LEDGER_SRC = [{ path: LEDGER, at: null }]; +const auditGaps = (folder) => pythonJSON(['import _audit', 'print(json.dumps(_audit.build(sys.argv[1])["gaps"]))'], [folder]); + +test('two recorded launches and one archived give one missing gap', () => { + const folder = makeRun({ ledger: { '_run.jsonl': [ENDED, ENDED] }, launches: [launch(accepted(D))] }); + const s = sections(folder); + assert.deepEqual(s.launches.recorded, + { value: { recorded: 2, archived: 1, named: [] }, state: 'present', sources: LEDGER_SRC }); + assert.deepEqual(s.launches.missing, [{ value: null, state: 'missing', sources: LEDGER_SRC, note: MISSING_NOTE }]); + const gaps = auditGaps(folder).filter((g) => g.where.startsWith('#/run/launches/missing')); + assert.deepEqual(gaps, [{ where: '#/run/launches/missing/0', state: 'missing', value: null, note: MISSING_NOTE }]); + // Equal counts add no gap. + const equal = makeRun({ ledger: { '_run.jsonl': [ENDED] }, launches: [launch(accepted(D))] }); + const e = sections(equal); + assert.deepEqual(e.launches.recorded.value, { recorded: 1, archived: 1, named: [] }); + assert.deepEqual(e.launches.missing, []); + assert.deepEqual(auditGaps(equal).filter((g) => g.where.startsWith('#/run/launches/missing')), []); + // Without a ledger the recorded count is unavailable and nothing is missing. + const none = sections(makeRun({ ledger: null, launches: [launch(accepted(D))] })); + assert.deepEqual([none.launches.recorded.state, none.launches.recorded.note], ['unavailable', 'no ledger in the archive']); + assert.deepEqual(none.launches.missing, []); +}); + +test('named missing launches are listed by wf id', () => { + const carried = (wf) => ({ task: '_run', event: 'carried', wf, rulings: 0 }); + const folder = makeRun({ + ledger: { '_run.jsonl': [ENDED, carried('wf_aaaaaa'), ENDED, carried('wf_bbbbbb'), ENDED, ENDED] }, + launches: [launch(accepted(D))], + files: { [`${LEDGER}/doctor-wf_cccccc.json`]: '{}\n' }, + }); + const s = sections(folder); + assert.deepEqual(s.launches.recorded.value, + { recorded: 4, archived: 1, named: ['wf_aaaaaa', 'wf_bbbbbb', 'wf_cccccc'] }); + // Named ones first, then one unnamed launch. + assert.deepEqual(s.launches.missing.map((g) => [g.value, g.state, g.note]), + [['wf_bbbbbb', 'missing', MISSING_NOTE], ['wf_cccccc', 'missing', MISSING_NOTE], [null, 'missing', MISSING_NOTE]]); + assert.deepEqual(auditGaps(folder).filter((g) => g.where.startsWith('#/run/launches/missing')).map((g) => g.where), + ['#/run/launches/missing/0', '#/run/launches/missing/1', '#/run/launches/missing/2']); +}); + test('an override at another head is stale and changes nothing', () => { const rejected = accepted(D, { acceptance: { status: 'rejected', delivered_sha: D, reasons: [{ kind: 'checks_failed', class: 'failed', detail: `failing at ${D}: npm test zqcmd (exit 1)` }], warnings: ['w npm test zqcmd'] }, diff --git a/tests/audit.test.mjs b/tests/audit.test.mjs index 3be0b86..09d6f0c 100644 --- a/tests/audit.test.mjs +++ b/tests/audit.test.mjs @@ -573,6 +573,20 @@ test('legacy layouts get a view', () => { } }); +test('the summary counts recorded launches the archive is missing', () => { + const ended = { task: '_run', event: 'run_ended', status: 'complete', agents: 3, rulings: 0 }; + const result = { tasks: { A1: { status: 'done', commits: [S('a')] } } }; + const one = { wf: 'wf_aaaaaa', startTime: START, durationMs: DURATION, result }; + const { json, md, stdout } = view(makeRun({ ledger: { '_run.jsonl': [ended, ended] }, launches: [one] })); + assert.ok(stdout.split('\n').includes('launches: 1 archived, 2 recorded, latest wf_aaaaaa'), stdout); + assert.ok(md.includes('- launches: 1 archived, 2 recorded, latest wf_aaaaaa\n'), md); + assert.ok(md.includes('- missing launch: unknown [missing] - the ledger records this launch; its transcripts are not archived'), md); + assert.equal(json.gaps.filter((g) => g.where.startsWith('#/run/launches/missing')).length, 1); + // Equal counts keep the old line. + const same = view(makeRun({ ledger: { '_run.jsonl': [ended] }, launches: [one] })); + assert.ok(same.stdout.split('\n').includes('launches: 1, latest wf_aaaaaa'), same.stdout); +}); + test('a Linux-origin manifest needs no cygpath', () => { const r = repoRun(); const folder = makeRun(acceptedSpec(r)); From d929d73e69af0ce82cdaa061af1b867342c8ae27 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:28:26 -0700 Subject: [PATCH 09/14] feat: a start check names failures that predate the run --- run.workflow.js | 340 ++++++++++++++++++++++++++++++++--- src/acceptance.js | 24 ++- src/phases.js | 155 ++++++++++++++-- src/prompts-phase.js | 161 ++++++++++++++++- tests/acceptance.test.mjs | 53 ++++++ tests/phase-prompts.test.mjs | 91 ++++++++++ tests/repair.test.mjs | 257 ++++++++++++++++++++++++++ 7 files changed, 1035 insertions(+), 46 deletions(-) diff --git a/run.workflow.js b/run.workflow.js index 6945919..e8c8746 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -3467,8 +3467,9 @@ function finalFixPrompt(m, findings, base) { // the project checks' result at head (run before the re-review), or null. // repairs: the repairs after a failing verify inside the range // ([{attempt, from, head, failed}], spec 4.5); with no findings the -// re-review judges only those. -function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = []) { +// re-review judges only those. startCheck (1.6.8 spec 4.4): the classified +// start check, shown beside the failing verify, or null. +function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = [], startCheck = null) { const dir = shellQuote(featureDir(m)); const said = (f) => ((f.dispositions || []).length > 0 ? f.dispositions.map((d) => ` fixer: ${d.status} - ${d.reason}` @@ -3499,6 +3500,11 @@ function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = [ '', ...(verify ? [ checksResultText(verify), + ...(startCheck ? [ + ...startCheckLines(startCheck), + 'A failure that fails at the start point too predates this run: do not hold a finding open over that failure' + + ' alone.', + ] : []), findings.length > 0 ? 'Still judge every finding yourself; a failing check is evidence, not a verdict.' : 'A failing check is evidence, not a verdict.', '', @@ -3572,10 +3578,152 @@ function verifyPrompt(m, sha, from = null) { ].join('\n'); } +// The start check after a failing verify (1.6.8 spec 4.4): in a detached +// worktree at /start on the run's start point +// (m.start_points.prelude), with verify's setup and environment (run-checks +// --phase start sets PL_PHASE=verify), run each failing command of verify +// (the failing run-checks JSON at sha), narrowed to its failing tests when the +// project can select them, and report which tests ran and failed. The agent +// reports what the logs show; the engine classifies it (classifyStartCheck). +// Its own checkout rule replaces checkoutRules, which would confine it to the +// feature checkout it must not touch. +function startCheckPrompt(m, verify, sha) { + const q = shellQuote; + const start = m.start_points.prelude; + const wt = `${m.repo.worktree_root}/start`; + const admin = gitAdmin(m); + const feature = featureDir(m); + const setup = finalSetup(m); + const failing = (Array.isArray(verify && verify.results) ? verify.results : []).filter((r) => r && r.exit !== 0); + const evidence = `${m.repo.ledger_dir}/checks/verify-${sha}.json`; + const lines = failing.map((r) => [ + ` - ${r.command} (exit ${r.exit}, group ${r.group})`, + present(r.log) ? ` log: ${r.log}` : ` log: beside ${evidence} (its results name each log)`, + ].join('\n')); + const run = `cd ${q(wt)} && ${pythonCommand(m)} ${q(`${m.skill_dir}/scripts/run-checks`)} ${q(wt)} ` + + `--out ${resultPath()} --root ${q(m.repo.ledger_dir)} --phase 'start' --cmd 'GROUP' 'COMMAND'`; + const remove = `${admin} worktree remove --force ${q(wt)}`; + const prune = `${admin} worktree prune`; + const add = `${admin} worktree add --detach ${q(wt)} ${q(start)}`; + const own = [ + 'Your shell may start in another checkout of this repo, so never rely on the current directory:', + `- every shell command starts with cd ${q(wt)} && or uses git -C ${q(wt)}, except the worktree commands above,`, + ` which run as given (${admin} ...);`, + `- every file you write is under ${wt}, except what run-checks and await-checks write under the ledger;`, + `- never commit, in ${wt} or anywhere else; do not change, stage or commit anything in the feature checkout`, + ` ${q(feature)}, and do not switch its branch.`, + ].join('\n'); + return [ + `You are the start check for parallel-lanes run ${m.run_id}: the delivery checks failed at ${sha}. Find out`, + `whether the failing tests fail at the run's start point ${start} too. Report what the logs show; the run`, + 'classifies it.', + `Work only in the start worktree ${wt}. Do not change, stage or commit anything in the feature checkout`, + `${q(feature)}.`, + `1. Drop stale worktree entries, then list the worktrees: ${prune} && ${admin} worktree list --porcelain`, + ` - Nothing at ${wt} (no worktree listed there and no folder): add it with ${add}`, + ` - A worktree of this repo listed at ${wt}, detached at ${start}: it is the run's own, left by an earlier start`, + ` check. Remove it first (${remove}, then ${prune}), then add it as above.`, + ` - Anything else at that path (a worktree on a branch or at another commit, or a folder git does not list):`, + ' return status unavailable with results [] and notes saying what is there, and touch nothing.', + `2. Run the setup commands there, in order: ${setup.length > 0 ? `cd ${q(wt)} && ${setup.join(' && ')}` : '(none)'}`, + `3. The failing commands at ${sha}:`, + ...lines, + ' Read each log for the names of the tests that failed. If the command can select just those tests through', + " the project's own filter (a test-name or test-file argument it supports), run that narrower command;", + ' otherwise run the same command unchanged. Run them all in one run-checks call in the start worktree, one', + " --cmd pair per failing command (its group, then the command you run there, each single-quoted, writing any", + " single quote inside as '\\'').", + ...procedureLines(m, { + name: 'start', start: run, + stop: 'return status unavailable with results [] and notes naming both outcomes the wait printed.', + exits: ['These are results like any other: report each exit as the wait printed it; the run decides what it means.'], + }).map((l) => ` ${l}`), + '4. Read each start log and name the tests that ran and failed there. A test the log does not show running', + ' (filtered out, skipped, or the command found nothing) is not failing or passing: list it in not_run.', + `5. Remove the worktree: ${remove}, then ${prune}. --force is allowed only on this path. If the removal fails`, + ' (a process holding files), say so in notes; cleanup or the next start check removes it.', + '6. Never change, stage or commit anything in the feature checkout.', + '', + phaseRules(m, own), + '', + `Return status checked, start_sha = the output of git -C ${q(wt)} rev-parse HEAD (it must be ${start}),`, + 'evidence = the path reserve printed (the run-checks result file), results = one entry per failing command', + 'above: {verify_command (the command as listed above), start_command (the command you ran), narrowed (true when', + 'you ran a narrower command), exit_at_start (its exit in the run-checks result; null when it did not run),', + 'failed_at_head (the tests its verify log shows failing), failed_at_start (the tests its start log shows', + 'failing), not_run (the tests of failed_at_head its start log does not show running)}, and notes. Return status', + 'unavailable with results [] when you could not check, and say why in notes.', + ].join('\n'); +} + +// The start check's result (spec 4.4): what the logs show, per failing +// command; the engine classifies it (classifyStartCheck). +function startCheckSchema() { + const tests = { type: 'array', items: { type: 'string' } }; + return { + type: 'object', + properties: { + status: { type: 'string', enum: ['checked', 'unavailable'] }, + start_sha: { type: 'string' }, + evidence: { type: 'string' }, + results: { + type: 'array', + items: { + type: 'object', + properties: { + verify_command: { type: 'string' }, + start_command: { type: 'string' }, + narrowed: { type: 'boolean' }, + exit_at_start: { type: ['integer', 'null'] }, + failed_at_head: tests, + failed_at_start: tests, + not_run: tests, + }, + required: ['verify_command', 'start_command', 'narrowed', 'exit_at_start', 'failed_at_head', 'failed_at_start', + 'not_run'], + }, + }, + notes: { type: 'string' }, + }, + required: ['status', 'start_sha', 'evidence', 'results', 'notes'], + }; +} + +// What a classified start check (classifyStartCheck) says, for the repair +// and final re-review prompts: one line per failing command with its +// at_start and tests; [] for null. +function startCheckLines(sc) { + if (!sc || typeof sc !== 'object') return []; + if (sc.status !== 'checked') { + return [`The start check did not run (${sc.why || 'no result'}): no conclusion about the start point.`]; + } + const start = sc.start_sha; + const list = (tests) => (tests.length > 0 ? tests.join(', ') : '(no tests named)'); + const line = (c) => { + const head = Array.isArray(c.failed_at_head) ? c.failed_at_head : []; + const atStart = Array.isArray(c.failed_at_start) ? c.failed_at_start : []; + if (c.at_start === 'fails') return `- ${c.verify_command}: fails at the start point ${start} too: ${list(head)}`; + if (c.at_start === 'passes') return `- ${c.verify_command}: passes at the start point ${start}: this run broke it`; + if (c.at_start === 'partly') { + const both = head.filter((t) => atStart.includes(t)); + const rest = head.filter((t) => !atStart.includes(t)); + return `- ${c.verify_command}: partly: ${list(both)} fail at the start point ${start} too (they predate this run);` + + ` ${list(rest)} do not`; + } + return `- ${c.verify_command}: unknown: no conclusion from the start point`; + }; + return [ + `The start check ran the failing checks at the run's start point ${start} (evidence ${sc.evidence}), for the` + + ` failures at ${sc.verify_head}:`, + ...(Array.isArray(sc.commands) ? sc.commands : []).map(line), + ]; +} + // The repair agent after a failing verify (spec 4.5, 4.6). verify is the // failing run-checks JSON at from, the head the repair starts at; attempt is -// its number. -function verifyRepairPrompt(m, verify, from, attempt) { +// its number. startCheck (1.6.8 spec 4.4): the classified start check, whose +// lines say which failures predate the run, or null. +function verifyRepairPrompt(m, verify, from, attempt, startCheck = null) { const evidence = `${m.repo.ledger_dir}/checks/verify-${from}.json`; const failing = (Array.isArray(verify && verify.results) ? verify.results : []).filter((r) => r && r.exit !== 0); const lines = failing.map((r) => [ @@ -3589,8 +3737,13 @@ function verifyRepairPrompt(m, verify, from, attempt) { 'Failing checks:', ...lines, `The full evidence is ${evidence}.`, + ...(startCheck ? [ + ...startCheckLines(startCheck), + "A failure that fails at the start point too predates this run: it lies outside this run's scope.", + ] : []), '', 'Find the cause and fix it with the smallest change that is correct, so the checks pass for the right reason.', + `Never create a worktree or checkout outside ${featureDir(m)}.`, 'Never weaken, skip or delete a check, test or acceptance criterion to get a pass, and never change the', 'commands, lane_commands or task_checks, or the test runner configuration, to make them pass. Fixing a wrong', 'test is allowed when the test, not the code, is wrong: say why in notes. If the failure is a flaky, timeout,', @@ -5054,7 +5207,10 @@ function afterChecksWarning(verify, sha) { // input: {m, tasks (the run report's), final, e2e ({checked_sha, items}|null), // verify (run-checks JSON|null), post ({status, checked_sha}|null), -// delivered_sha, fix_unreviewed}. Returns {status 'accepted'|'unverified'| +// delivered_sha, fix_unreviewed, start_check (classifyStartCheck's result, +// optional: when it was made for the delivered head and every failing +// command fails at the start point, the reason is checks_failed_at_start; +// when only some do, checks_failed and a warning naming them)}. Returns {status 'accepted'|'unverified'| // 'rejected', delivered_sha, reasons: [{kind, class 'failed'|'missing', // detail}], warnings: [string]}. rejected: something failed or is open or // deferred; unverified: nothing failed, but required evidence is missing or @@ -5086,8 +5242,23 @@ function acceptanceOf(input) { || want.some((w, i) => verify.results[i].group !== w.group || verify.results[i].command !== w.command)) { add('checks_incomplete', 'missing', 'the verify step did not run exactly the configured test, lint and build commands'); } else if (verify.ok !== true || verify.results.some((r) => r.exit !== 0)) { - const failed = verify.results.filter((r) => r.exit !== 0).map((r) => `${r.command} (exit ${r.exit})`); - add('checks_failed', 'failed', failed.length > 0 ? `failing at ${sha}: ${failed.join(', ')}` : `checks reported not ok at ${sha}`); + const failing = verify.results.filter((r) => r.exit !== 0); + const failed = failing.map((r) => `${r.command} (exit ${r.exit})`); + // The start check (1.6.8 spec 4.4), when it was made for the delivered + // head: each failing command's entry when the engine classified it fails. + const sc = input.start_check; + const here = sc && sc.status === 'checked' && sc.verify_head === sha && Array.isArray(sc.commands) ? sc : null; + const atStart = here === null ? [] : failing.map((r) => here.commands + .find((c) => c && c.verify_command === r.command && c.at_start === 'fails') || null); + if (failing.length > 0 && atStart.length === failing.length && atStart.every((c) => c !== null)) { + const each = failing.map((r, i) => `${r.command} (exit ${r.exit}): ${(atStart[i].failed_at_head || []).join(', ')}`); + add('checks_failed_at_start', 'failed', `failing at ${sha} and at the start point ${here.start_sha}: ` + + `${each.join('; ')}; evidence ${here.evidence}`); + } else { + add('checks_failed', 'failed', failed.length > 0 ? `failing at ${sha}: ${failed.join(', ')}` : `checks reported not ok at ${sha}`); + const too = failing.filter((r, i) => atStart[i]).map((r) => r.command); + if (too.length > 0) warnings.push(`fails at the start point ${here.start_sha} too: ${too.join(', ')}`); + } } // Checks that ran on uncommitted tracked or staged changes or with an // untracked file present, or changed a tracked file, tested something @@ -5180,8 +5351,12 @@ function acceptanceOf(input) { // spec 4.5): {admit, first}, the admission check and the number of the first // attempt; with it, a failing verify after a fix that returned done with a // sha head goes to runRepairs before the re-review, which then covers the -// repairs too. final.repairs lists them ([] when none). -async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, taskMinors = [], repair = null) { +// repairs too. final.repairs lists them ([] when none). startCheckAt(verify, +// sha) (1.6.8 spec 4.4, supervised runs too): called right after that +// verify, before any repair; a non-null result is final.start_check, and +// when every failing command fails at the start point no repair runs. +async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, taskMinors = [], repair = null, + startCheckAt = null) { const standard = tierSettings('standard'); const call = (label, prompt, schema) => io.agent(prompt, { label, phase: 'Final review', schema, ...standard }); @@ -5260,6 +5435,12 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t // The checks at the delivered head, before the re-review (spec: no // repeated checks at one commit). if (verifyAt !== null && fixRan) final.verify = await verifyAt(final.head); + // A failing verify there gets the start check before any repair. + if (startCheckAt !== null && final.verify) { + const sc = await startCheckAt(final.verify, final.head); + if (sc !== null) final.start_check = sc; + } + const allFailAtStart = Boolean(final.start_check && final.start_check.all_fail); // A refused call says why (refusalCause: the budget, or agents unavailable). const cause = (r) => refusalCause({ unavailable: Boolean(r.__unavailable) }); if (!fix || fix.__budget) return settle([], null, fix ? `final fix not run: ${cause(fix)}` : 'no result from final fix'); @@ -5274,10 +5455,12 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t const dispositions = Array.isArray(fix.dispositions) ? fix.dispositions : []; // A failing verify after the fix (autonomous): bounded repairs (spec 4.5) // before the re-review, whose range tip..final.head then covers them. Only - // after a fix that returned a sha head (spec 4.5). + // after a fix that returned a sha head (spec 4.5), and not when every + // failing command fails at the start point too (1.6.8 spec 4.4). let repaired = null; - if (repair !== null && verifyAt !== null && final.verify && isSha(fix.head)) { - repaired = await runRepairs(m, io, { head: final.head, verify: final.verify, verifyAt, ...repair }); + if (repair !== null && verifyAt !== null && final.verify && isSha(fix.head) && !allFailAtStart) { + repaired = await runRepairs(m, io, { head: final.head, verify: final.verify, verifyAt, ...repair, + startCheck: final.start_check || null }); final.head = repaired.head; final.verify = repaired.verify; final.repairs = repaired.repairs; @@ -5286,7 +5469,7 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t // given for its id (more than one shows the contradiction). const verifying = findings.map((f) => ({ ...f, dispositions: dispositions.filter((d) => d && d.id === f.id) })); const rr = await call('final re-review', finalReReviewPrompt(m, tip, final.head, verifying, final.verify || null, - final.repairs.filter((r) => r.eligible)), finalReReviewSchema()); + final.repairs.filter((r) => r.eligible), final.start_check || null), finalReReviewSchema()); // A refused re-review returns the budget sentinel, which has no results. if (!rr || !Array.isArray(rr.results)) { settle(dispositions, null, rr && rr.__budget ? `final re-review not run: ${cause(rr)}` @@ -5337,6 +5520,88 @@ function repairDue(m, verify, sha) { return !nonDefectFailure(results); } +// Whether a failing verify at sha calls for a start check (1.6.8 spec 4.4): +// repairDue's conditions (not autonomy or admission), and a recorded start +// point (m.start_points.prelude) that is a sha other than sha. +function startCheckDue(m, verify, sha) { + const start = m.start_points && typeof m.start_points === 'object' ? m.start_points.prelude : null; + return repairDue(m, verify, sha) && isSha(start) && start !== sha; +} + +// Whether path is a start-check evidence file /checks/start..json, +// compared with backslashes turned into '/', and without case when the +// ledger is on a drive (a Windows agent may print C:/ in another case). +function startEvidenceOk(m, path) { + if (!present(path)) return false; + const ledger = String(m.repo.ledger_dir).split('\\').join('/').replace(/\/+$/, ''); + const given = path.split('\\').join('/'); + const drive = /^[A-Za-z]:\//.test(ledger); + const prefix = `${ledger}/checks/start.`; + const [p, g] = drive ? [prefix.toLowerCase(), given.toLowerCase()] : [prefix, given]; + return g.startsWith(p) && /^[0-9]+\.json$/.test(g.slice(p.length)); +} + +// The engine's classification of a start check's result r (spec 4.4 "The +// engine classifies"), per failing command of verify (the failing verify at +// sha): fails only when r is checked, for the start point, with its evidence +// under the ledger, a defect exit at the start (not 126, 127 or a +// nonDefectFailure exit), test names, nothing not run, and every head +// failure failing at the start; passes when it exited 0 with nothing not +// run; partly when some but not all head failures fail at the start; +// unknown otherwise, and for a command r leaves out or lists twice. Every +// one is unknown when r is not checked, or names another start sha or +// evidence path (why says which). A null, refused or malformed r is +// unavailable. Returns {status, why?, start_sha, evidence, verify_head, +// commands: [{...r's entry, at_start}], all_fail, some_fail, notes}. +function classifyStartCheck(m, verify, sha, r) { + const start = m.start_points && typeof m.start_points === 'object' ? m.start_points.prelude : null; + const failing = (Array.isArray(verify && verify.results) ? verify.results : []).filter((x) => x && x.exit !== 0); + const unavailable = (why, notes = '') => ({ + status: 'unavailable', why, start_sha: start, evidence: null, verify_head: sha, commands: [], + all_fail: false, some_fail: false, notes, + }); + if (!r || typeof r !== 'object' || r.__budget) { + return unavailable(r && r.__budget ? refusalCause({ unavailable: Boolean(r.__unavailable) }) : 'no result'); + } + const notes = typeof r.notes === 'string' ? r.notes : ''; + if (r.status === 'unavailable') return unavailable('the start check reported unavailable', notes); + if (r.status !== 'checked' || !Array.isArray(r.results)) return unavailable('a malformed result', notes); + const evidence = present(r.evidence) ? r.evidence : null; + const why = r.start_sha !== start ? `it reported start sha ${JSON.stringify(r.start_sha)}, not ${start}` + : !startEvidenceOk(m, evidence) ? `its evidence ${JSON.stringify(r.evidence)} is not a checks/start..json file` + + ` under ${m.repo.ledger_dir}` : null; + const names = (v) => (Array.isArray(v) ? v.filter((t) => typeof t === 'string') : []); + const atStart = (e) => { + if (why !== null) return 'unknown'; + const exit = e.exit_at_start; + if (typeof exit !== 'number') return 'unknown'; + const head = names(e.failed_at_head); + const startFails = names(e.failed_at_start); + const notRun = names(e.not_run); + if (exit === 0) return notRun.length === 0 ? 'passes' : 'unknown'; + if (exit === 126 || exit === 127 || nonDefectFailure([{ exit }])) return 'unknown'; + if (head.length === 0) return 'unknown'; + const both = head.filter((t) => startFails.includes(t)).length; + if (both === head.length) return notRun.length === 0 ? 'fails' : 'unknown'; + return both > 0 ? 'partly' : 'unknown'; + }; + const commands = failing.map((f) => { + const entries = r.results.filter((e) => e && typeof e === 'object' && e.verify_command === f.command); + if (entries.length !== 1) { + return { + verify_command: f.command, start_command: null, narrowed: false, exit_at_start: null, failed_at_head: [], + failed_at_start: [], not_run: [], at_start: 'unknown', + }; + } + return { ...entries[0], at_start: atStart(entries[0]) }; + }); + const fails = commands.filter((c) => c.at_start === 'fails').length; + return { + status: 'checked', ...(why === null ? {} : { why }), start_sha: start, evidence, verify_head: sha, commands, + all_fail: commands.length > 0 && fails === commands.length, some_fail: fails > 0, notes, + }; +} + // Whether a repair's reported head may be delivered (spec 4.5): verify ran // at it, and run-checks --descends-from found from, the repair's start, an // ancestor (the check must name from, not some other sha). @@ -5364,7 +5629,8 @@ function validRepairReview(rr, delivered) { // The repair loop (spec 4.5). ctx = {head, verify (the failing verify at // head), verifyAt(sha, from), admit() (true admits one more attempt and -// counts it), first (the number of the first attempt: repairs_spent + 1)}. +// counts it), first (the number of the first attempt: repairs_spent + 1), +// startCheck (optional: the classified start check, for each repair prompt)}. // Each attempt is one 'verify repair ' agent (Opus); a sha head that // differs from where it started is verified with --descends-from and // delivered only when eligible. Returns {head, verify, repairs, unreviewed}: @@ -5377,7 +5643,7 @@ async function runRepairs(m, io, ctx) { let n = ctx.first; while (repairDue(m, verify, head) && ctx.admit()) { const failed = verify.results.filter((r) => r.exit !== 0).map((r) => r.command); - const r = await io.agent(verifyRepairPrompt(m, verify, head, n), + const r = await io.agent(verifyRepairPrompt(m, verify, head, n, ctx.startCheck || null), { label: `verify repair ${n}`, phase: 'Verify', schema: repairSchema(), ...tierSettings('standard') }); // A refused repair's status says why: budget, or unavailable (spec 4.1). const refusedAs = r && r.__budget ? (r.__unavailable ? 'unavailable' : 'budget') : null; @@ -5656,7 +5922,8 @@ function scheduleFix(m, entry) { // stopped_lanes:[{lane, task, reason}], preflight:{conflicts, rulings, undeclared, schedule}, // integrate:{status, notes, post_integrate, fix_review}, e2e:{items, checked_sha}|null, // final:{findings, fixed, declined, open, cannot_verify, missing_lenses, head, lens_heads, -// review_problem?, task_minors_open}, +// review_problem?, task_minors_open, start_check? (classifyStartCheck's +// result, only when a start check was due: 1.6.8 spec 4.4)}, // verify (the run-checks result at the delivered revision, run once)|null, // verify_stopped (why the last verify's checks stopped twice without a // result, spec 4.1; verify is then null)|null, @@ -6314,6 +6581,21 @@ async function runAll(manifest, io) { return ok; }; const repair = autonomous ? { admit, first: spent + 1 } : null; + // The start check after a failing verify (1.6.8 spec 4.4): at most once + // per launch, only when startCheckDue, and an ordinary agent call (not + // overBudget). With no agent free, or the agents unavailable, it records + // unavailable for the budget without a call, so nothing is refused. + // Returns the classified start check, or null when none was due. + let startChecked = false; + const startCheckAt = async (v, sha) => { + if (startChecked || !startCheckDue(m, v, sha)) return null; + startChecked = true; + if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < 1) { + return { ...classifyStartCheck(m, v, sha, null), why: 'budget' }; + } + const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), sonnetHigh); + return classifyStartCheck(m, v, sha, r); + }; // A budget stop once every task is in: the checks still run at the // feature head the run reached, and the stopped report carries them; not // once agents are unavailable (the verify agent would be refused too). @@ -6333,7 +6615,7 @@ async function runAll(manifest, io) { io.phase('Final review'); final = await runFinalReview(m, e2e, tip, counted, [...(integrate ? integrate.fix_review : []), ...waveFixReview], - hasChecks ? verifyAt : null, taskMinors, repair); + hasChecks ? verifyAt : null, taskMinors, repair, hasChecks ? startCheckAt : null); // Checks that ran inside the final review (a fix agent ran) are the run's // verify: the report carries them once, at the top level. const verifiedInReview = 'verify' in final; @@ -6385,13 +6667,23 @@ async function runAll(manifest, io) { // A verify refused under the agents-unavailable latch must not let the run // report complete with its checks missing (spec 4.1). if (state.refused.length > 0) return budgetReport(); + // A failing verify here gets the start check before any repair (1.6.8 + // spec 4.4), supervised runs too. + if (hasChecks && !verifiedInReview && verify) { + const sc = await startCheckAt(verify, delivered); + if (sc !== null) final.start_check = sc; + if (state.refused.length > 0) return budgetReport(); + } // No final findings and a failing verify (autonomous): the same bounded - // repairs (spec 4.5). A repair that moved the head gets a final re-review - // of its range with no findings under verification; anything but a valid - // result for the delivered head leaves it unreviewed (closed). - if (repair !== null && hasChecks && !verifiedInReview && verify) { + // repairs (spec 4.5), not when every failing command fails at the start + // point too. A repair that moved the head gets a final re-review of its + // range with no findings under verification; anything but a valid result + // for the delivered head leaves it unreviewed (closed). + if (repair !== null && hasChecks && !verifiedInReview && verify + && !(final.start_check && final.start_check.all_fail)) { const start = delivered; - const out = await runRepairs(m, counted, { head: start, verify, verifyAt, ...repair }); + const out = await runRepairs(m, counted, { head: start, verify, verifyAt, ...repair, + startCheck: final.start_check || null }); final.repairs = out.repairs; verify = out.verify; final.head = out.head; @@ -6401,7 +6693,8 @@ async function runAll(manifest, io) { final.unreviewed_fix = out.unreviewed; } else if (out.head !== start) { const rr = await call('final re-review', 'Final review', - finalReReviewPrompt(m, start, out.head, [], verify, out.repairs.filter((r) => r.eligible)), finalReReviewSchema()); + finalReReviewPrompt(m, start, out.head, [], verify, out.repairs.filter((r) => r.eligible), + final.start_check || null), finalReReviewSchema()); if (state.refused.length > 0) return budgetReport(); const problem = validRepairReview(rr, out.head); Object.assign(final, settleFinalFindings([], [], rr && Array.isArray(rr.new_findings) ? rr : null, @@ -6428,6 +6721,7 @@ async function runAll(manifest, io) { post = postCheck; acceptance = acceptanceOf({ m, tasks, final, e2e, verify, post, delivered_sha: delivered, fix_unreviewed: fixUnreviewed, + start_check: final.start_check || null, }); io.log(`parallel-lanes: acceptance ${acceptance.status} at ${delivered}` + (acceptance.reasons.length > 0 ? `: ${acceptance.reasons.map((r) => r.kind).join(', ')}` : '')); diff --git a/src/acceptance.js b/src/acceptance.js index f380ae8..f4d78f1 100644 --- a/src/acceptance.js +++ b/src/acceptance.js @@ -122,7 +122,10 @@ function afterChecksWarning(verify, sha) { // input: {m, tasks (the run report's), final, e2e ({checked_sha, items}|null), // verify (run-checks JSON|null), post ({status, checked_sha}|null), -// delivered_sha, fix_unreviewed}. Returns {status 'accepted'|'unverified'| +// delivered_sha, fix_unreviewed, start_check (classifyStartCheck's result, +// optional: when it was made for the delivered head and every failing +// command fails at the start point, the reason is checks_failed_at_start; +// when only some do, checks_failed and a warning naming them)}. Returns {status 'accepted'|'unverified'| // 'rejected', delivered_sha, reasons: [{kind, class 'failed'|'missing', // detail}], warnings: [string]}. rejected: something failed or is open or // deferred; unverified: nothing failed, but required evidence is missing or @@ -154,8 +157,23 @@ function acceptanceOf(input) { || want.some((w, i) => verify.results[i].group !== w.group || verify.results[i].command !== w.command)) { add('checks_incomplete', 'missing', 'the verify step did not run exactly the configured test, lint and build commands'); } else if (verify.ok !== true || verify.results.some((r) => r.exit !== 0)) { - const failed = verify.results.filter((r) => r.exit !== 0).map((r) => `${r.command} (exit ${r.exit})`); - add('checks_failed', 'failed', failed.length > 0 ? `failing at ${sha}: ${failed.join(', ')}` : `checks reported not ok at ${sha}`); + const failing = verify.results.filter((r) => r.exit !== 0); + const failed = failing.map((r) => `${r.command} (exit ${r.exit})`); + // The start check (1.6.8 spec 4.4), when it was made for the delivered + // head: each failing command's entry when the engine classified it fails. + const sc = input.start_check; + const here = sc && sc.status === 'checked' && sc.verify_head === sha && Array.isArray(sc.commands) ? sc : null; + const atStart = here === null ? [] : failing.map((r) => here.commands + .find((c) => c && c.verify_command === r.command && c.at_start === 'fails') || null); + if (failing.length > 0 && atStart.length === failing.length && atStart.every((c) => c !== null)) { + const each = failing.map((r, i) => `${r.command} (exit ${r.exit}): ${(atStart[i].failed_at_head || []).join(', ')}`); + add('checks_failed_at_start', 'failed', `failing at ${sha} and at the start point ${here.start_sha}: ` + + `${each.join('; ')}; evidence ${here.evidence}`); + } else { + add('checks_failed', 'failed', failed.length > 0 ? `failing at ${sha}: ${failed.join(', ')}` : `checks reported not ok at ${sha}`); + const too = failing.filter((r, i) => atStart[i]).map((r) => r.command); + if (too.length > 0) warnings.push(`fails at the start point ${here.start_sha} too: ${too.join(', ')}`); + } } // Checks that ran on uncommitted tracked or staged changes or with an // untracked file present, or changed a tracked file, tested something diff --git a/src/phases.js b/src/phases.js index b64c572..33a60bf 100644 --- a/src/phases.js +++ b/src/phases.js @@ -33,8 +33,12 @@ // spec 4.5): {admit, first}, the admission check and the number of the first // attempt; with it, a failing verify after a fix that returned done with a // sha head goes to runRepairs before the re-review, which then covers the -// repairs too. final.repairs lists them ([] when none). -async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, taskMinors = [], repair = null) { +// repairs too. final.repairs lists them ([] when none). startCheckAt(verify, +// sha) (1.6.8 spec 4.4, supervised runs too): called right after that +// verify, before any repair; a non-null result is final.start_check, and +// when every failing command fails at the start point no repair runs. +async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, taskMinors = [], repair = null, + startCheckAt = null) { const standard = tierSettings('standard'); const call = (label, prompt, schema) => io.agent(prompt, { label, phase: 'Final review', schema, ...standard }); @@ -113,6 +117,12 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t // The checks at the delivered head, before the re-review (spec: no // repeated checks at one commit). if (verifyAt !== null && fixRan) final.verify = await verifyAt(final.head); + // A failing verify there gets the start check before any repair. + if (startCheckAt !== null && final.verify) { + const sc = await startCheckAt(final.verify, final.head); + if (sc !== null) final.start_check = sc; + } + const allFailAtStart = Boolean(final.start_check && final.start_check.all_fail); // A refused call says why (refusalCause: the budget, or agents unavailable). const cause = (r) => refusalCause({ unavailable: Boolean(r.__unavailable) }); if (!fix || fix.__budget) return settle([], null, fix ? `final fix not run: ${cause(fix)}` : 'no result from final fix'); @@ -127,10 +137,12 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t const dispositions = Array.isArray(fix.dispositions) ? fix.dispositions : []; // A failing verify after the fix (autonomous): bounded repairs (spec 4.5) // before the re-review, whose range tip..final.head then covers them. Only - // after a fix that returned a sha head (spec 4.5). + // after a fix that returned a sha head (spec 4.5), and not when every + // failing command fails at the start point too (1.6.8 spec 4.4). let repaired = null; - if (repair !== null && verifyAt !== null && final.verify && isSha(fix.head)) { - repaired = await runRepairs(m, io, { head: final.head, verify: final.verify, verifyAt, ...repair }); + if (repair !== null && verifyAt !== null && final.verify && isSha(fix.head) && !allFailAtStart) { + repaired = await runRepairs(m, io, { head: final.head, verify: final.verify, verifyAt, ...repair, + startCheck: final.start_check || null }); final.head = repaired.head; final.verify = repaired.verify; final.repairs = repaired.repairs; @@ -139,7 +151,7 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t // given for its id (more than one shows the contradiction). const verifying = findings.map((f) => ({ ...f, dispositions: dispositions.filter((d) => d && d.id === f.id) })); const rr = await call('final re-review', finalReReviewPrompt(m, tip, final.head, verifying, final.verify || null, - final.repairs.filter((r) => r.eligible)), finalReReviewSchema()); + final.repairs.filter((r) => r.eligible), final.start_check || null), finalReReviewSchema()); // A refused re-review returns the budget sentinel, which has no results. if (!rr || !Array.isArray(rr.results)) { settle(dispositions, null, rr && rr.__budget ? `final re-review not run: ${cause(rr)}` @@ -190,6 +202,88 @@ function repairDue(m, verify, sha) { return !nonDefectFailure(results); } +// Whether a failing verify at sha calls for a start check (1.6.8 spec 4.4): +// repairDue's conditions (not autonomy or admission), and a recorded start +// point (m.start_points.prelude) that is a sha other than sha. +function startCheckDue(m, verify, sha) { + const start = m.start_points && typeof m.start_points === 'object' ? m.start_points.prelude : null; + return repairDue(m, verify, sha) && isSha(start) && start !== sha; +} + +// Whether path is a start-check evidence file /checks/start..json, +// compared with backslashes turned into '/', and without case when the +// ledger is on a drive (a Windows agent may print C:/ in another case). +function startEvidenceOk(m, path) { + if (!present(path)) return false; + const ledger = String(m.repo.ledger_dir).split('\\').join('/').replace(/\/+$/, ''); + const given = path.split('\\').join('/'); + const drive = /^[A-Za-z]:\//.test(ledger); + const prefix = `${ledger}/checks/start.`; + const [p, g] = drive ? [prefix.toLowerCase(), given.toLowerCase()] : [prefix, given]; + return g.startsWith(p) && /^[0-9]+\.json$/.test(g.slice(p.length)); +} + +// The engine's classification of a start check's result r (spec 4.4 "The +// engine classifies"), per failing command of verify (the failing verify at +// sha): fails only when r is checked, for the start point, with its evidence +// under the ledger, a defect exit at the start (not 126, 127 or a +// nonDefectFailure exit), test names, nothing not run, and every head +// failure failing at the start; passes when it exited 0 with nothing not +// run; partly when some but not all head failures fail at the start; +// unknown otherwise, and for a command r leaves out or lists twice. Every +// one is unknown when r is not checked, or names another start sha or +// evidence path (why says which). A null, refused or malformed r is +// unavailable. Returns {status, why?, start_sha, evidence, verify_head, +// commands: [{...r's entry, at_start}], all_fail, some_fail, notes}. +function classifyStartCheck(m, verify, sha, r) { + const start = m.start_points && typeof m.start_points === 'object' ? m.start_points.prelude : null; + const failing = (Array.isArray(verify && verify.results) ? verify.results : []).filter((x) => x && x.exit !== 0); + const unavailable = (why, notes = '') => ({ + status: 'unavailable', why, start_sha: start, evidence: null, verify_head: sha, commands: [], + all_fail: false, some_fail: false, notes, + }); + if (!r || typeof r !== 'object' || r.__budget) { + return unavailable(r && r.__budget ? refusalCause({ unavailable: Boolean(r.__unavailable) }) : 'no result'); + } + const notes = typeof r.notes === 'string' ? r.notes : ''; + if (r.status === 'unavailable') return unavailable('the start check reported unavailable', notes); + if (r.status !== 'checked' || !Array.isArray(r.results)) return unavailable('a malformed result', notes); + const evidence = present(r.evidence) ? r.evidence : null; + const why = r.start_sha !== start ? `it reported start sha ${JSON.stringify(r.start_sha)}, not ${start}` + : !startEvidenceOk(m, evidence) ? `its evidence ${JSON.stringify(r.evidence)} is not a checks/start..json file` + + ` under ${m.repo.ledger_dir}` : null; + const names = (v) => (Array.isArray(v) ? v.filter((t) => typeof t === 'string') : []); + const atStart = (e) => { + if (why !== null) return 'unknown'; + const exit = e.exit_at_start; + if (typeof exit !== 'number') return 'unknown'; + const head = names(e.failed_at_head); + const startFails = names(e.failed_at_start); + const notRun = names(e.not_run); + if (exit === 0) return notRun.length === 0 ? 'passes' : 'unknown'; + if (exit === 126 || exit === 127 || nonDefectFailure([{ exit }])) return 'unknown'; + if (head.length === 0) return 'unknown'; + const both = head.filter((t) => startFails.includes(t)).length; + if (both === head.length) return notRun.length === 0 ? 'fails' : 'unknown'; + return both > 0 ? 'partly' : 'unknown'; + }; + const commands = failing.map((f) => { + const entries = r.results.filter((e) => e && typeof e === 'object' && e.verify_command === f.command); + if (entries.length !== 1) { + return { + verify_command: f.command, start_command: null, narrowed: false, exit_at_start: null, failed_at_head: [], + failed_at_start: [], not_run: [], at_start: 'unknown', + }; + } + return { ...entries[0], at_start: atStart(entries[0]) }; + }); + const fails = commands.filter((c) => c.at_start === 'fails').length; + return { + status: 'checked', ...(why === null ? {} : { why }), start_sha: start, evidence, verify_head: sha, commands, + all_fail: commands.length > 0 && fails === commands.length, some_fail: fails > 0, notes, + }; +} + // Whether a repair's reported head may be delivered (spec 4.5): verify ran // at it, and run-checks --descends-from found from, the repair's start, an // ancestor (the check must name from, not some other sha). @@ -217,7 +311,8 @@ function validRepairReview(rr, delivered) { // The repair loop (spec 4.5). ctx = {head, verify (the failing verify at // head), verifyAt(sha, from), admit() (true admits one more attempt and -// counts it), first (the number of the first attempt: repairs_spent + 1)}. +// counts it), first (the number of the first attempt: repairs_spent + 1), +// startCheck (optional: the classified start check, for each repair prompt)}. // Each attempt is one 'verify repair ' agent (Opus); a sha head that // differs from where it started is verified with --descends-from and // delivered only when eligible. Returns {head, verify, repairs, unreviewed}: @@ -230,7 +325,7 @@ async function runRepairs(m, io, ctx) { let n = ctx.first; while (repairDue(m, verify, head) && ctx.admit()) { const failed = verify.results.filter((r) => r.exit !== 0).map((r) => r.command); - const r = await io.agent(verifyRepairPrompt(m, verify, head, n), + const r = await io.agent(verifyRepairPrompt(m, verify, head, n, ctx.startCheck || null), { label: `verify repair ${n}`, phase: 'Verify', schema: repairSchema(), ...tierSettings('standard') }); // A refused repair's status says why: budget, or unavailable (spec 4.1). const refusedAs = r && r.__budget ? (r.__unavailable ? 'unavailable' : 'budget') : null; @@ -509,7 +604,8 @@ function scheduleFix(m, entry) { // stopped_lanes:[{lane, task, reason}], preflight:{conflicts, rulings, undeclared, schedule}, // integrate:{status, notes, post_integrate, fix_review}, e2e:{items, checked_sha}|null, // final:{findings, fixed, declined, open, cannot_verify, missing_lenses, head, lens_heads, -// review_problem?, task_minors_open}, +// review_problem?, task_minors_open, start_check? (classifyStartCheck's +// result, only when a start check was due: 1.6.8 spec 4.4)}, // verify (the run-checks result at the delivered revision, run once)|null, // verify_stopped (why the last verify's checks stopped twice without a // result, spec 4.1; verify is then null)|null, @@ -1167,6 +1263,21 @@ async function runAll(manifest, io) { return ok; }; const repair = autonomous ? { admit, first: spent + 1 } : null; + // The start check after a failing verify (1.6.8 spec 4.4): at most once + // per launch, only when startCheckDue, and an ordinary agent call (not + // overBudget). With no agent free, or the agents unavailable, it records + // unavailable for the budget without a call, so nothing is refused. + // Returns the classified start check, or null when none was due. + let startChecked = false; + const startCheckAt = async (v, sha) => { + if (startChecked || !startCheckDue(m, v, sha)) return null; + startChecked = true; + if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < 1) { + return { ...classifyStartCheck(m, v, sha, null), why: 'budget' }; + } + const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), sonnetHigh); + return classifyStartCheck(m, v, sha, r); + }; // A budget stop once every task is in: the checks still run at the // feature head the run reached, and the stopped report carries them; not // once agents are unavailable (the verify agent would be refused too). @@ -1186,7 +1297,7 @@ async function runAll(manifest, io) { io.phase('Final review'); final = await runFinalReview(m, e2e, tip, counted, [...(integrate ? integrate.fix_review : []), ...waveFixReview], - hasChecks ? verifyAt : null, taskMinors, repair); + hasChecks ? verifyAt : null, taskMinors, repair, hasChecks ? startCheckAt : null); // Checks that ran inside the final review (a fix agent ran) are the run's // verify: the report carries them once, at the top level. const verifiedInReview = 'verify' in final; @@ -1238,13 +1349,23 @@ async function runAll(manifest, io) { // A verify refused under the agents-unavailable latch must not let the run // report complete with its checks missing (spec 4.1). if (state.refused.length > 0) return budgetReport(); + // A failing verify here gets the start check before any repair (1.6.8 + // spec 4.4), supervised runs too. + if (hasChecks && !verifiedInReview && verify) { + const sc = await startCheckAt(verify, delivered); + if (sc !== null) final.start_check = sc; + if (state.refused.length > 0) return budgetReport(); + } // No final findings and a failing verify (autonomous): the same bounded - // repairs (spec 4.5). A repair that moved the head gets a final re-review - // of its range with no findings under verification; anything but a valid - // result for the delivered head leaves it unreviewed (closed). - if (repair !== null && hasChecks && !verifiedInReview && verify) { + // repairs (spec 4.5), not when every failing command fails at the start + // point too. A repair that moved the head gets a final re-review of its + // range with no findings under verification; anything but a valid result + // for the delivered head leaves it unreviewed (closed). + if (repair !== null && hasChecks && !verifiedInReview && verify + && !(final.start_check && final.start_check.all_fail)) { const start = delivered; - const out = await runRepairs(m, counted, { head: start, verify, verifyAt, ...repair }); + const out = await runRepairs(m, counted, { head: start, verify, verifyAt, ...repair, + startCheck: final.start_check || null }); final.repairs = out.repairs; verify = out.verify; final.head = out.head; @@ -1254,7 +1375,8 @@ async function runAll(manifest, io) { final.unreviewed_fix = out.unreviewed; } else if (out.head !== start) { const rr = await call('final re-review', 'Final review', - finalReReviewPrompt(m, start, out.head, [], verify, out.repairs.filter((r) => r.eligible)), finalReReviewSchema()); + finalReReviewPrompt(m, start, out.head, [], verify, out.repairs.filter((r) => r.eligible), + final.start_check || null), finalReReviewSchema()); if (state.refused.length > 0) return budgetReport(); const problem = validRepairReview(rr, out.head); Object.assign(final, settleFinalFindings([], [], rr && Array.isArray(rr.new_findings) ? rr : null, @@ -1281,6 +1403,7 @@ async function runAll(manifest, io) { post = postCheck; acceptance = acceptanceOf({ m, tasks, final, e2e, verify, post, delivered_sha: delivered, fix_unreviewed: fixUnreviewed, + start_check: final.start_check || null, }); io.log(`parallel-lanes: acceptance ${acceptance.status} at ${delivered}` + (acceptance.reasons.length > 0 ? `: ${acceptance.reasons.map((r) => r.kind).join(', ')}` : '')); diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 98ad007..47cebaa 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -1112,8 +1112,9 @@ function finalFixPrompt(m, findings, base) { // the project checks' result at head (run before the re-review), or null. // repairs: the repairs after a failing verify inside the range // ([{attempt, from, head, failed}], spec 4.5); with no findings the -// re-review judges only those. -function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = []) { +// re-review judges only those. startCheck (1.6.8 spec 4.4): the classified +// start check, shown beside the failing verify, or null. +function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = [], startCheck = null) { const dir = shellQuote(featureDir(m)); const said = (f) => ((f.dispositions || []).length > 0 ? f.dispositions.map((d) => ` fixer: ${d.status} - ${d.reason}` @@ -1144,6 +1145,11 @@ function finalReReviewPrompt(m, base, head, findings, verify = null, repairs = [ '', ...(verify ? [ checksResultText(verify), + ...(startCheck ? [ + ...startCheckLines(startCheck), + 'A failure that fails at the start point too predates this run: do not hold a finding open over that failure' + + ' alone.', + ] : []), findings.length > 0 ? 'Still judge every finding yourself; a failing check is evidence, not a verdict.' : 'A failing check is evidence, not a verdict.', '', @@ -1217,10 +1223,152 @@ function verifyPrompt(m, sha, from = null) { ].join('\n'); } +// The start check after a failing verify (1.6.8 spec 4.4): in a detached +// worktree at /start on the run's start point +// (m.start_points.prelude), with verify's setup and environment (run-checks +// --phase start sets PL_PHASE=verify), run each failing command of verify +// (the failing run-checks JSON at sha), narrowed to its failing tests when the +// project can select them, and report which tests ran and failed. The agent +// reports what the logs show; the engine classifies it (classifyStartCheck). +// Its own checkout rule replaces checkoutRules, which would confine it to the +// feature checkout it must not touch. +function startCheckPrompt(m, verify, sha) { + const q = shellQuote; + const start = m.start_points.prelude; + const wt = `${m.repo.worktree_root}/start`; + const admin = gitAdmin(m); + const feature = featureDir(m); + const setup = finalSetup(m); + const failing = (Array.isArray(verify && verify.results) ? verify.results : []).filter((r) => r && r.exit !== 0); + const evidence = `${m.repo.ledger_dir}/checks/verify-${sha}.json`; + const lines = failing.map((r) => [ + ` - ${r.command} (exit ${r.exit}, group ${r.group})`, + present(r.log) ? ` log: ${r.log}` : ` log: beside ${evidence} (its results name each log)`, + ].join('\n')); + const run = `cd ${q(wt)} && ${pythonCommand(m)} ${q(`${m.skill_dir}/scripts/run-checks`)} ${q(wt)} ` + + `--out ${resultPath()} --root ${q(m.repo.ledger_dir)} --phase 'start' --cmd 'GROUP' 'COMMAND'`; + const remove = `${admin} worktree remove --force ${q(wt)}`; + const prune = `${admin} worktree prune`; + const add = `${admin} worktree add --detach ${q(wt)} ${q(start)}`; + const own = [ + 'Your shell may start in another checkout of this repo, so never rely on the current directory:', + `- every shell command starts with cd ${q(wt)} && or uses git -C ${q(wt)}, except the worktree commands above,`, + ` which run as given (${admin} ...);`, + `- every file you write is under ${wt}, except what run-checks and await-checks write under the ledger;`, + `- never commit, in ${wt} or anywhere else; do not change, stage or commit anything in the feature checkout`, + ` ${q(feature)}, and do not switch its branch.`, + ].join('\n'); + return [ + `You are the start check for parallel-lanes run ${m.run_id}: the delivery checks failed at ${sha}. Find out`, + `whether the failing tests fail at the run's start point ${start} too. Report what the logs show; the run`, + 'classifies it.', + `Work only in the start worktree ${wt}. Do not change, stage or commit anything in the feature checkout`, + `${q(feature)}.`, + `1. Drop stale worktree entries, then list the worktrees: ${prune} && ${admin} worktree list --porcelain`, + ` - Nothing at ${wt} (no worktree listed there and no folder): add it with ${add}`, + ` - A worktree of this repo listed at ${wt}, detached at ${start}: it is the run's own, left by an earlier start`, + ` check. Remove it first (${remove}, then ${prune}), then add it as above.`, + ` - Anything else at that path (a worktree on a branch or at another commit, or a folder git does not list):`, + ' return status unavailable with results [] and notes saying what is there, and touch nothing.', + `2. Run the setup commands there, in order: ${setup.length > 0 ? `cd ${q(wt)} && ${setup.join(' && ')}` : '(none)'}`, + `3. The failing commands at ${sha}:`, + ...lines, + ' Read each log for the names of the tests that failed. If the command can select just those tests through', + " the project's own filter (a test-name or test-file argument it supports), run that narrower command;", + ' otherwise run the same command unchanged. Run them all in one run-checks call in the start worktree, one', + " --cmd pair per failing command (its group, then the command you run there, each single-quoted, writing any", + " single quote inside as '\\'').", + ...procedureLines(m, { + name: 'start', start: run, + stop: 'return status unavailable with results [] and notes naming both outcomes the wait printed.', + exits: ['These are results like any other: report each exit as the wait printed it; the run decides what it means.'], + }).map((l) => ` ${l}`), + '4. Read each start log and name the tests that ran and failed there. A test the log does not show running', + ' (filtered out, skipped, or the command found nothing) is not failing or passing: list it in not_run.', + `5. Remove the worktree: ${remove}, then ${prune}. --force is allowed only on this path. If the removal fails`, + ' (a process holding files), say so in notes; cleanup or the next start check removes it.', + '6. Never change, stage or commit anything in the feature checkout.', + '', + phaseRules(m, own), + '', + `Return status checked, start_sha = the output of git -C ${q(wt)} rev-parse HEAD (it must be ${start}),`, + 'evidence = the path reserve printed (the run-checks result file), results = one entry per failing command', + 'above: {verify_command (the command as listed above), start_command (the command you ran), narrowed (true when', + 'you ran a narrower command), exit_at_start (its exit in the run-checks result; null when it did not run),', + 'failed_at_head (the tests its verify log shows failing), failed_at_start (the tests its start log shows', + 'failing), not_run (the tests of failed_at_head its start log does not show running)}, and notes. Return status', + 'unavailable with results [] when you could not check, and say why in notes.', + ].join('\n'); +} + +// The start check's result (spec 4.4): what the logs show, per failing +// command; the engine classifies it (classifyStartCheck). +function startCheckSchema() { + const tests = { type: 'array', items: { type: 'string' } }; + return { + type: 'object', + properties: { + status: { type: 'string', enum: ['checked', 'unavailable'] }, + start_sha: { type: 'string' }, + evidence: { type: 'string' }, + results: { + type: 'array', + items: { + type: 'object', + properties: { + verify_command: { type: 'string' }, + start_command: { type: 'string' }, + narrowed: { type: 'boolean' }, + exit_at_start: { type: ['integer', 'null'] }, + failed_at_head: tests, + failed_at_start: tests, + not_run: tests, + }, + required: ['verify_command', 'start_command', 'narrowed', 'exit_at_start', 'failed_at_head', 'failed_at_start', + 'not_run'], + }, + }, + notes: { type: 'string' }, + }, + required: ['status', 'start_sha', 'evidence', 'results', 'notes'], + }; +} + +// What a classified start check (classifyStartCheck) says, for the repair +// and final re-review prompts: one line per failing command with its +// at_start and tests; [] for null. +function startCheckLines(sc) { + if (!sc || typeof sc !== 'object') return []; + if (sc.status !== 'checked') { + return [`The start check did not run (${sc.why || 'no result'}): no conclusion about the start point.`]; + } + const start = sc.start_sha; + const list = (tests) => (tests.length > 0 ? tests.join(', ') : '(no tests named)'); + const line = (c) => { + const head = Array.isArray(c.failed_at_head) ? c.failed_at_head : []; + const atStart = Array.isArray(c.failed_at_start) ? c.failed_at_start : []; + if (c.at_start === 'fails') return `- ${c.verify_command}: fails at the start point ${start} too: ${list(head)}`; + if (c.at_start === 'passes') return `- ${c.verify_command}: passes at the start point ${start}: this run broke it`; + if (c.at_start === 'partly') { + const both = head.filter((t) => atStart.includes(t)); + const rest = head.filter((t) => !atStart.includes(t)); + return `- ${c.verify_command}: partly: ${list(both)} fail at the start point ${start} too (they predate this run);` + + ` ${list(rest)} do not`; + } + return `- ${c.verify_command}: unknown: no conclusion from the start point`; + }; + return [ + `The start check ran the failing checks at the run's start point ${start} (evidence ${sc.evidence}), for the` + + ` failures at ${sc.verify_head}:`, + ...(Array.isArray(sc.commands) ? sc.commands : []).map(line), + ]; +} + // The repair agent after a failing verify (spec 4.5, 4.6). verify is the // failing run-checks JSON at from, the head the repair starts at; attempt is -// its number. -function verifyRepairPrompt(m, verify, from, attempt) { +// its number. startCheck (1.6.8 spec 4.4): the classified start check, whose +// lines say which failures predate the run, or null. +function verifyRepairPrompt(m, verify, from, attempt, startCheck = null) { const evidence = `${m.repo.ledger_dir}/checks/verify-${from}.json`; const failing = (Array.isArray(verify && verify.results) ? verify.results : []).filter((r) => r && r.exit !== 0); const lines = failing.map((r) => [ @@ -1234,8 +1382,13 @@ function verifyRepairPrompt(m, verify, from, attempt) { 'Failing checks:', ...lines, `The full evidence is ${evidence}.`, + ...(startCheck ? [ + ...startCheckLines(startCheck), + "A failure that fails at the start point too predates this run: it lies outside this run's scope.", + ] : []), '', 'Find the cause and fix it with the smallest change that is correct, so the checks pass for the right reason.', + `Never create a worktree or checkout outside ${featureDir(m)}.`, 'Never weaken, skip or delete a check, test or acceptance criterion to get a pass, and never change the', 'commands, lane_commands or task_checks, or the test runner configuration, to make them pass. Fixing a wrong', 'test is allowed when the test, not the code, is wrong: say why in notes. If the failure is a flaky, timeout,', diff --git a/tests/acceptance.test.mjs b/tests/acceptance.test.mjs index 8f7c11c..04e0317 100644 --- a/tests/acceptance.test.mjs +++ b/tests/acceptance.test.mjs @@ -192,6 +192,59 @@ test('a check that exits 75 (busy) or 124 (timed out) at the delivered head stil } }); +// ---- 1.6.8: the start check (spec 4.4 "Acceptance") ---- + +// Two failing commands at SHA, and a start check made for SHA whose +// classification of each is given. +function startChecked(testAt, lintAt, head = SHA) { + const input = passing(); + input.m.commands = { test: ['npm test'], lint: ['npm run lint'], build: [] }; + input.verify = { + ...input.verify, ok: false, results: [ + { group: 'test', command: 'npm test', exit: 1 }, + { group: 'lint', command: 'npm run lint', exit: 2 }, + ], + }; + const cmd = (command, exit, tests, at) => ({ + verify_command: command, start_command: command, narrowed: false, exit_at_start: exit, + failed_at_head: tests, failed_at_start: tests, not_run: [], at_start: at, + }); + input.start_check = { + status: 'checked', start_sha: 's0', evidence: '/work/ledger/checks/start.1.json', verify_head: head, + commands: [cmd('npm test', 1, ['slow test', 'other test'], testAt), cmd('npm run lint', 2, ['rule x'], lintAt)], + all_fail: testAt === 'fails' && lintAt === 'fails', some_fail: testAt === 'fails' || lintAt === 'fails', notes: '', + }; + return input; +} + +test('every failing command failing at the start is checks_failed_at_start, still rejected', () => { + const a = acceptanceOf(startChecked('fails', 'fails')); + assert.equal(a.status, 'rejected'); + assert.deepEqual(kinds(a), [['checks_failed_at_start', 'failed']]); + assert.equal(a.reasons[0].detail, 'failing at d1 and at the start point s0: npm test (exit 1): slow test, other test; ' + + 'npm run lint (exit 2): rule x; evidence /work/ledger/checks/start.1.json'); + assert.deepEqual(a.warnings, []); +}); + +test('some commands failing at the start: checks_failed plus the warning', () => { + const a = acceptanceOf(startChecked('fails', 'partly')); + assert.equal(a.status, 'rejected'); + assert.deepEqual(kinds(a), [['checks_failed', 'failed']]); + assert.equal(a.reasons[0].detail, 'failing at d1: npm test (exit 1), npm run lint (exit 2)'); + assert.deepEqual(a.warnings, ['fails at the start point s0 too: npm test']); +}); + +test('a start check for another head changes nothing', () => { + const plain = acceptanceOf({ ...startChecked('fails', 'fails'), start_check: null }); + for (const input of [startChecked('fails', 'fails', 'other'), startChecked('passes', 'unknown'), + { ...startChecked('fails', 'fails'), start_check: { status: 'unavailable', why: 'budget', commands: [], + verify_head: SHA, all_fail: false, some_fail: false } }]) { + const a = acceptanceOf(input); + assert.deepEqual([a.status, a.reasons, a.warnings], [plain.status, plain.reasons, plain.warnings]); + } + assert.deepEqual(kinds(plain), [['checks_failed', 'failed']]); +}); + test('untracked build output stays a warning', () => { const input = passing(); input.verify = { ...input.verify, clean: false, tracked_before: [], tracked_after: [] }; diff --git a/tests/phase-prompts.test.mjs b/tests/phase-prompts.test.mjs index 7bec0a1..6310be5 100644 --- a/tests/phase-prompts.test.mjs +++ b/tests/phase-prompts.test.mjs @@ -421,3 +421,94 @@ test('final reviewers run focused checks in their own scratch folder', () => { assert.ok(lines.includes(SCRATCH), `${name}: ${p}`); } }); + +// ---- 1.6.8: the start check after a failing verify (spec 4.4) ---- + +const { startCheckPrompt, startCheckSchema } = await loadHelpers(['startCheckPrompt', 'startCheckSchema']); + +const START = H('s'); +const failingVerify = () => ({ + head: H('f1'), ok: false, results: [ + { group: 'test', command: 'npm test', exit: 1, log: '/work/ledger/checks/verify-f1.1.log', tail: 'FAIL slow test' }, + { group: 'lint', command: 'npm run lint', exit: 0, log: '/work/ledger/checks/verify-f1.2.log', tail: 'clean' }, + ], +}); +// A classified start check (classifyStartCheck's shape) for the verify above. +const classified = (atStart, head = ['slow test', 'new test'], start = ['slow test']) => ({ + status: 'checked', start_sha: START, evidence: '/work/ledger/checks/start.1.json', verify_head: H('f1'), + commands: [{ verify_command: 'npm test', start_command: 'npm test -- -t slow', narrowed: true, exit_at_start: 1, + failed_at_head: head, failed_at_start: start, not_run: [], at_start: atStart }], + all_fail: atStart === 'fails', some_fail: atStart === 'fails', notes: '', +}); + +test('the start-check prompt names its worktree, the start sha, the start phase and reserve, and the removal', () => { + const m = manifest({ start_points: { prelude: START }, + repo: { ...manifest().repo, worktree_root: '/work/my wt' } }); + const p = startCheckPrompt(m, failingVerify(), H('f1')); + const text = flat(p); + const wt = "'/work/my wt/start'"; + assert.ok(p.includes(`git -C '/work/repo' worktree add --detach ${wt} '${START}'`), p); + assert.ok(text.includes(START) && text.includes(H('f1')), p); + // The ownership check: a worktree of this repo detached at the start sha is the run's own. + assert.ok(text.includes(`detached at ${START}`) && text.includes('worktree list --porcelain'), p); + assert.ok(/Anything else at that path.*return status unavailable/.test(text), p); + assert.ok(p.includes(`git -C '/work/repo' worktree remove --force ${wt}`), p); + assert.ok(p.includes("git -C '/work/repo' worktree prune"), p); + assert.ok(text.includes('--force is allowed only on this path'), p); + // The setup commands run in the start worktree; the failing command and its log are named. + assert.ok(p.includes(`cd ${wt} && npm ci`), p); + assert.ok(text.includes('npm test (exit 1, group test)') && text.includes('/work/ledger/checks/verify-f1.1.log'), p); + assert.ok(!text.includes('npm run lint (exit 0'), 'a passing command is not listed'); + // One run-checks call in that worktree, in the start phase, reserved as start. + assert.ok(p.includes(`${RESERVE}'start'`), p); + const start = p.split('\n').find((l) => l.includes("/scripts/run-checks' ") && l.includes("--phase 'start'")); + assert.ok(start && start.includes(`cd ${wt} && `) && start.includes(`/scripts/run-checks' ${wt} --out '

'`), p); + assert.ok(start.includes("--root '/work/ledger'"), start); + assert.ok(p.includes(WAIT), p); + // It never touches the feature checkout and keeps its own checkout rule. + assert.ok(text.includes("do not change, stage or commit anything in the feature checkout '/work/repo'"), p); + assert.ok(text.includes(`every shell command starts with cd ${wt} &&`), p); + assert.ok(!text.includes("every shell command starts with cd '/work/repo' &&"), p); + assert.ok(text.includes('is not failing or passing: list it in not_run'), p); + // Shadow mode administers worktrees through the shadow git dir. + const shadow = manifest({ start_points: { prelude: START }, + repo: { ...manifest().repo, mode: 'shadow', git_dir: '/work/shadow.git' } }); + assert.ok(startCheckPrompt(shadow, failingVerify(), H('f1')) + .includes(`git --git-dir='/work/shadow.git' worktree add --detach '/work/wt/start' '${START}'`)); + const s = startCheckSchema(); + assert.deepEqual(s.properties.status.enum, ['checked', 'unavailable']); + assert.deepEqual(s.required, ['status', 'start_sha', 'evidence', 'results', 'notes']); + assert.deepEqual(s.properties.results.items.required, ['verify_command', 'start_command', 'narrowed', 'exit_at_start', + 'failed_at_head', 'failed_at_start', 'not_run']); +}); + +test('the repair prompt forbids worktrees outside the feature dir and carries the start check', () => { + const m = manifest({ start_points: { prelude: START } }); + const plain = flat(verifyRepairPrompt(m, failingVerify(), H('f1'), 1)); + assert.ok(plain.includes('Never create a worktree or checkout outside /work/repo.'), plain); + assert.ok(!plain.includes('start point'), plain); + const partly = flat(verifyRepairPrompt(m, failingVerify(), H('f1'), 1, classified('partly'))); + assert.ok(partly.includes(`npm test: partly: slow test fail at the start point ${START} too`), partly); + assert.ok(partly.includes('new test'), partly); + assert.ok(partly.includes('/work/ledger/checks/start.1.json'), partly); + const passes = flat(verifyRepairPrompt(m, failingVerify(), H('f1'), 1, classified('passes', ['slow test'], []))); + assert.ok(passes.includes(`npm test: passes at the start point ${START}: this run broke it`), passes); + const unknown = flat(verifyRepairPrompt(m, failingVerify(), H('f1'), 1, classified('unknown'))); + assert.ok(unknown.includes('npm test: unknown: no conclusion'), unknown); + const none = flat(verifyRepairPrompt(m, failingVerify(), H('f1'), 1, { status: 'unavailable', why: 'budget', + start_sha: START, evidence: null, verify_head: H('f1'), commands: [], all_fail: false, some_fail: false, notes: '' })); + assert.ok(none.includes('The start check did not run (budget): no conclusion'), none); +}); + +test('the final re-review gets the start check beside the failing verify', () => { + const m = manifest({ start_points: { prelude: START } }); + const f = [{ id: 'F1', severity: 'important', file: 'src/a.js', line: 1, issue: 'x', fix: 'y', dispositions: [] }]; + const p = flat(finalReReviewPrompt(m, H('tip'), H('f1'), f, failingVerify(), [], classified('fails', ['slow test'], + ['slow test']))); + const verifyAt = p.indexOf(`The project checks already ran at ${H('f1')}`); + const startAt = p.indexOf(`npm test: fails at the start point ${START} too: slow test`); + assert.ok(verifyAt >= 0 && startAt > verifyAt, p); + assert.ok(p.includes('predates this run: do not hold a finding open over that failure alone'), p); + const without = flat(finalReReviewPrompt(m, H('tip'), H('f1'), f, failingVerify(), [])); + assert.ok(!without.includes('start point'), without); +}); diff --git a/tests/repair.test.mjs b/tests/repair.test.mjs index 925e435..4be303c 100644 --- a/tests/repair.test.mjs +++ b/tests/repair.test.mjs @@ -608,3 +608,260 @@ test('setup_skipped and the check policy reach the report', async () => { const plain = await run(manifest(), { ...noFindings({ verify: [verified(TIP)] }), ...taskScript(ALL) }); assert.deepEqual(plain.result.setup_skipped, []); }); + +// ---- 1.6.8: the start check after a failing verify (spec 4.4) ---- + +const { startCheckDue, classifyStartCheck } = await loadHelpers(['startCheckDue', 'classifyStartCheck']); + +const SP = H('s'); +const started = (overrides = {}) => manifest({ start_points: { prelude: SP }, ...overrides }); +// One command's start-check entry for the verify command npm test. +const atStartOf = (exit, head, start, notRun = [], extra = {}) => ({ + verify_command: 'npm test', start_command: 'npm test -- -t slow', narrowed: true, exit_at_start: exit, + failed_at_head: head, failed_at_start: start, not_run: notRun, ...extra, +}); +const startResult = (results, extra = {}) => ({ + status: 'checked', start_sha: SP, evidence: '/work/ledger/checks/start.1.json', results, notes: '', ...extra, +}); +const failsAtStart = () => startResult([atStartOf(1, ['slow test'], ['slow test'])]); +const passesAtStart = () => startResult([atStartOf(0, ['slow test'], [])]); +const partlyAtStart = () => startResult([atStartOf(1, ['slow test', 'new test'], ['slow test'])]); + +test('a failing verify starts one start check before the first repair', async () => { + const { result, calls } = await run(started(), { + ...withFix({ + verify: [verified(H('f1'), 1), descends(H('r1'), H('f1'))], + 'start check': [passesAtStart()], + 'verify repair 1': [repaired(H('r1'))], + 'final re-review': [resolved(H('r1'))], + }), + ...taskScript(ALL), + }); + const order = labels(calls).filter((l) => ['verify', 'start check', 'verify repair 1'].includes(l)); + assert.deepEqual(order, ['verify', 'start check', 'verify repair 1', 'verify']); + const sc = calls.find((c) => c.label === 'start check'); + assert.equal(sc.phase, 'Verify'); + assert.equal(sc.model, 'sonnet'); + assert.equal(sc.effort, 'high'); + assert.equal(result.final.start_check.status, 'checked'); + assert.equal(result.final.start_check.verify_head, H('f1')); + assert.deepEqual(result.final.start_check.commands.map((c) => c.at_start), ['passes']); + assert.equal(result.acceptance.status, 'accepted', JSON.stringify(result.acceptance)); +}); + +test('a start check runs at both verify sites', async () => { + for (const [name, script, head] of [ + ['after a final fix', withFix({ + verify: [verified(H('f1'), 1), descends(H('r1'), H('f1'))], + 'verify repair 1': [repaired(H('r1'))], + 'final re-review': [resolved(H('r1'))], + }), H('f1')], + ['no final fix', noFindings({ + verify: [verified(TIP, 1), descends(H('r1'), TIP)], + 'verify repair 1': [repaired(H('r1'))], + 'final re-review': [{ head: H('r1'), results: [], new_findings: [] }], + }), TIP], + ]) { + const { result, calls } = await run(started(), { ...script, 'start check': [passesAtStart()], ...taskScript(ALL) }); + const l = labels(calls); + assert.equal(l.filter((x) => x === 'start check').length, 1, `${name}: ${l}`); + assert.ok(l.indexOf('start check') < l.indexOf('verify repair 1'), `${name}: ${l}`); + assert.equal(result.final.start_check.verify_head, head, name); + const p = calls.find((c) => c.label === 'start check').prompt; + assert.ok(p.includes(head) && p.includes(SP), name); + } +}); + +test('no start check without start_points', async () => { + const { result, calls } = await run(manifest(), { + ...withFix({ + verify: [verified(H('f1'), 1), descends(H('r1'), H('f1'))], + 'verify repair 1': [repaired(H('r1'))], + 'final re-review': [resolved(H('r1'))], + }), + ...taskScript(ALL), + }); + assert.ok(!labels(calls).includes('start check')); + assert.ok(!('start_check' in result.final), JSON.stringify(result.final)); +}); + +test('no start check when the start point is the head', async () => { + const { result, calls } = await run(started({ start_points: { prelude: H('f1') } }), { + ...withFix({ + verify: [verified(H('f1'), 1), descends(H('r1'), H('f1'))], + 'verify repair 1': [repaired(H('r1'))], + 'final re-review': [resolved(H('r1'))], + }), + ...taskScript(ALL), + }); + assert.ok(!labels(calls).includes('start check')); + assert.ok(!('start_check' in result.final)); + assert.equal(startCheckDue(started({ start_points: { prelude: H('f1') } }), verified(H('f1'), 1), H('f1')), false); + assert.equal(startCheckDue(started(), verified(H('f1'), 1), H('f1')), true); + assert.equal(startCheckDue(started({ start_points: { prelude: 'not a sha' } }), verified(H('f1'), 1), H('f1')), false); + assert.equal(startCheckDue(started(), verified(H('f1'), 0), H('f1')), false, 'nothing failed'); +}); + +test('no start check for a non-defect failure', async () => { + for (const exit of [75, 124, 137]) { + const { result, calls } = await run(started(), { + ...withFix({ verify: [verified(H('f1'), exit)], 'final re-review': [resolved(H('f1'))] }), + ...taskScript(ALL), + }); + assert.ok(!labels(calls).includes('start check'), `exit ${exit}`); + assert.ok(!('start_check' in result.final), `exit ${exit}`); + } +}); + +test('no start check at the budget-stop verify', async () => { + // Pre-flight, 4 implement + 4 review, integrate and 3 lenses spend 13: the + // final fix is refused and the budget stop runs the checks at the tip. + const m = started({ limits: { review_rounds: 5, max_parallel_lanes: 3, max_agents: 13 } }); + const { result, calls } = await run(m, { ...withFix({ verify: [verified(TIP, 1)] }), ...taskScript(ALL) }); + assert.equal(result.status, 'stopped'); + assert.ok(labels(calls).includes('verify')); + assert.ok(!labels(calls).includes('start check')); + assert.ok(!('start_check' in result.final)); +}); + +test('a start check runs only once in a launch', async () => { + const { result, calls } = await run(started(), { + ...withFix({ + verify: [verified(H('f1'), 1), descends(H('r1'), H('f1'), 1), descends(H('r2'), H('r1'), 1)], + 'start check': [passesAtStart()], + 'verify repair 1': [repaired(H('r1'))], + 'verify repair 2': [repaired(H('r2'))], + 'final re-review': [resolved(H('r2'))], + }), + ...taskScript(ALL), + }); + assert.equal(labels(calls).filter((l) => l === 'start check').length, 1); + assert.equal(result.final.repairs.length, 2); + assert.equal(result.final.start_check.verify_head, H('f1')); +}); + +test('every command fails at the start: no repair', async () => { + for (const [name, script] of [ + ['after a final fix', withFix({ verify: [verified(H('f1'), 1)], 'final re-review': [resolved(H('f1'))] })], + ['no final fix', noFindings({ verify: [verified(TIP, 1)] })], + ]) { + const { result, calls } = await run(started(), { ...script, 'start check': [failsAtStart()], ...taskScript(ALL) }); + assert.ok(!labels(calls).some((l) => l.startsWith('verify repair')), `${name}: ${labels(calls)}`); + assert.deepEqual(result.final.repairs, [], name); + assert.equal(result.final.start_check.all_fail, true, name); + assert.equal(result.acceptance.status, 'rejected', name); + assert.ok(kinds(result).includes('checks_failed_at_start'), `${name}: ${kinds(result)}`); + assert.ok(!kinds(result).includes('checks_failed'), name); + } + // The final re-review gets the findings beside the failing verify. + const { calls } = await run(started(), { + ...withFix({ verify: [verified(H('f1'), 1)], 'final re-review': [resolved(H('f1'))] }), + 'start check': [failsAtStart()], ...taskScript(ALL), + }); + const rr = calls.find((c) => c.label === 'final re-review').prompt; + assert.ok(rr.includes(`fails at the start point ${SP} too`) && rr.includes('slow test'), rr); +}); + +test('partly: repairs run and their prompt names the tests', async () => { + const { result, calls } = await run(started(), { + ...withFix({ + verify: [verified(H('f1'), 1), descends(H('r1'), H('f1'))], + 'start check': [partlyAtStart()], + 'verify repair 1': [repaired(H('r1'))], + 'final re-review': [resolved(H('r1'))], + }), + ...taskScript(ALL), + }); + assert.deepEqual(result.final.start_check.commands.map((c) => c.at_start), ['partly']); + assert.equal(result.final.repairs.length, 1); + const p = calls.find((c) => c.label === 'verify repair 1').prompt; + assert.ok(p.includes('npm test: partly') && p.includes('slow test') && p.includes('new test'), p); + assert.ok(p.includes(SP), p); +}); + +test('a supervised run gets the start check and no repair', async () => { + for (const [name, script] of [ + ['after a final fix', withFix({ verify: [verified(H('f1'), 1)], 'final re-review': [resolved(H('f1'))] })], + ['no final fix', noFindings({ verify: [verified(TIP, 1)] })], + ]) { + const { result, calls } = await run(started({ autonomy: 'supervised' }), { + ...script, 'start check': [passesAtStart()], ...taskScript(ALL), + }); + assert.ok(labels(calls).includes('start check'), name); + assert.ok(!labels(calls).some((l) => l.startsWith('verify repair')), name); + assert.deepEqual(kinds(result), ['checks_failed'], name); + } +}); + +test('no room for the start check: unavailable and no call', async () => { + // 13 agents up to the lenses, then verify (counted, past the cap): none left. + const m = started({ limits: { review_rounds: 5, max_parallel_lanes: 3, max_agents: 14 } }); + const { result, calls } = await run(m, { ...noFindings({ verify: [verified(TIP, 1)] }), ...taskScript(ALL) }); + assert.equal(result.status, 'complete', JSON.stringify(result.reason)); + assert.ok(!labels(calls).includes('start check')); + assert.equal(result.final.start_check.status, 'unavailable'); + assert.equal(result.final.start_check.why, 'budget'); + assert.equal(result.final.start_check.all_fail, false); + assert.deepEqual(kinds(result), ['checks_failed']); +}); + +// The engine's guards (spec 4.4 "The engine classifies"). +test('classifyStartCheck: each guard gives the expected at_start', () => { + const m = started(); + const sha = H('f1'); + const v = verified(sha, 1); + const at = (r) => classifyStartCheck(m, v, sha, r).commands.map((c) => c.at_start); + assert.deepEqual(at(failsAtStart()), ['fails']); + const good = classifyStartCheck(m, v, sha, failsAtStart()); + assert.deepEqual([good.status, good.all_fail, good.some_fail, good.start_sha, good.verify_head, good.evidence], + ['checked', true, true, SP, sha, '/work/ledger/checks/start.1.json']); + assert.deepEqual(at(failsAtStart()), ['fails']); + assert.deepEqual(at({ ...failsAtStart(), start_sha: H('other') }), ['unknown'], 'start sha'); + for (const evidence of ['/work/ledger/checks/verify-x.json', '/elsewhere/checks/start.1.json', + '/work/ledger/checks/start.json', '/work/ledger/checks/start.1.json.bak', '']) { + assert.deepEqual(at({ ...failsAtStart(), evidence }), ['unknown'], `evidence ${evidence}`); + } + assert.deepEqual(at(passesAtStart()), ['passes'], 'exit 0'); + assert.deepEqual(at(startResult([atStartOf(0, ['slow test'], [], ['slow test'])])), ['unknown'], 'exit 0, not run'); + for (const exit of [126, 127, 124, 75, 137, -1, null]) { + assert.deepEqual(at(startResult([atStartOf(exit, ['slow test'], ['slow test'])])), ['unknown'], `exit ${exit}`); + } + assert.deepEqual(at(startResult([atStartOf(1, ['slow test'], ['slow test'], ['other test'])])), ['unknown'], 'not_run'); + assert.deepEqual(at(partlyAtStart()), ['partly'], 'a head failure missing at the start'); + assert.deepEqual(at(startResult([atStartOf(1, ['slow test'], [])])), ['unknown'], 'none fails at the start'); + assert.deepEqual(at(startResult([atStartOf(1, [], [])])), ['unknown'], 'no test names'); + // Not checked: no command is classified. + const notChecked = classifyStartCheck(m, v, sha, { ...failsAtStart(), status: 'unavailable' }); + assert.deepEqual([notChecked.status, notChecked.commands, notChecked.all_fail], ['unavailable', [], false]); + // A wrong start sha or evidence path says why. + assert.match(classifyStartCheck(m, v, sha, { ...failsAtStart(), start_sha: H('other') }).why, /start sha/); + assert.match(classifyStartCheck(m, v, sha, { ...failsAtStart(), evidence: '/x/start.1.json' }).why, /evidence/); + assert.ok(!('why' in good), 'a sound result has no why'); + // A failing command the agent left out is unknown, so not every one fails. + const two = verified(sha, 1, { results: [...verified(sha, 1).results, + { group: 'lint', command: 'npm run lint', exit: 2, log: '/work/ledger/checks/l.log', tail: '' }] }); + const m2 = started({ commands: { setup: [], test: ['npm test'], lint: ['npm run lint'], build: [] } }); + const partial = classifyStartCheck(m2, two, sha, failsAtStart()); + assert.deepEqual(partial.commands.map((c) => [c.verify_command, c.at_start]), + [['npm test', 'fails'], ['npm run lint', 'unknown']]); + assert.deepEqual([partial.all_fail, partial.some_fail], [false, true]); + // A Windows ledger: backslashes and another drive-letter case still match. + const win = started({ repo: { ...manifest().repo, ledger_dir: 'C:/Work/Ledger' } }); + const winPath = ['c:', 'work', 'ledger', 'checks', 'start.2.json'].join(String.fromCharCode(92)); + assert.deepEqual(classifyStartCheck(win, v, sha, { ...failsAtStart(), evidence: winPath }).commands + .map((c) => c.at_start), ['fails']); +}); + +test('classifyStartCheck: a null or malformed result is unavailable', () => { + const m = started(); + const sha = H('f1'); + for (const r of [null, undefined, 'x', { status: 'checked' }, { status: 'maybe', results: [] }, { __budget: true }]) { + const c = classifyStartCheck(m, verified(sha, 1), sha, r); + assert.equal(c.status, 'unavailable', JSON.stringify(r)); + assert.deepEqual([c.all_fail, c.some_fail, c.verify_head], [false, false, sha], JSON.stringify(r)); + assert.equal(typeof c.why, 'string', JSON.stringify(r)); + } + const said = classifyStartCheck(m, verified(sha, 1), sha, { status: 'unavailable', start_sha: SP, evidence: '', + results: [], notes: 'a folder is in the way' }); + assert.deepEqual([said.status, said.all_fail, said.notes], ['unavailable', false, 'a folder is in the way']); +}); From 11a6597d17c9543d340ebf6813186ec6d474a350 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:38:54 -0700 Subject: [PATCH 10/14] feat: a resumed integration reuses a passing check --- run.workflow.js | 58 +++++++++++++++++++++++++++++------- src/phases.js | 14 +++++---- src/prompts-phase.js | 35 ++++++++++++++++++++-- src/prompts-task.js | 9 ++++-- tests/phase-prompts.test.mjs | 56 +++++++++++++++++++++++++++++++++- tests/run.test.mjs | 54 +++++++++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 21 deletions(-) diff --git a/run.workflow.js b/run.workflow.js index e8c8746..8b7d38c 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1355,14 +1355,17 @@ function checksPhase(laneId) { // (optional) is the evidence file it writes under the ledger dir: // resultPath() goes in as given (already quoted), for the path the agent's // reserve prints. phase (1.6.5) is run-checks' --phase: the commands run -// with PL_PHASE set to it. null for an empty list. -function runChecksCommand(m, dir, checks, out = null, phase = null) { +// with PL_PHASE set to it. recordSetup (1.6.8, spec 4.7) lists setup +// commands run-checks records in its evidence (--record-setup, in order) +// and never runs. null for an empty list. +function runChecksCommand(m, dir, checks, out = null, phase = null, recordSetup = []) { if (checks.length === 0) return null; const parts = checks.flatMap((c) => ['--cmd', shellQuote(c.group), shellQuote(c.command)]); const target = out === resultPath() ? out : shellQuote(out); const evidence = out === null ? [] : ['--out', target, '--root', shellQuote(m.repo.ledger_dir)]; + const setup = recordSetup.flatMap((s) => ['--record-setup', shellQuote(s)]); return [`cd ${shellQuote(dir)} && ${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/run-checks`)}`, shellQuote(dir), - ...evidence, ...(phase === null ? [] : ['--phase', shellQuote(phase)]), ...parts].join(' '); + ...evidence, ...(phase === null ? [] : ['--phase', shellQuote(phase)]), ...setup, ...parts].join(' '); } // Every test, lint and build command of a ledger lane, as [{group, command}] @@ -2424,6 +2427,15 @@ function statusSchema() { }; } +// The integrate agent's result (1.6.8, spec 4.7): statusSchema plus the +// optional checks_reused, the integrate evidence file reuse-checks printed +// when the agent reused a passing check instead of running the checks +// (else null). +function integrateSchema() { + const s = statusSchema(); + return { ...s, properties: { ...s.properties, checks_reused: { type: ['string', 'null'] } } }; +} + function preflightSchema() { // undeclared and code_deps share the item shape {task, producer, what}. const dependencies = () => ({ @@ -2736,8 +2748,17 @@ function integratePrompt(m, preludeTip, opts = {}) { // and the lanes' distinct ones, then every distinct check of the project // and the lane_commands overrides, with the long-check procedure (1.6.1) // under the reserve name integrate, its logs beside the result. + // The run-checks call records the setup commands (1.6.8, spec 4.7), so a + // resumed integration whose merges made no commit can reuse that evidence + // through reuse-checks, given the same setup and check commands. const setup = finalSetup(m); - const checks = runChecksCommand(m, featureDir(m), finalChecks(m), resultPath(), 'integrate'); + const checks = runChecksCommand(m, featureDir(m), finalChecks(m), resultPath(), 'integrate', setup); + const reuse = checks === null ? null : [ + `${pythonCommand(m)} ${q(`${m.skill_dir}/scripts/reuse-checks`)} ${q(m.repo.ledger_dir)} --name integrate`, + `--head --checkout ${dir}`, + ...setup.map((s) => `--setup ${q(s)}`), + ...finalChecks(m).map((c) => `--cmd ${q(c.group)} ${q(c.command)}`), + ].join(' '); // Heal mode reports checks that did not finish (1.6.2 spec 4.3): the // engine then starts no fix and stops. The other modes keep the general // exit-code rule. @@ -2747,8 +2768,20 @@ function integratePrompt(m, preludeTip, opts = {}) { 'tests_failed true and checks_unfinished true, naming the exits in notes. A failing command with any other exit', 'is a real failure: checks_unfinished false.', ] : null; + // Only heal mode returns tests_failed, so only its reuse names it. + const reuseLines = reuse === null ? [] : [ + ' Always run the setup commands first: later phases rely on them in this checkout.', + ' Only when no merge in step 2 made a commit (every lane branch was already merged, so HEAD did not move),', + ` run this read-only call, with the full sha git -C ${dir} rev-parse HEAD prints:`, + ` ${reuse}`, + ` When it exits 0, skip the run-checks call below: return checks_reused = the file it printed${ + testFailure === 'heal' ? ' and tests_failed false' : ''},`, + ' and say in notes that the checks were reused from that file. When it exits 1 or 2, or a merge in step 2', + ' made a commit, run the checks below.', + ]; const step3 = [ ` setup: ${setup.length > 0 ? setup.join(' && ') : '(none)'}`, + ...reuseLines, checks === null ? ' checks: (no test, lint or build command)' : " checks, as one run-checks call (each command's exit status is kept; it exits non-zero when any fails).", ...(checks === null ? [] : [ @@ -2872,7 +2905,8 @@ function integratePrompt(m, preludeTip, opts = {}) { ...(joinStartPoint ? ['', joinStartPointLine(m, testFailure === 'heal')] : []), '', `Return status done or failed, head (the full sha printed by git -C ${dir} rev-parse HEAD when you finish),`, - `notes (merges, conflicts resolved, command results, cleanup)${returnTail}`, + `notes (merges, conflicts resolved, command results, cleanup)${reuse === null ? '' : ', checks_reused (the file' + + ' reuse-checks printed when you reused its checks, else null)'}${returnTail}`, ].join('\n'); } @@ -5920,7 +5954,8 @@ function scheduleFix(m, entry) { // minor_findings (the approving review's, taskMinorFindings; [] unless done // in this launch), rulings (the adjudicator's rulings for the task, [] when none)}}, // stopped_lanes:[{lane, task, reason}], preflight:{conflicts, rulings, undeclared, schedule}, -// integrate:{status, notes, post_integrate, fix_review}, e2e:{items, checked_sha}|null, +// integrate:{status, notes, checks_reused (the evidence file a resumed +// integration reused, else null: 1.6.8 spec 4.7), post_integrate, fix_review}, e2e:{items, checked_sha}|null, // final:{findings, fixed, declined, open, cannot_verify, missing_lenses, head, lens_heads, // review_problem?, task_minors_open, start_check? (classifyStartCheck's // result, only when a start check was due: 1.6.8 spec 4.4)}, @@ -6281,7 +6316,10 @@ async function runAll(manifest, io) { // Post-integrate re-review findings (C2): reported under // integrate.fix_review and carried into the final fix wave. const fixReview = []; - const integrateReport = (r) => ({ ...phaseResult(r, 'integrate'), post_integrate: null, fix_review: fixReview }); + // checks_reused (1.6.8, spec 4.7): the integrate evidence file the agent + // reused instead of running the checks, else null. + const integrateReport = (r) => ({ ...phaseResult(r, 'integrate'), + checks_reused: r && present(r.checks_reused) ? r.checks_reused : null, post_integrate: null, fix_review: fixReview }); // The Opus fix + scoped re-review (C2) for a post-integration failure. // Returns the head to rerun from (base when the fix made no commit), or // BUDGET when a spawn was refused. @@ -6311,7 +6349,7 @@ async function runAll(manifest, io) { let integ = await callM('integrate', 'Integrate', integratePrompt(m, preludeTip, { conflictMode: 'abort', joinStartPoint: joinNoHook, laneTips: tips, keepWorktrees: wavesOn }), - statusSchema(), sonnetHigh); + integrateSchema(), sonnetHigh); if (state.refused.length > 0) return budgetReport(); // Any Sonnet result that is not a finished integration (a done without a // head included) escalates. @@ -6338,7 +6376,7 @@ async function runAll(manifest, io) { joinStartPoint: joinNoHook, laneTips: tips, keepWorktrees: wavesOn, - }), statusSchema(), standard); + }), integrateSchema(), standard); if (state.refused.length > 0) return budgetReport(); } integrate = integrateReport(integ); @@ -6360,7 +6398,7 @@ async function runAll(manifest, io) { integ = await callM('integrate', 'Integrate', integratePrompt(m, preludeTip, { testFailure: 'heal', joinStartPoint: joinNoHook, laneTips: tips, keepWorktrees: wavesOn }), - statusSchema(), standard); + integrateSchema(), standard); if (state.refused.length > 0) return budgetReport(); integrate = integrateReport(integ); if (integrate.status !== 'done' || integ.tests_failed === true) { diff --git a/src/phases.js b/src/phases.js index 33a60bf..8b6d466 100644 --- a/src/phases.js +++ b/src/phases.js @@ -602,7 +602,8 @@ function scheduleFix(m, entry) { // minor_findings (the approving review's, taskMinorFindings; [] unless done // in this launch), rulings (the adjudicator's rulings for the task, [] when none)}}, // stopped_lanes:[{lane, task, reason}], preflight:{conflicts, rulings, undeclared, schedule}, -// integrate:{status, notes, post_integrate, fix_review}, e2e:{items, checked_sha}|null, +// integrate:{status, notes, checks_reused (the evidence file a resumed +// integration reused, else null: 1.6.8 spec 4.7), post_integrate, fix_review}, e2e:{items, checked_sha}|null, // final:{findings, fixed, declined, open, cannot_verify, missing_lenses, head, lens_heads, // review_problem?, task_minors_open, start_check? (classifyStartCheck's // result, only when a start check was due: 1.6.8 spec 4.4)}, @@ -963,7 +964,10 @@ async function runAll(manifest, io) { // Post-integrate re-review findings (C2): reported under // integrate.fix_review and carried into the final fix wave. const fixReview = []; - const integrateReport = (r) => ({ ...phaseResult(r, 'integrate'), post_integrate: null, fix_review: fixReview }); + // checks_reused (1.6.8, spec 4.7): the integrate evidence file the agent + // reused instead of running the checks, else null. + const integrateReport = (r) => ({ ...phaseResult(r, 'integrate'), + checks_reused: r && present(r.checks_reused) ? r.checks_reused : null, post_integrate: null, fix_review: fixReview }); // The Opus fix + scoped re-review (C2) for a post-integration failure. // Returns the head to rerun from (base when the fix made no commit), or // BUDGET when a spawn was refused. @@ -993,7 +997,7 @@ async function runAll(manifest, io) { let integ = await callM('integrate', 'Integrate', integratePrompt(m, preludeTip, { conflictMode: 'abort', joinStartPoint: joinNoHook, laneTips: tips, keepWorktrees: wavesOn }), - statusSchema(), sonnetHigh); + integrateSchema(), sonnetHigh); if (state.refused.length > 0) return budgetReport(); // Any Sonnet result that is not a finished integration (a done without a // head included) escalates. @@ -1020,7 +1024,7 @@ async function runAll(manifest, io) { joinStartPoint: joinNoHook, laneTips: tips, keepWorktrees: wavesOn, - }), statusSchema(), standard); + }), integrateSchema(), standard); if (state.refused.length > 0) return budgetReport(); } integrate = integrateReport(integ); @@ -1042,7 +1046,7 @@ async function runAll(manifest, io) { integ = await callM('integrate', 'Integrate', integratePrompt(m, preludeTip, { testFailure: 'heal', joinStartPoint: joinNoHook, laneTips: tips, keepWorktrees: wavesOn }), - statusSchema(), standard); + integrateSchema(), standard); if (state.refused.length > 0) return budgetReport(); integrate = integrateReport(integ); if (integrate.status !== 'done' || integ.tests_failed === true) { diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 47cebaa..354b8a3 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -69,6 +69,15 @@ function statusSchema() { }; } +// The integrate agent's result (1.6.8, spec 4.7): statusSchema plus the +// optional checks_reused, the integrate evidence file reuse-checks printed +// when the agent reused a passing check instead of running the checks +// (else null). +function integrateSchema() { + const s = statusSchema(); + return { ...s, properties: { ...s.properties, checks_reused: { type: ['string', 'null'] } } }; +} + function preflightSchema() { // undeclared and code_deps share the item shape {task, producer, what}. const dependencies = () => ({ @@ -381,8 +390,17 @@ function integratePrompt(m, preludeTip, opts = {}) { // and the lanes' distinct ones, then every distinct check of the project // and the lane_commands overrides, with the long-check procedure (1.6.1) // under the reserve name integrate, its logs beside the result. + // The run-checks call records the setup commands (1.6.8, spec 4.7), so a + // resumed integration whose merges made no commit can reuse that evidence + // through reuse-checks, given the same setup and check commands. const setup = finalSetup(m); - const checks = runChecksCommand(m, featureDir(m), finalChecks(m), resultPath(), 'integrate'); + const checks = runChecksCommand(m, featureDir(m), finalChecks(m), resultPath(), 'integrate', setup); + const reuse = checks === null ? null : [ + `${pythonCommand(m)} ${q(`${m.skill_dir}/scripts/reuse-checks`)} ${q(m.repo.ledger_dir)} --name integrate`, + `--head --checkout ${dir}`, + ...setup.map((s) => `--setup ${q(s)}`), + ...finalChecks(m).map((c) => `--cmd ${q(c.group)} ${q(c.command)}`), + ].join(' '); // Heal mode reports checks that did not finish (1.6.2 spec 4.3): the // engine then starts no fix and stops. The other modes keep the general // exit-code rule. @@ -392,8 +410,20 @@ function integratePrompt(m, preludeTip, opts = {}) { 'tests_failed true and checks_unfinished true, naming the exits in notes. A failing command with any other exit', 'is a real failure: checks_unfinished false.', ] : null; + // Only heal mode returns tests_failed, so only its reuse names it. + const reuseLines = reuse === null ? [] : [ + ' Always run the setup commands first: later phases rely on them in this checkout.', + ' Only when no merge in step 2 made a commit (every lane branch was already merged, so HEAD did not move),', + ` run this read-only call, with the full sha git -C ${dir} rev-parse HEAD prints:`, + ` ${reuse}`, + ` When it exits 0, skip the run-checks call below: return checks_reused = the file it printed${ + testFailure === 'heal' ? ' and tests_failed false' : ''},`, + ' and say in notes that the checks were reused from that file. When it exits 1 or 2, or a merge in step 2', + ' made a commit, run the checks below.', + ]; const step3 = [ ` setup: ${setup.length > 0 ? setup.join(' && ') : '(none)'}`, + ...reuseLines, checks === null ? ' checks: (no test, lint or build command)' : " checks, as one run-checks call (each command's exit status is kept; it exits non-zero when any fails).", ...(checks === null ? [] : [ @@ -517,7 +547,8 @@ function integratePrompt(m, preludeTip, opts = {}) { ...(joinStartPoint ? ['', joinStartPointLine(m, testFailure === 'heal')] : []), '', `Return status done or failed, head (the full sha printed by git -C ${dir} rev-parse HEAD when you finish),`, - `notes (merges, conflicts resolved, command results, cleanup)${returnTail}`, + `notes (merges, conflicts resolved, command results, cleanup)${reuse === null ? '' : ', checks_reused (the file' + + ' reuse-checks printed when you reused its checks, else null)'}${returnTail}`, ].join('\n'); } diff --git a/src/prompts-task.js b/src/prompts-task.js index fb6c408..94628c0 100644 --- a/src/prompts-task.js +++ b/src/prompts-task.js @@ -124,14 +124,17 @@ function checksPhase(laneId) { // (optional) is the evidence file it writes under the ledger dir: // resultPath() goes in as given (already quoted), for the path the agent's // reserve prints. phase (1.6.5) is run-checks' --phase: the commands run -// with PL_PHASE set to it. null for an empty list. -function runChecksCommand(m, dir, checks, out = null, phase = null) { +// with PL_PHASE set to it. recordSetup (1.6.8, spec 4.7) lists setup +// commands run-checks records in its evidence (--record-setup, in order) +// and never runs. null for an empty list. +function runChecksCommand(m, dir, checks, out = null, phase = null, recordSetup = []) { if (checks.length === 0) return null; const parts = checks.flatMap((c) => ['--cmd', shellQuote(c.group), shellQuote(c.command)]); const target = out === resultPath() ? out : shellQuote(out); const evidence = out === null ? [] : ['--out', target, '--root', shellQuote(m.repo.ledger_dir)]; + const setup = recordSetup.flatMap((s) => ['--record-setup', shellQuote(s)]); return [`cd ${shellQuote(dir)} && ${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/run-checks`)}`, shellQuote(dir), - ...evidence, ...(phase === null ? [] : ['--phase', shellQuote(phase)]), ...parts].join(' '); + ...evidence, ...(phase === null ? [] : ['--phase', shellQuote(phase)]), ...setup, ...parts].join(' '); } // Every test, lint and build command of a ledger lane, as [{group, command}] diff --git a/tests/phase-prompts.test.mjs b/tests/phase-prompts.test.mjs index 6310be5..e5b221a 100644 --- a/tests/phase-prompts.test.mjs +++ b/tests/phase-prompts.test.mjs @@ -11,10 +11,11 @@ import { loadHelpers, H } from './harness.mjs'; const { integratePrompt, postIntegrateFixPrompt, finalFixPrompt, verifyPrompt, verifySchema, verifyRepairPrompt, repairSchema, finalReReviewPrompt, finalChecks, finalSetup, repairFixMessage, postIntegratePrompt, statusSchema, + integrateSchema, } = await loadHelpers([ 'integratePrompt', 'postIntegrateFixPrompt', 'finalFixPrompt', 'verifyPrompt', 'verifySchema', 'verifyRepairPrompt', 'repairSchema', 'finalReReviewPrompt', 'finalChecks', 'finalSetup', 'repairFixMessage', 'postIntegratePrompt', - 'statusSchema', + 'statusSchema', 'integrateSchema', ]); function task(id) { @@ -185,6 +186,59 @@ test('statusSchema has an optional checks_unfinished for integrate (spec 4.3)', assert.deepEqual(s.required, ['status', 'head', 'notes']); }); +// ---- 1.6.8: a resumed integration reuses a passing check (spec 4.7) + +test('the integrate prompt runs setup, then reuse-checks only when its merges made no commit', () => { + const m = manifest({ + lane_commands: { alpha: { test: ['npm test -- alpha'], setup: ['npm ci -w alpha'] }, beta: { lint: [] } }, + }); + for (const opts of [{ conflictMode: 'abort' }, { testFailure: 'heal' }, {}]) { + const p = integratePrompt(m, H('pre'), opts); + const text = flat(p); + const reuse = p.split('\n').find((l) => l.includes("/scripts/reuse-checks' ")); + assert.ok(reuse, p); + assert.ok(reuse.includes("'python3' '/skills/parallel-lanes/scripts/reuse-checks' '/work/ledger' --name integrate " + + "--head --checkout '/work/repo'"), reuse); + // The same setup and check commands as the run-checks call, in order. + for (const s of finalSetup(m)) assert.ok(reuse.includes(`--setup '${s}'`), s); + assert.ok(reuse.indexOf("--setup 'npm ci'") < reuse.indexOf("--setup 'npm ci -w alpha'"), reuse); + assert.deepEqual(cmdPairs(reuse), finalChecks(m)); + assert.ok(reuse.lastIndexOf('--setup') < reuse.indexOf('--cmd'), reuse); + // The run-checks call records each setup command, never running it. + const line = runChecksLines(p)[0]; + assert.ok(line.includes("--record-setup 'npm ci' --record-setup 'npm ci -w alpha'"), line); + assert.deepEqual(cmdPairs(line), cmdPairs(reuse)); + // Setup always runs first; reuse only when the merges made no commit. + assert.ok(text.indexOf('setup: npm ci && npm ci -w alpha') < text.indexOf(flat(reuse)), text); + assert.ok(text.includes('Always run the setup commands first'), text); + assert.ok(text.includes('Only when no merge in step 2 made a commit'), text); + assert.ok(text.includes('When it exits 0, skip the run-checks call below: return checks_reused'), text); + // Only heal mode returns tests_failed. + assert.equal(text.includes('the file it printed and tests_failed false'), opts.testFailure === 'heal', text); + assert.ok(text.includes('say in notes that the checks were reused'), text); + assert.ok(text.indexOf(flat(reuse)) < text.indexOf(flat(line)), 'reuse is tried before the run-checks call'); + assert.ok(text.includes('checks_reused (the file reuse-checks printed when you reused its checks, else null)'), text); + } + // No setup commands: no --setup and no --record-setup. + const bare = manifest({ commands: { setup: [], test: ['npm test'], lint: [], build: [] } }); + const p = integratePrompt(bare, H('pre')); + const reuse = p.split('\n').find((l) => l.includes("/scripts/reuse-checks' ")); + assert.ok(!reuse.includes('--setup'), reuse); + assert.ok(!runChecksLines(p)[0].includes('--record-setup'), p); + // No checks at all: nothing to reuse. + const none = integratePrompt(manifest({ commands: { setup: ['npm ci'], test: [], lint: [], build: [] } }), H('pre')); + assert.ok(!none.includes('reuse-checks'), none); +}); + +test('integrateSchema extends statusSchema with an optional checks_reused', () => { + const s = integrateSchema(); + const base = statusSchema(); + assert.deepEqual(s.required, base.required); + assert.deepEqual(s.properties.checks_reused, { type: ['string', 'null'] }); + for (const [k, v] of Object.entries(base.properties)) assert.deepEqual(s.properties[k], v, k); + assert.ok(!('checks_reused' in base.properties)); +}); + // ---- 1.6.1: the long-check procedure in phase prompts (spec 4.1, 4.4) test('integrate reserves integrate and keeps logs with the evidence', () => { diff --git a/tests/run.test.mjs b/tests/run.test.mjs index 077c017..f1593c7 100644 --- a/tests/run.test.mjs +++ b/tests/run.test.mjs @@ -340,6 +340,60 @@ test('shadow integrate prompt: lane branches are deleted from the feature worktr assert.match(p, /Cleanup never fails the integration/); }); +// 1.6.8 (spec 4.7): a resumed integration whose merges made no commit may +// reuse a passing check; the report keeps the evidence file it reused. +test('an integrate result with checks_reused reaches the report', async () => { + const file = '/work/ledger/checks/integrate.3.json'; + const reused = { status: 'done', head: H('I1'), notes: 'checks reused from integrate.3.json', tests_failed: false, + checks_reused: file }; + const first = await run(manifest(), { ...phaseScript({ integrate: [reused] }), ...taskScript(ALL) }); + assert.equal(first.result.status, 'complete'); + assert.equal(first.result.integrate.checks_reused, file); + // The escalated rerun (the second integrate call) and the heal rerun (the + // third) report it the same way, from the result that ran last. + const healed = await run(manifest(), { + ...phaseScript({ + integrate: [ + { status: 'failed', head: '', notes: 'tests red', conflict_files: [] }, + { status: 'done', head: H('I2'), notes: 'merged but tests fail', tests_failed: true }, + { status: 'done', head: H('I3'), notes: 'reused', checks_reused: file }, + ], + 'post-integrate fix': [{ status: 'done', head: H('FX'), notes: 'fixed import' }], + 'post-integrate re-review': [{ findings: [] }], + }), + ...taskScript(ALL), + }); + assert.equal(healed.result.status, 'complete'); + assert.equal(healed.result.integrate.checks_reused, file); + const escalated = await run(manifest(), { + ...phaseScript({ + integrate: [ + { status: 'failed', head: '', notes: 'tests red', conflict_files: [] }, + { status: 'done', head: H('I2'), notes: 'reused', checks_reused: file }, + ], + }), + ...taskScript(ALL), + }); + assert.equal(escalated.result.integrate.checks_reused, file); + // Every integrate call offers the field. + const integ = healed.calls.filter((c) => c.label === 'integrate'); + assert.equal(integ.length, 3); + for (const c of integ) assert.deepEqual(c.schema.properties.checks_reused, { type: ['string', 'null'] }); +}); + +test('a missing checks_reused is null', async () => { + const { result } = await run(manifest(), { ...phaseScript(), ...taskScript(ALL) }); + assert.equal(result.status, 'complete'); + assert.ok('checks_reused' in result.integrate, JSON.stringify(result.integrate)); + assert.equal(result.integrate.checks_reused, null); + const failed = await run(manifest({ autonomy: 'supervised' }), { + ...phaseScript({ integrate: [{ status: 'failed', head: '', notes: 'x' }, null], 'integrate retry': [null] }), + ...taskScript(ALL), + }); + assert.equal(failed.result.integrate.status, 'failed'); + assert.equal(failed.result.integrate.checks_reused, null); +}); + test('pre-flight conflicts stop before any implement (supervised)', async () => { const m = manifest({ autonomy: 'supervised' }); const script = phaseScript({ 'pre-flight': [{ conflicts: ['plan contradicts spec on X'], rulings: [], undeclared: [], code_deps: [] }] }); From 8cb92862b16ee9ed3e2c06b6a1dd7bca6b83513f Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 10:59:21 -0700 Subject: [PATCH 11/14] docs: archive integrity, the start check, permits, reuse and widening --- SKILL.md | 6 + reference.md | 305 +++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 275 insertions(+), 36 deletions(-) diff --git a/SKILL.md b/SKILL.md index 8b6911a..6a409ca 100644 --- a/SKILL.md +++ b/SKILL.md @@ -282,9 +282,15 @@ The run returns `status`: "check-verify error" with the exit code and stderr, keep the marker, and recover as reference.md "Verify evidence" says. Lead with `acceptance` (reference.md "Report"). Only `accepted` is delivered work. `rejected` or `unverified`: show every reason, notify, keep the marker; never call it done. + `checks_failed_at_start` (the failures fail at the run's start point too) is shown like any + rejection: show its detail, which names the evidence, and suggest the `ledger accept` text + for it (reference.md "Report"). The user fixes and resumes, or explicitly accepts named reasons or warnings: record that with ` /scripts/ledger accept ""`. Then rerun archive-run, audit and tool-outcomes, so the override reaches the archive. + Once the accepts cover every reason (and the warnings the user named) and those reruns are + done, remove the marker with `bash /scripts/active-run remove `, so a later + session does not resume the run. Report: run ` /scripts/run-report --out /..report.json` (Launch step 4's transcript dir) and append diff --git a/reference.md b/reference.md index 6421d74..da2e79a 100644 --- a/reference.md +++ b/reference.md @@ -79,7 +79,7 @@ PATH, else `C:/Program Files/Git`), never the WSL `bash.exe` in System32. | `commands` | `setup`, `test`, `lint`, `build`: lists of shell commands run from a checkout. Take them from the plan's conventions, then the project (package.json scripts, pyproject, Makefile). Use `[]` for a group the project lacks. | | `lane_commands` | Optional `{: {setup?, test?, lint?, build?}}` overrides for lanes that need only a subset (e.g. backend lanes skip the frontend suite). They are the lane's own checks: integration and verify run every distinct one, so a narrower run of a project check belongs in `task_checks`, not here. | | `task_checks` | Optional `{: {test, lint?, build?}}`: the targeted checks a task runs once before committing (see Check ownership). `test` is required and names at least one command; an absent `lint` or `build` inherits the lane's. A batch uses its last task's entry. Every command must be a narrower run of a check the delivery inventory already runs, in the same checkout and environment; must run correctly at the task that uses it (list the lane's test files created or changed by that task and the lane's earlier tasks, cumulatively, so no entry names a file a later task creates); must fail when it selects no tests (name existing test files; a filter that passes on zero matches needs a wrapper that fails); and keeps an explicit worker limit like the project's test command (for example `node --test --test-concurrency=4 ...`). Derive the entries from the briefs' test files and commands; a task you cannot map gets no entry and runs its lane's checks. Never part of the delivery inventory. | -| `widen_paths` | Optional list of project-relative paths (a trailing `/` marks a directory; an existing directory named without it counts as one too) of shared infrastructure: test harness and helpers, package and lock files, build and CI config, modules many lanes import. A task whose change touches one runs project-wide checks instead of its targeted ones. Take them from the plan's shared files; no absolute path or `..`. | +| `widen_paths` | Optional list of project-relative paths (a trailing `/` marks a directory; an existing directory named without it counts as one too) of shared infrastructure: test harness and helpers, package and lock files, build and CI config, modules many lanes import. A task whose change touches one runs project-wide checks instead of its targeted ones. Name the files every check runs through (wrappers, shared fixtures, settings), not a folder that also holds tools and tests; name a folder only when everything under it is shared (`Packages/`, `ProjectSettings/`). For example, run f2a listed `Tools/Unity/` for the Unity batch wrappers in it: units RP2 and RB4 declared other files under it (`Tools/Unity/tests/__init__.py`) and widened to every Unity suite, 72 minutes on the critical path (32% of the run) that found nothing; the wrapper files themselves were the entries it needed. The dry run lists every unit that will widen (`checks.widening`, see Confirmation header). Take them from the plan's shared files; no absolute path or `..`. | | `repairs_spent` | Optional integer, default 0: repairs after a failing verify that earlier launches of this run made (the sum of `final.repairs` lengths in their reports; a report without `final` made none). Set it on a transient relaunch or a resume, like the budget carry-over; a run allows 2 in all (see Check ownership). | | `prelude` | Tasks run first on the feature branch, before lanes. | | `lanes` | `[{id, name, setup_note?, tasks}]`. `id` matches `^[A-Za-z0-9_][A-Za-z0-9._-]*$` (it names the ledger file); `name` is the progress phase label. Ids `prelude` and `join` are reserved. No file may appear in two lanes unless an `overlaps` entry records it; files are compared normalized (`./a`, `a//b`, `a/../b`) and case-insensitively. | @@ -179,7 +179,19 @@ for 9 of 12 units; delivery runs 3 checks (1 added by lane_commands); repairs up characters; the Bash tool on Windows fails at about 7,000: shorten the project's check commands, for example with a wrapper script", when the longest engine-built check command is over 6,000 characters, with "'s task-checks command" or "the integration checks command" -and its length (see "Command length" in Check ownership). One line comes from the dry run's +and its length (see "Command length" in Check ownership). From 1.6.8 one more line comes +from the summary's `widening` (`[{unit, paths: [{file, widen_path}]}]`), when it is not empty: +"Widening: 2 units widen when they change: RP2 (Tools/Unity/tests/__init__.py under +Tools/Unity/), RB4 (Tools/Unity/a.py under Tools/Unity/, +1 more)": the number of units, then +each unit with its first file and the widen path that covers it, and `+k more` when k more of its +files are covered. A unit is listed when it has a `task_checks` entry (a unit without one checks +its lane commands whatever it changes) and a declared file a widen path covers, or a declared +directory that holds a widen path (then `file` is that directory). A widen path ending in `/` +covers the paths under it; one without `/` covers the equal path and the paths under it (the +dry run cannot tell whether it names a directory; task-checks treats an existing one as a +directory). Paths compare after `\` becomes `/`; units come in run order and files in declared +order (every task of a batch). The line is advice: such a unit runs its project-wide checks only +when it really changes that file. One line comes from the dry run's `join_waves`: "Join: waves 1-2-4 (DJ1 | DJ2, DJ4 | DJ3, DJ5, DJ6, DJ7)", or "Join: serial ()" (see Join waves). @@ -420,7 +432,12 @@ worktrees, when no wave has two units, and when the join started in an earlier l - `run-checks --phase NAME` (and `task-checks --phase`) sets `PL_PHASE=NAME` (prelude, lane, join, integrate, post-integrate, final-fix, verify) for every check; without it `PL_PHASE` is removed. A project's check script may read it: a join unit runs in a lane worktree. Task prompts - tell agents to set it for commands they run themselves. + tell agents to set it for commands they run themselves. `--phase start` (1.6.8) is the start + check's (see Check ownership): it runs in `/start` with `PL_PHASE=verify`, so it + reproduces verify's environment, and its evidence records `phase: start`. A wrapper that + chooses tests by phase must key on `PL_PHASE` before the checkout path: one that leaves slow + tests out in a worktree path would leave them out at the start point too, where the start + check reports them `not_run` (so `unknown`, never a wrong `passes`). - Ledger: appends hold each lane file's OS lock (up to 5 s); a reader skips a last line without a newline that is not JSON. New events `wave_merged` and `wave_checked` (task `_run`); `started` may carry `dir`, `unit_branch` and `restart`. `ledger status` adds `join_started`, @@ -531,6 +548,27 @@ and reviewed has none either: there is no task left to check, and a pre-flight t re-rule work already built. Its `preflight` is empty with `skipped: "no task left to run"`, and the dry run plans no pre-flight agent. +**Pre-flight permits** (1.6.8). When a pre-flight ruling has a task change a file outside its +Files list, pre-flight also returns it in `permits` (`[{task, files, ruling}]`, optional, read as +`[]` when absent): the task id, the files, and the ruling's 0-based index in `rulings`. The +engine judges each file after `wave_moves` and keeps it only when the task runs in this launch +(not done and reviewed), the ruling exists, the file is project-relative (no `..`, no trailing +`/`), no task that can run at the same time declares it (the file or a folder above it, compared +without case), and no permit kept before it in pre-flight's order gives it to such a task. Two +tasks can run at the same time when they are in different lanes (done ones too: their branches +still merge), or are join tasks of different units of one wave; a prelude task, and a join task +when the join runs one unit at a time, run beside no other task. A permit naming a file the task +already declares is kept and changes nothing. Kept permits are `preflight.permits`; the others +are `preflight.permits_dropped` (`[{task, file, why}]`, why one of `not a task of this launch`, +`no such ruling`, `not a project-relative file`, `declared by , which can run at the same +time`, `also permitted to , which can run at the same time`), and the run log names each +dropped one. Each kept file reaches its task's task-checks call as `--permit '=PF-'` +(n = ruling + 1, as the prompts number the rulings; every task of a batch) and its implementer +prompt as `PF- permits to change : changing them does not widen your checks.` +Both keys are present only when the pre-flight agent ran: the lite and `no task left to run` +`preflight` have neither, and neither does a pre-flight that stops the run on a schedule +problem. A later launch gets the permits from the ledger ("Carry-over"). + Run rulings: pre-flight's own rulings, then the adjudicator's ruling on pre-flight conflicts, reach every task not yet done, every adjudicator and every final reviewer as "Rulings already made for this run (binding)", in that order, each quoted as written, so no agent decides a settled point again. @@ -547,7 +585,9 @@ From 1.6.3 what earlier agents decided or found reaches the agents after them, o **The ledger holds every ruling.** A `ruling` event may carry `by` (`implementer`, `reviewer`, `adjudicator`, `preflight`), `scope` (`lane` or `run`), `overrides` (the ids it departs from), `files` (paths an adjudicator ruling permits outside a task's Files list; an implementer lists -the files its ruling explains, which permits nothing), `pf` and `wf`. The +the files its ruling explains, which permits nothing), `pf`, `wf` and (1.6.8) `permits` (`[{task, +files}]`, the files a pre-flight ruling permits to a task; anything else is refused with +`'permits' must be a list of {task, files}`). The ledger checks the values, not whether an agent told the truth. A ruling's id is `-R`, n counting that ledger file's rulings in append order (`rig-R4`, `_run-R2`); this launch's pre-flight rulings are numbered `PF-1`... in the prompts until `carry-launch` records @@ -567,7 +607,9 @@ is critical or important, and then cites the ruling. **carry-launch.** After each launch (Launch step 5), ` /scripts/ledger carry-launch ` reads that launch's Workflow result file and records, -once per wf id: each pre-flight ruling (by `preflight`, scope `run`, its `PF-n`), skipping the +once per wf id: each pre-flight ruling (by `preflight`, scope `run`, its `PF-n`, and from 1.6.8 +`permits`: the entries of `preflight.permits` whose `ruling` is its index, as `{task, files}`; a +malformed entry is left out, not the whole launch), skipping the pre-flight adjudication the adjudicator recorded itself (`preflight.adjudicated`); one `minors` snapshot, the task minors still open after that launch; and a `carried` event that binds any `PF-n` a ruling of that launch overrode. Launches are carried in launch order (each @@ -587,9 +629,18 @@ critical or important. On a resume the session copies `ledger status`'s `minors` **A relaunch's pre-flight** is told which tasks are built, gets the `ledger rulings` command and `inputs_changed`, and checks only the tasks still to run: a matter an earlier ruling settled is -not a conflict and gets no new ruling unless the plan or spec changed since. With no task left to +not a conflict and gets no new ruling unless the plan or spec changed since. It is also told +that permits an earlier launch recorded are carried and need no new ruling. With no task left to run there is no pre-flight (`preflight.skipped`). +**Carried permits** (1.6.8). Once carried, a pre-flight ruling in force permits what its +`permits` names for its task (files only: an entry naming a directory, an absolute path or one +that leaves the project permits nothing; a ruling another overrides permits nothing). In a later +launch task-checks counts such a path as declared and lists it in `policy.permitted` with the +ledger's ruling id (for example `_run-R2`, since `PF-n` numbers restart every launch); only a +`--permit` of this launch shows `PF-n`. Implementer and reviewer rulings permit nothing, and a +pre-flight ruling's `files` permit nothing. + **Outside the Files list.** Setup writes `/owners.json`: which tasks list each path, and where each task runs. A reviewer's scope list and ` /scripts/ledger owners --task ...` say, for each path: owned by a task `earlier in the run` @@ -598,7 +649,9 @@ and where each task runs. A reviewer's scope list and ` /scri instead of changing it, and its own ruling never counts), `in your Files list; also in X's Files` (the reviewer checks the plan's split of that shared file), or `no task owns it`. `ledger undeclared ` (and `ledger status --manifest`) report `{task: [{path, owner, -relation}]}`. +relation}]}`; from 1.6.8 a path permitted to the task (or to a task sharing its range, a batch) +by a ruling in force in that ledger, an adjudicator's `files` or a pre-flight ruling's `permits` +as task-checks reads them, counts as declared and is not reported. From 1.6.6 owners.json also gives each task its `declared`: its exact Files list as the manifest gives it (no case change or normalising, manifest order, each exact string once, `[]` for a task @@ -665,6 +718,14 @@ raised it). the final review), the stopped report carries `verify` at the feature head it reached; it counts in `agents_spawned`, which can then exceed the cap by one, or two when a verify that returned nothing is retried. +- The `start check` agent (1.6.8, see Check ownership) is an ordinary agent under `max_agents`, + not exempt like `verify`: it judges logs and may run long. The dry run lists one `start check` + row (Verify, Sonnet high) beside `verify` whenever verify is planned, as an upper bound (it runs + only after a failing verify, at most once per launch), so the table's agent count and the + `max_agents` it suggests grow by one. With no agent free, or the agents unavailable, the run + makes no call and records `final.start_check` as `unavailable` with `why: 'budget'`, and + nothing else changes; `repairReserve` is unchanged, since the start check runs before repair + admission and its agent is already counted. - Agents unavailable (see "Usage limit") uses the same refusal path: once the availability probe found no agent answering, every later spawn is refused, `verify` included (a refused verify is never stored as evidence), and the run stops with reason `agents_unavailable` @@ -720,10 +781,11 @@ tool call never ends the agent's turn. | Implementer (task, retry) | Focused tests while iterating, then `scripts/task-checks` once before committing (a batch: once, before its last commit). | | Task fixer | The tests that cover the amended code; no task checks, no full suite. | | Task reviewer, re-reviewer | Nothing: they read the task-check evidence and may report a wrong selection as a finding. | -| Integration | The delivery inventory once: `finalSetup`, then one `run-checks` call over `finalChecks` (the project's commands plus each distinct `lane_commands` one). | +| Integration | The delivery inventory once: `finalSetup`, then one `run-checks` call over `finalChecks` (the project's commands plus each distinct `lane_commands` one). From 1.6.8 a resumed integration whose merges made no commit may reuse a passing check of the same head instead (see "Reuse" below); setup still runs. | | Post-integration fixer | After a test failure: focused tests (it may rerun the failing command to reproduce it); the integration rerun owns the full commands. After a hook failure: every project command, since only the hook reruns. | | Final fixer | Autonomous: focused checks; verify owns the full inventory. Supervised: every project command, as before 1.6.0. | | Verify | The full delivery inventory at the delivered head, fresh, every time. Acceptance reads only this. | +| Start check (1.6.8) | After a failing verify: the failing commands, narrowed to their failing tests when the project can select them, at the run's start point in `/start` (see "The start check" below). | **task-checks** (`scripts/task-checks`; the workflow builds the call) picks one list: - `lane`: the unit has no `task_checks` entry: the lane's checks run, as before 1.6.0. @@ -734,12 +796,18 @@ tool call never ends the agent's turn. worktree never ran the project's setup, and integration still runs every check. Reasons, all recorded in order: `no_entry`, `no_files` (no Files list), `outside_files` (a -changed path the Files list does not cover, nor an adjudicator ruling in force permits: one filed +changed path the Files list does not cover, nor a ruling permits: a `--permit PATH=PF-` of this +launch's pre-flight (1.6.8, repeatable, one per kept file of the unit's tasks, see +"Adjudicator"; PATH is normalised like `--declared`, so `\` becomes `/`, and a directory, an +absolute path, one that leaves the project or a value without `=PF-` exits 2), an earlier +launch's pre-flight ruling in force whose `permits` names the file for one of the unit's tasks +("Carry-over"), or an adjudicator ruling in force: one filed under the unit's task, or under `_run` for a path no task's Files list names (neither the path nor a folder above it), in the folder of a file the unit declares or in a directory it declares (folders compared by case); a permit names files, and an entry naming a directory permits nothing; such a path is -listed in `policy.permitted` with the ruling's id), `widen_path` (a changed path under +listed in `policy.permitted` as `{path, ruling}`, the ruling being `PF-` for a `--permit` +and the ledger's ruling id otherwise), `widen_path` (a changed path under `widen_paths`), `forced` (the agent's `--force-full ''`, for a shared contract, configuration, generated or ignored inputs, a dependency that is not a direct import, or a targeted list that turned out invalid), `git_failed`. Changed paths are the working tree @@ -794,7 +862,51 @@ warns when a unit's task-checks command, or the integration's run-checks command 6,000 characters (see Confirmation header); the fix is to shorten the project's check commands, for example with a wrapper script. The warning is advice and never refuses. -**Repair after a failing verify** (autonomous runs only; supervised runs reject as before): +**The start check** (1.6.8) finds out whether a failing verify's failures predate the run. +- **When:** right after the first verify of the delivered head in a launch that fails for a + repair's reasons (verify at the head, the exact inventory, no `head_after`, a clean checkout, + and not only exits 75, 124, 128 or above, negative or not a number), whatever the autonomy + and before any repair admission, so a supervised run gets it too. Only when the manifest's + `start_points.prelude` is a sha other than the head (a manifest without `start_points`, or a + run that changed nothing, gets none), and at most once per launch (a later launch may run its + own). Both verify sites call it: the verify after a final fix and the main verify after the + final review. The verify of a budget stop never does. Its agent's budget is in "Budgets". +- **The agent:** `start check`, phase Verify, Sonnet high, with its own checkout rule. It adds a + detached worktree at `/start` on the start sha (`git worktree add --detach`, + from `repo.git_dir` in shadow mode); a worktree of this repo already there and detached at the + start sha is the run's own, left by an earlier start check, and is removed first (`git + worktree remove --force`, `git worktree prune`); anything else at that path makes it return + `unavailable` and touch nothing. It runs the setup commands (`finalSetup`) there, reads each + failing command's log for the failing tests, and runs, in one `run-checks --phase start` call + reserved as `start` (`/checks/start..json`, the "Long checks" procedure), the + narrower command when the project's own filter can select just those tests, else the same + command. It reads each start log for the tests that ran and failed (a test the log does not + show running is `not_run`, never failing or passing), then removes the worktree with `git + worktree remove --force` and `git worktree prune` (`--force` only on this path; a failed + removal is left to cleanup or the next start check). It never changes, stages or commits + anything in the feature checkout. It returns `{status: checked|unavailable, start_sha, + evidence, results: [{verify_command, start_command, narrowed, exit_at_start, failed_at_head, + failed_at_start, not_run}], notes}`: what the logs show, without judging it. +- **The engine classifies** each failing command (`at_start`): `fails` only when the status is + `checked`, `start_sha` is `start_points.prelude`, `evidence` is a `checks/start..json` path + under the ledger dir (`\` read as `/`, without case on a drive path), `exit_at_start` is a + defect exit (non-zero, not 126 or 127, and not one of "Exit codes"' non-defect exits), + `failed_at_head` names tests, `not_run` is empty and every head failure fails at the start; + `passes` when it exited 0 with nothing `not_run`; `partly` when some but not all head failures + fail at the start; `unknown` otherwise (a command whose log names no tests, like a lint, is + `unknown` unless it passes at the start, and so is a command the result leaves out or lists + twice). A wrong start sha or evidence path makes every command `unknown`. The result is + `final.start_check` (see "Report"). +- **Effect:** when every failing command `fails`, no repair runs (the failures are out of scope) + and acceptance gives `checks_failed_at_start` instead of `checks_failed` (still rejected; the + user may accept it with `ledger accept`). Otherwise repairs run as below, and each repair + prompt, and the final re-review beside the failing verify, carry one line per command: `fails + at the start point too`, `passes at the start point : this run broke it`, `partly` + with the tests that predate the run, or `unknown: no conclusion from the start point`. The + final re-review does not hold a finding open over a failure that predates the run alone. + +**Repair after a failing verify** (autonomous runs only; supervised runs reject as before; the +start check above runs first in both): - Due only when verify ran at the head, with exactly the configured inventory, no `head_after`, a clean checkout, and a failure that is not made only of exits 75, 124, 128 or above, or negative (a busy project, a timeout or a signal is not a defect to fix; see "Exit @@ -809,7 +921,10 @@ for example with a wrapper script. The warning is advice and never refuses. correct fix, focused checks only, `fix: make the delivery checks pass`. It never weakens, skips or deletes a check, test or criterion, never changes the commands or the scripts and config they run, and reports blocked for a flaky, timeout, crash, environment or - out-of-scope failure. + out-of-scope failure. From 1.6.8 its prompt also carries the start check's line for each + failing command, and says never to create a worktree or checkout outside the feature dir. +- No repair runs when the start check found that every failing command fails at the start + point too. - Its head is delivered only when verify, run with `run-checks --descends-from `, confirms the branch head is that sha and descends from where the repair started; otherwise the observed head is recorded and the result is closed (`final_fix_unreviewed`). @@ -817,7 +932,9 @@ for example with a wrapper script. The warning is advice and never refuses. fix, or by a `final re-review` of the repair range when there were no findings. Repairs have no id: a problem in one goes in `new_findings`. A missing, refused, other-head or malformed re-review leaves the repair unreviewed. -- After 2 repairs, or without room, a still-failing verify is `checks_failed`, rejected. +- After 2 repairs, or without room, a still-failing verify is `checks_failed`, rejected + (`checks_failed_at_start` when the start check made for that head found every failing + command failing at the start point). - The report's `final.repairs` lists each attempt `{attempt, from, observed_head, eligible, head, failed, status, notes}`; `check_policy` records the policy the run ran under. @@ -832,7 +949,28 @@ stale). tracked changes, the tracked list is read with `--untracked-files=no` (one more call), so a submodule holding only untracked files is not a tracked change, as before. The tracked and untracked lists are as before; `clean` is the after snapshot being empty, so it no longer -follows a `status.showUntrackedFiles=no` setting. +follows a `status.showUntrackedFiles=no` setting. From 1.6.8 `--record-setup COMMAND` +(repeatable) records the setup commands as `setup_commands` in the evidence, in order, and never +runs them; without it the key is absent. + +**Reuse** (1.6.8). A resumed integration need not rerun a check an earlier launch passed at the +same head. The integrate agent always runs the setup commands first (later phases rely on them in +the feature checkout); then, only when its merges made no commit, it runs ` +/scripts/reuse-checks --name integrate --head --checkout [--setup COMMAND]... --cmd GROUP COMMAND [--cmd ...]`, with the same setup and check +commands as its run-checks call. It reads `/checks/integrate..json`, highest `n` (as a +number) first, skipping a file that is unreadable, not a JSON object or an error record, and +reuses the first where `phase` is `integrate`, `head` is HEAD with no `head_after`, `checkout` +is the feature dir (compared after realpath and normcase), `ok` and `clean` are true and every +exit is 0, `tracked_before` and `untracked_before` are empty, `tree_changed` is false, the +results' `(group, command)` pairs equal the `--cmd` list in order, and `setup_commands` equals +the `--setup` list. It prints `{reuse: true, file}` (exit 0) or `{reuse: false, why}` (exit 1; +the why names the newest file and the first rule it fails); exit 2 is a usage error. On exit 0 +the agent skips the run-checks call, returns `checks_reused` (the file) and says so in its notes; +otherwise it runs the checks as before. Its run-checks call passes `--record-setup` for each +setup command, so a later resume can reuse that evidence. Evidence without +`setup_commands` (1.6.7 and earlier) is never reused. Verify, wave checks, task checks and +post-integrate checks are never reused. ## Long checks @@ -923,7 +1061,8 @@ agent improvised. ## Exit codes The contract for the exit codes of commands run through `scripts/run-checks` (task checks, -integration, verify and the rechecks, fixers' and repairs' checks, long commands). It does not +integration, verify and the rechecks, the start check, fixers' and repairs' checks, long +commands). It does not cover setup commands and hook commands: their exits are read as before. | Exit | Means | What the run does | @@ -940,7 +1079,13 @@ failure beside a timeout is still a real failure. The codes steer repair, fix an acceptance: a check that exits `75` or `124` has still failed (`ok` false), and acceptance reads it as `checks_failed` like any other failure. Per caller: - **Verify:** returns `run-checks`' result as printed. A repair after a failing verify (see - "Check ownership") is not due for a failure made only of these exits. + "Check ownership") is not due for a failure made only of these exits, and neither is the + start check. +- **Start check** (1.6.8): the agent reports each exit as the wait printed it, and the engine + reads it. At the start point `0` (with no test `not_run`) is `passes`; `75`, `124`, `128` and + above, negative, and also `126` and `127` (the command could not be run or was not found at + the start point, which says nothing about the tests) are `unknown` for that command, never + `fails`. - **Integration:** the integrate agent sets `checks_unfinished` true (beside `tests_failed`) when every failing command exited `75`, `124`, `128` and above or negative. The engine then starts no post-integration fix and stops with `integration checks did not finish: `, @@ -1130,6 +1275,18 @@ Fields added in 1.6.2, all optional and additive: - `index_commit` (a sha or null), `index_saved` and, when that is false, `index_error` in each `setup_result.preserved` entry (see "Cleanup"). +Fields added in 1.6.8, all additive (a reader treats a missing key as null or `[]`): +- `final.start_check`, only when a start check was due (it ran, or was recorded unavailable for + the budget; see "The start check" in Check ownership): `{status, why?, start_sha, evidence, + verify_head, commands: [{verify_command, start_command, narrowed, exit_at_start, + failed_at_head, failed_at_start, not_run, at_start}], all_fail, some_fail, notes}`, with + `at_start` one of `fails`, `passes`, `partly`, `unknown`; `status` is `checked` or + `unavailable` (`why` says which refusal or problem, `budget` when no agent was free). +- `integrate.checks_reused`: the integrate evidence file a resumed integration reused (see + "Reuse" in Check ownership), else null. +- `preflight.permits` (`[{task, files, ruling}]`, the kept ones) and `preflight.permits_dropped` + (`[{task, file, why}]`), when the pre-flight agent ran (see "Adjudicator"). + `unreadable_lines` (per agent, and the total at the top level) counts the non-blank transcript lines that are not valid JSON, such as a truncated write, or not valid UTF-8. A line with invalid UTF-8 counts even when it still parses after the bad bytes are replaced (they may have @@ -1150,7 +1307,12 @@ from the evidence: `accepted` (no reason), `rejected` (a check failed at `delive blocking finding is open, or a task is deferred or not done), or `unverified` (nothing failed, but evidence is missing or covers another revision). Reasons are `{kind, class, detail}`: `checks_failed`, `checks_missing`, `checks_stale`, `checks_incomplete` (from `verify`, the -`scripts/run-checks` result at `delivered_sha`), `e2e_failed`, `e2e_missing`, `e2e_stale` +`scripts/run-checks` result at `delivered_sha`), `checks_failed_at_start` (1.6.8, class +`failed`, still `rejected`: in place of `checks_failed` when the start check made for +`delivered_sha` found every failing command failing at the start point too; detail `failing at + and at the start point : (exit N): ; evidence `; when only +some do, `checks_failed` stays and a warning says `fails at the start point too: +`), `e2e_failed`, `e2e_missing`, `e2e_stale` (`e2e.checked_sha`), `post_integrate_failed`, `post_integrate_missing`, `post_integrate_stale`, `blocking_findings` (open critical or important final findings), `review_missing` (a final lens with no result), `review_unbound` (the final lenses did not all @@ -1208,7 +1370,13 @@ what their hook adds. Show the status and every reason first; only `accepted` is delivered work. A reason or warning the user explicitly accepts is recorded with `ledger accept ""` (an `accepted` event; `ledger status` lists them under `accepted`) -and shown as accepted by the user, never folded into `accepted`. +and shown as accepted by the user, never folded into `accepted`. `checks_failed_at_start` is +shown like any rejection, with its detail and evidence file, and the hand-back suggests the +accept text, for example `ledger accept +"checks_failed_at_start: fails at the start point too (evidence )"`. +Once the accepts cover every reason (and the warnings the user named) and archive-run, audit +and tool-outcomes have run again, remove the marker with `bash /scripts/active-run +remove `: a marker left `unaccepted` sends a later session to Resume. Each task result's `commits` is its range `[base, head]` (the base before its first commit and its last head), not a list of commits; the ledger's `committed` events list every commit. @@ -1530,9 +1698,10 @@ run, ask: reuse (resume the old run) or remove. To remove: Cleanup below for its ## Run history -` /scripts/archive-run ... [--history-dir DIR]` -copies a run that ended after a launch into the run history (never a dry run). Run it after -`run-report` has written its report beside the manifest. +` /scripts/archive-run [...] [--ledger DIR] +[--notes FILE...] [--history-dir DIR]` copies a run that ended after a launch into the run +history (never a dry run). Run it after `run-report` has written its report beside the manifest. +A call needs at least one transcript dir, or `--notes` for a run already in the index. - **History dir:** `--history-dir`, else `PL_HISTORY_DIR`, else `/parallel-lanes/history/`. `_history.history_dir` resolves it for Python and @@ -1545,14 +1714,51 @@ copies a run that ended after a launch into the run history (never a dry run). R `cleanup` finds the run by the same key. - **Folder:** `/-/` (the UTC date of the first launch; `-2`, `-3` ... when another run owns the name). It holds the manifest, the ledger dir (`checks/` - included), the run-report JSON, the plan and spec when they exist, each transcript dir - `wf_/` with its `wf_.json` result file, and, written by other tools, `autopsy.json`, - `autopsy.md` and `cleanup/`. Copying is idempotent: a missing file is added, a changed file is - replaced, and a file already in the archive is never deleted. `launches` counts the `wf_` - dirs in the folder, so archiving again never raises it. + included), the run-report JSON, the plan and spec when they exist (pinned, below), each + transcript dir `wf_/` with its `wf_.json` result file, `notes/` (1.6.8, below), + `derive.json` and `setup.json` (1.6.8: copied when present and the manifest's directory is the + run-owned `/parallel-lanes/runs//`; a plans-folder run directory is shared + between runs, so nothing generic is taken from there), and, written by other tools, + `autopsy.json`, `autopsy.md` and `cleanup/`. Copying is idempotent: a missing file is added, a + changed file is replaced, and a file already in the archive is never deleted. `launches` + counts the `wf_` dirs in the folder, so archiving again never raises it. Two different + files that would get one name (compared without case) are refused with exit 2 before anything + is written. +- **Pinning** (1.6.8): an archived plan or spec is only ever the version a launch recorded. Each + setup `run_started` event in the ledger's `_run.jsonl` carries `head`, `plan_sha256` and + `spec_sha256`; the latest one's sha is the one pinned. An archived copy with that sha is kept, + even when the live file differs. Otherwise the first source with that sha is written: the live + file, then, for a file inside the repo, its blob at each recorded setup head from the latest + back, as git stores it (`git show :`) and with the checkout filters (`git cat-file + --filters`, so a CRLF checkout matches), the path looked up in git without case (`git ls-tree`), + with `--git-dir` in shadow mode. When no source has the sha, an archived copy is kept and, + without one, the live file is copied, with a `plan_unpinned` or `spec_unpinned` warning naming + the recorded sha (the audit then reports the mismatch as a gap). A ledger with no recorded sha + (before `plan_sha256`) never replaces an archived copy; without one the live file is copied. A + spec of `null` is not pinned, and a plan outside the repo (the plans folder) can match only + its live file. +- **Launch counts** (1.6.8): the launches the ledger records are the larger of its `run_ended` + count (one per launch the session recorded, relaunches included) and the number of distinct wf + ids its `carried` events and `doctor-.json` files name (a launch whose session died has no + `run_ended`). When the folder holds fewer `wf_` dirs, archive-run warns `launches_missing` + with both counts and the named wf ids that have no dir ("unknown" when none are named): pass + the missing transcript dirs to archive-run again. +- **`--ledger DIR`** (1.6.8) reads the ledger from DIR instead of `repo.ledger_dir`, for a run + whose live folder is gone: `archive-run ... --ledger + `. DIR must hold `_run.jsonl` (else exit 2). The run is found in the index by + its key, so it keeps its folder; a ledger copied onto itself is left as it is. The ledger a call + reads for pinning and counting is `--ledger`, else `repo.ledger_dir` when it exists, else the + folder's archived ledger. +- **`--notes FILE...`** (1.6.8) copies each file to `/notes/` with the same + add-or-replace rule (hand-written observations, a review, recommendations). It takes every + following argument up to the next `--` option. Two different notes with one basename exit 2 + with nothing written. Without transcript dirs the call is allowed only for a run already in + the index (else exit 2): it adds the notes, pins, counts, and copies the ledger only when its + source exists. - **`index.json`:** `{"version": 1, "runs": [entry, ...]}`, one entry per run with `run_id`, `folder`, `ledger_dir`, `project`, `plan`, `engine_version`, `machine`, `platform`, - `first_launch`, `last_launch`, `launches`, `status` and `acceptance`. A value that cannot be + `first_launch`, `last_launch`, `launches`, `launches_recorded` (1.6.8, from the folder's + archived ledger as counted above; `unavailable` without one), `status` and `acceptance`. A value that cannot be read is `unavailable` (an older run has `engine_version: "unavailable"`; it is never inferred). Stored paths use `/`, and `C:/...` on Windows; a Linux path from another machine stays as written. @@ -1565,7 +1771,9 @@ copies a run that ended after a launch into the run history (never a dry run). R - **`--reindex`:** `archive-run --reindex [--history-dir DIR]` rebuilds `index.json` from the folders already there (imported runs, hand-archived runs). Every folder holding a `*.lanes.json` is one run; the report is the first of `*.report.json`, `*.run-report.json` - and `report.json`. Folders keep their names; it prints `{"runs": N, "skipped": [...]}`. + and `report.json`; `launches_recorded` comes from the folder's archived ledger + (`..ledger`, else its one `*.ledger` dir). Folders keep their names; it + prints `{"runs": N, "skipped": [...]}`. - **Comparing two runs:** `history --runs A,B [--json]` (two run ids or folder names) prints, for each run, its plan's sha256, task ids, lanes, a hash of its `commands`, versions, launches, status, acceptance, wall time, fixed phases, task time, full-suite time and tokens, and whether @@ -1579,9 +1787,16 @@ copies a run that ended after a launch into the run history (never a dry run). R `history` only add their own files (`index.json`, `autopsy.json`, `autopsy.md`, `history.json`, `history.md`) and never change or remove an imported file. -archive-run prints `{"folder": ..., "added": [...], "updated": [...]}`. Exit 0 on success; exit 2 -on a usage error, an unreadable manifest, transcript dir, ledger dir or index, a failed copy, or -the lock not acquired; exit 3 when the history dir is inside the project (nothing written). +archive-run prints `{"folder": ..., "added": [...], "updated": [...], "pinned": [...], "kept": +[...], "replaced": [...], "warnings": [{"code", "detail"}]}`: `pinned` names a plan or spec newly +archived with its recorded sha, `kept` one whose archived copy already had it, `replaced` one +whose archived copy lacked it and was replaced (a file with no recorded sha, or none that +matched, is in none of the three); `warnings` holds `plan_unpinned`, `spec_unpinned` and +`launches_missing`. Show the warnings in the hand-back. Exit 0 on success; exit 2 +on a usage error, an unreadable manifest, transcript dir, note, ledger dir (a `--ledger` DIR +without `_run.jsonl` included) or index, two notes with one basename, a notes-only call for a +run not in the index, a failed copy, or the lock not acquired; exit 3 when the history dir is +inside the project (nothing written). `--reindex` exits 0, or 2 when the history dir is not a readable directory or the lock is not acquired. @@ -1795,6 +2010,15 @@ has holes. It does not change `acceptance` and is not a second verdict engine. placement, changes (rulings, events, escalations), commits and review (reviews, each with its brief and spec hashes against the run's, packages, verdicts and approval), each with its state. A launch is the union of `wf_*` dirs and `wf_*.json` files; its result comes only from `/.json`. +- **Launch counts** (1.6.8): the run section compares the launches the archived ledger records + (counted as archive-run counts them, see "Run history") with the archived ones. + `run.launches.recorded` is `{recorded, archived, named}`; each missing launch is one + `missing` entry in `run.launches.missing` and one evidence gap ("the ledger records this + launch; its transcripts are not archived"), named wf ids first, then one unnamed entry per + launch left. A launch with a `wf_` dir or a `wf_.json` counts as archived. When more + are recorded than archived the summary line reads `launches: 1 archived, 2 recorded, latest + `; equal counts add no gap and leave the line as it was. Without an archived ledger the + recorded count is `unavailable` (one gap) and nothing is listed missing. **check-verify `--ledger`.** The audit calls `check-verify --ledger /..ledger` for the gate and keeps only its status, a reason from @@ -1817,7 +2041,8 @@ nothing is written. exit 2 also when `--out` cannot be made or written (`cannot non-zero exit goes to `tool-outcomes --audit-exit` and never changes `acceptance`. After `ledger accept`, run archive-run, audit and `tool-outcomes` again so the override and any -session-verify evidence reach the archive and `audit.json`. +session-verify evidence reach the archive and `audit.json`; once the accepts cover every reason, +remove the marker (see "Report"). ## Doctor @@ -2026,13 +2251,20 @@ and changes nothing; show the plan, and run it with `--yes` only on the user's y can still resume. - **Ownership rule:** it removes only `/lane-` for each manifest lane (and `/feature` in shadow mode), each a worktree of the same repository on its - expected branch (`pl--`, or `repo.branch`), plus those branches. Anything else - under `worktree_root` is left and listed as `not owned by this run`. A worktree with + expected branch (`pl--`, or `repo.branch`), plus those branches, and (1.6.8) the + start check's worktree `/start` when `git worktree list --porcelain` shows it + detached, of this repository, at the run's start sha (`start_points.prelude`, else the head of + the first setup `run_started` in the ledger). Anything else + under `worktree_root` (the start path on another sha or on a branch included) is left and listed as `not owned by this run`. A worktree with uncommitted files, or a locked one, is kept and named. Untracked files count as uncommitted even when the Git configuration hides them (`status.showUntrackedFiles`): cleanup reads the status with `--untracked-files=all`. But ignored files do not keep a worktree, and `git worktree remove` deletes them with it (build output, `node_modules`, an ignored `.env` or - local config): copy any ignored file you still need out of the worktree before `--yes`. + local config): copy any ignored file you still need out of the worktree before `--yes`. The + start worktree is the exception: it holds no commits, and a failed start check can leave files + behind, so it is removed with `git worktree remove --force` even when it has uncommitted or + untracked files (it is still kept when locked, and checked again right before the removal). + The plan lists it, and `restore.md` gets `git worktree add --detach ` for it. - **Locks, during `--yes`:** cleanup takes the run lock the way a resume does (`active-run acquire`), so no resume or second cleanup works on the run meanwhile, and in git mode the checkout lock of the main checkout, so no other run's setup switches it. A run lock held by @@ -2058,7 +2290,8 @@ and changes nothing; show the plan, and run it with `--yes` only on the user's y (`git merge --ff-only`); otherwise the checkout and branch stay as they are, with the reason, and cleanup can be run again later. Shadow mode keeps every branch and the shadow repo (`shadow remove` stays a separate step). -- **It never** uses `--force` or `branch -D`, deletes a remote branch, makes a merge commit, +- **It never** uses `--force` (except `git worktree remove --force` for the start worktree) or + `branch -D`, deletes a remote branch, makes a merge commit, resets or rebases, deletes `refs/parallel-lanes//abandoned/` refs, or removes the manifest, the ledger or the history folder. From d5c9a847792ff9b4aebe104a71eb5243be4075d1 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 11:25:05 -0700 Subject: [PATCH 12/14] fix: address the final review findings --- reference.md | 29 +++++++++++----- run.workflow.js | 54 +++++++++++++++++++---------- scripts/_pin.py | 39 ++++++++++++++------- scripts/cleanup | 7 ++-- scripts/ledger | 2 +- scripts/run-checks | 19 ++++++++-- scripts/task-checks | 2 +- src/phases.js | 38 ++++++++++++-------- src/prompts-phase.js | 6 ++-- src/prompts-task.js | 10 ++++-- tests/archive-pin.test.mjs | 39 +++++++++++++++++++++ tests/check-policy-prompts.test.mjs | 5 +-- tests/join-waves-run.test.mjs | 21 +++++++++++ tests/phase-prompts.test.mjs | 10 ++++-- tests/repair.test.mjs | 15 ++++++++ tests/run-checks-phase.test.mjs | 48 +++++++++++++++++++++++++ tests/run.test.mjs | 19 ++++++++++ 17 files changed, 293 insertions(+), 70 deletions(-) diff --git a/reference.md b/reference.md index da2e79a..c1ed540 100644 --- a/reference.md +++ b/reference.md @@ -639,7 +639,13 @@ that leaves the project permits nothing; a ruling another overrides permits noth launch task-checks counts such a path as declared and lists it in `policy.permitted` with the ledger's ruling id (for example `_run-R2`, since `PF-n` numbers restart every launch); only a `--permit` of this launch shows `PF-n`. Implementer and reviewer rulings permit nothing, and a -pre-flight ruling's `files` permit nothing. +pre-flight ruling's `files` permit nothing. A known gap: the engine checks a new pre-flight +permit only against the Files lists and the other permits of its own launch, never against a +carried one (the relaunch's pre-flight is told only that carried permits exist, and `ledger +rulings` does not print them). So a carried permit for a task still to run and a new permit for +the same undeclared file to a task that can run at the same time (another lane, or another unit +of its join wave) both stand, and neither task widens for that file; the task reviews and the +final lenses still see both changes. **Outside the Files list.** Setup writes `/owners.json`: which tasks list each path, and where each task runs. A reviewer's scope list and ` /scripts/ledger owners @@ -722,9 +728,10 @@ raised it). not exempt like `verify`: it judges logs and may run long. The dry run lists one `start check` row (Verify, Sonnet high) beside `verify` whenever verify is planned, as an upper bound (it runs only after a failing verify, at most once per launch), so the table's agent count and the - `max_agents` it suggests grow by one. With no agent free, or the agents unavailable, the run - makes no call and records `final.start_check` as `unavailable` with `why: 'budget'`, and - nothing else changes; `repairReserve` is unchanged, since the start check runs before repair + `max_agents` it suggests grow by one. With no agent free (after a final fix: fewer than two, + since the final re-review must follow it), or the agents unavailable, the run makes no call + and records `final.start_check` as `unavailable` with `why: 'budget'`, and nothing else + changes; `repairReserve` is unchanged, since the start check runs before repair admission and its agent is already counted. - Agents unavailable (see "Usage limit") uses the same refusal path: once the availability probe found no agent answering, every later spawn is refused, `verify` included (a refused @@ -951,7 +958,8 @@ submodule holding only untracked files is not a tracked change, as before. The t untracked lists are as before; `clean` is the after snapshot being empty, so it no longer follows a `status.showUntrackedFiles=no` setting. From 1.6.8 `--record-setup COMMAND` (repeatable) records the setup commands as `setup_commands` in the evidence, in order, and never -runs them; without it the key is absent. +runs them; `--record-setup-none` records that there were none (`setup_commands: []`); without +either the key is absent. **Reuse** (1.6.8). A resumed integration need not rerun a check an earlier launch passed at the same head. The integrate agent always runs the setup commands first (later phases rely on them in @@ -968,7 +976,8 @@ the `--setup` list. It prints `{reuse: true, file}` (exit 0) or `{reuse: false, the why names the newest file and the first rule it fails); exit 2 is a usage error. On exit 0 the agent skips the run-checks call, returns `checks_reused` (the file) and says so in its notes; otherwise it runs the checks as before. Its run-checks call passes `--record-setup` for each -setup command, so a later resume can reuse that evidence. Evidence without +setup command (`--record-setup-none` when there are none), so a later resume can reuse that +evidence. Evidence without `setup_commands` (1.6.7 and earlier) is never reused. Verify, wave checks, task checks and post-integrate checks are never reused. @@ -1281,7 +1290,9 @@ Fields added in 1.6.8, all additive (a reader treats a missing key as null or `[ verify_head, commands: [{verify_command, start_command, narrowed, exit_at_start, failed_at_head, failed_at_start, not_run, at_start}], all_fail, some_fail, notes}`, with `at_start` one of `fails`, `passes`, `partly`, `unknown`; `status` is `checked` or - `unavailable` (`why` says which refusal or problem, `budget` when no agent was free). + `unavailable` (`why` says which refusal or problem, `budget` when no agent was free); `why` + also appears on a `checked` result whose start sha or evidence path is wrong (every command + is then `unknown`). - `integrate.checks_reused`: the integrate evidence file a resumed integration reused (see "Reuse" in Check ownership), else null. - `preflight.permits` (`[{task, files, ruling}]`, the kept ones) and `preflight.permits_dropped` @@ -1730,8 +1741,8 @@ A call needs at least one transcript dir, or `--notes` for a run already in the even when the live file differs. Otherwise the first source with that sha is written: the live file, then, for a file inside the repo, its blob at each recorded setup head from the latest back, as git stores it (`git show :`) and with the checkout filters (`git cat-file - --filters`, so a CRLF checkout matches), the path looked up in git without case (`git ls-tree`), - with `--git-dir` in shadow mode. When no source has the sha, an archived copy is kept and, + --filters`, so a CRLF checkout matches), the path looked up in git without case (`git ls-tree`) + only when git does not hold it as given, with `--git-dir` in shadow mode. When no source has the sha, an archived copy is kept and, without one, the live file is copied, with a `plan_unpinned` or `spec_unpinned` warning naming the recorded sha (the audit then reports the mismatch as a gap). A ledger with no recorded sha (before `plan_sha256`) never replaces an archived copy; without one the live file is copied. A diff --git a/run.workflow.js b/run.workflow.js index 8b7d38c..13d468f 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1357,13 +1357,17 @@ function checksPhase(laneId) { // reserve prints. phase (1.6.5) is run-checks' --phase: the commands run // with PL_PHASE set to it. recordSetup (1.6.8, spec 4.7) lists setup // commands run-checks records in its evidence (--record-setup, in order) -// and never runs. null for an empty list. -function runChecksCommand(m, dir, checks, out = null, phase = null, recordSetup = []) { +// and never runs: an empty list records that there were none +// (--record-setup-none), null (the default) records nothing. null for an +// empty checks list. +function runChecksCommand(m, dir, checks, out = null, phase = null, recordSetup = null) { if (checks.length === 0) return null; const parts = checks.flatMap((c) => ['--cmd', shellQuote(c.group), shellQuote(c.command)]); const target = out === resultPath() ? out : shellQuote(out); const evidence = out === null ? [] : ['--out', target, '--root', shellQuote(m.repo.ledger_dir)]; - const setup = recordSetup.flatMap((s) => ['--record-setup', shellQuote(s)]); + const setup = recordSetup === null ? [] + : recordSetup.length === 0 ? ['--record-setup-none'] + : recordSetup.flatMap((s) => ['--record-setup', shellQuote(s)]); return [`cd ${shellQuote(dir)} && ${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/run-checks`)}`, shellQuote(dir), ...evidence, ...(phase === null ? [] : ['--phase', shellQuote(phase)]), ...setup, ...parts].join(' '); } @@ -3659,6 +3663,8 @@ function startCheckPrompt(m, verify, sha) { ` check. Remove it first (${remove}, then ${prune}), then add it as above.`, ` - Anything else at that path (a worktree on a branch or at another commit, or a folder git does not list):`, ' return status unavailable with results [] and notes saying what is there, and touch nothing.', + ` Once it is added, record the output of git -C ${q(wt)} rev-parse HEAD: you return it as start_sha, and step 5`, + ' removes the worktree before you return.', `2. Run the setup commands there, in order: ${setup.length > 0 ? `cd ${q(wt)} && ${setup.join(' && ')}` : '(none)'}`, `3. The failing commands at ${sha}:`, ...lines, @@ -3680,8 +3686,8 @@ function startCheckPrompt(m, verify, sha) { '', phaseRules(m, own), '', - `Return status checked, start_sha = the output of git -C ${q(wt)} rev-parse HEAD (it must be ${start}),`, - 'evidence = the path reserve printed (the run-checks result file), results = one entry per failing command', + `Return status checked, start_sha = the output of git -C ${q(wt)} rev-parse HEAD you recorded in step 1`, + `(it must be ${start}), evidence = the path reserve printed (the run-checks result file), results = one entry per failing command`, 'above: {verify_command (the command as listed above), start_command (the command you ran), narrowed (true when', 'you ran a narrower command), exit_at_start (its exit in the run-checks result; null when it did not run),', 'failed_at_head (the tests its verify log shows failing), failed_at_start (the tests its start log shows', @@ -5386,7 +5392,7 @@ function acceptanceOf(input) { // attempt; with it, a failing verify after a fix that returned done with a // sha head goes to runRepairs before the re-review, which then covers the // repairs too. final.repairs lists them ([] when none). startCheckAt(verify, -// sha) (1.6.8 spec 4.4, supervised runs too): called right after that +// sha, room) (1.6.8 spec 4.4, supervised runs too): called right after that // verify, before any repair; a non-null result is final.start_check, and // when every failing command fails at the start point no repair runs. async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, taskMinors = [], repair = null, @@ -5469,9 +5475,10 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t // The checks at the delivered head, before the re-review (spec: no // repeated checks at one commit). if (verifyAt !== null && fixRan) final.verify = await verifyAt(final.head); - // A failing verify there gets the start check before any repair. + // A failing verify there gets the start check before any repair, only + // with room for it and the re-review that must follow (room 2). if (startCheckAt !== null && final.verify) { - const sc = await startCheckAt(final.verify, final.head); + const sc = await startCheckAt(final.verify, final.head, 2); if (sc !== null) final.start_check = sc; } const allFailAtStart = Boolean(final.start_check && final.start_check.all_fail); @@ -5849,8 +5856,11 @@ function preflightUndeclared(m, entries) { // order gives it to such a task. Two tasks can run at the same time when they // are in different lanes, or are join tasks of different units of one wave of // waves (the join's waves after wave_moves; null when the join runs one unit -// at a time). Prelude tasks never run beside another task. -function preflightPermits(m, permits, rulings, waves) { +// at a time). Prelude tasks never run beside another task. log (optional) +// gets one line per dropped file: 'dropped the permit of for +// ()', or, for an entry with no string task, 'dropped permit entry +// ()'. +function preflightPermits(m, permits, rulings, waves, log = null) { const all = [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]; const runnable = new Set(all.filter((t) => taskState(m, t.id) !== 'skip').map((t) => t.id)); const laneOf = new Map(); @@ -5883,7 +5893,12 @@ function preflightPermits(m, permits, rulings, waves) { const taskId = shaped && typeof p.task === 'string' ? p.task : null; const listed = shaped && Array.isArray(p.files); const files = listed ? p.files : [shaped && p.files !== undefined ? p.files : null]; - const drop = (file, why) => dropped.push({ task: taskId, file, why }); + const drop = (file, why) => { + dropped.push({ task: taskId, file, why }); + if (log === null) return; + log(taskId === null ? `dropped permit entry ${String(JSON.stringify(p))} (${why})` + : `dropped the permit of ${taskId} for ${file} (${why})`); + }; if (taskId === null || !runnable.has(taskId)) { for (const f of files) drop(f, 'not a task of this launch'); continue; @@ -6223,13 +6238,12 @@ async function runAll(manifest, io) { } // Permits (spec 4.6) are judged against the waves after the moves; a // result without permits has none. + // A dropped entry with no string task is logged as given (JSON). const permits = preflightPermits(m, pre.permits, pre.rulings, - shown.mode === 'waves' ? wavePlan(m, waveEdges).waves : null); + shown.mode === 'waves' ? wavePlan(m, waveEdges).waves : null, + (line) => io.log(`parallel-lanes: pre-flight: ${line}`)); preflight.permits = permits.kept; preflight.permits_dropped = permits.dropped; - for (const d of permits.dropped) { - io.log(`parallel-lanes: pre-flight: dropped the permit of ${d.task} for ${d.file} (${d.why})`); - } m = withConsumesExtra(m, kept); // Pre-flight's rulings bind every task and final reviewer of this run // (taskContext and the final review prompts show m.run_rulings); its @@ -6621,14 +6635,16 @@ async function runAll(manifest, io) { const repair = autonomous ? { admit, first: spent + 1 } : null; // The start check after a failing verify (1.6.8 spec 4.4): at most once // per launch, only when startCheckDue, and an ordinary agent call (not - // overBudget). With no agent free, or the agents unavailable, it records - // unavailable for the budget without a call, so nothing is refused. + // overBudget). With fewer than `room` agents free (1, or 2 inside the + // final review, whose re-review must follow it), or the agents + // unavailable, it records unavailable for the budget without a call, so + // the advisory check never refuses a later call. // Returns the classified start check, or null when none was due. let startChecked = false; - const startCheckAt = async (v, sha) => { + const startCheckAt = async (v, sha, room = 1) => { if (startChecked || !startCheckDue(m, v, sha)) return null; startChecked = true; - if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < 1) { + if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < room) { return { ...classifyStartCheck(m, v, sha, null), why: 'budget' }; } const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), sonnetHigh); diff --git a/scripts/_pin.py b/scripts/_pin.py index 92a3fed..663d232 100644 --- a/scripts/_pin.py +++ b/scripts/_pin.py @@ -36,8 +36,9 @@ head once), 'git show :' and 'git cat-file --filters :' (the bytes a checkout writes, so a CRLF checkout matches). is the live path relative to repo_root as git stores - it at that head: the name from 'git ls-tree -r --name-only -z ' - equal to it, else one equal to it compared without case. A live path + it at that head: the path itself when git holds it there, else the + first name from 'git ls-tree -r --name-only -z ' equal to it + compared without case (the tree is listed only then). A live path outside repo_root has no git source. 4. No source has the sha: an archived copy is kept; without one the live file is copied (nothing when it is missing). Either way: the warning. @@ -127,7 +128,8 @@ def repo_path(live, repo_root): def stored_path(git_cmd, head, rel): """The path git stores at head for rel: the equal name, else the first - equal without case; None when git holds neither.""" + equal without case; None when git holds neither. It lists the whole + tree, so git_sources calls it only when rel itself is missing at head.""" out = git_bytes(git_cmd, ["ls-tree", "-r", "--name-only", "-z", head]) if out is None: return None @@ -151,17 +153,30 @@ def heads_from_last(recorded): return out +def blob_sources(git_cmd, head, path): + """The bytes 'git show :' and 'git cat-file --filters + :' give, each that succeeds, in that order.""" + out = [] + spec = head + ":" + path + for args in (["show", spec], ["cat-file", "--filters", spec]): + data = git_bytes(git_cmd, args) + if data is not None: + out.append(data) + return out + + def git_sources(git_cmd, recorded, rel): - """Yields the bytes of each git source of rel, in rule 3's order.""" + """Yields the bytes of each git source of rel, in rule 3's order: the + exact path first; the tree is listed only when git holds no rel at a + head.""" for head in heads_from_last(recorded): - path = stored_path(git_cmd, head, rel) - if path is None: - continue - spec = head + ":" + path - for args in (["show", spec], ["cat-file", "--filters", spec]): - data = git_bytes(git_cmd, args) - if data is not None: - yield data + found = blob_sources(git_cmd, head, rel) + if not found: + path = stored_path(git_cmd, head, rel) + if path is not None and path != rel: + found = blob_sources(git_cmd, head, path) + for data in found: + yield data def result(action, source=None, state=None, warning=None): diff --git a/scripts/cleanup b/scripts/cleanup index 81ea0a4..8ff7e67 100755 --- a/scripts/cleanup +++ b/scripts/cleanup @@ -674,7 +674,9 @@ def write_restore_point(repo, plan, run_folder): lines += [git + " fetch " + shlex.quote(bundle) + " " + shlex.quote(ref + ":" + ref) for ref in sorted(saved)] if not saved: lines.append("# no run refs existed, so there is nothing to restore") - lines += ["```", "", "## Worktrees", "", "Recreate a removed worktree once its branch is back:", "", "```bash"] + lines += ["```", "", "## Worktrees", "", + "Recreate a removed worktree (a lane or feature worktree once its branch is back; the start worktree" + " at its sha):", "", "```bash"] removed = [w for w in plan["worktrees"] if w["action"] == "remove"] lines += [git + " worktree add " + shlex.quote(w["path"]) + " " + shlex.quote(w["branch"]) if w["branch"] is not None else @@ -796,7 +798,8 @@ def remove_owned(repo, m, plan): reason = recheck_worktree(repo, w) if reason is not None: note(False, entry, reason) - still_out[w["branch"]] = "its worktree " + w["path"] + " " + reason + if w["branch"] is not None: + still_out[w["branch"]] = "its worktree " + w["path"] + " " + reason continue if w["branch"] is None: # The start worktree holds no commits: the one forced removal. diff --git a/scripts/ledger b/scripts/ledger index e86f336..f385bfb 100755 --- a/scripts/ledger +++ b/scripts/ledger @@ -72,7 +72,7 @@ non-empty strings, no other keys; else the error "'permits' must be a list of {task, files}"). The values are checked, not whether an agent told the truth. Implementer and reviewer rulings permit nothing; an adjudicator ruling in force permits its 'files' (adjudicator_permits); a pre-flight -ruling permits what its `permits` names for its task (preflight_permits; +ruling permits what its 'permits' names for its task (preflight_permits; its 'files' permit nothing). task-checks, undeclared and status --manifest count a permitted path as declared. diff --git a/scripts/run-checks b/scripts/run-checks index 35253eb..8a07b1b 100755 --- a/scripts/run-checks +++ b/scripts/run-checks @@ -2,7 +2,8 @@ """Run a checkout's project commands one by one and keep every exit status. usage: run-checks DIR [--out FILE] [--root ROOT] [--descends-from SHA] - [--lock-held] [--phase NAME] [--record-setup COMMAND ...] + [--lock-held] [--phase NAME] + [--record-setup COMMAND ... | --record-setup-none] --cmd GROUP COMMAND [--cmd ...] --phase NAME (prelude, lane, join, integrate, post-integrate, final-fix, @@ -19,7 +20,9 @@ null without it. records the setup commands the caller ran before this call: the JSON gains "setup_commands": [COMMAND, ...] in the order given; without the flag the key is absent. They are recorded only, never run. reuse-checks compares them, so -a passing check is reused only after the same setup. +a passing check is reused only after the same setup. --record-setup-none +records that there were none ("setup_commands": []); given with +--record-setup it is a usage error. Each COMMAND runs with bash -c in DIR (the bash _shell.find_bash resolves: Git Bash on Windows, never the WSL launcher), in the given order, and every @@ -162,7 +165,8 @@ BUSY_EXIT = 75 # sysexits EX_TEMPFAIL: busy or temporary, retried once BUSY_WAIT_S = 60 SHA = re.compile(r"^[0-9a-f]{7,40}$") USAGE = ("usage: run-checks DIR [--out FILE] [--root ROOT] [--descends-from SHA] [--lock-held] " - "[--phase NAME] [--record-setup COMMAND ...] --cmd GROUP COMMAND [--cmd GROUP COMMAND ...]") + "[--phase NAME] [--record-setup COMMAND ... | --record-setup-none] " + "--cmd GROUP COMMAND [--cmd GROUP COMMAND ...]") # The run phases a check can belong to (--phase, PL_PHASE). PHASES = ("prelude", "lane", "join", "integrate", "post-integrate", "final-fix", "verify", "start") # The PL_PHASE a phase sets when it is not the phase's own name: the start @@ -202,6 +206,7 @@ def parse(argv): out = root = descends = phase = None lock_held = False cmds, setup = [], None + setup_none = False while rest: if rest[0] == "--cmd" and len(rest) >= 3 and rest[1] and rest[2]: cmds.append((rest[1], rest[2])) @@ -209,6 +214,9 @@ def parse(argv): elif rest[0] == "--record-setup" and len(rest) >= 2 and rest[1]: setup = (setup or []) + [rest[1]] rest = rest[2:] + elif rest[0] == "--record-setup-none": + setup_none = True + rest = rest[1:] elif rest[0] == "--lock-held": lock_held = True rest = rest[1:] @@ -226,6 +234,11 @@ def parse(argv): rest = rest[2:] else: fail(2, USAGE) + if setup_none: + # No setup commands, recorded as such: never beside a command. + if setup is not None: + fail(2, USAGE) + setup = [] return directory, out, root, cmds, descends, lock_held, phase, setup diff --git a/scripts/task-checks b/scripts/task-checks index 85ae19c..afa38d6 100755 --- a/scripts/task-checks +++ b/scripts/task-checks @@ -81,7 +81,7 @@ this unit by, in this order: by ledger carry-launch) names the file for one of the --task ids, found by the ledger script's preflight_permits. ruling is the ledger's ruling id for the last two. Implementer and reviewer -rulings permit nothing; a pre-flight ruling permits what its `permits` names +rulings permit nothing; a pre-flight ruling permits what its 'permits' names for its task. When the ledger cannot be read only --permit paths are permitted; when the owner map cannot be read, none of _run's adjudicator rulings are. diff --git a/src/phases.js b/src/phases.js index 8b6d466..d3684c7 100644 --- a/src/phases.js +++ b/src/phases.js @@ -34,7 +34,7 @@ // attempt; with it, a failing verify after a fix that returned done with a // sha head goes to runRepairs before the re-review, which then covers the // repairs too. final.repairs lists them ([] when none). startCheckAt(verify, -// sha) (1.6.8 spec 4.4, supervised runs too): called right after that +// sha, room) (1.6.8 spec 4.4, supervised runs too): called right after that // verify, before any repair; a non-null result is final.start_check, and // when every failing command fails at the start point no repair runs. async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, taskMinors = [], repair = null, @@ -117,9 +117,10 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t // The checks at the delivered head, before the re-review (spec: no // repeated checks at one commit). if (verifyAt !== null && fixRan) final.verify = await verifyAt(final.head); - // A failing verify there gets the start check before any repair. + // A failing verify there gets the start check before any repair, only + // with room for it and the re-review that must follow (room 2). if (startCheckAt !== null && final.verify) { - const sc = await startCheckAt(final.verify, final.head); + const sc = await startCheckAt(final.verify, final.head, 2); if (sc !== null) final.start_check = sc; } const allFailAtStart = Boolean(final.start_check && final.start_check.all_fail); @@ -497,8 +498,11 @@ function preflightUndeclared(m, entries) { // order gives it to such a task. Two tasks can run at the same time when they // are in different lanes, or are join tasks of different units of one wave of // waves (the join's waves after wave_moves; null when the join runs one unit -// at a time). Prelude tasks never run beside another task. -function preflightPermits(m, permits, rulings, waves) { +// at a time). Prelude tasks never run beside another task. log (optional) +// gets one line per dropped file: 'dropped the permit of for +// ()', or, for an entry with no string task, 'dropped permit entry +// ()'. +function preflightPermits(m, permits, rulings, waves, log = null) { const all = [...m.prelude, ...m.lanes.flatMap((l) => l.tasks), ...m.join]; const runnable = new Set(all.filter((t) => taskState(m, t.id) !== 'skip').map((t) => t.id)); const laneOf = new Map(); @@ -531,7 +535,12 @@ function preflightPermits(m, permits, rulings, waves) { const taskId = shaped && typeof p.task === 'string' ? p.task : null; const listed = shaped && Array.isArray(p.files); const files = listed ? p.files : [shaped && p.files !== undefined ? p.files : null]; - const drop = (file, why) => dropped.push({ task: taskId, file, why }); + const drop = (file, why) => { + dropped.push({ task: taskId, file, why }); + if (log === null) return; + log(taskId === null ? `dropped permit entry ${String(JSON.stringify(p))} (${why})` + : `dropped the permit of ${taskId} for ${file} (${why})`); + }; if (taskId === null || !runnable.has(taskId)) { for (const f of files) drop(f, 'not a task of this launch'); continue; @@ -871,13 +880,12 @@ async function runAll(manifest, io) { } // Permits (spec 4.6) are judged against the waves after the moves; a // result without permits has none. + // A dropped entry with no string task is logged as given (JSON). const permits = preflightPermits(m, pre.permits, pre.rulings, - shown.mode === 'waves' ? wavePlan(m, waveEdges).waves : null); + shown.mode === 'waves' ? wavePlan(m, waveEdges).waves : null, + (line) => io.log(`parallel-lanes: pre-flight: ${line}`)); preflight.permits = permits.kept; preflight.permits_dropped = permits.dropped; - for (const d of permits.dropped) { - io.log(`parallel-lanes: pre-flight: dropped the permit of ${d.task} for ${d.file} (${d.why})`); - } m = withConsumesExtra(m, kept); // Pre-flight's rulings bind every task and final reviewer of this run // (taskContext and the final review prompts show m.run_rulings); its @@ -1269,14 +1277,16 @@ async function runAll(manifest, io) { const repair = autonomous ? { admit, first: spent + 1 } : null; // The start check after a failing verify (1.6.8 spec 4.4): at most once // per launch, only when startCheckDue, and an ordinary agent call (not - // overBudget). With no agent free, or the agents unavailable, it records - // unavailable for the budget without a call, so nothing is refused. + // overBudget). With fewer than `room` agents free (1, or 2 inside the + // final review, whose re-review must follow it), or the agents + // unavailable, it records unavailable for the budget without a call, so + // the advisory check never refuses a later call. // Returns the classified start check, or null when none was due. let startChecked = false; - const startCheckAt = async (v, sha) => { + const startCheckAt = async (v, sha, room = 1) => { if (startChecked || !startCheckDue(m, v, sha)) return null; startChecked = true; - if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < 1) { + if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < room) { return { ...classifyStartCheck(m, v, sha, null), why: 'budget' }; } const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), sonnetHigh); diff --git a/src/prompts-phase.js b/src/prompts-phase.js index 354b8a3..3fcf7ef 100644 --- a/src/prompts-phase.js +++ b/src/prompts-phase.js @@ -1301,6 +1301,8 @@ function startCheckPrompt(m, verify, sha) { ` check. Remove it first (${remove}, then ${prune}), then add it as above.`, ` - Anything else at that path (a worktree on a branch or at another commit, or a folder git does not list):`, ' return status unavailable with results [] and notes saying what is there, and touch nothing.', + ` Once it is added, record the output of git -C ${q(wt)} rev-parse HEAD: you return it as start_sha, and step 5`, + ' removes the worktree before you return.', `2. Run the setup commands there, in order: ${setup.length > 0 ? `cd ${q(wt)} && ${setup.join(' && ')}` : '(none)'}`, `3. The failing commands at ${sha}:`, ...lines, @@ -1322,8 +1324,8 @@ function startCheckPrompt(m, verify, sha) { '', phaseRules(m, own), '', - `Return status checked, start_sha = the output of git -C ${q(wt)} rev-parse HEAD (it must be ${start}),`, - 'evidence = the path reserve printed (the run-checks result file), results = one entry per failing command', + `Return status checked, start_sha = the output of git -C ${q(wt)} rev-parse HEAD you recorded in step 1`, + `(it must be ${start}), evidence = the path reserve printed (the run-checks result file), results = one entry per failing command`, 'above: {verify_command (the command as listed above), start_command (the command you ran), narrowed (true when', 'you ran a narrower command), exit_at_start (its exit in the run-checks result; null when it did not run),', 'failed_at_head (the tests its verify log shows failing), failed_at_start (the tests its start log shows', diff --git a/src/prompts-task.js b/src/prompts-task.js index 94628c0..12687ce 100644 --- a/src/prompts-task.js +++ b/src/prompts-task.js @@ -126,13 +126,17 @@ function checksPhase(laneId) { // reserve prints. phase (1.6.5) is run-checks' --phase: the commands run // with PL_PHASE set to it. recordSetup (1.6.8, spec 4.7) lists setup // commands run-checks records in its evidence (--record-setup, in order) -// and never runs. null for an empty list. -function runChecksCommand(m, dir, checks, out = null, phase = null, recordSetup = []) { +// and never runs: an empty list records that there were none +// (--record-setup-none), null (the default) records nothing. null for an +// empty checks list. +function runChecksCommand(m, dir, checks, out = null, phase = null, recordSetup = null) { if (checks.length === 0) return null; const parts = checks.flatMap((c) => ['--cmd', shellQuote(c.group), shellQuote(c.command)]); const target = out === resultPath() ? out : shellQuote(out); const evidence = out === null ? [] : ['--out', target, '--root', shellQuote(m.repo.ledger_dir)]; - const setup = recordSetup.flatMap((s) => ['--record-setup', shellQuote(s)]); + const setup = recordSetup === null ? [] + : recordSetup.length === 0 ? ['--record-setup-none'] + : recordSetup.flatMap((s) => ['--record-setup', shellQuote(s)]); return [`cd ${shellQuote(dir)} && ${pythonCommand(m)} ${shellQuote(`${m.skill_dir}/scripts/run-checks`)}`, shellQuote(dir), ...evidence, ...(phase === null ? [] : ['--phase', shellQuote(phase)]), ...setup, ...parts].join(' '); } diff --git a/tests/archive-pin.test.mjs b/tests/archive-pin.test.mjs index 8edec37..69d2df7 100644 --- a/tests/archive-pin.test.mjs +++ b/tests/archive-pin.test.mjs @@ -189,6 +189,45 @@ test('a ledger without plan_sha256 never replaces an archived plan', () => { assert.deepEqual(out.warnings, []); }); +function gitAt(gitDir, ...args) { + const res = spawnSync('git', ['--git-dir', gitDir, ...args], { encoding: 'utf8', env: cleanEnv() }); + assert.equal(res.status, 0, `git ${args.join(' ')}: ${res.stderr}`); + return res.stdout.trim(); +} + +test('a shadow-mode run pins from its git dir, with paths relative to repo.root', () => { + // The project has no .git: its history lives in a separate git dir whose + // core.worktree is the project, as the shadow mode sets it up. + const r = makeRun(); + const proj = join(r.root, 'shadowproj'); + const gitDir = join(r.root, 'shadow.git'); + mkdirSync(join(proj, 'docs'), { recursive: true }); + writeFileSync(join(proj, 'docs', 'plan.md'), '# Plan\nin the shadow\n'); + writeFileSync(join(proj, 'docs', 'spec.md'), '# Spec\nin the shadow\n'); + gitAt(gitDir, 'init', '-q'); + gitAt(gitDir, 'config', 'core.bare', 'false'); + gitAt(gitDir, 'config', 'core.worktree', proj); + gitAt(gitDir, 'config', 'core.autocrlf', 'false'); + gitAt(gitDir, '--work-tree', proj, 'add', '-A'); + gitAt(gitDir, '--work-tree', proj, ...IDENTITY, 'commit', '-q', '-m', 'docs'); + const head = gitAt(gitDir, 'rev-parse', 'HEAD'); + assert.ok(!existsSync(join(proj, '.git')), 'the project holds no git dir'); + const plan = join(proj, 'docs', 'plan.md'); + const specFile = join(proj, 'docs', 'spec.md'); + const planBytes = readFileSync(plan); + writeJson(r.manifest, { version: 1, run_id: 'x1', plan, spec: specFile, + repo: { mode: 'shadow', root: proj, git_dir: gitDir, ledger_dir: r.ledger } }); + writeFileSync(join(r.ledger, '_run.jsonl'), JSON.stringify({ task: '_run', event: 'run_started', phase: 'setup', + head, plan_sha256: sha(planBytes), spec_sha256: sha(readFileSync(specFile)) }) + '\n'); + // Edited after the recorded setup event: only the shadow git dir has the sha. + writeFileSync(plan, '# Plan\nedited after the run\n'); + const out = archive(r); + assert.ok(readFileSync(join(r.folder, 'plan.md')).equals(planBytes), 'the archived plan is the shadow commit'); + assert.ok(out.pinned.includes('plan.md'), JSON.stringify(out)); + assert.ok(out.pinned.includes('spec.md'), JSON.stringify(out)); + assert.deepEqual(out.warnings, []); +}); + test('a spec of null pins nothing', () => { const r = makeRun({ spec: false }); const out = archive(r); diff --git a/tests/check-policy-prompts.test.mjs b/tests/check-policy-prompts.test.mjs index 89d6772..0f493eb 100644 --- a/tests/check-policy-prompts.test.mjs +++ b/tests/check-policy-prompts.test.mjs @@ -18,7 +18,7 @@ const { preflightPrompt, integratePrompt, resolveConflictsPrompt, postIntegrateFixPrompt, postIntegrateReReviewPrompt, postIntegratePrompt, e2ePrompt, finalReviewPrompt, combinedFinalReviewPrompt, finalFixPrompt, finalReReviewPrompt, verifyPrompt, verifyRepairPrompt, adjudicatorPrompt, declaredFingerprint, declaredFiles, startCommand, taskChecksLines, - shellQuote, + shellQuote, startCheckPrompt, } = await loadHelpers([ 'implementPrompt', 'reviewPrompt', 'reReviewPrompt', 'fixPrompt', 'laneChecks', 'wideChecks', 'taskChecksCommand', 'taskEvidenceLine', 'fixMessagesLines', 'fallbackImplementer', 'fallbackFixer', 'laneWhere', 'featureWhere', @@ -26,7 +26,7 @@ const { 'resultPath', 'agentRules', 'preflightPrompt', 'integratePrompt', 'resolveConflictsPrompt', 'postIntegrateFixPrompt', 'postIntegrateReReviewPrompt', 'postIntegratePrompt', 'e2ePrompt', 'finalReviewPrompt', 'combinedFinalReviewPrompt', 'finalFixPrompt', 'finalReReviewPrompt', 'verifyPrompt', 'verifyRepairPrompt', 'adjudicatorPrompt', - 'declaredFingerprint', 'declaredFiles', 'startCommand', 'taskChecksLines', 'shellQuote', + 'declaredFingerprint', 'declaredFiles', 'startCommand', 'taskChecksLines', 'shellQuote', 'startCheckPrompt', ]); const fp = declaredFingerprint; @@ -300,6 +300,7 @@ function everyPrompt() { verify: verifyPrompt(m, H('d1')), verifyFrom: verifyPrompt(m, H('d1'), H('d0')), verifyRepair: verifyRepairPrompt(m, verify, H('v1'), 1), + startCheck: startCheckPrompt({ ...m, start_points: { ...m.start_points, prelude: H('s0') } }, verify, H('v1')), adjudicator: adjudicatorPrompt(m, ctx), }; } diff --git a/tests/join-waves-run.test.mjs b/tests/join-waves-run.test.mjs index b076165..21167df 100644 --- a/tests/join-waves-run.test.mjs +++ b/tests/join-waves-run.test.mjs @@ -541,6 +541,27 @@ test('an earlier-producer finding moves the unit to a later wave and is reported assert.ok(callOf(calls, 'J3 implement').prompt.includes("cd '/work/repo'"), 'J3 runs alone, on the feature checkout'); }); +test('pre-flight permits are judged against the waves after the moves', async () => { + // Waves [J1, J3], [J2]; the code dependency moves J3 to wave 2, beside J2, + // so a permit giving J3 a file J2 declares is dropped (before the move J3 + // and J2 never ran at the same time, and the permit would stand). + const m = manifest({ join: [task('J1', []), task('J2', ['J1']), task('J3', [])] }); + const { result } = await run(m, script(H('M2'), { + 'pre-flight': [{ conflicts: [], rulings: ['Ruling: J3 may change src/J2.js - it shares a helper - none'], + undeclared: [], code_deps: [{ task: 'J3', producer: 'J1', what: 'calls it' }], + permits: [{ task: 'J3', files: ['src/J2.js'], ruling: 0 }] }], + 'J2 implement': [done(H('J1'), H('J2'))], + 'J3 implement': [done(H('J1'), H('J3'))], + 'wave 1 merge': [], + 'wave 2 merge': [merged(H('M2'), { merged: ['J2', 'J3'] })], + })); + assert.equal(result.status, 'complete', JSON.stringify(result.reason || result.errors)); + assert.deepEqual(result.preflight.wave_moves, [{ unit: 'J3', from: 1, to: 2, producer: 'J1' }]); + assert.deepEqual(result.preflight.permits, []); + assert.deepEqual(result.preflight.permits_dropped, + [{ task: 'J3', file: 'src/J2.js', why: 'declared by J2, which can run at the same time' }]); +}); + test('a later-producer code dependency is still a schedule conflict', async () => { const { result } = await run(manifest(), script(H('J3'), { 'pre-flight': [{ conflicts: [], rulings: [], undeclared: [], code_deps: [{ task: 'J1', producer: 'J2', what: 'calls it' }] }], diff --git a/tests/phase-prompts.test.mjs b/tests/phase-prompts.test.mjs index e5b221a..8620515 100644 --- a/tests/phase-prompts.test.mjs +++ b/tests/phase-prompts.test.mjs @@ -219,12 +219,14 @@ test('the integrate prompt runs setup, then reuse-checks only when its merges ma assert.ok(text.indexOf(flat(reuse)) < text.indexOf(flat(line)), 'reuse is tried before the run-checks call'); assert.ok(text.includes('checks_reused (the file reuse-checks printed when you reused its checks, else null)'), text); } - // No setup commands: no --setup and no --record-setup. + // No setup commands: no --setup, and the run-checks call records that + // there were none (setup_commands: []), so reuse-checks can match it. const bare = manifest({ commands: { setup: [], test: ['npm test'], lint: [], build: [] } }); const p = integratePrompt(bare, H('pre')); const reuse = p.split('\n').find((l) => l.includes("/scripts/reuse-checks' ")); assert.ok(!reuse.includes('--setup'), reuse); - assert.ok(!runChecksLines(p)[0].includes('--record-setup'), p); + assert.ok(runChecksLines(p)[0].includes(' --record-setup-none '), p); + assert.ok(!runChecksLines(p)[0].includes('--record-setup '), p); // No checks at all: nothing to reuse. const none = integratePrompt(manifest({ commands: { setup: ['npm ci'], test: [], lint: [], build: [] } }), H('pre')); assert.ok(!none.includes('reuse-checks'), none); @@ -524,6 +526,10 @@ test('the start-check prompt names its worktree, the start sha, the start phase assert.ok(text.includes(`every shell command starts with cd ${wt} &&`), p); assert.ok(!text.includes("every shell command starts with cd '/work/repo' &&"), p); assert.ok(text.includes('is not failing or passing: list it in not_run'), p); + // The start sha is read before step 5 removes the worktree, and returned from that record. + const record = text.indexOf(`record the output of git -C ${wt} rev-parse HEAD`); + assert.ok(record >= 0 && record < text.indexOf('2. Run the setup commands'), p); + assert.ok(text.includes(`start_sha = the output of git -C ${wt} rev-parse HEAD you recorded in step 1`), p); // Shadow mode administers worktrees through the shadow git dir. const shadow = manifest({ start_points: { prelude: START }, repo: { ...manifest().repo, mode: 'shadow', git_dir: '/work/shadow.git' } }); diff --git a/tests/repair.test.mjs b/tests/repair.test.mjs index 4be303c..2ed4112 100644 --- a/tests/repair.test.mjs +++ b/tests/repair.test.mjs @@ -805,6 +805,21 @@ test('no room for the start check: unavailable and no call', async () => { assert.deepEqual(kinds(result), ['checks_failed']); }); +test('one agent left after a final fix: the re-review gets it, not the start check', async () => { + // 13 agents up to the lenses, the final fix and its verify: one left. + const m = started({ limits: { review_rounds: 5, max_parallel_lanes: 3, max_agents: 16 } }); + const { result, calls } = await run(m, { + ...withFix({ verify: [verified(H('f1'), 1)], 'final re-review': [resolved(H('f1'))] }), + 'start check': [passesAtStart()], ...taskScript(ALL), + }); + assert.equal(result.status, 'complete', JSON.stringify(result.reason)); + assert.ok(!labels(calls).includes('start check'), `${labels(calls)}`); + assert.ok(labels(calls).includes('final re-review'), `${labels(calls)}`); + assert.equal(result.final.start_check.status, 'unavailable'); + assert.equal(result.final.start_check.why, 'budget'); + assert.deepEqual(kinds(result), ['checks_failed']); +}); + // The engine's guards (spec 4.4 "The engine classifies"). test('classifyStartCheck: each guard gives the expected at_start', () => { const m = started(); diff --git a/tests/run-checks-phase.test.mjs b/tests/run-checks-phase.test.mjs index 7295d7b..8a06ffa 100644 --- a/tests/run-checks-phase.test.mjs +++ b/tests/run-checks-phase.test.mjs @@ -128,6 +128,54 @@ test('--record-setup is recorded and not run', () => { assert.deepEqual(recorded(record), ['integrate']); }); +test('--record-setup is recorded in the early report of a failed --descends-from, not run', () => { + const { dir, record } = fresh(); + // A commit on a side branch is not an ancestor of main's HEAD. + git(dir, 'checkout', '-q', '-b', 'side'); + writeFileSync(join(dir, 'b.txt'), 'b\n'); + git(dir, 'add', '-A'); + git(dir, 'commit', '-q', '-m', 'side'); + const side = git(dir, 'rev-parse', 'HEAD'); + git(dir, 'checkout', '-q', 'main'); + const made = join(TMP, `setup${serial}.txt`).replace(/\\/g, '/'); + const setup = `node -e 'require("fs").writeFileSync(${JSON.stringify(made)}, "x")'`; + const out = join(TMP, `evidence${serial}.json`); + const r = runChecks(dir, ['--out', out, '--descends-from', side, '--record-setup', setup], + [['test', recorder(record)]]); + const report = JSON.parse(readFileSync(out, 'utf8')); + assert.deepEqual(report.descends_from, { sha: side, ok: false }, r.stdout); + assert.deepEqual(report.results, []); + assert.deepEqual(report.setup_commands, [setup]); + assert.deepEqual(JSON.parse(r.stdout).setup_commands, [setup]); + assert.equal(existsSync(made), false, 'a recorded setup command must not run'); + assert.equal(existsSync(record), false, 'no check runs'); +}); + +test('--record-setup-none records no setup commands, and reuse-checks with no --setup reuses it', () => { + const { dir, record } = fresh(); + const ledger = join(TMP, `ledger${serial}`); + mkdirSync(join(ledger, 'checks'), { recursive: true }); + const out = join(ledger, 'checks', 'integrate.1.json'); + const cmd = recorder(record); + const r = runChecks(dir, ['--out', out, '--root', ledger, '--phase', 'integrate', '--record-setup-none'], + [['test', cmd]]); + assert.equal(r.status, 0, r.stderr); + assert.deepEqual(JSON.parse(readFileSync(out, 'utf8')).setup_commands, []); + const res = spawnSync('python3', ['-B', join(SCRIPTS, 'reuse-checks'), ledger, '--name', 'integrate', + '--head', git(dir, 'rev-parse', 'HEAD'), '--checkout', dir, '--cmd', 'test', cmd], { encoding: 'utf8' }); + assert.equal(res.status, 0, `${res.stdout} ${res.stderr}`); + assert.equal(JSON.parse(res.stdout).reuse, true, res.stdout); +}); + +test('--record-setup-none beside --record-setup is a usage error', () => { + const { dir, record } = fresh(); + for (const extra of [['--record-setup-none', '--record-setup', 'npm ci'], ['--record-setup', 'npm ci', '--record-setup-none']]) { + const r = runChecks(dir, extra, [['test', recorder(record)]]); + assert.equal(r.status, 2, `${extra.join(' ')}: ${r.stderr}`); + } + assert.equal(existsSync(record), false); +}); + test('without --record-setup there is no setup_commands key', () => { const { dir, record } = fresh(); const r = runChecks(dir, [], [['test', recorder(record)]]); diff --git a/tests/run.test.mjs b/tests/run.test.mjs index f1593c7..0e7a8c7 100644 --- a/tests/run.test.mjs +++ b/tests/run.test.mjs @@ -1930,6 +1930,25 @@ test('a kept permit reaches task-checks and the implementer', async () => { } }); +test('a dropped permit entry with no string task is logged as given', async () => { + const script = { + ...phaseScript({ 'pre-flight': [{ conflicts: [], rulings: ONE_RULING, undeclared: [], code_deps: [], + permits: [null, { task: 7, files: ['a.py'], ruling: 0 }] }] }), + ...taskScript(ALL), + }; + const { result, logs } = await run(manifest(), script); + assert.deepEqual(result.preflight.permits_dropped, [ + { task: null, file: null, why: 'not a task of this launch' }, + { task: null, file: 'a.py', why: 'not a task of this launch' }, + ]); + const dropped = logs.filter((l) => l.startsWith('parallel-lanes: pre-flight: dropped permit')); + assert.deepEqual(dropped, [ + 'parallel-lanes: pre-flight: dropped permit entry null (not a task of this launch)', + 'parallel-lanes: pre-flight: dropped permit entry {"task":7,"files":["a.py"],"ruling":0} (not a task of this launch)', + ], JSON.stringify(logs)); + assert.ok(!logs.some((l) => l.includes('the permit of null')), JSON.stringify(logs)); +}); + test('the pre-flight prompt asks for permits with a ruling that adds a file; the schema does not require them', () => { const p = preflightPrompt(manifest()).replace(/\s+/g, ' '); assert.ok(p.includes('When a ruling has a task change a file outside its Files list, also return it in permits as' From 09a6772bf65e4c86285dd64340483b366181ca7e Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:36:00 -0700 Subject: [PATCH 13/14] feat: Haiku 5.5 runs light implementers, docs-only final fixes and the start check --- README.md | 3 ++- SKILL.md | 6 +++--- reference.md | 17 ++++++++-------- run.workflow.js | 41 ++++++++++++++++++++++----------------- scripts/_transcripts.py | 2 +- src/phases.js | 25 +++++++++++++----------- src/plan.js | 16 ++++++++------- tests/batch.test.mjs | 4 ++-- tests/dryrun.test.mjs | 18 ++++++++--------- tests/engine.test.mjs | 16 +++++++-------- tests/repair.test.mjs | 2 +- tests/run-report.test.mjs | 17 ++++++++++++++++ tests/run.test.mjs | 27 +++++++++++++++++++------- tests/validate.test.mjs | 4 ++-- 14 files changed, 120 insertions(+), 78 deletions(-) diff --git a/README.md b/README.md index e53a180..a9a83ce 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,8 @@ session lists it, and you can resume with one word. Finished tasks are skipped. parallel saves; when that is 1 or less (always for a single lane), a direct build is recommended first, and you can still pick parallel lanes. 2. **Manifest.** Claude writes a JSON manifest covering lanes, model tier per task - (`standard`, `sonnet`, or `light`; security-sensitive tasks are always `standard`), + (`standard` on Opus, `sonnet` on Sonnet, or `light` on Haiku; security-sensitive tasks are + always `standard`), project commands, commit rules taken from your CLAUDE.md, and budgets. 3. **Dry run and consent.** The workflow validates the manifest without spawning anything, and Claude shows the table. "Just run it" and auto mode do not skip it. diff --git a/SKILL.md b/SKILL.md index 6a409ca..fa49d46 100644 --- a/SKILL.md +++ b/SKILL.md @@ -140,9 +140,9 @@ Fields: reference.md "Manifest fields". In order: a join task without it waits for every earlier one (reference.md "Join waves"). 4. `profile` and tiers: reference.md "Profiles", "Tiers", "Batching". `profile: "lite"` only for one lane, at most 8 tasks, no security task, and no `hooks.post_integrate`; else - `full`. Tiers `standard`, `sonnet`, `light` (security tasks always `standard`); `batch` - keys for consecutive tiny light tasks. The user's model preference wins when it is stricter - (e.g. "Opus for everything" means all `standard`). + `full`. Tiers `standard` (Opus), `sonnet` (Sonnet), `light` (Haiku); security tasks always + `standard`; `batch` keys for consecutive tiny light tasks. The user's model preference wins + when it is stricter (e.g. "Opus for everything" means all `standard`). 5. `commit_rules`: one string from the user's and project's rules (memory, CLAUDE.md, the plan's conventions, CONTRIBUTING; reference.md). Every agent prompt carries it. 6. `commands`, `task_checks` and `widen_paths` (from the plan's tests and shared files), paths, diff --git a/reference.md b/reference.md index c1ed540..394c152 100644 --- a/reference.md +++ b/reference.md @@ -477,9 +477,9 @@ worktrees, when no wave has two units, and when the join started in an earlier l | Tier | Model | Use for | |---|---|---| -| standard | Opus (current), effort high | Default. Every task with logic or tests of logic. Always Opus: pre-flight, adjudicator, resolver, and final review agents, and reviewers (medium for small non-security diffs, see below). Integrate, e2e, and minor-only or docs-only final fixes start on Sonnet high and escalate to Opus. | +| standard | Opus (current), effort high | Default. Every task with logic or tests of logic. Always Opus: pre-flight, adjudicator, resolver, and final review agents, and reviewers (medium for small non-security diffs, see below). Integrate, e2e and minor-only final fixes start on Sonnet high, docs-only final fixes on Haiku high; each escalates to Opus. The start check runs on Haiku high. | | sonnet | Sonnet (current), effort high | Implementers of well-specified tasks with some logic. After the first `changes` verdict the task escalates to Opus. | -| light | Sonnet (current), effort high | Implementers of mechanical tasks only: docs-only, example or config files without tests, version bumps, pure renames, fixture data. Escalates to Opus after the second `changes` verdict. | +| light | Haiku (current), effort high | Implementers of mechanical tasks only: docs-only, example or config files without tests, version bumps, pure renames, fixture data. Escalates to Opus after the second `changes` verdict. | - `security: true` for tasks touching authentication, authorization, tokens, crypto, untrusted input (uploads, parsing external files, request bodies), file paths from users, @@ -490,8 +490,9 @@ worktrees, when no wave has two units, and when the join started in an earlier l everything else runs Opus at `high`. Mechanical run steps (clean merge plus commands, E2E execution) start on Sonnet and escalate to Opus on any conflict or failure. - The user's preference wins when stricter: "Opus for everything" means every task is - `standard`. There is no Haiku tier and reviewers are never lighter than Opus; if the user - asks for that, say so. + `standard`, and "no Haiku" means `sonnet` instead of `light` (a docs-only final fix and + the start check still run on Haiku). Haiku runs only those three; reviewers are never + lighter than Opus, and if the user asks for that, say so. ## Batching @@ -703,8 +704,8 @@ raised it). - `limits.max_agents`: set to 2 x the dry-run estimate (which lists the Verify agents as an upper bound: `verify` runs whenever a check command exists, the rechecks only when later commits made earlier evidence stale). The `model` and `effort` of each dry-run agent are a - worst case for its first attempt (`final_fix` is listed on Opus, though a fix of only minor - or docs-only findings starts on Sonnet); escalations, retries, and fix rounds are extra + worst case for its first attempt (`final_fix` is listed on Opus, though a fix of only docs + findings starts on Haiku and one of only minor findings on Sonnet); escalations, retries, and fix rounds are extra agents the list does not show. A refused agent ends its task or phase without work; the run stops with reason `budget`, resumable, never accepted. - `limits.max_rulings`: 25 adjudicator rulings per run. @@ -726,7 +727,7 @@ raised it). returned nothing is retried. - The `start check` agent (1.6.8, see Check ownership) is an ordinary agent under `max_agents`, not exempt like `verify`: it judges logs and may run long. The dry run lists one `start check` - row (Verify, Sonnet high) beside `verify` whenever verify is planned, as an upper bound (it runs + row (Verify, Haiku high) beside `verify` whenever verify is planned, as an upper bound (it runs only after a failing verify, at most once per launch), so the table's agent count and the `max_agents` it suggests grow by one. With no agent free (after a final fix: fewer than two, since the final re-review must follow it), or the agents unavailable, the run makes no call @@ -878,7 +879,7 @@ for example with a wrapper script. The warning is advice and never refuses. run that changed nothing, gets none), and at most once per launch (a later launch may run its own). Both verify sites call it: the verify after a final fix and the main verify after the final review. The verify of a budget stop never does. Its agent's budget is in "Budgets". -- **The agent:** `start check`, phase Verify, Sonnet high, with its own checkout rule. It adds a +- **The agent:** `start check`, phase Verify, Haiku high, with its own checkout rule. It adds a detached worktree at `/start` on the start sha (`git worktree add --detach`, from `repo.git_dir` in shadow mode); a worktree of this repo already there and detached at the start sha is the run's own, left by an earlier start check, and is removed first (`git diff --git a/run.workflow.js b/run.workflow.js index 13d468f..c7b1eba 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -883,12 +883,14 @@ function withDefaultHooks(m) { return { ...m, hooks: {} }; } -// Model settings. The sonnet and light tiers apply to implementers of -// sonnet and light tasks only; reviewers always run standard. Integrate, -// e2e, and minor-only or docs-only final fixes start on Sonnet with settings -// of their own (phases.js). +// Model settings. The sonnet tier runs Sonnet and the light tier Haiku, +// for implementers of those tasks only; reviewers always run standard. +// Integrate, e2e and minor-only final fixes start on Sonnet, docs-only +// final fixes and the start check on Haiku, with settings of their own +// (phases.js). function tierSettings(tier) { - return tier === 'sonnet' || tier === 'light' + if (tier === 'light') return { model: 'haiku', effort: 'high' }; + return tier === 'sonnet' ? { model: 'sonnet', effort: 'high' } : { model: 'opus', effort: 'high' }; } @@ -912,7 +914,7 @@ function tierSettings(tier) { // once, whenever a project or lane test, lint or build command exists // (finalChecks): after the final fix and before the final re-review (which // gets its result), or after the lenses when they found nothing; it is -// listed in that place. The start check (Sonnet) is listed beside it as the +// listed in that place. The start check (Haiku) is listed beside it as the // upper bound: it runs only after a failing verify. e2e_recheck and // post_integrate_recheck are listed as the upper bound: they run only when a // later commit made the earlier result stale. Retries, adjudications, @@ -971,7 +973,7 @@ function planAgents(m) { for (const role of [...lenses, 'final_fix']) add('Final review', null, null, role, standard); if (finalChecks(m).length > 0) { add('Verify', null, null, 'verify', sonnetHigh); - add('Verify', null, null, 'start_check', sonnetHigh); + add('Verify', null, null, 'start_check', { model: 'haiku', effort: 'high' }); } add('Final review', null, null, 'final_re_review', standard); if (m.hooks.e2e) add('Verify', null, null, 'e2e_recheck', sonnetHigh); @@ -5451,26 +5453,28 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t return final; }; - // Final fix tier (spec decision 5): Sonnet when every finding is minor or - // every finding is in documentation; otherwise Opus. A Sonnet fix that does - // not finish reruns once on Opus (same label); still one fix wave. + // Final fix tier (spec decision 5): Haiku when every finding is in + // documentation, else Sonnet when every finding is minor, otherwise Opus. A + // Haiku or Sonnet fix that does not finish reruns once on Opus (same + // label); still one fix wave. const docsOnly = findings.every((f) => typeof f.file === 'string' && f.file.endsWith('.md')); const minorOnly = findings.every((f) => f.severity === 'minor'); - const fixSettings = minorOnly || docsOnly ? { model: 'sonnet', effort: 'high' } : standard; + const fixSettings = docsOnly ? { model: 'haiku', effort: 'high' } + : minorOnly ? { model: 'sonnet', effort: 'high' } : standard; const callFix = (settings) => io.agent(finalFixPrompt(m, findings, tip), { label: 'final fix', phase: 'Final review', schema: finalFixSchema(), ...settings }); let fix = await callFix(fixSettings); // A refused first call spawned no fix agent: no code changed, so the // caller's budget stop runs the checks. const fixRan = !(fix && fix.__budget); - // Whatever a fix agent did, commits it reports are delivered code: a Sonnet - // fix's head stands unless its Opus rerun reports a head of its own. - // A head that is not a commit sha names no commit, so the run cannot say - // what the fix delivered: a Sonnet fix reruns on Opus, and an Opus one - // leaves every finding open. + // Whatever a fix agent did, commits it reports are delivered code: a Haiku + // or Sonnet fix's head stands unless its Opus rerun reports a head of its + // own. A head that is not a commit sha names no commit, so the run cannot + // say what the fix delivered: a Haiku or Sonnet fix reruns on Opus, and an + // Opus one leaves every finding open. const badHead = (r) => Boolean(r && !r.__budget && present(r.head) && !isSha(r.head)); if (fix && isSha(fix.head)) final.head = fix.head; - if (fixSettings.model === 'sonnet' && (!fix || fix.status !== 'done' || badHead(fix))) fix = await callFix(standard); + if (fixSettings !== standard && (!fix || fix.status !== 'done' || badHead(fix))) fix = await callFix(standard); if (fix && isSha(fix.head)) final.head = fix.head; // The checks at the delivered head, before the re-review (spec: no // repeated checks at one commit). @@ -6647,7 +6651,8 @@ async function runAll(manifest, io) { if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < room) { return { ...classifyStartCheck(m, v, sha, null), why: 'budget' }; } - const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), sonnetHigh); + const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), + { model: 'haiku', effort: 'high' }); return classifyStartCheck(m, v, sha, r); }; // A budget stop once every task is in: the checks still run at the diff --git a/scripts/_transcripts.py b/scripts/_transcripts.py index 23e5391..ceafb47 100644 --- a/scripts/_transcripts.py +++ b/scripts/_transcripts.py @@ -99,7 +99,7 @@ def parse_label(label, ids): def model_tier(model): if isinstance(model, str): low = model.lower() - for tier in ("opus", "sonnet"): + for tier in ("opus", "sonnet", "haiku"): if tier in low: return tier return None diff --git a/src/phases.js b/src/phases.js index d3684c7..e825e54 100644 --- a/src/phases.js +++ b/src/phases.js @@ -93,26 +93,28 @@ async function runFinalReview(m, e2e, base, io, carried = [], verifyAt = null, t return final; }; - // Final fix tier (spec decision 5): Sonnet when every finding is minor or - // every finding is in documentation; otherwise Opus. A Sonnet fix that does - // not finish reruns once on Opus (same label); still one fix wave. + // Final fix tier (spec decision 5): Haiku when every finding is in + // documentation, else Sonnet when every finding is minor, otherwise Opus. A + // Haiku or Sonnet fix that does not finish reruns once on Opus (same + // label); still one fix wave. const docsOnly = findings.every((f) => typeof f.file === 'string' && f.file.endsWith('.md')); const minorOnly = findings.every((f) => f.severity === 'minor'); - const fixSettings = minorOnly || docsOnly ? { model: 'sonnet', effort: 'high' } : standard; + const fixSettings = docsOnly ? { model: 'haiku', effort: 'high' } + : minorOnly ? { model: 'sonnet', effort: 'high' } : standard; const callFix = (settings) => io.agent(finalFixPrompt(m, findings, tip), { label: 'final fix', phase: 'Final review', schema: finalFixSchema(), ...settings }); let fix = await callFix(fixSettings); // A refused first call spawned no fix agent: no code changed, so the // caller's budget stop runs the checks. const fixRan = !(fix && fix.__budget); - // Whatever a fix agent did, commits it reports are delivered code: a Sonnet - // fix's head stands unless its Opus rerun reports a head of its own. - // A head that is not a commit sha names no commit, so the run cannot say - // what the fix delivered: a Sonnet fix reruns on Opus, and an Opus one - // leaves every finding open. + // Whatever a fix agent did, commits it reports are delivered code: a Haiku + // or Sonnet fix's head stands unless its Opus rerun reports a head of its + // own. A head that is not a commit sha names no commit, so the run cannot + // say what the fix delivered: a Haiku or Sonnet fix reruns on Opus, and an + // Opus one leaves every finding open. const badHead = (r) => Boolean(r && !r.__budget && present(r.head) && !isSha(r.head)); if (fix && isSha(fix.head)) final.head = fix.head; - if (fixSettings.model === 'sonnet' && (!fix || fix.status !== 'done' || badHead(fix))) fix = await callFix(standard); + if (fixSettings !== standard && (!fix || fix.status !== 'done' || badHead(fix))) fix = await callFix(standard); if (fix && isSha(fix.head)) final.head = fix.head; // The checks at the delivered head, before the re-review (spec: no // repeated checks at one commit). @@ -1289,7 +1291,8 @@ async function runAll(manifest, io) { if (state.refused.length > 0 || state.unavailable || effectiveLimits(m).max_agents - state.agents < room) { return { ...classifyStartCheck(m, v, sha, null), why: 'budget' }; } - const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), sonnetHigh); + const r = await callM('start check', 'Verify', startCheckPrompt(m, v, sha), startCheckSchema(), + { model: 'haiku', effort: 'high' }); return classifyStartCheck(m, v, sha, r); }; // A budget stop once every task is in: the checks still run at the diff --git a/src/plan.js b/src/plan.js index 26ea860..1e37575 100644 --- a/src/plan.js +++ b/src/plan.js @@ -51,12 +51,14 @@ function withDefaultHooks(m) { return { ...m, hooks: {} }; } -// Model settings. The sonnet and light tiers apply to implementers of -// sonnet and light tasks only; reviewers always run standard. Integrate, -// e2e, and minor-only or docs-only final fixes start on Sonnet with settings -// of their own (phases.js). +// Model settings. The sonnet tier runs Sonnet and the light tier Haiku, +// for implementers of those tasks only; reviewers always run standard. +// Integrate, e2e and minor-only final fixes start on Sonnet, docs-only +// final fixes and the start check on Haiku, with settings of their own +// (phases.js). function tierSettings(tier) { - return tier === 'sonnet' || tier === 'light' + if (tier === 'light') return { model: 'haiku', effort: 'high' }; + return tier === 'sonnet' ? { model: 'sonnet', effort: 'high' } : { model: 'opus', effort: 'high' }; } @@ -80,7 +82,7 @@ function tierSettings(tier) { // once, whenever a project or lane test, lint or build command exists // (finalChecks): after the final fix and before the final re-review (which // gets its result), or after the lenses when they found nothing; it is -// listed in that place. The start check (Sonnet) is listed beside it as the +// listed in that place. The start check (Haiku) is listed beside it as the // upper bound: it runs only after a failing verify. e2e_recheck and // post_integrate_recheck are listed as the upper bound: they run only when a // later commit made the earlier result stale. Retries, adjudications, @@ -139,7 +141,7 @@ function planAgents(m) { for (const role of [...lenses, 'final_fix']) add('Final review', null, null, role, standard); if (finalChecks(m).length > 0) { add('Verify', null, null, 'verify', sonnetHigh); - add('Verify', null, null, 'start_check', sonnetHigh); + add('Verify', null, null, 'start_check', { model: 'haiku', effort: 'high' }); } add('Final review', null, null, 'final_re_review', standard); if (m.hooks.e2e) add('Verify', null, null, 'e2e_recheck', sonnetHigh); diff --git a/tests/batch.test.mjs b/tests/batch.test.mjs index 0c84d31..2fc9cf9 100644 --- a/tests/batch.test.mjs +++ b/tests/batch.test.mjs @@ -82,7 +82,7 @@ test('two batched tasks run as one implementer and one review, with a result for const r = await runLane(m, m.lanes[0], H('b0'), s.io); assert.deepEqual(labels(s.calls), ['T2-T3 implement', 'T2-T3 review']); const [impl, rev] = s.calls; - assert.deepEqual([impl.model, impl.effort], ['sonnet', 'high']); + assert.deepEqual([impl.model, impl.effort], ['haiku', 'high']); assert.deepEqual([rev.model, rev.effort], ['opus', 'medium'], 'review effort from the batch changed_lines'); for (const id of ['T2', 'T3']) { assert.ok(impl.prompt.includes(`/work/ledger/briefs/${id}.md`), `${id} brief file`); @@ -117,7 +117,7 @@ test('a batch fix round and re-review cover every task of the batch', async () = const r = await runLane(m, m.lanes[0], H('b0'), s.io); assert.deepEqual(labels(s.calls), ['T2-T3 implement', 'T2-T3 review', 'T2-T3 fix 1', 'T2-T3 re-review 1']); const [, , fix, rr] = s.calls; - assert.deepEqual([fix.model, fix.effort], ['sonnet', 'high']); + assert.deepEqual([fix.model, fix.effort], ['haiku', 'high']); assert.ok(fix.prompt.includes('FIX-ME')); for (const id of ['T2', 'T3']) { assert.ok(fix.prompt.includes(finishTasks), `${id} committed via finish-task in the fix`); diff --git a/tests/dryrun.test.mjs b/tests/dryrun.test.mjs index 2b11faf..bd868ff 100644 --- a/tests/dryrun.test.mjs +++ b/tests/dryrun.test.mjs @@ -151,11 +151,11 @@ test('a task in done but not reviewed produces a review agent only', () => { assert.deepEqual(roles(forTask(agents, 'T2')), ['review']); }); -test('a light task implementer runs at sonnet/high and its reviewer at opus/high', () => { +test('a light task implementer runs at haiku/high and its reviewer at opus/high', () => { const m = manifest(); m.lanes[1].tasks[0].tier = 'light'; const [implement, review] = forTask(planAgents(m), 'T4'); - assert.deepEqual([implement.role, implement.model, implement.effort], ['implement', 'sonnet', 'high']); + assert.deepEqual([implement.role, implement.model, implement.effort], ['implement', 'haiku', 'high']); assert.deepEqual([review.role, review.model, review.effort], ['review', 'opus', 'high']); }); @@ -167,13 +167,13 @@ test('a sonnet task implementer runs at sonnet/high and its reviewer at opus/hig assert.deepEqual([review.role, review.model, review.effort], ['review', 'opus', 'high']); }); -test('integrate and e2e run at sonnet/high; every other agent but a light implementer at opus/high', () => { +test('integrate and e2e run at sonnet/high, the start check and a light implementer at haiku/high, the rest at opus/high', () => { const m = manifest({ hooks: { post_integrate: 'check', e2e: 'run' } }); m.join[0].tier = 'light'; - const sonnet = new Set(['integrate', 'e2e', 'verify', 'start_check', 'e2e_recheck']); + const sonnet = new Set(['integrate', 'e2e', 'verify', 'e2e_recheck']); for (const a of planAgents(m)) { - const want = sonnet.has(a.role) || (a.task === 'T5' && a.role === 'implement') - ? ['sonnet', 'high'] : ['opus', 'high']; + const haiku = a.role === 'start_check' || (a.task === 'T5' && a.role === 'implement'); + const want = haiku ? ['haiku', 'high'] : sonnet.has(a.role) ? ['sonnet', 'high'] : ['opus', 'high']; assert.deepEqual([a.model, a.effort], want, `${a.role} ${a.task}`); } }); @@ -195,7 +195,7 @@ test('the planned agents list a start check after verify', () => { const agents = planAgents(manifest()); const at = agents.findIndex((a) => a.role === 'verify'); assert.deepEqual(agents[at + 1], { - phase: 'Verify', lane: null, task: null, role: 'start_check', model: 'sonnet', effort: 'high', + phase: 'Verify', lane: null, task: null, role: 'start_check', model: 'haiku', effort: 'high', }); assert.equal(agents.filter((a) => a.role === 'start_check').length, 1); const none = manifest({ commands: { setup: [], test: [], lint: [], build: [] } }); @@ -228,7 +228,7 @@ test('lite plan: no pre-flight, integrate or post-integrate; one combined final ['Final review', null, null, 'final_review_combined', 'opus'], ['Final review', null, null, 'final_fix', 'opus'], ['Verify', null, null, 'verify', 'sonnet'], - ['Verify', null, null, 'start_check', 'sonnet'], + ['Verify', null, null, 'start_check', 'haiku'], ['Final review', null, null, 'final_re_review', 'opus'], ['Verify', null, null, 'e2e_recheck', 'sonnet'], ]); @@ -238,7 +238,7 @@ test('batched light tasks plan one implement and one review for the batch', () = const m = batchedManifest(); const lane = planAgents(m).filter((a) => a.lane === 'alpha'); assert.deepEqual(lane.map((a) => [a.task, a.role, a.model, a.effort]), [ - ['T2-T3', 'implement', 'sonnet', 'high'], + ['T2-T3', 'implement', 'haiku', 'high'], ['T2-T3', 'review', 'opus', 'high'], ]); }); diff --git a/tests/engine.test.mjs b/tests/engine.test.mjs index 480b853..e3a662e 100644 --- a/tests/engine.test.mjs +++ b/tests/engine.test.mjs @@ -171,8 +171,8 @@ test('a light task escalates to standard after the second changes verdict', asyn 'T2 implement', 'T2 review', 'T2 fix 1', 'T2 re-review 1', 'T2 implement', 'T2 review', ]); const [impl1, rev1, fix1, , impl2, rev2] = s.calls; - assert.deepEqual([impl1.model, impl1.effort], ['sonnet', 'high']); - assert.deepEqual([fix1.model, fix1.effort], ['sonnet', 'high']); + assert.deepEqual([impl1.model, impl1.effort], ['haiku', 'high']); + assert.deepEqual([fix1.model, fix1.effort], ['haiku', 'high']); assert.deepEqual([rev1.model, rev1.effort], ['opus', 'high']); assert.deepEqual([impl2.model, impl2.effort], ['opus', 'high'], 'next implement call uses opus/high'); assert.ok(impl2.prompt.includes('second'), 'the escalated implementer sees the open findings'); @@ -192,7 +192,7 @@ test('a light task escalates to standard after a blocked implement', async () => }); const r = await runTask(m, task('T2', { tier: 'light' }), WHERE, H('b0'), s.io); assert.deepEqual(labels(s.calls), ['T2 implement', 'T2 implement', 'T2 review']); - assert.equal(s.calls[0].model, 'sonnet'); + assert.equal(s.calls[0].model, 'haiku'); assert.equal(s.calls[1].model, 'opus'); assert.equal(s.calls[1].effort, 'high'); assert.ok(s.calls[1].prompt.includes('cannot find the parser')); @@ -211,7 +211,7 @@ for (const [name, fixResult] of [['reports blocked', blocked(H('h1'), 'fix stuck const r = await runTask(m, task('T2', { tier: 'light' }), WHERE, H('b0'), s.io); assert.deepEqual(labels(s.calls), ['T2 implement', 'T2 review', 'T2 fix 1', 'T2 implement', 'T2 review']); const [, , fix1, impl2, rev2] = s.calls; - assert.deepEqual([fix1.model, fix1.effort], ['sonnet', 'high']); + assert.deepEqual([fix1.model, fix1.effort], ['haiku', 'high']); assert.deepEqual([impl2.model, impl2.effort], ['opus', 'high'], 'next implement call uses opus/high'); assert.ok(impl2.prompt.includes('first'), 'the escalated implementer sees the open findings'); assert.ok(rev2.prompt.includes(H('b0')) && rev2.prompt.includes(H('h2')), 'the full task range is reviewed again'); @@ -720,7 +720,7 @@ test('autonomous: a light task that blocks escalates to standard before any adju const r = await runTask(m, task('T2', { tier: 'light' }), WHERE, H('b0'), s.io); assert.deepEqual(labels(s.calls), ['T2 implement', 'T2 implement', 'T2 adjudicate', 'T2 implement', 'T2 review']); - assert.deepEqual(s.calls.map((c) => c.model), ['sonnet', 'opus', 'opus', 'opus', 'opus']); + assert.deepEqual(s.calls.map((c) => c.model), ['haiku', 'opus', 'opus', 'opus', 'opus']); assert.ok(s.calls[2].prompt.includes('standard stuck')); assert.equal(r.status, 'done'); assert.equal(r.tier_used, 'standard'); @@ -735,8 +735,8 @@ test('autonomous: a light task question is adjudicated without escalating', asyn }); const r = await runTask(m, task('T2', { tier: 'light' }), WHERE, H('b0'), s.io); assert.deepEqual(labels(s.calls), ['T2 implement', 'T2 adjudicate', 'T2 implement', 'T2 review']); - assert.equal(s.calls[0].model, 'sonnet'); - assert.equal(s.calls[2].model, 'sonnet', 'the rerun keeps the current tier'); + assert.equal(s.calls[0].model, 'haiku'); + assert.equal(s.calls[2].model, 'haiku', 'the rerun keeps the current tier'); assert.ok(s.calls[2].prompt.includes('ANSWER-3: src/a.js')); assert.equal(r.status, 'done'); assert.equal(r.tier_used, 'light'); @@ -1054,7 +1054,7 @@ test('a light task does not escalate on the first changes verdict, only on the s }); const r = await runTask(m, task('T2', { tier: 'light' }), WHERE, H('b0'), s.io); assert.deepEqual(labels(s.calls), ['T2 implement', 'T2 review', 'T2 fix 1', 'T2 re-review 1']); - assert.deepEqual([s.calls[2].model, s.calls[2].effort], ['sonnet', 'high']); + assert.deepEqual([s.calls[2].model, s.calls[2].effort], ['haiku', 'high']); assert.equal(r.tier_used, 'light'); }); diff --git a/tests/repair.test.mjs b/tests/repair.test.mjs index 2ed4112..80fb625 100644 --- a/tests/repair.test.mjs +++ b/tests/repair.test.mjs @@ -641,7 +641,7 @@ test('a failing verify starts one start check before the first repair', async () assert.deepEqual(order, ['verify', 'start check', 'verify repair 1', 'verify']); const sc = calls.find((c) => c.label === 'start check'); assert.equal(sc.phase, 'Verify'); - assert.equal(sc.model, 'sonnet'); + assert.equal(sc.model, 'haiku'); assert.equal(sc.effort, 'high'); assert.equal(result.final.start_check.status, 'checked'); assert.equal(result.final.start_check.verify_head, H('f1')); diff --git a/tests/run-report.test.mjs b/tests/run-report.test.mjs index 988fd69..3d7df7b 100644 --- a/tests/run-report.test.mjs +++ b/tests/run-report.test.mjs @@ -247,6 +247,23 @@ test('totals say unavailable when an agent\'s output count is incomplete', () => assert.equal(out.tiers.sonnet.output_tokens_min, 12); }); +test('Haiku agents get a haiku tier, and a rerun on Opus counts as a haiku escalation', () => { + const dir = join(TMP, 'haiku'); + mkdirSync(dir, { recursive: true }); + writeAgent(dir, 'h1', 'T1 implement', 'haiku', + [usageLine('m1', 'claude-haiku-5-5', 7, 'end_turn', '2026-10-02T10:00:00.000Z')]); + writeAgent(dir, 'h2', 'T1 implement', 'opus', + [usageLine('m2', 'claude-opus-5-5', 9, 'end_turn', '2026-10-02T10:05:00.000Z')]); + const r = run([dir, MANIFEST]); + assert.equal(r.code, 0, r.stderr); + const out = JSON.parse(r.stdout); + assert.equal(out.tiers.haiku.agents, 1); + assert.equal(out.tiers.haiku.output_tokens, 7); + assert.equal(out.tiers.haiku.escalations, 1); + assert.equal(out.tiers.opus.agents, 1); + assert.equal(out.models['claude-haiku-5-5'], 1); +}); + test('effort comes from the result file where the Workflow tool writes it', () => { // Transcripts in /subagents/workflows/wf_/, result in // /workflows/wf_.json; nothing beside the transcript dir. diff --git a/tests/run.test.mjs b/tests/run.test.mjs index 0e7a8c7..cc0811e 100644 --- a/tests/run.test.mjs +++ b/tests/run.test.mjs @@ -275,17 +275,18 @@ test('the review approval command passes the spec', async () => { assert.ok(!noSpec.includes(`${head} '`), 'no spec path without a spec'); }); -test('mechanical phases start on sonnet high; everything else runs opus high and all carry the commit rules', async () => { +test('mechanical phases start on sonnet high, a light implementer on haiku high; the rest run opus high and all carry the commit rules', async () => { const m = manifest(); m.lanes[1].tasks[0].tier = 'light'; const { calls } = await run(m, { ...phaseScript(), ...taskScript(ALL) }); - // Integrate and e2e start on Sonnet (D5); the light T4 implementer too. On a - // clean run they succeed first try, so no Opus rerun follows. - const sonnet = new Set(['T4 implement', 'integrate', 'e2e', 'verify', 'e2e recheck']); + // Integrate and e2e start on Sonnet (D5), the light T4 implementer on + // Haiku. On a clean run they succeed first try, so no Opus rerun follows. + const sonnet = new Set(['integrate', 'e2e', 'verify', 'e2e recheck']); for (const c of calls) { assert.ok(c.prompt.includes('COMMIT-RULES: plain ASCII, no trailers'), c.label); assert.ok(/^[\x00-\x7f]*$/.test(c.prompt), `${c.label}: plain ASCII`); - const want = sonnet.has(c.label) ? ['sonnet', 'high'] : ['opus', 'high']; + const want = c.label === 'T4 implement' ? ['haiku', 'high'] + : sonnet.has(c.label) ? ['sonnet', 'high'] : ['opus', 'high']; assert.deepEqual([c.model, c.effort], want, c.label); } }); @@ -2248,7 +2249,7 @@ test('e2e all PASS on sonnet does not rerun on opus', async () => { assert.deepEqual(e2e.map((c) => c.model), ['sonnet']); }); -test('final fix tier: sonnet for minor-only or docs-only findings, opus otherwise, sonnet null reruns on opus', async () => { +test('final fix tier: haiku for docs-only findings, sonnet for other minor-only ones, opus otherwise; a null reruns on opus', async () => { const minorF = (file = 'src/a.js') => ({ severity: 'minor', file, line: 1, issue: 'nit', fix: 'tidy' }); const okFix = { status: 'done', head: H('f1'), tests: 'pass', notes: '', declined: [] }; const oneLens = (findings) => ({ @@ -2266,7 +2267,12 @@ test('final fix tier: sonnet for minor-only or docs-only findings, opus otherwis const docs = await run(manifest(), { ...phaseScript({ ...oneLens([finding('typo', 'README.md', 2)]), 'final fix': [okFix] }), ...taskScript(ALL), }); - assert.equal(docs.calls.find((c) => c.label === 'final fix').model, 'sonnet'); + assert.equal(docs.calls.find((c) => c.label === 'final fix').model, 'haiku'); + + const docsMinor = await run(manifest(), { + ...phaseScript({ ...oneLens([minorF('README.md')]), 'final fix': [okFix] }), ...taskScript(ALL), + }); + assert.equal(docsMinor.calls.find((c) => c.label === 'final fix').model, 'haiku'); const mixed = await run(manifest(), { ...phaseScript(), ...taskScript(ALL) }); assert.equal(mixed.calls.find((c) => c.label === 'final fix').model, 'opus'); @@ -2278,6 +2284,13 @@ test('final fix tier: sonnet for minor-only or docs-only findings, opus otherwis const fixes = nul.calls.filter((c) => c.label === 'final fix'); assert.deepEqual(fixes.map((c) => c.model), ['sonnet', 'opus']); assert.equal(nul.result.status, 'complete'); + + const docsNul = await run(manifest(), { + ...phaseScript({ ...oneLens([minorF('README.md')]), 'final fix': [null, okFix], 'final fix retry': [null] }), + ...taskScript(ALL), + }); + assert.deepEqual(docsNul.calls.filter((c) => c.label === 'final fix').map((c) => c.model), ['haiku', 'opus']); + assert.equal(docsNul.result.status, 'complete'); }); // Reviewer of 1.2.1: commits a Sonnet final fix made stay delivered when its diff --git a/tests/validate.test.mjs b/tests/validate.test.mjs index 6363bd0..6c8bede 100644 --- a/tests/validate.test.mjs +++ b/tests/validate.test.mjs @@ -463,9 +463,9 @@ test('a sonnet task with security set is reported', () => { assertError(validateManifest(m), 'T2', 'sonnet', 'security'); }); -test('sonnet and light tiers run Sonnet at high effort; standard runs Opus at high', () => { +test('the sonnet tier runs Sonnet, the light tier Haiku, standard Opus, all at high effort', () => { assert.deepEqual(tierSettings('sonnet'), { model: 'sonnet', effort: 'high' }); - assert.deepEqual(tierSettings('light'), { model: 'sonnet', effort: 'high' }); + assert.deepEqual(tierSettings('light'), { model: 'haiku', effort: 'high' }); assert.deepEqual(tierSettings('standard'), { model: 'opus', effort: 'high' }); }); From 99f9c7ba0058e7bcbbfe47f46c0a1904d1082bf3 Mon Sep 17 00:00:00 2001 From: noderaven <130021665+noderaven@users.noreply.github.com> Date: Sat, 17 Oct 2026 14:11:08 -0700 Subject: [PATCH 14/14] chore: release 1.6.8 --- VERSION | 2 +- run.workflow.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/VERSION b/VERSION index 400084b..d8c5e72 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.6.7 +1.6.8 diff --git a/run.workflow.js b/run.workflow.js index c7b1eba..2b5f9e9 100644 --- a/run.workflow.js +++ b/run.workflow.js @@ -1,6 +1,6 @@ export const meta = { name: 'parallel-lanes', - description: 'parallel-lanes v1.6.7: Execute an implementation plan as parallel lanes of tasks with per-task review, then integrate.', + description: 'parallel-lanes v1.6.8: Execute an implementation plan as parallel lanes of tasks with per-task review, then integrate.', phases: [ { title: 'Pre-flight', detail: 'plan and spec conflicts' }, { title: 'Prelude', detail: 'shared tasks on the feature branch' }, @@ -6806,7 +6806,7 @@ function noHead(r, label) { // The skill version this script was built from (scripts/build stamps VERSION // here and in the meta description): the version actually running. Logged // first and returned with every result, dry runs included. -const VERSION = '1.6.7'; +const VERSION = '1.6.8'; log(`parallel-lanes v${VERSION} loaded`); if (args !== null && typeof args === 'object' && args.dry_run === true) {