diff --git a/README.md b/README.md index 4fec6ae..2432d94 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ The available installable artifacts are: | `agents` | Codex: `$CODEX_HOME/agents/` as TOML; other harnesses: their native `agents/` directory as Markdown | | `meta-mode-tools` | `tools/meta-mode/` beside the resolved skill root; shared consumers use `~/.agents/tools/meta-mode/` | | `guide` | `docs/guide/` beside the resolved skill root | +| `session-context` | `session-context/` beside the resolved skill root; plain-text routing guidance, never implicit | Codex skills default to `~/.agents/skills/`, while Codex agents default to `~/.codex/agents/`. An unset or empty `CODEX_HOME` uses `~/.codex`. @@ -155,7 +156,11 @@ reviewed and copied into a project repository's committed `.cursor/automations/benny/` directory using its own setup instructions. mstack does not ship portable `commands/`, `hooks/`, or `settings/` trees: those are harness- and project-owned configuration surfaces and must be -written or merged explicitly by the user. +written or merged explicitly by the user. The optional `session-context` +artifact is plain-text guidance only; select it with `--artifact session-context` +and configure the harness's own session surface explicitly. It does not register +a startup hook, grant permissions, or make session routing implicit. Hooks remain +unsupported unless explicitly configured outside mstack. ## Choose installation directories diff --git a/docs/guide/03-understand.md b/docs/guide/03-understand.md index 9fa1c6e..f009465 100644 --- a/docs/guide/03-understand.md +++ b/docs/guide/03-understand.md @@ -46,7 +46,7 @@ When another agent (or you, last week) left a branch mid-flight: /meta-mode take over this branch. read the decision log, figure out what's done, and continue from there. don't redo finished work. ``` -The [Session pickup playbook](../../skills/meta-mode/playbooks/session-pickup.md) treats the prior trail as authoritative. It reconstructs the branch state and decisions, names the resume point, and verifies inherited claims against the original goal instead of re-deriving everything from scratch. +The [Session pickup playbook](../../skills/meta-mode/playbooks/session-pickup.md) treats the prior trail as authoritative. It first reads the latest project-local checkpoint with `node "/resume.mjs" read --project .`, follows its note and evidence paths, reconstructs the branch state and decisions, names the resume point, and verifies inherited claims against the original goal instead of re-deriving everything from scratch. Checkpoints live under Git metadata, so this workflow does not depend on a particular Harness or vendor path. **Pitfall:** don't skip this page's skills because "the agent will read the code anyway." An agent that starts editing without a traced model tends to fix the symptom at the first plausible spot. `/how` first is cheaper than the second bug. diff --git a/docs/session-context/SESSION-CONTEXT.md b/docs/session-context/SESSION-CONTEXT.md new file mode 100644 index 0000000..3c96475 --- /dev/null +++ b/docs/session-context/SESSION-CONTEXT.md @@ -0,0 +1,32 @@ +# Optional session context + +This file is an explicit, user-selected routing artifact for sessions that should +start with mstack's `/meta-mode` workflow. Load or reference it from the active +harness configuration when you want that behavior; the mstack installer never +registers it as a hook, startup command, or implicit instruction. + +## Opt-in contract + +1. Select this artifact explicitly with `--artifact session-context`. +2. Configure the active harness to read this file using its documented, + user-owned session or project configuration surface. +3. Confirm the harness configuration and review the file before using it in a + sensitive repository. + +When loaded, route the user's request through `/meta-mode` at session start. +Keep routing advisory: do not execute commands, modify files, install hooks, or +send data without the harness's normal approval and tool policy. If the file is +not explicitly selected and configured, do nothing. + +## Security and portability boundary + +This is plain text, not executable configuration. It does not grant tools, +permissions, network access, or persistence. Harnesses differ in whether they +support session context, startup instructions, or project configuration, so +mstack does not provide an adapter that silently wires this file into any of +them. A harness-specific integration must be configured and reviewed by the +user. Hooks remain unsupported unless explicitly configured outside mstack. + +Do not put secrets, credentials, transcript contents, or machine-specific paths +in this file. Keep the canonical skill tree Harness-neutral; this artifact is a +separate optional boundary for users who choose session routing. diff --git a/package.json b/package.json index 051c219..ba84e6d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "model-budget": "node scripts/model-budget.mjs", "check-upstream": "node scripts/check-upstream.mjs", "check-playbooks": "node tools/meta-mode/check-playbooks.mjs", + "audit-worktrees": "node tools/meta-mode/worktree-audit.mjs", "check-harness-policy": "node scripts/check-harness-policy.mjs", "skill-baseline": "node scripts/skill-baseline.mjs", "run-role": "node scripts/run-role.mjs", diff --git a/profiles/artifacts.json b/profiles/artifacts.json index f8b3fb8..4f16313 100644 --- a/profiles/artifacts.json +++ b/profiles/artifacts.json @@ -22,6 +22,11 @@ "path": ["docs", "guide"], "description": "The adapted mstack workflow guide and its images." }, + "session-context": { + "source": "docs/session-context", + "path": ["session-context"], + "description": "Optional plain-text session routing guidance; never installed implicitly or registered as a hook." + }, "benny": { "source": "automations/benny", "installable": false, diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index c434ea2..5977d7a 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -266,13 +266,13 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/orchestrate.md", "sourceDigest": "d42b4375f1bd4ce23da0b575a198571a2117df7eea10c3c9fc6d92ae1053f7be", - "targetDigest": "c25dcc2662e15ccb91ff19fbe1a156b7fb97d7d49d0c3478dcd9f001e643e398" + "targetDigest": "01d014c9693e4214ef56ab95f0bbc864a47efddcdcb53eda629319cb63766c3f" }, "skills/meta-mode/playbooks/pause-safely.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/pause-safely.md", "sourceDigest": "7d5fc10e144265f1b35f8157926541e738c1de64b6a85b3e61b51cf268c3dffd", - "targetDigest": "77968cfe5f4a71a8cd33e0ae8ffcdbd88dd693a212c240457773e6c40c96c21d" + "targetDigest": "50f1fa76fcdbf92db5a772a0470a128850c960c9eb68156846cc4f5a503f863b" }, "skills/meta-mode/playbooks/perf-issue.md": { "upstream": "pstack", @@ -302,13 +302,13 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/session-pickup.md", "sourceDigest": "1604fb463f7c7410d43fa024ca76c1c1824c166a01fd69d61f073d06fdca2a47", - "targetDigest": "97a3d7b0fcecf53c5c3fce9ec17f105196745086911c363d2dc7841bc56e40e2" + "targetDigest": "5bbf52a3e43524aca7f474962a9e9a6a25d8cd2cc96569dcddccd5bf35466ac8" }, "skills/meta-mode/playbooks/shipping.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/shipping.md", "sourceDigest": "c301902136e0a9a0a55403c35374f6f5738e501aee66c27458dc197b87557550", - "targetDigest": "0102de98f44f1b9921352fe60dbd3789710985bed2a491fd033bf012a1a812b2" + "targetDigest": "d898c5b32402a3e22a213311d33527423c57cb81b050c8e1e36d42cd2d85a24c" }, "skills/meta-mode/playbooks/trace-forensics.md": { "upstream": "pstack", @@ -326,7 +326,7 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/worktree-cleanup.md", "sourceDigest": "fc464dc4121926df1704f483c5feca83717c0a40b4651d3e11bd3aedd89441b6", - "targetDigest": "db0c8eeef5dc5054e79c1b3b8215159f48e12a4fb81225d551080f8773fe1b2c" + "targetDigest": "7a13c4a1e27e084f9bb9ce9f7c998d5584efeb3e4bfa15a6422f261f9db0bc77" }, "skills/meta-mode/references/bugbot-triage.md": { "upstream": "pstack", @@ -721,27 +721,27 @@ "tools/meta-mode/check-playbooks.mjs": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "3a753d840c391cbf93979d6d474bc3965b387bc507de6925420ca78ff5db6aaf" + "targetDigest": "e962e30da22e1668b0036072b9f9f002664ac3fbc17c66a9161fd31163072282" }, "tools/meta-mode/orch/orch.test.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/orch.test.ts", "sourceDigest": "174b86d21ff51ccd28d1a8c938cb2e0c6fa287b209d4ca6c03932fcb1b53372e", - "targetDigest": "913d5c03ba456de547ec609458a2b14c5a3894d33385b0597ee44315252f870b", + "targetDigest": "821c2cd328e441590bb236733ca48a6f889e13c7c49447c5a0c170ac999af5a5", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/orch/orch.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/orch.ts", "sourceDigest": "f091687df627a0b75fabd54af58945a9cd6c7039ef0622012ac9ed60cd8ec434", - "targetDigest": "f091687df627a0b75fabd54af58945a9cd6c7039ef0622012ac9ed60cd8ec434", + "targetDigest": "b820c51d3ed13fa62ef5fc969aacf2b0b3c7f471a518f6159d3c3a530a363cd7", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/orch/store.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/store.ts", "sourceDigest": "0eae7cf69282e827b1227166f2e32cee3560f2f3e65b3f1f2faf6fb83d3b4c5b", - "targetDigest": "39394893aa8191f411a5eee33a554a6232e4568ecf11d7b43f6a21752d4c97cd", + "targetDigest": "c879408105d96814345f9e15f9a909505fec1374036c01e58820a55374a194bb", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/package.json": { @@ -751,10 +751,30 @@ "targetDigest": "c7ac7fd69bb925415cdc15cd636567304ee40f8d665986e45ed8c0ee658f6caf", "reason": "Runtime tools are packaged separately from portable skill instructions." }, + "tools/meta-mode/pr-safety.mjs": { + "upstream": null, + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "1ce77cecdcb4be6c9622257f2e2efac790e47fbd35b1fd8dd93a4d7ef123f2cf" + }, + "tools/meta-mode/pr-safety.test.mjs": { + "upstream": null, + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "46d00f3086850f82bf713d1fa91c60debde6623fe7bf3007a49eaafb42ddae3e" + }, "tools/meta-mode/README.md": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "5a2dcf51ad2a816941a116b0e4c9c4e113c9d2a721a15f5ac8ef7a4b1a9afeaf" + "targetDigest": "62e8431ec27bffa8ec911f6286ad183d16b6f451de34f53dd5ea63de5acd0088" + }, + "tools/meta-mode/resume.mjs": { + "upstream": null, + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "2cdd3e939db59a68d847489c4eeec6a26674ec5bec2852c7e242959af5a5ff8c" + }, + "tools/meta-mode/resume.test.mjs": { + "upstream": null, + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "6f1074ac94da0c4d1e9a6504acfac428ebc47a3f2656b260b374795d345abd63" }, "tools/meta-mode/watch-pr/cli.test.ts": { "upstream": "pstack", @@ -840,17 +860,17 @@ "targetDigest": "d955603be6cc0e8b8ffcec722f635192b2261410b1f2929abea94480e47eb5d4", "reason": "Runtime tools are packaged separately from portable skill instructions." }, + "tools/meta-mode/worktree-audit.mjs": { + "upstream": null, + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "ee7946ec0558ddeeb7466003d120124c6c884218f05d51e920c90d4fc010c20c" + }, "tools/meta-mode/worktree-audit.sh": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/worktree-audit.sh", "sourceDigest": "95b8530f3c0ccada8cabc4b0727c052fbc85c141531375aaace65c427af8aecc", - "targetDigest": "68df230b34f10558e021bb6cc9696e4653acc92645a17b35c6b953aa40837fc2", + "targetDigest": "6a87d48f5664868537d722e059d4a3807a1283cc0382ec01e7d5f764098dbdbf", "reason": "Runtime tools are packaged separately from portable skill instructions." - }, - "tools/meta-mode/worktree-audit.test.sh": { - "upstream": null, - "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "dfc8c11e867f5000a996e8b4aeafc630319964bb1b2946b1a626996dd4919e0f" } }, "omitted": { diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index 80755ef..6d23ce0 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -303,6 +303,11 @@ "target": "b67fd0a7950464e1094225a2f85add0714849c1daab6c5289ad7bbc653baa536", "reason": "Document one canonical verification contract and the mixed pstack/mstack project workflow." }, + "docs/guide/03-understand.md": { + "source": "5db58a1fc2cc7bf1843945fcc7d93eba4f73e26fde5cd097d006be641fe5883e", + "target": "1fd73edc1cdde214e82b4b116ba3b1d3ec1dcae18e1ef019de99ee12439c3cf1", + "reason": "Point session pickup at the portable project-local resume checkpoint." + }, "docs/guide/06-verify-and-ship.md": { "source": "53a6bc8b3e850e34c4d8b1785a488eb7fbcbfe94ed588aeeaca252a5eb6e442c", "target": "e99e7108a757eee96397ebb79c6bbbc6f30084a35eb32d98f8de2e2b9c1aad18", diff --git a/scripts/check-playbooks.test.mjs b/scripts/check-playbooks.test.mjs index 2bbc48e..138655a 100644 --- a/scripts/check-playbooks.test.mjs +++ b/scripts/check-playbooks.test.mjs @@ -5,7 +5,7 @@ import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; -import { checkPlaybooks } from "../tools/meta-mode/check-playbooks.mjs"; +import { checkPlaybooks, checkPlaybooksDetailed } from "../tools/meta-mode/check-playbooks.mjs"; const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const script = join(repoRoot, "tools", "meta-mode", "check-playbooks.mjs"); @@ -107,3 +107,35 @@ test("leaves a repository without project playbooks valid", (t) => { assert.deepEqual(checkPlaybooks(root, join(root, "missing-bundled")), []); execFileSync(process.execPath, [script, "--root", root], { encoding: "utf8" }); }); + +test("classifies duplicate extends and anchors as warnings, with strict mode failing", (t) => { + const { root, bundledRoot } = fixture(t, { + "bug-fix.md": "---\nextends: bug-fix, bug-fix\nwhen: Use it for bugs.\n---\n- **In** \"Binary-search the cause.\": first.\n- **In** \"Binary-search the cause.\": second.\n", + }, { "bug-fix.md": "Binary-search the cause.\n" }); + const detailed = checkPlaybooksDetailed(root, bundledRoot); + assert.deepEqual(detailed.errors, []); + assert.equal(detailed.warnings.length, 2); + assert.equal(checkPlaybooks(root, bundledRoot).length, 2); + const normal = run(root, bundledRoot); + assert.equal(normal.status, 0); + assert.match(normal.stderr, /warning:.*extends/); + const strict = run(root, bundledRoot, "--strict"); + assert.equal(strict.status, 1); + const json = run(root, bundledRoot, "--json"); + assert.equal(json.status, 0); + const output = JSON.parse(json.stdout); + assert.equal(output.ok, true); + assert.equal(output.errors.length, 0); + assert.equal(output.warnings.length, 2); +}); + +test("rejects an anchor found in more than one base playbook", (t) => { + const { root, bundledRoot } = fixture(t, { + "combined.md": "---\nextends: first, second\nwhen: Use it.\n---\n- **After** \"Shared step\": adjust.\n", + }, { "first.md": "Shared step\n", "second.md": "Shared step\n" }); + const result = run(root, bundledRoot, "--json"); + assert.equal(result.status, 1); + const output = JSON.parse(result.stdout); + assert.equal(output.errors[0].code, "ambiguous-anchor"); + assert.match(output.errors[0].message, /ambiguous/); +}); diff --git a/scripts/install.test.mjs b/scripts/install.test.mjs index bca1fdc..c51b283 100644 --- a/scripts/install.test.mjs +++ b/scripts/install.test.mjs @@ -367,6 +367,35 @@ test("rejects empty and whitespace-only environment targets", () => { } }); +test("default installs do not add the opt-in session context artifact", () => { + const { root, env } = fixture(); + try { + const result = run(["--harness", "codex"], env); + assert.equal(result.status, 0, result.stderr); + assert.equal(existsSync(join(env.HARNESS_SKILLS_CODEX_DIR, "session-context")), false); + assert.equal(existsSync(join(root, "codex skills", "..", "session-context")), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("installs the session context artifact only when explicitly selected", () => { + const { root, env } = fixture(); + try { + const result = run( + ["--harness", "codex", "--no-skills", "--artifact", "session-context"], + env, + ); + assert.equal(result.status, 0, result.stderr); + const target = join(env.HARNESS_SKILLS_CODEX_DIR, "..", "session-context", "SESSION-CONTEXT.md"); + assert.equal(existsSync(target), true); + assert.match(readFileSync(target, "utf8"), /Hooks remain unsupported unless explicitly configured/); + assert.match(result.stdout, /Installed 0 skill copies and 1 artifact copies/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("installs selected optional artifacts without skills", () => { const { root, env } = fixture(); try { diff --git a/scripts/worktree-audit.test.mjs b/scripts/worktree-audit.test.mjs index 2b2e2de..9d7e2b8 100644 --- a/scripts/worktree-audit.test.mjs +++ b/scripts/worktree-audit.test.mjs @@ -1,12 +1,94 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { audit, classify, parseWorktreePorcelain } from "../tools/meta-mode/worktree-audit.mjs"; -test("worktree audit reads transcript timestamps on Unix", { skip: process.platform === "win32" }, () => { - const result = spawnSync("bash", [resolve("tools", "meta-mode", "worktree-audit.test.sh")], { - cwd: resolve("."), - encoding: "utf8", - }); - assert.equal(result.status, 0, result.stderr || result.stdout); +function git(cwd, ...args) { + return execFileSync("git", ["-C", cwd, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); +} + +// A repository whose origin/main contains the worktree's HEAD, so an unclaimed clean worktree reads as `safe`. +function fixture(t) { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "mstack-audit-"))); + t.after(() => rmSync(root, { recursive: true, force: true })); + const remote = join(root, "remote.git"); + const repo = join(root, "repo"); + execFileSync("git", ["init", "-q", "--bare", "-b", "main", remote]); + execFileSync("git", ["init", "-q", "-b", "main", repo]); + git(repo, "config", "user.name", "Test User"); + git(repo, "config", "user.email", "test@example.test"); + writeFileSync(join(repo, "tracked.txt"), "fixture\n"); + git(repo, "add", "tracked.txt"); + git(repo, "commit", "-q", "-m", "fixture"); + git(repo, "remote", "add", "origin", remote); + git(repo, "push", "-q", "origin", "main"); + const worktree = join(root, "audit-worktree"); + git(repo, "worktree", "add", "-q", "-b", "audit-worktree", worktree); + return { root, repo, worktree }; +} + +function runAudit(repo, transcripts) { + const env = transcripts ? { MSTACK_TRANSCRIPTS_DIR: transcripts } : {}; + const rows = audit(repo, env, { listPrs: () => [] }); + assert.equal(rows.length, 1); + return rows[0]; +} + +test("parses NUL porcelain paths without shell or quote loss", () => { + const result = parseWorktreePorcelain([ + "worktree C:/Users/Test User/工作 tree", "HEAD abc", "branch refs/heads/feature/quoted \"name\"", "", + "worktree \\\\server\\share\\space path", "HEAD def", "detached", "", + ].join("\0")); + assert.deepEqual(result, [ + { path: "C:/Users/Test User/工作 tree", head: "abc", branch: 'feature/quoted "name"', detached: false }, + { path: "\\\\server\\share\\space path", head: "def", branch: null, detached: true }, + ]); +}); + +test("classification preserves safety gates", () => { + assert.equal(classify({ dirty: "unknown", pr: "-", recent: false, merged: true }), "hold-unknown"); + assert.equal(classify({ dirty: "unknown", pr: "#4/MERGED", recent: false, merged: true }), "hold-unknown"); + assert.equal(classify({ dirty: "wip:1", pr: "-", recent: false, merged: true }), "hold-wip"); + assert.equal(classify({ dirty: "clean", pr: "#4/OPEN", recent: false, merged: true }), "hold-open-pr"); + assert.equal(classify({ dirty: "clean", pr: "-", recent: true, merged: false }), "verify-recent-chat"); + assert.equal(classify({ dirty: "clean", pr: "-", recent: false, merged: true }), "safe"); + assert.equal(classify({ dirty: "clean", pr: "-", recent: false, merged: false }), "review"); +}); + +test("audits a real worktree: a clean merged worktree is safe, a recent transcript holds it", (t) => { + const { root, repo, worktree } = fixture(t); + assert.equal(runAudit(repo).bucket, "safe"); + + const transcripts = join(root, "transcripts"); + mkdirSync(transcripts); + writeFileSync(join(transcripts, "unrelated.jsonl"), JSON.stringify({ cwd: join(root, "elsewhere") }) + "\n"); + assert.equal(runAudit(repo, transcripts).bucket, "safe"); + + writeFileSync(join(transcripts, "session.jsonl"), JSON.stringify({ cwd: `${worktree}/` }) + "\n"); + const row = runAudit(repo, transcripts); + assert.equal(row.bucket, "verify-recent-chat"); + assert.equal(row.lastChat, new Date().toISOString().slice(0, 10)); +}); + +test("matches a Windows path that a JSONL transcript stores with doubled backslashes", (t) => { + const { root, repo, worktree } = fixture(t); + const transcripts = join(root, "transcripts"); + mkdirSync(transcripts); + // JSON.stringify of `H:\x\wt` writes `H:\\x\\wt`; build that spelling regardless of the host separator. + const windowsSpelling = worktree.replaceAll("\\", "/").replaceAll("/", "\\\\"); + writeFileSync(join(transcripts, "windows.jsonl"), `{"cwd":"${windowsSpelling}"}\n`); + assert.equal(runAudit(repo, transcripts).bucket, "verify-recent-chat"); +}); + +test("a failing git status is unknown and never safe", (t) => { + const { repo, worktree } = fixture(t); + // Replace the gitfile (hidden on Windows, so it cannot be overwritten in place) with one Git rejects. + rmSync(join(worktree, ".git")); + writeFileSync(join(worktree, ".git"), "not a gitfile\n"); + const row = runAudit(repo); + assert.equal(row.dirty, "unknown"); + assert.equal(row.bucket, "hold-unknown"); }); diff --git a/skills/meta-mode/playbooks/orchestrate.md b/skills/meta-mode/playbooks/orchestrate.md index ba6b27d..c22ee07 100644 --- a/skills/meta-mode/playbooks/orchestrate.md +++ b/skills/meta-mode/playbooks/orchestrate.md @@ -86,7 +86,7 @@ A dependency is a context relay, not just ordering. Undeclared upstream context Scale verification to the unit. When VERIFY is a single cheap command, the worker runs it and reports the output, and the coordinator spot-checks receipts. A dedicated verifier agent (on a different model family than the worker) is for units whose verification is expensive, judgment-laden, or high-blast-radius. A verifier agent whose entire product would be rerunning one command is ceremony, not verification. -Write ledger rows with `orch ledger record`. Check the current PR and head SHA with `orch ledger check`. `ledger.tsv`, one row per verdict, keyed by PR number plus head SHA: `live-ui-verified | unit-test-verified | type-check-only | verifier-blocked | verifier-failed`. CI green is an input to a verdict, not a verdict. Behavioral work needs better than `type-check-only`. `verifier-blocked` is not a pass. Respawn when the environment heals. `verifier-failed` gets a fix unit, not a re-verify. A worker may self-report. A verifier overrides it on the same key. A new head SHA voids the row, so re-verify after restack. The ledger answers "was this verified", not memory and not the transcript. +Write ledger rows with `orch ledger record`. Check the current PR and head SHA with `orch ledger check`. `ledger.tsv`, one row per verdict, keyed by PR number plus head SHA, and recording the base SHA, base branch, and stable patch-id that the verdict covers: `live-ui-verified | unit-test-verified | type-check-only | verifier-blocked | verifier-failed`. CI green is an input to a verdict, not a verdict. Behavioral work needs better than `type-check-only`. `verifier-blocked` is not a pass. Respawn when the environment heals. `verifier-failed` gets a fix unit, not a re-verify. A worker may self-report. A verifier overrides it on the same key. A new head SHA is a new key, and `orch ledger check` rejects a row whose base SHA, base branch, or patch-id differs, so re-verify after restack or retarget. Runtime evidence is required; CI green is only an input. The ledger answers "was this verified", not memory and not the transcript. A unit is not done until its output is externalized the moment it lands, never batched to the end of the run. A worker pushes its branch, a verifier writes its ledger row, receipts land in the store. Work that exists only on one VM when that VM dies was never done. diff --git a/skills/meta-mode/playbooks/pause-safely.md b/skills/meta-mode/playbooks/pause-safely.md index 9d1c5f8..5206bea 100644 --- a/skills/meta-mode/playbooks/pause-safely.md +++ b/skills/meta-mode/playbooks/pause-safely.md @@ -5,6 +5,6 @@ 1. Stop at a safe boundary. Finish the current atomic step or back out of it. Start nothing new, and cancel any nested subagents. 2. Take no irreversible action to pause. No PR and no push unless you already had one out. 3. Make the work durable. Commit uncommitted edits as one clear `wip:` commit on the current branch so nothing is lost. If the tree is broken, say so in the commit body in one line. -4. Write the resume note off-context. Capture intent, what you were doing, progress and what's verified, current state, next steps, key files, and gotchas. For the compaction trigger write it to a file like `/tmp/-resume.md`. If a show-me-your-work trail exists, point at it instead of duplicating it. +4. Write the resume note off-context. Capture intent, what you were doing, progress and what's verified, current state, next steps, key files, and gotchas. Store it in the repository worktree, then create and publish a checkpoint with `node "/resume.mjs" begin --project . --note --artifact ` and `node "/resume.mjs" publish --project . --id `. The checkpoint is durable under the repository's shared Git metadata, so it survives removing this worktree, and it records a hash of the note and evidence files, not a vendor-specific `/tmp` path. If a show-me-your-work trail exists, point at it instead of duplicating it. **Reply:** where you are in the loop, what's on disk versus still in your head (paths, no diff dumps), the commits you made and whether the tree is clean, and the first action on resume. This is a pause, not a final report. diff --git a/skills/meta-mode/playbooks/session-pickup.md b/skills/meta-mode/playbooks/session-pickup.md index a1e8538..c14bce4 100644 --- a/skills/meta-mode/playbooks/session-pickup.md +++ b/skills/meta-mode/playbooks/session-pickup.md @@ -2,7 +2,7 @@ **You own the resume point. Read the prior trail, don't redo it.** -1. Locate the prior trail. Use the active workspace's history adapter, a remote-run URL, or a pushed branch. Read the metadata overview and last messages first, then scan back for the decision points. Parse a long transcript in a worker and keep the reduced timeline in the main thread (the **principle-guard-the-context-window** skill). +1. Locate the prior trail. First read the latest published checkpoint with `node "/resume.mjs" read --project .`, then follow its note and artifact paths. If `filesOk` is false or it warns that a file is changed or missing, treat that file as stale and say so. If none exists, use the active workspace's history adapter, a remote-run URL, or a pushed branch. Read the metadata overview and last messages first, then scan back for the decision points. Parse a long transcript in a worker and keep the reduced timeline in the main thread (the **principle-guard-the-context-window** skill). 2. Reconstruct operational state. The branch and worktree, what already landed (`git log`, `git diff` against the base), the open todos, the decisions made. The prior trail is authoritative input. Resist the bias to re-derive it. 3. Diff done vs pending. Compare what shipped against what was planned, name the resume point, do not re-run the prior repro or redo completed work. A "let me verify from scratch" pass means you're treating the trail as untrustworthy when it's authoritative. 4. Route the remaining work to the matching playbook and pick the verdict: continue the execution, ship a finished recommendation, ratify or override a prior conclusion, or postmortem a failed run. The pickup playbook ends here. The routed playbook owns the rest. diff --git a/skills/meta-mode/playbooks/shipping.md b/skills/meta-mode/playbooks/shipping.md index e341865..90a4dd4 100644 --- a/skills/meta-mode/playbooks/shipping.md +++ b/skills/meta-mode/playbooks/shipping.md @@ -4,9 +4,9 @@ This is the half after `playbooks/babysit.md`. -1. **Resolve the forge, then verify every PR independently.** GitHub CLI (`gh`) is the default. If `command -v origin` succeeds and Origin can resolve the repository, use `origin pr ...` for PR view, watch, edit, and merge operations. Otherwise stay on `gh` and record the fallback. Never require Graphite (`gt`). Use one read-only worker per PR. Each worker runs in a configured environment and exercises the real surface with the active harness's runtime driver. Each returns `PASS`, `PASS+NOTES` or `FAIL` and records the verdict on its PR. A clean CI result is not a runtime verdict. +1. **Resolve the forge, then verify every PR independently.** GitHub CLI (`gh`) is the default. If `command -v origin` succeeds and Origin can resolve the repository, use `origin pr ...` for PR view, watch, edit, and merge operations. Otherwise stay on `gh` and record the fallback. Never require Graphite (`gt`). Use one read-only worker per PR. Each worker runs in a configured environment and exercises the real surface with the active harness's runtime driver. Each returns `PASS`, `PASS+NOTES` or `FAIL` and records the verdict on its PR. A clean CI result is not a runtime verdict. Store the verdict with `orch ledger record --base-sha --base-branch --patch-id --evidence --verifier `; evidence must describe a runtime or behavioral check, never CI alone. 2. **Land only the contiguous verified run rooted at the bottom.** Walk up from the lowest unmerged PR and stop at the first one without a passing verdict, where both `PASS` and `PASS+NOTES` pass. A verified PR sitting above an unverified one is not landable. Report the ceiling as a PR number and say what breaks the chain. -3. **Re-check that each verdict still describes the patch.** Record the verdict head SHA, base SHA, and stable `git patch-id` of that PR's base-to-head diff. A rebase or base retarget rewrites SHAs and can silently invalidate a verdict without touching a check. Before landing a PR, compare the recorded patch-id with its current base-to-head patch-id. When the patch changed only in tests, docs, or lint config, check each lane under [the build-output reuse procedure](../references/build-output-reuse.md). Reuse only lanes whose compared build differences are all documented noise. Dev-server lanes and lanes with no saved build output must rerun. Re-verify any other changed patch. When the patch did not change, keep the code verdict but re-run mergeability and CI at the current head. Never use matching commit messages or a green check from an older SHA as a substitute. +3. **Re-check that each verdict still describes the patch.** Record the verdict head SHA, base SHA, base branch, and stable `git patch-id` of that PR's base-to-head diff. Check with `orch ledger check --base-sha --base-branch --patch-id `. The check rejects stale head, base, branch, or patch identity; a missing or rejected row is not verified. A rebase or base retarget rewrites SHAs and can silently invalidate a verdict without touching a check. Before landing a PR, compare the recorded patch-id with its current base-to-head patch-id. When the patch changed only in tests, docs, or lint config, check each lane under [the build-output reuse procedure](../references/build-output-reuse.md). Reuse only lanes whose compared build differences are all documented noise. Dev-server lanes and lanes with no saved build output must rerun. Re-verify any other changed patch. When the patch did not change, keep the code verdict but re-run mergeability and CI at the current head. Never use matching commit messages or a green check from an older SHA as a substitute. 4. **Prepare only the bottom PR.** Fetch current trunk. Rebase the lowest verified branch onto the exact trunk tip when needed, push it, and retarget only that PR to trunk with `origin pr edit --base ` or `gh pr edit --base `. Re-run step 3 after the push. Do not retarget, arm, or merge descendants yet. 5. **Land one PR at a time.** If the bottom PR is mergeable now, squash it with `origin pr merge --squash` or `gh pr merge --squash`. If requirements are still running and the user asked for merge-when-ready, arm only that PR with `origin pr merge --squash --auto` or `gh pr merge --squash --auto`. Origin's `--auto` is Origin merge-when-ready. GitHub's `--auto` is GitHub auto-merge. Wait for that PR to merge before preparing the next one. 6. **Do not read GitHub `autoMergeRequest` as stack readiness.** At most it says GitHub auto-merge was requested for one GitHub PR. It does not prove Origin merge-when-ready is armed, that a descendant is queued, that a patch verdict is current, or that the contiguous stack is safe. Confirm the active forge's state for the current bottom PR, and say that the state is unknown if the active forge cannot report it. diff --git a/skills/meta-mode/playbooks/worktree-cleanup.md b/skills/meta-mode/playbooks/worktree-cleanup.md index 52ed1e9..ee5f0f4 100644 --- a/skills/meta-mode/playbooks/worktree-cleanup.md +++ b/skills/meta-mode/playbooks/worktree-cleanup.md @@ -2,10 +2,10 @@ **You own the disk and the safety gate.** Prune merged or abandoned git worktrees and stale iOS simulators to reclaim space. Deletion is irreversible, so every step guards against deleting something in use or holding uncommitted work. -1. Snapshot and audit. Record `df -h /`, then run `bash "/worktree-audit.sh"` (principle-build-the-lever). It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.harness/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. +1. Snapshot and audit. Record `df -h /`, then run `node "/worktree-audit.mjs"` (the `.sh` name remains a compatibility wrapper) (principle-build-the-lever). It reads paths from `git worktree list`, never hand-typed, since a hand-typed `myrepo-worktrees/x` misses one that lives at `.harness/worktrees/myrepo/x` (principle-encode-lessons-in-structure). It classifies each worktree by size, age, merge state, uncommitted work, PR state, and the newest chat that touched it, then suggests a bucket. The transcript scan is slow, so background it. 2. The bucket is advice, not permission. The pinned and active chats are the real artifact (principle-prove-it-works). Get that set from the user or sidebar and cross-check every candidate. The lever has marked `safe` a worktree the user had pinned, so the pinned set wins. 3. Verify usage before deleting. For every `verify-recent-chat` row, or anything you doubt, fan subagents out to read the transcripts and report whether the chat is pinned or ongoing and which worktrees it touches (principle-guard-the-context-window, transcripts are bulk). A pinned chat spawns arena and repro trees into sibling worktrees via background subagents, and those are in use even when their names never hit the sidebar. -4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. +4. Pause on irreversible loss. `wip:N` is N tracked uncommitted edits. Show the diff and get a decision first, since removing a clean worktree is recoverable from its branch but uncommitted work is gone. `scratch:N` is untracked throwaway, safe to drop, but name the files. `unknown` means `git status` failed, so the worktree may hold uncommitted work and its bucket is `hold-unknown`. Inspect it by hand before any decision. Per Autonomy, clean and merged and not-in-use proceeds. `wip` and in-use pause. 5. Prune the confirmed set. Per path, `git worktree remove --force `. If the dir survives on ignored build artifacts, `rm -rf` it, then `git worktree prune`. Branch refs survive, so no commits are lost. Confirm with `df -h /` and re-list. 6. Simulators and other reclaimers. Simulators are usually the next-biggest win. `xcrun simctl --set testing delete all` (XCTestDevices clones), `xcrun simctl delete unavailable`, and `xcrun simctl runtime list` then `runtime delete ` for old runtimes. More when needed: Xcode `DerivedData` and `iOS DeviceSupport`, the active Harness state directory and workspace snapshot cache, package caches (pnpm, uv, brew, yarn). Clear only caches the user has not said to keep. diff --git a/tools/meta-mode/README.md b/tools/meta-mode/README.md index 3e6a515..7eee1cb 100644 --- a/tools/meta-mode/README.md +++ b/tools/meta-mode/README.md @@ -6,17 +6,36 @@ This directory contains the optional Bun tools that support `meta-mode`: - `watch-pr/` reads GitHub checks and review signals for a pull request stack. - `check-plan.mjs` validates a generated plan. - `check-playbooks.mjs` validates project playbook extensions against the bundled playbooks. -- `worktree-audit.sh` produces a read-only worktree audit. +- `worktree-audit.mjs` produces a read-only, cross-platform worktree audit. +- `worktree-audit.sh` remains a compatibility wrapper for the historical name. +- `resume.mjs` creates, publishes, and reads durable cold-start checkpoints under the repository's common Git directory. +- `pr-safety.mjs` provides harness-neutral deadline, landing-revision, guarded-merge, and command-transport helpers. The tools are not required by the portable skill installer. Run them from this directory with Bun after installing dependencies from `package.json`. Their inputs and outputs are deliberately separate from the canonical `skills/meta-mode/SKILL.md`, so each Harness can use its own equivalent runtime. -Run `node check-playbooks.mjs ` to validate `.agents/playbooks/` before using project extensions. Pass `--bundled ` to check against another installed skill tree. +Run `node check-playbooks.mjs ` to validate `.agents/playbooks/` before using project extensions. Pass `--bundled ` to check against another installed skill tree. The normal output remains human-readable. Add `--json` for a machine-readable object with `errors`, `warnings`, and `diagnostics`. Errors fail the command; warnings for duplicate `extends` entries or anchors do not fail unless you add `--strict`. -`worktree-audit.sh` can include the latest session that touched a worktree when +`pr-safety.mjs` accepts both GitHub CLI (`gh`) and Origin-style guarded merge arguments; callers retain responsibility for authorization and post-merge readback. + +`worktree-audit.mjs` (or its `worktree-audit.sh` compatibility wrapper) can include the latest session that touched a worktree when you set `MSTACK_TRANSCRIPTS_DIR` to a directory containing the active Harness's workspace transcripts. Leave it unset when transcript history is unavailable; the audit reports `-` in the `LAST_CHAT` column and still checks every Git worktree. + +Resume checkpoints are project-local and Git-bound. They are stored under +`/mstack/resume//`, so each worktree has its own +namespace and removing a linked worktree does not delete them. The note and +evidence paths must be regular files inside the worktree. `begin` writes an +unpublished draft; `publish` validates those paths, records their sha256, and +atomically exposes the complete record; `read` never returns drafts and, without +`--id`, returns the checkpoint with the newest `publishedAt`. `read` re-hashes +the referenced files and reports each one as `ok`, `changed`, or `missing` +(`filesOk` plus a stderr warning), so a stale note is not mistaken for current. +Only `begin` needs `user.name` and `user.email`. + +`worktree-audit.mjs` reports `unknown` in the `DIRTY` column when `git status` +fails and classifies that worktree as `hold-unknown`, never `safe`. diff --git a/tools/meta-mode/check-playbooks.mjs b/tools/meta-mode/check-playbooks.mjs index 4993bf4..9494028 100644 --- a/tools/meta-mode/check-playbooks.mjs +++ b/tools/meta-mode/check-playbooks.mjs @@ -31,39 +31,73 @@ function readPlaybook(root, stem) { return readdirSync(root).includes(file) ? readText(join(root, file)) : null; } -export function checkPlaybooks(root, bundled = BUNDLED) { +function diagnostic(severity, message, code) { + return { severity, message, code }; +} + +export function checkPlaybooksDetailed(root, bundled = BUNDLED) { if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`Project root is not a directory: ${root}`); const directory = join(resolve(root), ".agents", "playbooks"); - if (!existsSync(directory)) return []; + if (!existsSync(directory)) return { diagnostics: [], errors: [], warnings: [] }; if (!existsSync(bundled) || !statSync(bundled).isDirectory()) throw new Error(`Bundled playbooks directory not found: ${bundled}`); - const problems = []; + const diagnostics = []; + const add = (severity, message, code) => diagnostics.push(diagnostic(severity, message, code)); for (const name of readdirSync(directory).filter((file) => file.endsWith(".md")).sort()) { const relativePath = `.agents/playbooks/${name}`; const text = readText(join(directory, name)); const when = field(text, "when"); - if (!when) problems.push(`${relativePath}: its frontmatter needs a "when:" line`); + if (!when) add("error", `${relativePath}: its frontmatter needs a "when:" line`, "missing-when"); - const bases = field(text, "extends") - .split(",") - .map((stem) => stem.trim()) - .filter(Boolean) - .map((stem) => ({ stem, text: readPlaybook(bundled, stem) })); + const extendsValue = field(text, "extends"); + const stems = extendsValue.split(",").map((stem) => stem.trim()).filter(Boolean); + const seenStems = new Set(); + for (const stem of stems) { + if (seenStems.has(stem)) { + add("warning", `${relativePath}: extends \`${stem}\` more than once`, "duplicate-extends"); + } + seenStems.add(stem); + } + const bases = stems.map((stem) => ({ stem, text: readPlaybook(bundled, stem) })); for (const base of bases) { - if (base.text === null) problems.push(`${relativePath}: extends \`${base.stem}\`, which this mstack has no playbook for`); + if (base.text === null) add("error", `${relativePath}: extends \`${base.stem}\`, which this mstack has no playbook for`, "missing-base"); } + const anchors = new Map(); for (const line of text.split("\n")) { if (CHANGE_VERB.test(line) && !CHANGE.test(line)) { - problems.push(`${relativePath}: a change has no straight-quoted step text to anchor on: ${line.trim().slice(0, 80)}`); + add("error", `${relativePath}: a change has no straight-quoted step text to anchor on: ${line.trim().slice(0, 80)}`, "unquoted-anchor"); } const anchor = line.match(CHANGE)?.[1]; - if (anchor && !bases.some((base) => base.text && flat(base.text).includes(flat(anchor)))) { - problems.push(`${relativePath}: "${anchor}" is not in any playbook it extends`); + if (!anchor) continue; + const normalized = flat(anchor); + if (anchors.has(normalized)) add("warning", `${relativePath}: anchor "${anchor}" is repeated`, "duplicate-anchor"); + anchors.set(normalized, true); + const matches = []; + for (const base of [...new Map(bases.filter((base) => base.text).map((base) => [base.stem, base])).values()]) { + const source = flat(base.text); + let at = source.indexOf(normalized); + while (at !== -1) { + matches.push(base.stem); + at = source.indexOf(normalized, at + normalized.length); + } + } + if (matches.length === 0) { + add("error", `${relativePath}: "${anchor}" is not in any playbook it extends`, "stale-anchor"); + } else if (matches.length > 1) { + add("error", `${relativePath}: "${anchor}" is ambiguous in the playbooks it extends`, "ambiguous-anchor"); } } } - return problems; + return { + diagnostics, + errors: diagnostics.filter((item) => item.severity === "error"), + warnings: diagnostics.filter((item) => item.severity === "warning"), + }; +} + +export function checkPlaybooks(root, bundled = BUNDLED) { + return checkPlaybooksDetailed(root, bundled).diagnostics.map(({ message }) => message); } function parseArgs(args) { @@ -72,7 +106,10 @@ function parseArgs(args) { for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === "--help") return { help: true, values, positional }; - if (arg === "--root" || arg === "--bundled") { + if (arg === "--json" || arg === "--strict") { + if (values.has(arg)) throw new Error(`Duplicate option: ${arg}`); + values.set(arg, true); + } else if (arg === "--root" || arg === "--bundled") { if (values.has(arg)) throw new Error(`Duplicate option: ${arg}`); const value = args[++index]; if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`); @@ -100,18 +137,22 @@ if (directInvocation()) { try { const parsed = parseArgs(process.argv.slice(2)); if (parsed.help) { - console.log("Usage: node tools/meta-mode/check-playbooks.mjs [--root ] [--bundled ]\n\nChecks .agents/playbooks against bundled meta-mode playbooks."); + console.log("Usage: node tools/meta-mode/check-playbooks.mjs [--root ] [--bundled ] [--json] [--strict]\n\nChecks .agents/playbooks against bundled meta-mode playbooks. Warnings do not fail unless --strict is used."); process.exit(0); } const root = parsed.values.get("--root") ?? parsed.positional[0] ?? "."; const bundled = parsed.values.get("--bundled") ?? BUNDLED; - const problems = checkPlaybooks(root, bundled); - if (problems.length > 0) { - console.error(problems.join("\n")); - process.exitCode = 1; + const result = checkPlaybooksDetailed(root, bundled); + const strict = parsed.values.has("--strict"); + const failed = result.errors.length > 0 || (strict && result.warnings.length > 0); + if (parsed.values.has("--json")) { + console.log(JSON.stringify({ ok: !failed, strict, errors: result.errors, warnings: result.warnings, diagnostics: result.diagnostics }, null, 2)); + } else if (result.diagnostics.length > 0) { + for (const item of result.diagnostics) console.error(`${item.severity === "warning" ? "warning: " : ""}${item.message}`); } else { console.log("Every project playbook matches this mstack's playbooks."); } + process.exitCode = failed ? 1 : 0; } catch (error) { console.error(error.message); process.exitCode = 1; diff --git a/tools/meta-mode/orch/orch.test.ts b/tools/meta-mode/orch/orch.test.ts index 11b4c7c..db60a0b 100644 --- a/tools/meta-mode/orch/orch.test.ts +++ b/tools/meta-mode/orch/orch.test.ts @@ -261,7 +261,13 @@ describe("Store", () => { const { store } = await initializedStore(); try { - await store.ledger.check({ pr: 184530, sha: "abc123" }); + await store.ledger.check({ + pr: 184530, + sha: "abc123", + baseSha: "base123", + baseBranch: "main", + patchId: "patch123", + }); throw new Error("expected ledger check to fail"); } catch (error) { expect(error).toBeInstanceOf(NotFoundError); @@ -271,6 +277,9 @@ describe("Store", () => { json: { pr: "184530", sha: "abc123", + baseSha: "base123", + baseBranch: "main", + patchId: "patch123", verdict: "NOT-VERIFIED", }, }); @@ -278,14 +287,20 @@ describe("Store", () => { } expect(() => parseVerdict("looks-good")).toThrow("verdict must be"); + const identity = { + baseSha: "base123", + baseBranch: "main", + patchId: "patch123", + }; const recorded = await store.ledger.record({ pr: 184530, sha: "abc123", + ...identity, verdict: "unit-test-verified", evidence: "reports/verify.md", verifier: "sol", }); - expect(await store.ledger.check({ pr: 184530, sha: "abc123" })).toEqual( + expect(await store.ledger.check({ pr: 184530, sha: "abc123", ...identity })).toEqual( recorded ); expect(await store.ledger.summary()).toEqual({ @@ -295,12 +310,58 @@ describe("Store", () => { await store.ledger.record({ pr: 184530, sha: "abc123", + ...identity, verdict: "live-ui-verified", evidence: "reports/live.md", + verifier: "runtime", }); expect(await store.ledger.summary()).toEqual({ "live-ui-verified": 1, }); + await expect( + store.ledger.check({ + pr: 184530, + sha: "abc123", + baseSha: "different-base", + baseBranch: "main", + patchId: "patch123", + }) + ).rejects.toBeInstanceOf(NotFoundError); + }); + + it("reads a legacy ledger as unbound and migrates it on the next record", async () => { + const { directory, store } = await initializedStore(); + const path = join(directory, "ledger.tsv"); + await writeFile( + path, + "pr\tsha\tverdict\tevidence\tverifier\tts\n" + + "7\tabc123\tunit-test-verified\treports/old.md\tsol\t2026-01-01T00:00:00.000Z\n" + ); + expect(await store.ledger.summary()).toEqual({ "unit-test-verified": 1 }); + const identity = { baseSha: "base123", baseBranch: "main", patchId: "patch123" }; + await expect( + store.ledger.check({ pr: 7, sha: "abc123", ...identity }) + ).rejects.toBeInstanceOf(NotFoundError); + + await store.ledger.record({ + pr: 8, + sha: "def456", + ...identity, + verdict: "live-ui-verified", + evidence: "reports/live.md", + verifier: "runtime", + }); + const migrated = (await readFile(path, "utf8")).split("\n"); + expect(migrated[0]).toBe( + "pr\tsha\tbaseSha\tbaseBranch\tpatchId\tverdict\tevidence\tverifier\tts" + ); + expect(migrated[1]).toBe( + "7\tabc123\t\t\t\tunit-test-verified\treports/old.md\tsol\t2026-01-01T00:00:00.000Z" + ); + expect(await store.ledger.summary()).toEqual({ + "unit-test-verified": 1, + "live-ui-verified": 1, + }); }); it("pushes, peeks, and atomically drains inbox pointers", async () => { @@ -563,7 +624,7 @@ describe("Store", () => { await writeFile( join(directory, "ledger.tsv"), - "pr\tsha\tverdict\tevidence\tverifier\tts\n1\tsha\tinvalid\treport\tme\tnow\n" + "pr\tsha\tbaseSha\tbaseBranch\tpatchId\tverdict\tevidence\tverifier\tts\n1\tsha\tbase\tmain\tpatch\tinvalid\treport\tme\tnow\n" ); await expect(store.ledger.summary()).rejects.toThrow( "ledger.tsv has invalid verdict invalid" @@ -673,11 +734,20 @@ describe("orch CLI", () => { "check", "184530", "abc123", + "--base-sha", + "base123", + "--base-branch", + "main", + "--patch-id", + "patch123", ]); expect(missingLedger.code).toBe(2); expect(JSON.parse(missingLedger.stdout)).toEqual({ pr: "184530", sha: "abc123", + baseSha: "base123", + baseBranch: "main", + patchId: "patch123", verdict: "NOT-VERIFIED", }); expect(missingLedger.stderr).toBe(""); diff --git a/tools/meta-mode/orch/orch.ts b/tools/meta-mode/orch/orch.ts index 219ac17..71d5379 100644 --- a/tools/meta-mode/orch/orch.ts +++ b/tools/meta-mode/orch/orch.ts @@ -57,8 +57,17 @@ interface UnitListOptions { } interface LedgerRecordOptions { + readonly baseSha: string; + readonly baseBranch: string; + readonly patchId: string; readonly evidence: string; - readonly verifier?: string; + readonly verifier: string; +} + +interface LedgerCheckOptions { + readonly baseSha: string; + readonly baseBranch: string; + readonly patchId: string; } interface InboxPushOptions { @@ -340,10 +349,13 @@ function createProgram(io: Io): Command { .action(() => requireSubcommand(program)); leaf(ledger, "record", "record a verification verdict") .argument("", "pull request number", positiveInteger) - .argument("", "commit SHA") + .argument("", "head commit SHA") .argument("", "verification verdict", parseVerdict) - .requiredOption("--evidence ", "evidence path") - .option("--verifier ", "verifier name") + .requiredOption("--base-sha ", "base commit SHA") + .requiredOption("--base-branch ", "base branch") + .requiredOption("--patch-id ", "stable git patch-id") + .requiredOption("--evidence ", "runtime evidence path") + .requiredOption("--verifier ", "verifier name") .action( ( pr: number, @@ -358,6 +370,9 @@ function createProgram(io: Io): Command { store.ledger.record({ pr, sha, + baseSha: options.baseSha, + baseBranch: options.baseBranch, + patchId: options.patchId, verdict, evidence: options.evidence, verifier: options.verifier, @@ -367,12 +382,22 @@ function createProgram(io: Io): Command { ); leaf(ledger, "check", "check a verification verdict") .argument("", "pull request number", positiveInteger) - .argument("", "commit SHA") - .action((pr: number, sha: string) => + .argument("", "head commit SHA") + .requiredOption("--base-sha ", "base commit SHA") + .requiredOption("--base-branch ", "base branch") + .requiredOption("--patch-id ", "stable git patch-id") + .action((pr: number, sha: string, options: LedgerCheckOptions) => runStore( program, io, - (store) => store.ledger.check({ pr, sha }), + (store) => + store.ledger.check({ + pr, + sha, + baseSha: options.baseSha, + baseBranch: options.baseBranch, + patchId: options.patchId, + }), (row) => row.verdict ) ); diff --git a/tools/meta-mode/orch/store.ts b/tools/meta-mode/orch/store.ts index cc91830..dc71fa6 100644 --- a/tools/meta-mode/orch/store.ts +++ b/tools/meta-mode/orch/store.ts @@ -15,7 +15,9 @@ import { import { basename, dirname, join, resolve } from "node:path"; const UNIT_HEADER = "id\ttrack\tstate\tbranch\tpr\tsha\tbrief"; -const LEDGER_HEADER = "pr\tsha\tverdict\tevidence\tverifier\tts"; +const LEDGER_HEADER = "pr\tsha\tbaseSha\tbaseBranch\tpatchId\tverdict\tevidence\tverifier\tts"; +// Ledgers written before verdicts were bound to the base and patch. They stay readable, and the next write migrates them. +const LEGACY_LEDGER_HEADER = "pr\tsha\tverdict\tevidence\tverifier\tts"; const LOCK_FILE = ".orch.lock"; export type Verdict = @@ -38,6 +40,9 @@ export interface Unit { export interface LedgerEntry { readonly pr: string; readonly sha: string; + readonly baseSha: string; + readonly baseBranch: string; + readonly patchId: string; readonly verdict: Verdict; readonly evidence: string; readonly verifier: string; @@ -136,14 +141,20 @@ export interface ListUnitsParams { export interface RecordLedgerParams { readonly pr: number; readonly sha: string; + readonly baseSha: string; + readonly baseBranch: string; + readonly patchId: string; readonly verdict: Verdict; readonly evidence: string; - readonly verifier?: string; + readonly verifier: string; } export interface CheckLedgerParams { readonly pr: number; readonly sha: string; + readonly baseSha: string; + readonly baseBranch: string; + readonly patchId: string; } export interface PushInboxParams { @@ -573,9 +584,24 @@ async function saveUnits(store: string, rows: readonly Unit[]): Promise { } async function readLedger(store: string): Promise { - return (await readTsv(join(store, "ledger.tsv"), LEDGER_HEADER, 6)).map( - (row) => { - const rawVerdict = row[2] ?? ""; + const path = join(store, "ledger.tsv"); + const legacy = + (await requiredFile(path)).replace(/\r/g, "").split("\n", 1)[0] === + LEGACY_LEDGER_HEADER; + const rows = await readTsv( + path, + legacy ? LEGACY_LEDGER_HEADER : LEDGER_HEADER, + legacy ? 6 : 9 + ); + // A legacy row has no base or patch identity, so no check can match it and it reads as not verified. + return rows + .map((cells) => + legacy + ? [cells[0] ?? "", cells[1] ?? "", "", "", "", ...cells.slice(2)] + : cells + ) + .map((row) => { + const rawVerdict = row[5] ?? ""; const verdict = verdictOrNull(rawVerdict); if (verdict === null) { throw new UserError(`ledger.tsv has invalid verdict ${rawVerdict}`); @@ -583,19 +609,24 @@ async function readLedger(store: string): Promise { return { pr: row[0] ?? "", sha: row[1] ?? "", + baseSha: row[2] ?? "", + baseBranch: row[3] ?? "", + patchId: row[4] ?? "", verdict, - evidence: row[3] ?? "", - verifier: row[4] ?? "", - ts: row[5] ?? "", + evidence: row[6] ?? "", + verifier: row[7] ?? "", + ts: row[8] ?? "", }; - } - ); + }); } function ledgerCells(row: LedgerEntry): readonly string[] { return [ row.pr, row.sha, + row.baseSha, + row.baseBranch, + row.patchId, row.verdict, row.evidence, row.verifier, @@ -992,7 +1023,7 @@ ${table( Verdicts: ${countLine(currentSummary.ledgerVerdicts)} ${table( - ["PR", "SHA", "Verdict", "Evidence", "Verifier", "Timestamp"], + ["PR", "Head SHA", "Base SHA", "Base branch", "Patch-id", "Verdict", "Evidence", "Verifier", "Timestamp"], ledgerRows.map(ledgerCells) )} @@ -1408,13 +1439,13 @@ export function openStore( const verdict = parseVerdict(params.verdict); const row: LedgerEntry = { pr: String(positiveInteger(params.pr, "PR")), - sha: requiredCell(params.sha, "SHA"), + sha: requiredCell(params.sha, "head SHA"), + baseSha: requiredCell(params.baseSha, "base SHA"), + baseBranch: requiredCell(params.baseBranch, "base branch"), + patchId: requiredCell(params.patchId, "stable patch-id"), verdict, evidence: requiredCell(params.evidence, "evidence"), - verifier: - params.verifier === undefined - ? "" - : requiredCell(params.verifier, "verifier"), + verifier: requiredCell(params.verifier, "verifier"), ts: new Date().toISOString(), }; const rows = [...(await readLedger(store))]; @@ -1432,14 +1463,29 @@ export function openStore( check: async (params) => { ensureOpen(); const pr = String(positiveInteger(params.pr, "PR")); - const sha = requiredCell(params.sha, "SHA"); + const sha = requiredCell(params.sha, "head SHA"); + const baseSha = requiredCell(params.baseSha, "base SHA"); + const baseBranch = requiredCell(params.baseBranch, "base branch"); + const patchId = requiredCell(params.patchId, "stable patch-id"); const row = (await readLedger(store)).find( (value) => value.pr === pr && value.sha === sha ); - if (row === undefined) { + if ( + row === undefined || + row.baseSha !== baseSha || + row.baseBranch !== baseBranch || + row.patchId !== patchId + ) { throw new NotFoundError("NOT-VERIFIED", { compact: "NOT-VERIFIED", - json: { pr, sha, verdict: "NOT-VERIFIED" }, + json: { + pr, + sha, + baseSha, + baseBranch, + patchId, + verdict: "NOT-VERIFIED", + }, }); } return row; diff --git a/tools/meta-mode/pr-safety.mjs b/tools/meta-mode/pr-safety.mjs new file mode 100644 index 0000000..67848bd --- /dev/null +++ b/tools/meta-mode/pr-safety.mjs @@ -0,0 +1,94 @@ +import { spawn } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export class DeadlineExceeded extends Error { + constructor(message = "operation deadline reached") { + super(message); + this.name = "DeadlineExceeded"; + } +} + +export class WatchDeadline { + #expiresAt; + constructor(timeoutMs = 0, now = () => performance.now()) { + this.now = now; + this.#expiresAt = timeoutMs > 0 ? now() + timeoutMs : Infinity; + } + remaining() { return Math.max(0, this.#expiresAt - this.now()); } + assert() { if (this.remaining() === 0) throw new DeadlineExceeded(); } +} + +const OID = /^[0-9a-f]{40,64}$/i; + +function requiredText(value, key, pattern = null) { + if (typeof value !== "string" || !value || value.trim() !== value || /[\u0000-\u001f\u007f]/.test(value)) { + throw new TypeError(`invalid landing revision ${key}`); + } + if (pattern && !pattern.test(value)) throw new TypeError(`invalid landing revision ${key}`); + return value; +} + +function oid(value, key) { + return requiredText(value, key, OID).toLowerCase(); +} + +export function parseLandingRevision(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("landing revision must be an object"); + if (!Number.isInteger(value.number) || value.number <= 0) throw new TypeError("invalid landing revision number"); + return { + owner: requiredText(value.owner, "owner", /^[A-Za-z0-9][A-Za-z0-9_.-]*$/), + repo: requiredText(value.repo, "repo", /^[A-Za-z0-9][A-Za-z0-9_.-]*$/), + number: value.number, + headRefOid: oid(value.headRefOid, "headRefOid"), + baseRefName: requiredText(value.baseRefName, "baseRefName"), + baseRefOid: oid(value.baseRefOid, "baseRefOid"), + }; +} + +export function sameLandingRevision(a, b) { + const left = parseLandingRevision(a), right = parseLandingRevision(b); + return left.owner.toLowerCase() === right.owner.toLowerCase() && left.repo.toLowerCase() === right.repo.toLowerCase() && left.number === right.number && left.headRefOid === right.headRefOid && left.baseRefName === right.baseRefName && left.baseRefOid === right.baseRefOid; +} + +export function assertLandingRevision(expected, actual) { + if (!sameLandingRevision(expected, actual)) throw new Error("landing revision changed while collecting safety evidence"); + return actual; +} + +export function guardedMergeArgs({ forge = "gh", pr, head }) { + if (!Number.isInteger(pr) || pr <= 0 || typeof head !== "string" || !OID.test(head)) throw new TypeError("merge requires a PR number and a full verified head oid"); + const expected = head.toLowerCase(); + if (forge === "gh") return ["pr", "merge", String(pr), "--match-head-commit", expected]; + if (forge === "origin") return ["pr", "merge", String(pr), "--expected-head", expected]; + throw new Error(`unsupported forge: ${forge}`); +} + +export function runCommand(argv, deadline = new WatchDeadline()) { + if (!Array.isArray(argv) || argv.length === 0) return Promise.reject(new TypeError("command argv must not be empty")); + deadline.assert(); + return new Promise((resolvePromise, reject) => { + const child = spawn(argv[0], argv.slice(1), { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = "", stderr = "", expired = false, timer; + const check = () => { const left = deadline.remaining(); if (!Number.isFinite(left)) return; if (left === 0) { expired = true; child.kill("SIGKILL"); } else timer = setTimeout(check, Math.min(left, 2_147_483_647)); }; + check(); + child.stdout.setEncoding("utf8"); child.stderr.setEncoding("utf8"); + child.stdout.on("data", chunk => { stdout += chunk; }); child.stderr.on("data", chunk => { stderr += chunk; }); + child.once("error", error => { clearTimeout(timer); reject(error); }); + child.once("close", code => { clearTimeout(timer); if (expired) reject(new DeadlineExceeded()); else resolvePromise({ code: code ?? -1, stdout, stderr }); }); + }); +} + +export async function runJsonCommand(argv, deadline) { + const result = await runCommand(argv, deadline); + if (result.code !== 0) throw new Error(result.stderr.trim().split(/\r?\n/, 1)[0] || `${argv[0]} exited ${result.code}`); + try { return JSON.parse(result.stdout); } catch (error) { throw new Error(`invalid JSON from ${argv[0]}: ${error.message}`); } +} + +export function resolveTool(relativePath, moduleUrl = import.meta.url) { + const root = resolve(dirname(fileURLToPath(moduleUrl)), "../.."); + const target = resolve(root, relativePath); + if (!existsSync(target)) throw new Error(`tool not found: ${relativePath}`); + return realpathSync(target); +} diff --git a/tools/meta-mode/pr-safety.test.mjs b/tools/meta-mode/pr-safety.test.mjs new file mode 100644 index 0000000..896f369 --- /dev/null +++ b/tools/meta-mode/pr-safety.test.mjs @@ -0,0 +1,37 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DeadlineExceeded, WatchDeadline, assertLandingRevision, guardedMergeArgs, parseLandingRevision, sameLandingRevision } from "./pr-safety.mjs"; + +test("landing revisions compare head, destination, and repository identity", () => { + const revision = parseLandingRevision({ owner: "Owner", repo: "Repo", number: 7, headRefOid: "a".repeat(40), baseRefName: "main", baseRefOid: "b".repeat(40) }); + assert.equal(sameLandingRevision(revision, { ...revision, owner: "owner", repo: "repo" }), true); + assert.equal(sameLandingRevision(revision, { ...revision, baseRefOid: "c".repeat(40) }), false); + assert.throws(() => assertLandingRevision(revision, { ...revision, headRefOid: "c".repeat(40) }), /changed/); +}); + +test("rejects incomplete landing revisions at the boundary", () => { + const valid = { owner: "Owner", repo: "Repo", number: 7, headRefOid: "a".repeat(40), baseRefName: "main", baseRefOid: "b".repeat(40) }; + for (const invalid of [ + { ...valid, owner: "" }, + { ...valid, repo: "repo name" }, + { ...valid, headRefOid: "abc" }, + { ...valid, baseRefOid: "" }, + { ...valid, baseRefName: " main" }, + ]) assert.throws(() => parseLandingRevision(invalid), /invalid landing revision/); +}); + +test("guarded merge always carries the verified head", () => { + const head = "a".repeat(40); + assert.deepEqual(guardedMergeArgs({ pr: 7, head }), ["pr", "merge", "7", "--match-head-commit", head]); + assert.deepEqual(guardedMergeArgs({ forge: "origin", pr: 7, head }), ["pr", "merge", "7", "--expected-head", head]); + for (const bad of ["", "abc", "HEAD", `${head} `, "g".repeat(40)]) assert.throws(() => guardedMergeArgs({ pr: 7, head: bad }), /full verified head oid/); +}); + +test("deadline is monotonic and rejects after expiry", () => { + let now = 100; + const deadline = new WatchDeadline(20, () => now); + assert.equal(deadline.remaining(), 20); + now = 121; + assert.equal(deadline.remaining(), 0); + assert.throws(() => deadline.assert(), DeadlineExceeded); +}); diff --git a/tools/meta-mode/resume.mjs b/tools/meta-mode/resume.mjs new file mode 100644 index 0000000..e7ed311 --- /dev/null +++ b/tools/meta-mode/resume.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const usage = `Usage: + node tools/meta-mode/resume.mjs begin --project --note [--artifact ]... [--id ] + node tools/meta-mode/resume.mjs publish --project --id + node tools/meta-mode/resume.mjs read --project [--id ] + +A resume is drafted under the repository's common Git directory (.git/mstack/resume/) +and becomes visible only after publish. Because it lives outside the worktree, removing a linked +worktree does not delete it. Published checkpoints record the sha256 of the note and artifacts, and +read reports any file that is missing or changed since publish.`; + +function git(project, args) { + try { + return execFileSync("git", ["-C", project, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); + } catch (error) { + const detail = error.stderr?.toString().trim(); + throw new Error(`Git query failed in ${project}: ${detail || error.message}`); + } +} + +function repository(project) { + const input = resolve(project || process.cwd()); + const root = resolve(git(input, ["rev-parse", "--show-toplevel"])); + const worktree = root; + // The common dir is shared by every linked worktree and survives `git worktree remove`. It can be printed relative to `input`. + const commonDir = resolve(input, git(input, ["rev-parse", "--git-common-dir"])); + // Resume ids are only unique within one worktree, so each worktree gets its own directory. + const key = createHash("sha256").update(root).digest("hex").slice(0, 16); + const gitDir = join(commonDir, "mstack", "resume", key); + return { input, root, worktree, gitDir }; +} + +// Only `begin` stamps an identity; reading a checkpoint must work on a machine with no Git identity. +function identity(repo) { + let name = ""; + let email = ""; + try { name = git(repo.input, ["config", "--get", "user.name"]); } catch { /* report the combined identity error below */ } + try { email = git(repo.input, ["config", "--get", "user.email"]); } catch { /* report the combined identity error below */ } + if (!name || !email) throw new Error("Git identity is incomplete: configure user.name and user.email before creating a resume"); + return { name, email }; +} + +function digest(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex"); +} + +// Hash every referenced file by worktree-relative path so `read` can tell a changed or deleted file from a valid one. +function fileHashes(repo, record) { + const hashes = {}; + for (const file of [record.note, ...record.artifacts]) { + const path = inside(repo.root, file, "Path"); + requireFile(path, "Referenced file"); + hashes[file] = digest(path); + } + return hashes; +} + +function verifyFiles(repo, hashes) { + return Object.entries(hashes ?? {}).map(([file, sha256]) => { + let status = "missing"; + try { + const path = inside(repo.root, file, "Path"); + if (existsSync(path) && lstatSync(path).isFile()) status = digest(path) === sha256 ? "ok" : "changed"; + } catch { /* an escaping or unreadable path counts as missing */ } + return { path: file, sha256, status }; + }); +} + +function inside(root, file, label) { + const path = resolve(root, file); + const rel = relative(root, path); + if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Error(`${label} must be inside the project worktree: ${file}`); + if (rel.split(/[\\/]/).includes(".git")) throw new Error(`${label} cannot be under .git: ${file}`); + return path; +} + +function requireFile(path, label) { + if (!existsSync(path)) throw new Error(`${label} does not exist: ${path}`); + if (!lstatSync(path).isFile()) throw new Error(`${label} is not a regular file: ${path}`); +} + +function resumeId(value) { + if (typeof value !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,96}$/.test(value)) { + throw new Error("Resume id must contain only letters, numbers, dot, underscore, or hyphen"); + } + return value; +} + +function atomic(path, value) { + const temporary = `${path}.tmp-${process.pid}-${Date.now()}`; + writeFileSync(temporary, value, { encoding: "utf8", flag: "wx", mode: 0o600 }); + renameSync(temporary, path); +} + +function store(repo) { + mkdirSync(repo.gitDir, { recursive: true, mode: 0o700 }); + return repo.gitDir; +} + +function begin(repo, options) { + if (!options.note) throw new Error("begin requires --note "); + const note = inside(repo.root, options.note, "Note path"); + requireFile(note, "Note"); + const artifacts = options.artifact.map((file) => { + const path = inside(repo.root, file, "Artifact path"); + requireFile(path, "Artifact"); + return relative(repo.root, path).replaceAll("\\", "/"); + }); + const id = resumeId(options.id || `resume-${Date.now()}-${process.pid}`); + const dir = store(repo); + const draft = join(dir, `${id}.draft.json`); + if (existsSync(draft) || existsSync(join(dir, `${id}.json`))) throw new Error(`Resume already exists: ${id}`); + const noteRel = relative(repo.root, note).replaceAll("\\", "/"); + const record = { version: 1, id, state: "draft", project: repo.root, worktree: repo.worktree, identity: identity(repo), note: noteRel, artifacts, createdAt: new Date().toISOString() }; + atomic(draft, JSON.stringify(record, null, 2) + "\n"); + console.log(JSON.stringify({ ...record, path: draft })); +} + +function publish(repo, options) { + if (!options.id) throw new Error("publish requires --id "); + const id = resumeId(options.id); + const dir = store(repo); + const draft = join(dir, `${id}.draft.json`); + if (!existsSync(draft)) throw new Error(`No draft resume found for id: ${id}`); + const record = JSON.parse(readFileSync(draft, "utf8")); + if (record.project !== repo.root || record.worktree !== repo.worktree) throw new Error("Draft belongs to a different project or worktree"); + requireFile(inside(repo.root, record.note, "Note path"), "Note"); + for (const artifact of record.artifacts) requireFile(inside(repo.root, artifact, "Artifact path"), "Artifact"); + // Hash at publish time so the checkpoint describes the files as they are when it becomes visible. + const published = { ...record, state: "published", publishedAt: new Date().toISOString(), files: fileHashes(repo, record) }; + const target = join(dir, `${id}.json`); + atomic(target, JSON.stringify(published, null, 2) + "\n"); + // Removing the draft after the atomic publication makes readers see either a complete record or no record. + unlinkSync(draft); + console.log(JSON.stringify({ ...published, path: target })); +} + +function readResume(repo, options) { + const dir = repo.gitDir; + if (!existsSync(dir)) throw new Error("No published resume checkpoints found"); + let id = options.id; + if (id) id = resumeId(id); + if (!id) { + // Newest by publication time, not by filename: ids are free-form and do not sort chronologically. + const published = readdirSync(dir) + .filter((name) => name.endsWith(".json") && !name.endsWith(".draft.json")) + .flatMap((name) => { + try { + const record = JSON.parse(readFileSync(join(dir, name), "utf8")); + return typeof record.publishedAt === "string" ? [{ id: name.slice(0, -5), publishedAt: record.publishedAt }] : []; + } catch { return []; } + }) + .sort((a, b) => (a.publishedAt === b.publishedAt ? a.id.localeCompare(b.id) : a.publishedAt.localeCompare(b.publishedAt))); + id = published.at(-1)?.id; + } + if (!id) throw new Error("No published resume checkpoints found"); + id = resumeId(id); + const path = join(dir, `${id}.json`); + if (!existsSync(path)) throw new Error(`No published resume found for id: ${id}`); + const record = JSON.parse(readFileSync(path, "utf8")); + if (record.project !== repo.root || record.worktree !== repo.worktree) throw new Error("Resume belongs to a different project or worktree"); + const files = verifyFiles(repo, record.files); + for (const file of files.filter((item) => item.status !== "ok")) console.error(`resume: warning: ${file.path} is ${file.status} since the checkpoint was published`); + console.log(JSON.stringify({ ...record, path, files, filesOk: files.every((item) => item.status === "ok") })); +} + +function parse(args) { + const command = args.shift(); + if (!command || !["begin", "publish", "read"].includes(command)) throw new Error(usage); + const options = { artifact: [] }; + while (args.length) { + const arg = args.shift(); + const key = { "--project": "project", "--note": "note", "--artifact": "artifact", "--id": "id" }[arg]; + if (!key) throw new Error(`Unknown option: ${arg}\n${usage}`); + const value = args.shift(); + if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`); + if (key === "artifact") options.artifact.push(value); else if (options[key]) throw new Error(`Duplicate option: ${arg}`); else options[key] = value; + } + return { command, options }; +} + +export { begin, publish, readResume, repository }; + +if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { + try { + const { command, options } = parse(process.argv.slice(2)); + const repo = repository(options.project); + ({ begin, publish, read: readResume }[command])(repo, options); + } catch (error) { + console.error(`resume: ${error.message}`); + process.exitCode = 1; + } +} diff --git a/tools/meta-mode/resume.test.mjs b/tools/meta-mode/resume.test.mjs new file mode 100644 index 0000000..3740e1b --- /dev/null +++ b/tools/meta-mode/resume.test.mjs @@ -0,0 +1,154 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +// Resolve relative to this file so the test works from any cwd, including `bun test` inside tools/meta-mode. +const script = fileURLToPath(new URL("./resume.mjs", import.meta.url)); + +function git(root, ...args) { + return execFileSync("git", ["-C", root, ...args], { encoding: "utf8" }).trim(); +} +function repo(t) { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), "mstack-resume-"))); + git(root, "init", "-q"); + git(root, "config", "user.name", "Test User"); + git(root, "config", "user.email", "test@example.test"); + writeFileSync(join(root, "note.md"), "next: verify the release\n"); + writeFileSync(join(root, "artifact.txt"), "evidence\n"); + git(root, "add", "."); + git(root, "commit", "-q", "-m", "fixture"); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} +// Every checkpoint directory under the common Git dir, one per worktree. +function stores(root) { + const base = join(resolve(root, git(root, "rev-parse", "--git-common-dir")), "mstack", "resume"); + return existsSync(base) ? readdirSync(base).map((key) => join(base, key)) : []; +} +function run(root, ...args) { + return spawnSync(process.execPath, [script, ...args, "--project", root], { encoding: "utf8" }); +} + +test("begin, publish, and read persist a useful checkpoint in Git storage", (t) => { + const root = repo(t); + const begun = run(root, "begin", "--id", "handoff", "--note", "note.md", "--artifact", "artifact.txt"); + assert.equal(begun.status, 0, begun.stderr); + const draft = join(stores(root)[0], "handoff.draft.json"); + assert.equal(existsSync(draft), true); + assert.equal(JSON.parse(readFileSync(draft)).state, "draft"); + const published = run(root, "publish", "--id", "handoff"); + assert.equal(published.status, 0, published.stderr); + assert.equal(existsSync(draft), false); + const record = JSON.parse(published.stdout); + assert.equal(record.state, "published"); + assert.equal(record.note, "note.md"); + const read = run(root, "read", "--id", "handoff"); + assert.equal(read.status, 0, read.stderr); + const result = JSON.parse(read.stdout); + assert.equal(result.artifacts[0], "artifact.txt"); + assert.equal(result.filesOk, true); + assert.deepEqual(result.files.map((file) => [file.path, file.status]), [["note.md", "ok"], ["artifact.txt", "ok"]]); +}); + +test("rejects unsafe paths, missing identity, and unpublishable notes", (t) => { + const root = repo(t); + let result = run(root, "begin", "--id", "bad", "--note", "../note.md"); + assert.equal(result.status, 1); + assert.match(result.stderr, /inside the project worktree/); + git(root, "config", "user.email", ""); + result = run(root, "begin", "--id", "bad", "--note", "note.md"); + assert.equal(result.status, 1); + assert.match(result.stderr, /Git identity is incomplete/); + git(root, "config", "user.email", "test@example.test"); + assert.equal(run(root, "begin", "--id", "missing", "--note", "note.md").status, 0); + rmSync(join(root, "note.md")); + result = run(root, "publish", "--id", "missing"); + assert.equal(result.status, 1); + assert.match(result.stderr, /Note does not exist/); +}); + +test("rejects traversal ids at publish and read boundaries", (t) => { + const root = repo(t); + assert.equal(run(root, "begin", "--id", "safe", "--note", "note.md").status, 0); + let result = run(root, "publish", "--id", "../escape"); + assert.equal(result.status, 1); + assert.match(result.stderr, /Resume id must contain/); + result = run(root, "read", "--id", "../escape"); + assert.equal(result.status, 1); + assert.match(result.stderr, /Resume id must contain/); +}); + +test("does not expose a draft through read", (t) => { + const root = repo(t); + assert.equal(run(root, "begin", "--id", "draft-only", "--note", "note.md").status, 0); + const result = run(root, "read", "--id", "draft-only"); + assert.equal(result.status, 1); + assert.match(result.stderr, /No published resume/); +}); + +test("read and publish work without a Git identity; only begin needs one", (t) => { + const root = repo(t); + assert.equal(run(root, "begin", "--id", "anon", "--note", "note.md").status, 0); + git(root, "config", "user.name", ""); + git(root, "config", "user.email", ""); + // A global or system identity must not mask the missing local one. + const env = { ...process.env, GIT_CONFIG_GLOBAL: join(root, "no-global"), GIT_CONFIG_SYSTEM: join(root, "no-system"), GIT_CONFIG_NOSYSTEM: "1" }; + const exec = (...args) => spawnSync(process.execPath, [script, ...args, "--project", root], { encoding: "utf8", env }); + assert.equal(exec("begin", "--id", "second", "--note", "note.md").status, 1); + assert.equal(exec("publish", "--id", "anon").status, 0); + const read = exec("read"); + assert.equal(read.status, 0, read.stderr); + assert.equal(JSON.parse(read.stdout).id, "anon"); +}); + +test("read without --id returns the newest published checkpoint, not the last filename", async (t) => { + const root = repo(t); + for (const id of ["zzz-first", "aaa-second"]) { + assert.equal(run(root, "begin", "--id", id, "--note", "note.md").status, 0); + assert.equal(run(root, "publish", "--id", id).status, 0); + await new Promise((done) => setTimeout(done, 25)); + } + const read = run(root, "read"); + assert.equal(read.status, 0, read.stderr); + assert.equal(JSON.parse(read.stdout).id, "aaa-second"); +}); + +test("checkpoints live in the common Git dir, per worktree, and outlive a removed worktree", (t) => { + const root = repo(t); + const linked = `${root}-linked`; + git(root, "worktree", "add", "-q", "-b", "linked", linked); + t.after(() => rmSync(linked, { recursive: true, force: true })); + const worktree = realpathSync.native(linked); + + // The same id in two worktrees must not collide. + for (const dir of [root, worktree]) { + assert.equal(run(dir, "begin", "--id", "shared", "--note", "note.md").status, 0); + assert.equal(run(dir, "publish", "--id", "shared").status, 0); + } + const keyed = stores(root); + assert.equal(keyed.length, 2); + assert.equal(keyed.every((dir) => existsSync(join(dir, "shared.json"))), true); + assert.equal(run(root, "read", "--id", "shared").status, 0); + + git(root, "worktree", "remove", "--force", worktree); + assert.equal(keyed.every((dir) => existsSync(join(dir, "shared.json"))), true); +}); + +test("read reports files that changed or went missing since publish", (t) => { + const root = repo(t); + assert.equal(run(root, "begin", "--id", "drift", "--note", "note.md", "--artifact", "artifact.txt").status, 0); + assert.equal(run(root, "publish", "--id", "drift").status, 0); + writeFileSync(join(root, "note.md"), "next: something else\n"); + rmSync(join(root, "artifact.txt")); + const read = run(root, "read", "--id", "drift"); + assert.equal(read.status, 0, read.stderr); + const result = JSON.parse(read.stdout); + assert.equal(result.filesOk, false); + assert.deepEqual(result.files.map((file) => [file.path, file.status]), [["note.md", "changed"], ["artifact.txt", "missing"]]); + assert.match(read.stderr, /note\.md is changed/); + assert.match(read.stderr, /artifact\.txt is missing/); +}); diff --git a/tools/meta-mode/worktree-audit.mjs b/tools/meta-mode/worktree-audit.mjs new file mode 100644 index 0000000..64f2a69 --- /dev/null +++ b/tools/meta-mode/worktree-audit.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +/** Read-only, cross-platform Git worktree audit. */ +import { spawnSync } from "node:child_process"; +import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function runGit(args, cwd, options = {}) { + const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024, ...options }); + return result.status === 0 ? result.stdout : ""; +} +/** Like runGit, but a failed command stays distinguishable from empty output. */ +function gitResult(args, cwd) { + const result = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }); + return { ok: result.status === 0, stdout: result.stdout ?? "" }; +} +function gitOk(args, cwd) { return spawnSync("git", args, { cwd, stdio: "ignore" }).status === 0; } + +/** Parse the NUL-delimited porcelain stream without treating paths as shell text. */ +export function parseWorktreePorcelain(input) { + const records = []; + let current = null; + for (const token of input.split("\0")) { + if (!token) { + if (current) { records.push(current); current = null; } + continue; + } + const space = token.indexOf(" "); + const key = space < 0 ? token : token.slice(0, space); + const value = space < 0 ? "" : token.slice(space + 1); + if (key === "worktree") { + if (current) records.push(current); + current = { path: value, head: "", branch: null, detached: false }; + } else if (current) { + if (key === "HEAD") current.head = value; + else if (key === "branch") current.branch = value.replace(/^refs\/heads\//, ""); + else if (key === "detached") current.detached = true; + } + } + if (current) records.push(current); + return records; +} + +function directorySize(path) { + try { + const stat = lstatSync(path); + if (!stat.isDirectory()) return stat.size; + return readdirSync(path, { withFileTypes: true }).reduce((total, entry) => total + directorySize(join(path, entry.name)), 0); + } catch { return 0; } +} +function humanSize(bytes) { + if (bytes < 1024) return `${bytes}B`; + const units = ["K", "M", "G", "T"]; + let value = bytes; + let unit = -1; + do { value /= 1024; unit++; } while (value >= 1024 && unit < units.length - 1); + return `${value.toFixed(value >= 10 ? 0 : 1)}${units[unit]}`; +} +function age(timestamp, now) { return timestamp > 0 ? `${Math.max(0, Math.floor((now - timestamp) / 86400000))}d` : "?"; } +function gitStatus(cwd) { + const { ok, stdout: output } = gitResult(["status", "--porcelain=v1", "-z"], cwd); + // A failed status says nothing about uncommitted work, so it must never read as clean. + if (!ok) return "unknown"; + if (!output) return "clean"; + const entries = output.split("\0").filter(Boolean); + const tracked = entries.filter((entry) => !entry.startsWith("?? ")).length; + return tracked ? `wip:${tracked}` : `scratch:${entries.length}`; +} +function remoteState(worktree, branch, head) { + if (!branch) return "detached"; + const remoteHead = runGit(["rev-parse", `origin/${branch}`], worktree).trim(); + if (!remoteHead) return "no-remote"; + if (remoteHead === head) return "pushed"; + const count = runGit(["rev-list", "--count", `origin/${branch}..HEAD`], worktree).trim(); + return `ahead${count || "0"}`; +} +function latestTranscript(transcripts, worktree, now) { + if (!transcripts || !existsSync(transcripts)) return { date: "-", recent: false }; + let newest = 0; + // Transcripts are JSONL, so a Windows path appears with doubled backslashes. Collapse every run to one slash on both sides. + const normalize = (text) => text.replace(/\\+/g, "/"); + const needle = normalize(worktree); + const scan = (dir) => { + let entries; + try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) scan(path); + else { + try { + const text = normalize(readFileSync(path, "utf8")); + if (text.includes(`${needle}/`) || text.includes(`${needle}\"`) || text.includes(`${needle}'`)) newest = Math.max(newest, lstatSync(path).mtimeMs); + } catch { /* transcript may disappear during a scan */ } + } + } + }; + scan(transcripts); + if (!newest) return { date: "-", recent: false }; + const date = new Date(newest).toISOString().slice(0, 10); + return { date, recent: (now - newest) / 86400000 <= 4 }; +} + +export function classify({ dirty, pr, recent, merged }) { + if (dirty === "unknown") return "hold-unknown"; + if (dirty.startsWith("wip:")) return "hold-wip"; + if (pr.includes("OPEN")) return "hold-open-pr"; + if (recent) return "verify-recent-chat"; + if (merged || pr !== "-") return "safe"; + return "review"; +} + +function listAuthoredPrs(repo) { + const gh = spawnSync("gh", ["pr", "list", "--author", "@me", "--state", "all", "--limit", "1000", "--json", "number,state,headRefName"], { cwd: repo, encoding: "utf8" }); + if (gh.status !== 0) return []; + try { return JSON.parse(gh.stdout); } catch { return []; } +} + +export function audit(repo = runGit(["rev-parse", "--show-toplevel"], process.cwd()).trim(), env = process.env, { listPrs = listAuthoredPrs } = {}) { + if (!repo) throw new Error("not in a git repo; pass a repo path"); + repo = resolve(repo); + const worktrees = parseWorktreePorcelain(runGit(["worktree", "list", "--porcelain", "-z"], repo)); + if (!worktrees.length) throw new Error("could not read git worktrees"); + runGit(["fetch", "origin", "main", "--quiet"], repo); // best effort; stale refs remain useful + const prs = listPrs(repo); + const now = Date.now(); + const main = worktrees[0]?.path; + return worktrees.slice(1).map((wt) => { + const timestamp = Number(runGit(["log", "-1", "--format=%ct", wt.path], wt.path).trim()) * 1000 || 0; + const pr = prs.find((item) => item.headRefName === wt.branch); + const prText = pr ? `#${pr.number}/${pr.state}` : "-"; + const merged = gitOk(["merge-base", "--is-ancestor", wt.head, "origin/main"], repo); + const transcript = latestTranscript(env.MSTACK_TRANSCRIPTS_DIR, wt.path, now); + const dirty = gitStatus(wt.path); + const bytes = directorySize(wt.path); + return { size: humanSize(bytes), bytes, age: age(timestamp, now), merged: merged ? "YES" : "no", dirty, remote: remoteState(wt.path, wt.branch, wt.head), pr: prText, lastChat: transcript.date, bucket: classify({ dirty, pr: prText, recent: transcript.recent, merged }), worktree: wt.path, main }; + }).sort((a, b) => b.bytes - a.bytes); +} + +export function format(rows) { + const lines = ["SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE"]; + for (const row of rows) lines.push([row.size, row.age, row.merged, row.dirty, row.remote, row.pr, row.lastChat, row.bucket, row.worktree].join("\t")); + return `${lines.join("\n")}\n`; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { process.stdout.write(format(audit(process.argv[2]))); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/tools/meta-mode/worktree-audit.sh b/tools/meta-mode/worktree-audit.sh index 3b023da..9dc5ed6 100644 --- a/tools/meta-mode/worktree-audit.sh +++ b/tools/meta-mode/worktree-audit.sh @@ -1,108 +1,6 @@ -#!/usr/bin/env bash -# Read-only worktree prune audit. Classifies every git worktree by size, merge -# state, uncommitted work, remote/PR state, and the most recent chat that -# operated in it. Emits a table sorted by size with a suggested bucket. Never -# deletes anything; deletion stays a human-gated step in the playbook. -# -# Usage: worktree-audit.sh [repo-path] (defaults to the current repo) -# Set MSTACK_TRANSCRIPTS_DIR to include session history in LAST_CHAT. The -# directory must already be scoped to this workspace by the active Harness. -set -u - -repo="${1:-$(git rev-parse --show-toplevel 2>/dev/null)}" -[ -z "$repo" ] && { echo "not in a git repo; pass a repo path" >&2; exit 1; } -cd "$repo" || exit 1 - -# Main worktree is the first entry; everything else is a candidate. -main_wt=$(git worktree list --porcelain | awk '/^worktree /{print $2; exit}') - -# origin/main drives the merge check. Best-effort; stale is fine for a first pass. -git fetch origin main --quiet 2>/dev/null || echo "warn: could not fetch origin/main; merged column may be stale" >&2 - -# PR state by branch, fetched once. Empty if gh is unavailable. -prs=$(mktemp) -gh pr list --author "@me" --state all --limit 1000 \ - --json number,state,headRefName 2>/dev/null > "$prs" || echo "[]" > "$prs" - -# Session history is Harness-specific, so callers provide the workspace-scoped -# directory instead of making the audit depend on one vendor's storage layout. -transcripts="${MSTACK_TRANSCRIPTS_DIR:-}" -now=$(date +%s) - -if stat -c '%Y' -- "$prs" >/dev/null 2>&1; then - file_mtime() { stat -c '%Y' -- "$1"; } -elif stat -f '%m' "$prs" >/dev/null 2>&1; then - file_mtime() { stat -f '%m' "$1"; } -else - file_mtime() { return 1; } -fi - -if date -d '@0' '+%Y-%m-%d' >/dev/null 2>&1; then - format_epoch_date() { date -d "@$1" '+%Y-%m-%d'; } -elif date -r 0 '+%Y-%m-%d' >/dev/null 2>&1; then - format_epoch_date() { date -r "$1" '+%Y-%m-%d'; } -else - format_epoch_date() { return 1; } -fi - -printf "SIZE\tAGE\tMERGED\tDIRTY\tREMOTE\tPR\tLAST_CHAT\tBUCKET\tWORKTREE\n" - -git worktree list --porcelain | awk '/^worktree /{print $2}' | while read -r wt; do - [ "$wt" = "$main_wt" ] && continue - - size=$(du -sh "$wt" 2>/dev/null | awk '{print $1}') - head=$(git -C "$wt" rev-parse HEAD 2>/dev/null) - head_ts=$(git -C "$wt" log -1 --format='%ct' HEAD 2>/dev/null || echo 0) - age=$([ "$head_ts" -gt 0 ] 2>/dev/null && echo "$(( (now - head_ts) / 86400 ))d" || echo "?") - - # Squash-merged branches are not ancestors of main, so PR state is the - # real signal; merge-base only catches fast-forward/rebase merges. - git merge-base --is-ancestor "$head" origin/main 2>/dev/null && merged=YES || merged=no - - # Distinguish real WIP (tracked edits) from disposable untracked scratch. - porcelain=$(git -C "$wt" status --porcelain 2>/dev/null) - if [ -z "$porcelain" ]; then dirty=clean - elif printf '%s\n' "$porcelain" | grep -qv '^??'; then - dirty="wip:$(printf '%s\n' "$porcelain" | grep -cv '^??')" - else dirty="scratch:$(printf '%s\n' "$porcelain" | grep -c '^??')"; fi - - branch=$(git -C "$wt" symbolic-ref --quiet --short HEAD 2>/dev/null || echo "") - if [ -z "$branch" ]; then remote=detached - elif git -C "$wt" show-ref --verify --quiet "refs/remotes/origin/$branch"; then - [ "$(git -C "$wt" rev-parse "origin/$branch" 2>/dev/null)" = "$head" ] \ - && remote=pushed \ - || remote="ahead$(git -C "$wt" rev-list --count "origin/$branch..HEAD" 2>/dev/null)" - else remote=no-remote; fi - - pr=$([ -n "$branch" ] && jq -r --arg b "$branch" \ - '.[] | select(.headRefName==$b) | "#\(.number)/\(.state)"' "$prs" 2>/dev/null | head -1) - [ -z "$pr" ] && pr="-" - - # Most recent chat whose transcript operated in this worktree. Match path - # followed by "/" or a quote so glint-482 does not match glint-482-r37. - last="-"; last_ts=0 - if [ -d "$transcripts" ]; then - while IFS= read -r -d '' transcript; do - grep -Fq -e "${wt}/" -e "${wt}\"" -- "$transcript" 2>/dev/null || continue - transcript_ts=$(file_mtime "$transcript" 2>/dev/null) || continue - if [ "$transcript_ts" -gt "$last_ts" ] 2>/dev/null; then last_ts=$transcript_ts; fi - done < <(find "$transcripts" -type f -print0 2>/dev/null) - if [ "$last_ts" -gt 0 ] 2>/dev/null; then - last=$(format_epoch_date "$last_ts" 2>/dev/null || echo "-") - fi - fi - recent=$([ "$last_ts" -gt 0 ] 2>/dev/null && [ $(( (now - last_ts) / 86400 )) -le 4 ] && echo yes || echo no) - - case "$dirty" in wip:*) bucket=hold-wip ;; *) - case "$pr" in *OPEN*) bucket=hold-open-pr ;; *) - if [ "$recent" = yes ]; then bucket=verify-recent-chat - elif [ "$merged" = YES ] || [ "$pr" != "-" ]; then bucket=safe - else bucket=review; fi ;; - esac ;; - esac - - printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" \ - "$size" "$age" "$merged" "$dirty" "$remote" "$pr" "$last" "$bucket" "$wt" -done | sort -t$'\t' -k1,1 -rh - -rm -f "$prs" +#!/usr/bin/env sh +# Compatibility wrapper for environments that invoke the historical .sh name. +# The implementation is Node.js so Git paths and behavior are portable. +set -eu +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec node "$script_dir/worktree-audit.mjs" "$@" diff --git a/tools/meta-mode/worktree-audit.test.sh b/tools/meta-mode/worktree-audit.test.sh deleted file mode 100644 index fbe7e04..0000000 --- a/tools/meta-mode/worktree-audit.test.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) -root=$(mktemp -d "${TMPDIR:-/tmp}/mstack-worktree-audit.XXXXXX") -trap 'rm -rf "$root"' EXIT - -remote="$root/remote.git" -repo="$root/repo" -worktree="$root/audit-worktree" -transcripts="$root/transcripts" -fake_bin="$root/bin" - -git init --bare --initial-branch=main "$remote" >/dev/null -git init --initial-branch=main "$repo" >/dev/null -git -C "$repo" config user.name "Test User" -git -C "$repo" config user.email "test@example.com" -printf 'fixture\n' > "$repo/tracked.txt" -git -C "$repo" add tracked.txt -git -C "$repo" commit -m "test fixture" >/dev/null -git -C "$repo" remote add origin "$remote" -git -C "$repo" push --set-upstream origin main >/dev/null -git -C "$repo" worktree add -b audit-worktree "$worktree" >/dev/null -worktree=$(cd "$worktree" && pwd -P) - -mkdir -p "$transcripts" "$fake_bin" -printf '{"cwd":"%s/"}\n' "$worktree" > "$transcripts/session.jsonl" -cat > "$fake_bin/gh" <<'EOF' -#!/usr/bin/env bash -printf '[]\n' -EOF -cat > "$fake_bin/rg" <<'EOF' -#!/usr/bin/env bash -exit 127 -EOF -chmod +x "$fake_bin/gh" "$fake_bin/rg" - -output=$(PATH="$fake_bin:$PATH" MSTACK_TRANSCRIPTS_DIR="$transcripts" bash "$script_dir/worktree-audit.sh" "$repo") -row=$(printf '%s\n' "$output" | awk -F '\t' -v worktree="$worktree" '$9 == worktree') - -[ -n "$row" ] || { printf 'missing audit row for %s\n%s\n' "$worktree" "$output" >&2; exit 1; } -[ "$(printf '%s\n' "$row" | awk -F '\t' '{print $7}')" = "$(date '+%Y-%m-%d')" ] || { - printf 'LAST_CHAT did not contain today: %s\n' "$row" >&2 - exit 1 -} -[ "$(printf '%s\n' "$row" | awk -F '\t' '{print $8}')" = "verify-recent-chat" ] || { - printf 'recent transcript did not select verify-recent-chat: %s\n' "$row" >&2 - exit 1 -}