From 73cbba960891c4258f062c20f937e6df118dcb13 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:46:26 +0800 Subject: [PATCH 01/13] feat(meta-mode): validate project playbook extensions --- README.md | 14 +++- package.json | 3 +- profiles/skill-manifest.json | 9 ++- profiles/upstream-manifest.json | 2 +- scripts/check-playbooks.test.mjs | 70 +++++++++++++++++ skills/meta-mode/SKILL.md | 4 + tools/meta-mode/README.md | 3 + tools/meta-mode/check-playbooks.mjs | 112 ++++++++++++++++++++++++++++ 8 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 scripts/check-playbooks.test.mjs create mode 100644 tools/meta-mode/check-playbooks.mjs diff --git a/README.md b/README.md index 204d917..2da941c 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ workflow even when no native skill creator or repository validator is installed. - `automations/benny/` keeps the optional Cursor automation pack from pstack. - `docs/guide/` contains the adapted upstream workflow guide. - `tools/meta-mode/` contains the optional Bun orchestration and PR watcher - tools. + tools, plus the project-playbook checker. - `scripts/install.mjs` stages, validates, and commits an installation. - `scripts/remote-install.mjs` stages and transfers an SSH environment install. - `scripts/validate.mjs` checks inventory, frontmatter, links, and portability. @@ -461,6 +461,18 @@ review the source-to-adaptation patches, then use `--write` to update content changes. The hashes detect drift; they do not establish that an adapted workflow behaves like its source. See [the baseline review process](docs/skill-integrity.md). +To validate project playbooks that extend the bundled `meta-mode` playbooks, run: + +```bash +node tools/meta-mode/check-playbooks.mjs +``` + +A project playbook belongs in `/.agents/playbooks/`. Its frontmatter +must contain `when:` and may contain a comma-separated `extends:` list. Changes +must quote the exact bundled step they anchor to with `After`, `Before`, `Replace`, +or `In`. Use `--bundled ` when checking a staged skill installation instead +of this checkout. + To compare the pstack inventory and non-skill artifacts, run: ```bash diff --git a/package.json b/package.json index 17eb418..051c219 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "check-models": "node scripts/model-config.mjs", "model-budget": "node scripts/model-budget.mjs", "check-upstream": "node scripts/check-upstream.mjs", + "check-playbooks": "node tools/meta-mode/check-playbooks.mjs", "check-harness-policy": "node scripts/check-harness-policy.mjs", "skill-baseline": "node scripts/skill-baseline.mjs", "run-role": "node scripts/run-role.mjs", @@ -30,7 +31,7 @@ "install-skills": "node scripts/install.mjs", "optimize-context": "node scripts/optimize-context.mjs", "reconcile-context": "node scripts/reconcile-context.mjs", - "test": "node scripts/validate.mjs && node scripts/skill-baseline.mjs --check && node --test scripts/install.test.mjs scripts/install-migration.test.mjs scripts/migration-integration.test.mjs scripts/skill-discovery.test.mjs scripts/harness-targets.test.mjs scripts/remote-discovery.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/model-budget.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/skill-baseline.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs scripts/history.test.mjs scripts/harness-policy.test.mjs scripts/harness-project.test.mjs scripts/check-upstream-adaptations.test.mjs scripts/pack-claude-skills.test.mjs" + "test": "node scripts/validate.mjs && node scripts/skill-baseline.mjs --check && node --test scripts/install.test.mjs scripts/install-migration.test.mjs scripts/migration-integration.test.mjs scripts/skill-discovery.test.mjs scripts/harness-targets.test.mjs scripts/remote-discovery.test.mjs scripts/context.test.mjs scripts/model-config.test.mjs scripts/model-budget.test.mjs scripts/audit-context.test.mjs scripts/worktree-audit.test.mjs scripts/sync-upstream.test.mjs scripts/runtime.test.mjs scripts/environment.test.mjs scripts/skill-integrity.test.mjs scripts/skill-baseline.test.mjs scripts/version-integrity.test.mjs scripts/agent-format.test.mjs scripts/history.test.mjs scripts/harness-policy.test.mjs scripts/harness-project.test.mjs scripts/check-upstream-adaptations.test.mjs scripts/check-playbooks.test.mjs scripts/pack-claude-skills.test.mjs" }, "bin": { "mstack": "scripts/install.mjs", diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index 90b15f1..e5ab41c 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -348,7 +348,7 @@ "upstream": "pstack", "source": "skills/poteto-mode/SKILL.md", "sourceDigest": "b3c505602b82fb3641a3983e9c46fb974881faa12ece1af804fae27afb7be532", - "targetDigest": "4efa5f64e1f6bd01fd08eb6fb720ab4c84c7f3d787fff9df71d0e07ab65b73fe" + "targetDigest": "7b536c7484e19cf426e3f382ee58143678bb8c94c1bab33b0b89f1f9b2d62d96" }, "skills/no-comments/SKILL.md": { "upstream": "pstack", @@ -718,6 +718,11 @@ "targetDigest": "ddcdb05af01b37d7513b2017d44e29c30bdaa79ca360c5913d701c16dde70907", "reason": "Runtime tools are packaged separately from portable skill instructions." }, + "tools/meta-mode/check-playbooks.mjs": { + "upstream": null, + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "847d83fe381880ec4d8a50b4fe942e503d7f611408343a8cd1f9207c45b45241" + }, "tools/meta-mode/orch/orch.test.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/orch.test.ts", @@ -749,7 +754,7 @@ "tools/meta-mode/README.md": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "c27f30aa0fa15655a9c8dbd9df698a2c69393778bbe516ad5ebdf82ca0de71bd" + "targetDigest": "5a2dcf51ad2a816941a116b0e4c9c4e113c9d2a721a15f5ac8ef7a4b1a9afeaf" }, "tools/meta-mode/watch-pr/cli.test.ts": { "upstream": "pstack", diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index 106468b..dd047fe 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -109,7 +109,7 @@ }, "meta-mode": { "source": "d5817d996f6da294c86cacb2bd1b0c40c85968844a6afd194fe69efa8654878f", - "target": "4efa5f64e1f6bd01fd08eb6fb720ab4c84c7f3d787fff9df71d0e07ab65b73fe" + "target": "7b536c7484e19cf426e3f382ee58143678bb8c94c1bab33b0b89f1f9b2d62d96" }, "no-comments": { "source": "5c5b0882297d704c3a9720c52b7a793c68b013eaf717989f0945624efdfe2b05", diff --git a/scripts/check-playbooks.test.mjs b/scripts/check-playbooks.test.mjs new file mode 100644 index 0000000..f0bd4f8 --- /dev/null +++ b/scripts/check-playbooks.test.mjs @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; +import { checkPlaybooks } from "../tools/meta-mode/check-playbooks.mjs"; + +const script = resolve("tools/meta-mode/check-playbooks.mjs"); + +function fixture(t, playbooks, bundled = { "bug-fix.md": "Binary-search the cause.\n" }) { + const root = mkdtempSync(join(tmpdir(), "mstack-check-playbooks-")); + const bundledRoot = join(root, "bundled"); + mkdirSync(join(root, ".agents", "playbooks"), { recursive: true }); + mkdirSync(bundledRoot, { recursive: true }); + for (const [name, content] of Object.entries(playbooks)) writeFileSync(join(root, ".agents", "playbooks", name), content); + for (const [name, content] of Object.entries(bundled)) writeFileSync(join(bundledRoot, name), content); + t.after(() => rmSync(root, { recursive: true, force: true })); + return { root, bundledRoot }; +} + +function run(root, bundledRoot, ...args) { + return spawnSync(process.execPath, [script, "--root", root, "--bundled", bundledRoot, ...args], { encoding: "utf8" }); +} + +test("accepts an anchored project playbook and normalizes CRLF", (t) => { + const { root, bundledRoot } = fixture(t, { + "bug-fix.md": "---\r\nextends: bug-fix\r\nwhen: Use it for bug reports.\r\n---\r\n- **In** \"Binary-search the cause\": compare with main.\r\n", + }); + assert.deepEqual(checkPlaybooks(root, bundledRoot), []); + const result = run(root, bundledRoot); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout, "Every project playbook matches this mstack's playbooks.\n"); +}); + +test("reports missing bases, stale anchors, missing when fields, and unquoted changes", (t) => { + const { root, bundledRoot } = fixture(t, { + "bug-fix.md": "---\nextends: bug-fix\nwhen: Use it for bugs.\n---\n- **After** \"Ask the user to reproduce it\": compare with main.\n", + "ship.md": "---\nextends: shipping-v2\nwhen: Use it to ship.\n---\n", + "broken.md": "---\nextends: bug-fix\n---\n- **Before** Binary-search the cause: compare with main.\n", + }); + const result = run(root, bundledRoot); + assert.equal(result.status, 1); + assert.match(result.stderr, /Ask the user to reproduce it.*not in any playbook/); + assert.match(result.stderr, /shipping-v2.*no playbook/); + assert.match(result.stderr, /broken\.md: its frontmatter needs a \"when:\" line/); + assert.match(result.stderr, /broken\.md: a change has no straight-quoted step text/); +}); + +test("runs through a symlink and rejects unknown options", (t) => { + const { root, bundledRoot } = fixture(t, { + "ship.md": "---\nextends: shipping-v2\nwhen: Use it to ship.\n---\n", + }); + const entry = join(root, "check-playbooks.mjs"); + symlinkSync(script, entry); + const symlinkResult = spawnSync(process.execPath, [entry, root, "--bundled", bundledRoot], { encoding: "utf8" }); + assert.equal(symlinkResult.status, 1); + assert.match(symlinkResult.stderr, /shipping-v2.*no playbook/); + + const unknown = run(root, bundledRoot, "--unknown"); + assert.equal(unknown.status, 1); + assert.match(unknown.stderr, /Unknown option: --unknown/); +}); + +test("leaves a repository without project playbooks valid", (t) => { + const root = mkdtempSync(join(tmpdir(), "mstack-check-playbooks-empty-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + assert.deepEqual(checkPlaybooks(root, join(root, "missing-bundled")), []); + execFileSync(process.execPath, [script, "--root", root], { encoding: "utf8" }); +}); diff --git a/skills/meta-mode/SKILL.md b/skills/meta-mode/SKILL.md index 0cb4f7c..4332cce 100644 --- a/skills/meta-mode/SKILL.md +++ b/skills/meta-mode/SKILL.md @@ -167,6 +167,10 @@ Comments follow the same rule as the reply. Write them clean as you go. Keep a c ## Playbooks +**Project playbooks.** A repository can add playbooks under `.agents/playbooks/`, one Markdown file per playbook. The frontmatter can name comma-separated `extends` stems and must include `when`, a sentence that names the requests the playbook serves. When the selected bundled playbook has a project extension, or the request matches a project's `when`, open the project playbook too. Copy the bundled steps into the todolist verbatim. Apply each project change at the step named by its quoted text. A change starts with `**After**`, `**Before**`, `**Replace**`, or `**In**`, followed by straight-quoted text copied from an extended playbook. + +Before using a project playbook, run `node /../tools/meta-mode/check-playbooks.mjs `. The checker reports missing bases, missing `when` fields, unanchored changes, and anchors that no longer occur after a mstack update. Report every line it prints. Do not guess where an unanchored change belongs. + Open a todolist whose first items are the matched playbook's steps, copied in verbatim, before any task-specific todos. A step you choose not to do stays in the list with a one-line `skip: `. Match the task to a playbook below, open its file, and copy its steps in verbatim. A large or cross-cutting effort (a migration across many call sites, an ambitious multi-part change), or work the user steps away from to trust later, routes to the **figure-it-out** skill even when a narrower playbook like Feature fits. Use **figure-it-out** whenever no bundled playbook fits. It designs a bespoke, rigorous playbook for the task. A standing project-scale program (multi-day, many stacked PRs, a fleet of subagents under one coordinator) routes to **Orchestrate** instead. figure-it-out designs one bespoke run, orchestrate runs the program. diff --git a/tools/meta-mode/README.md b/tools/meta-mode/README.md index c11f2af..3e6a515 100644 --- a/tools/meta-mode/README.md +++ b/tools/meta-mode/README.md @@ -5,6 +5,7 @@ This directory contains the optional Bun tools that support `meta-mode`: - `orch/` stores units, evidence, and gates for a long-running program. - `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. The tools are not required by the portable skill installer. Run them from this @@ -12,6 +13,8 @@ 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. + `worktree-audit.sh` 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; diff --git a/tools/meta-mode/check-playbooks.mjs b/tools/meta-mode/check-playbooks.mjs new file mode 100644 index 0000000..2920ce6 --- /dev/null +++ b/tools/meta-mode/check-playbooks.mjs @@ -0,0 +1,112 @@ +#!/usr/bin/env node + +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const BUNDLED = resolve(dirname(fileURLToPath(import.meta.url)), "../../skills/meta-mode/playbooks"); +const CHANGE = /^\s*(?:[-*]|\d+\.)\s+\*\*(?:After|Before|Replace|In)\*\*\s+"([^"]+)"/; +const CHANGE_VERB = /^\s*(?:[-*]|\d+\.)\s+\*\*(?:After|Before|Replace|In)\*\*/; + +function flat(text) { + return text.replace(/\s+/g, " "); +} + +function frontmatter(text) { + return text.match(/^---\n([\s\S]*?)\n---\n/)?.[1] ?? ""; +} + +function field(text, key) { + return frontmatter(text).match(new RegExp(`^${key}:[ \\t]*(.*)$`, "m"))?.[1].trim() ?? ""; +} + +function readPlaybook(root, stem) { + const path = join(root, `${stem}.md`); + return existsSync(path) ? readFileSync(path, "utf8").replaceAll("\r\n", "\n") : null; +} + +export function checkPlaybooks(root, bundled = BUNDLED) { + const directory = join(resolve(root), ".agents", "playbooks"); + if (!existsSync(directory)) return []; + + const problems = []; + for (const name of readdirSync(directory).filter((file) => file.endsWith(".md")).sort()) { + const relativePath = `.agents/playbooks/${name}`; + const text = readFileSync(join(directory, name), "utf8").replaceAll("\r\n", "\n"); + const when = field(text, "when"); + if (!when) problems.push(`${relativePath}: its frontmatter needs a "when:" line`); + + const bases = field(text, "extends") + .split(",") + .map((stem) => stem.trim()) + .filter(Boolean) + .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`); + } + + 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)}`); + } + 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`); + } + } + } + return problems; +} + +function parseArgs(args) { + const values = new Map(); + const positional = []; + 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 (values.has(arg)) throw new Error(`Duplicate option: ${arg}`); + const value = args[++index]; + if (!value || value.startsWith("--")) throw new Error(`${arg} requires a value`); + values.set(arg, value); + } else if (arg.startsWith("--")) { + throw new Error(`Unknown option: ${arg}`); + } else { + positional.push(arg); + } + } + if (positional.length > 1) throw new Error("Only one project root may be supplied"); + return { help: false, values, positional }; +} + +function directInvocation() { + if (!process.argv[1]) return false; + try { + return fileURLToPath(import.meta.url) === realpathSync(process.argv[1]); + } catch { + return false; + } +} + +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."); + 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; + } else { + console.log("Every project playbook matches this mstack's playbooks."); + } + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} From e401e1b35a960f8c358a02e32eed50ebc001478e Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:21:53 +0800 Subject: [PATCH 02/13] feat(meta-mode): make worktree audit cross-platform --- package.json | 1 + profiles/skill-manifest.json | 21 +-- scripts/worktree-audit.test.mjs | 36 +++-- .../meta-mode/playbooks/worktree-cleanup.md | 2 +- tools/meta-mode/README.md | 5 +- tools/meta-mode/worktree-audit.mjs | 134 ++++++++++++++++++ tools/meta-mode/worktree-audit.sh | 114 +-------------- tools/meta-mode/worktree-audit.test.sh | 49 ------- 8 files changed, 186 insertions(+), 176 deletions(-) create mode 100644 tools/meta-mode/worktree-audit.mjs delete mode 100644 tools/meta-mode/worktree-audit.test.sh 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/skill-manifest.json b/profiles/skill-manifest.json index e5ab41c..bf5a656 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -5,6 +5,11 @@ "mattpocock/skills": "6654f6b60cd9d5be8b54c6fafe44346dabeb3b76" }, "files": { + "scripts/worktree-audit.test.mjs": { + "upstream": null, + "reason": "Focused portability and safety behavior tests for the worktree audit.", + "targetDigest": "2b854de22c6029cd6746932b81be93d4eff23a29618d4a7624cf76b7f481b330" + }, "skills/architect/references/design-red-flags.md": { "upstream": "pstack", "source": "skills/architect/references/design-red-flags.md", @@ -326,7 +331,7 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/worktree-cleanup.md", "sourceDigest": "fc464dc4121926df1704f483c5feca83717c0a40b4651d3e11bd3aedd89441b6", - "targetDigest": "db0c8eeef5dc5054e79c1b3b8215159f48e12a4fb81225d551080f8773fe1b2c" + "targetDigest": "7963871a1ba7f31929502a864133fad4e20ac4ba62deafee38470f7a5e4e0332" }, "skills/meta-mode/references/bugbot-triage.md": { "upstream": "pstack", @@ -754,7 +759,7 @@ "tools/meta-mode/README.md": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "5a2dcf51ad2a816941a116b0e4c9c4e113c9d2a721a15f5ac8ef7a4b1a9afeaf" + "targetDigest": "b7efedcdd8565664138d949b97042cad0ebab90da570c8d36976e2dd270d9797" }, "tools/meta-mode/watch-pr/cli.test.ts": { "upstream": "pstack", @@ -840,17 +845,17 @@ "targetDigest": "d955603be6cc0e8b8ffcec722f635192b2261410b1f2929abea94480e47eb5d4", "reason": "Runtime tools are packaged separately from portable skill instructions." }, + "tools/meta-mode/worktree-audit.mjs": { + "upstream": null, + "reason": "Local cross-platform implementation replacing shell-specific audit behavior.", + "targetDigest": "f87b0b438dcd6d23c29512b3cc70af1a35049db841d5069298456bf11818281d" + }, "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/scripts/worktree-audit.test.mjs b/scripts/worktree-audit.test.mjs index 2b2e2de..50d314e 100644 --- a/scripts/worktree-audit.test.mjs +++ b/scripts/worktree-audit.test.mjs @@ -1,12 +1,32 @@ import assert from "node:assert/strict"; -import { spawnSync } from "node:child_process"; -import { resolve } from "node:path"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { 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); +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: "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("transcript fixtures can contain Unicode and spaces", () => { + const root = mkdtempSync(join(tmpdir(), "mstack-audit-")); + const path = join(root, "session file.jsonl"); + writeFileSync(path, JSON.stringify({ cwd: "C:/Users/Test User/工作 tree/" })); + assert.match(path, /session file/); }); diff --git a/skills/meta-mode/playbooks/worktree-cleanup.md b/skills/meta-mode/playbooks/worktree-cleanup.md index 52ed1e9..889be27 100644 --- a/skills/meta-mode/playbooks/worktree-cleanup.md +++ b/skills/meta-mode/playbooks/worktree-cleanup.md @@ -2,7 +2,7 @@ **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. diff --git a/tools/meta-mode/README.md b/tools/meta-mode/README.md index 3e6a515..39353c3 100644 --- a/tools/meta-mode/README.md +++ b/tools/meta-mode/README.md @@ -6,7 +6,8 @@ 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. The tools are not required by the portable skill installer. Run them from this directory with Bun after installing dependencies from `package.json`. Their @@ -15,7 +16,7 @@ inputs and outputs are deliberately separate from the canonical Run `node check-playbooks.mjs ` to validate `.agents/playbooks/` before using project extensions. Pass `--bundled ` to check against another installed skill tree. -`worktree-audit.sh` can include the latest session that touched a worktree when +`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 diff --git a/tools/meta-mode/worktree-audit.mjs b/tools/meta-mode/worktree-audit.mjs new file mode 100644 index 0000000..f67ac0e --- /dev/null +++ b/tools/meta-mode/worktree-audit.mjs @@ -0,0 +1,134 @@ +#!/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 : ""; +} +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 output = runGit(["status", "--porcelain=v1", "-z"], cwd); + 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; + const needle = worktree.replaceAll("\\", "/"); + 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 = readFileSync(path, "utf8").replaceAll("\\", "/"); + 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.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"; +} + +export function audit(repo = runGit(["rev-parse", "--show-toplevel"], process.cwd()).trim(), env = process.env) { + 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 + let prs = []; + const gh = spawnSync("gh", ["pr", "list", "--author", "@me", "--state", "all", "--limit", "1000", "--json", "number,state,headRefName"], { cwd: repo, encoding: "utf8" }); + if (gh.status === 0) { try { prs = JSON.parse(gh.stdout); } catch { prs = []; } } + 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 -} From 9285c85c1c50bc5a491feb8dfa46d761f133f864 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:15:37 +0800 Subject: [PATCH 03/13] feat(meta-mode): add durable resume checkpoints --- docs/guide/03-understand.md | 2 +- skills/meta-mode/playbooks/pause-safely.md | 2 +- skills/meta-mode/playbooks/session-pickup.md | 2 +- tools/meta-mode/README.md | 6 + tools/meta-mode/resume.mjs | 139 +++++++++++++++++++ tools/meta-mode/resume.test.mjs | 64 +++++++++ 6 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 tools/meta-mode/resume.mjs create mode 100644 tools/meta-mode/resume.test.mjs diff --git a/docs/guide/03-understand.md b/docs/guide/03-understand.md index 9fa1c6e..0e53119 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 tools/meta-mode/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/skills/meta-mode/playbooks/pause-safely.md b/skills/meta-mode/playbooks/pause-safely.md index 9d1c5f8..407b949 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 tools/meta-mode/resume.mjs begin --project . --note --artifact ` and `node tools/meta-mode/resume.mjs publish --project . --id `. The checkpoint is durable under Git's private metadata, 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..f6224b0 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 tools/meta-mode/resume.mjs read --project .`, then follow its note and artifact paths. 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/tools/meta-mode/README.md b/tools/meta-mode/README.md index 39353c3..5a1a4a0 100644 --- a/tools/meta-mode/README.md +++ b/tools/meta-mode/README.md @@ -8,6 +8,7 @@ This directory contains the optional Bun tools that support `meta-mode`: - `check-playbooks.mjs` validates project playbook extensions against the bundled playbooks. - `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 `.git/mstack/resume`. The tools are not required by the portable skill installer. Run them from this directory with Bun after installing dependencies from `package.json`. Their @@ -21,3 +22,8 @@ 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. The note and evidence paths +must be regular files inside the worktree. `begin` writes an unpublished draft; +`publish` validates those paths and atomically exposes the complete record; +`read` never returns drafts. Configure `user.name` and `user.email` before use. diff --git a/tools/meta-mode/resume.mjs b/tools/meta-mode/resume.mjs new file mode 100644 index 0000000..37963e6 --- /dev/null +++ b/tools/meta-mode/resume.mjs @@ -0,0 +1,139 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +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 in .git/mstack/resume and becomes visible only after 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; + const gitDir = join(resolve(git(input, ["rev-parse", "--absolute-git-dir"])), "mstack", "resume"); + let name = ""; + let email = ""; + try { name = git(input, ["config", "--get", "user.name"]); } catch { /* report the combined identity error below */ } + try { email = git(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 { input, root, worktree, gitDir, identity: { name, email } }; +} + +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 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 = options.id || `resume-${Date.now()}-${process.pid}`; + if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,96}$/.test(id)) throw new Error("Resume id must contain only letters, numbers, dot, underscore, or hyphen"); + 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 record = { version: 1, id, state: "draft", project: repo.root, worktree: repo.worktree, identity: repo.identity, note: relative(repo.root, note).replaceAll("\\", "/"), 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 dir = store(repo); + const draft = join(dir, `${options.id}.draft.json`); + if (!existsSync(draft)) throw new Error(`No draft resume found for id: ${options.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"); + const published = { ...record, state: "published", publishedAt: new Date().toISOString() }; + const target = join(dir, `${options.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 = store(repo); + let id = options.id; + if (!id) { + const entries = readdirSync(dir).filter((name) => name.endsWith(".json") && !name.endsWith(".draft.json")).sort(); + id = entries.at(-1)?.slice(0, -5); + } + if (!id) throw new Error("No published resume checkpoints found"); + 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"); + console.log(JSON.stringify({ ...record, path })); +} + +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..f7f2641 --- /dev/null +++ b/tools/meta-mode/resume.test.mjs @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import test from "node:test"; + +const script = resolve("tools/meta-mode/resume.mjs"); +function repo(t) { + const root = mkdtempSync(join(tmpdir(), "mstack-resume-")); + execFileSync("git", ["-C", root, "init", "-q"]); + execFileSync("git", ["-C", root, "config", "user.name", "Test User"]); + execFileSync("git", ["-C", root, "config", "user.email", "test@example.test"]); + writeFileSync(join(root, "note.md"), "next: verify the release\n"); + writeFileSync(join(root, "artifact.txt"), "evidence\n"); + t.after(() => rmSync(root, { recursive: true, force: true })); + return root; +} +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(root, ".git", "mstack", "resume", "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); + assert.equal(JSON.parse(read.stdout).artifacts[0], "artifact.txt"); +}); + +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/); + execFileSync("git", ["-C", 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/); + execFileSync("git", ["-C", 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("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/); +}); From 2679d92d73f808850489bce58997dfa854b73e65 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:21:53 +0800 Subject: [PATCH 04/13] Bind orch verification verdicts to patch identity --- skills/meta-mode/playbooks/orchestrate.md | 2 +- skills/meta-mode/playbooks/shipping.md | 4 +- tools/meta-mode/orch/orch.test.ts | 41 +++++++++++++-- tools/meta-mode/orch/orch.ts | 39 +++++++++++--- tools/meta-mode/orch/store.ts | 62 +++++++++++++++++------ 5 files changed, 119 insertions(+), 29 deletions(-) diff --git a/skills/meta-mode/playbooks/orchestrate.md b/skills/meta-mode/playbooks/orchestrate.md index ba6b27d..dea2696 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, base SHA, base branch, and stable patch-id: `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, base SHA, base branch, or patch-id voids the row, 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/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/tools/meta-mode/orch/orch.test.ts b/tools/meta-mode/orch/orch.test.ts index 11b4c7c..15f12ff 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,23 @@ 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("pushes, peeks, and atomically drains inbox pointers", async () => { @@ -563,7 +589,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 +699,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..294968f 100644 --- a/tools/meta-mode/orch/store.ts +++ b/tools/meta-mode/orch/store.ts @@ -15,7 +15,7 @@ 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"; const LOCK_FILE = ".orch.lock"; export type Verdict = @@ -38,6 +38,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 +139,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 +582,9 @@ 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( + return (await readTsv(join(store, "ledger.tsv"), LEDGER_HEADER, 9)).map( (row) => { - const rawVerdict = row[2] ?? ""; + const rawVerdict = row[5] ?? ""; const verdict = verdictOrNull(rawVerdict); if (verdict === null) { throw new UserError(`ledger.tsv has invalid verdict ${rawVerdict}`); @@ -583,10 +592,13 @@ 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] ?? "", }; } ); @@ -596,6 +608,9 @@ 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 +1007,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 +1423,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 +1447,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; From ef2b0c0f4590f01cd16a759d3e129878274b33d2 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:16:50 +0800 Subject: [PATCH 05/13] add portable PR safety helpers --- tools/meta-mode/README.md | 3 ++ tools/meta-mode/pr-safety.mjs | 74 ++++++++++++++++++++++++++++++ tools/meta-mode/pr-safety.test.mjs | 25 ++++++++++ 3 files changed, 102 insertions(+) create mode 100644 tools/meta-mode/pr-safety.mjs create mode 100644 tools/meta-mode/pr-safety.test.mjs diff --git a/tools/meta-mode/README.md b/tools/meta-mode/README.md index 5a1a4a0..4dc729e 100644 --- a/tools/meta-mode/README.md +++ b/tools/meta-mode/README.md @@ -9,6 +9,7 @@ This directory contains the optional Bun tools that support `meta-mode`: - `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 `.git/mstack/resume`. +- `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 @@ -17,6 +18,8 @@ inputs and outputs are deliberately separate from the canonical Run `node check-playbooks.mjs ` to validate `.agents/playbooks/` before using project extensions. Pass `--bundled ` to check against another installed skill tree. +`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; diff --git a/tools/meta-mode/pr-safety.mjs b/tools/meta-mode/pr-safety.mjs new file mode 100644 index 0000000..3d8774b --- /dev/null +++ b/tools/meta-mode/pr-safety.mjs @@ -0,0 +1,74 @@ +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(); } +} + +export function parseLandingRevision(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("landing revision must be an object"); + const required = ["owner", "repo", "number", "headRefOid", "baseRefName", "baseRefOid"]; + for (const key of required) if (typeof value[key] !== "string" && !(key === "number" && Number.isInteger(value[key]))) throw new TypeError(`invalid landing revision ${key}`); + if (value.number <= 0) throw new TypeError("invalid landing revision number"); + return { owner: value.owner, repo: value.repo, number: value.number, headRefOid: value.headRefOid, baseRefName: value.baseRefName, baseRefOid: value.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" || !head) throw new TypeError("merge requires a PR number and verified head SHA"); + if (forge === "gh") return ["pr", "merge", String(pr), "--match-head-commit", head]; + if (forge === "origin") return ["pr", "merge", String(pr), "--expected-head", head]; + 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..8a930d9 --- /dev/null +++ b/tools/meta-mode/pr-safety.test.mjs @@ -0,0 +1,25 @@ +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: "abc", baseRefName: "main", baseRefOid: "def" }); + assert.equal(sameLandingRevision(revision, { ...revision, owner: "owner", repo: "repo" }), true); + assert.equal(sameLandingRevision(revision, { ...revision, baseRefOid: "new" }), false); + assert.throws(() => assertLandingRevision(revision, { ...revision, headRefOid: "new" }), /changed/); +}); + +test("guarded merge always carries the verified head", () => { + assert.deepEqual(guardedMergeArgs({ pr: 7, head: "abc" }), ["pr", "merge", "7", "--match-head-commit", "abc"]); + assert.deepEqual(guardedMergeArgs({ forge: "origin", pr: 7, head: "abc" }), ["pr", "merge", "7", "--expected-head", "abc"]); + assert.throws(() => guardedMergeArgs({ pr: 7, head: "" }), /verified head/); +}); + +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); +}); From 4325b6789e1bce9fdcb55829ecbdf73ffc94f2fa Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:12:50 +0800 Subject: [PATCH 06/13] Add opt-in session context artifact --- README.md | 7 +++++- docs/session-context/SESSION-CONTEXT.md | 32 +++++++++++++++++++++++++ profiles/artifacts.json | 5 ++++ scripts/install.test.mjs | 29 ++++++++++++++++++++++ 4 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 docs/session-context/SESSION-CONTEXT.md diff --git a/README.md b/README.md index 2da941c..c94c199 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/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/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/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 { From cb19877ed9ff3c6cdaa6e3d0170f49b78f057785 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:12:14 +0800 Subject: [PATCH 07/13] feat(meta-mode): harden playbook checker output --- scripts/check-playbooks.test.mjs | 34 +++++++++++- tools/meta-mode/README.md | 2 +- tools/meta-mode/check-playbooks.mjs | 81 ++++++++++++++++++++++------- 3 files changed, 95 insertions(+), 22 deletions(-) diff --git a/scripts/check-playbooks.test.mjs b/scripts/check-playbooks.test.mjs index f0bd4f8..38ccf2e 100644 --- a/scripts/check-playbooks.test.mjs +++ b/scripts/check-playbooks.test.mjs @@ -4,7 +4,7 @@ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import test from "node:test"; -import { checkPlaybooks } from "../tools/meta-mode/check-playbooks.mjs"; +import { checkPlaybooks, checkPlaybooksDetailed } from "../tools/meta-mode/check-playbooks.mjs"; const script = resolve("tools/meta-mode/check-playbooks.mjs"); @@ -62,6 +62,38 @@ test("runs through a symlink and rejects unknown options", (t) => { assert.match(unknown.stderr, /Unknown option: --unknown/); }); +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/); +}); + test("leaves a repository without project playbooks valid", (t) => { const root = mkdtempSync(join(tmpdir(), "mstack-check-playbooks-empty-")); t.after(() => rmSync(root, { recursive: true, force: true })); diff --git a/tools/meta-mode/README.md b/tools/meta-mode/README.md index 4dc729e..f836797 100644 --- a/tools/meta-mode/README.md +++ b/tools/meta-mode/README.md @@ -16,7 +16,7 @@ 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`. `pr-safety.mjs` accepts both GitHub CLI (`gh`) and Origin-style guarded merge arguments; callers retain responsibility for authorization and post-merge readback. diff --git a/tools/meta-mode/check-playbooks.mjs b/tools/meta-mode/check-playbooks.mjs index 2920ce6..2c5cdf0 100644 --- a/tools/meta-mode/check-playbooks.mjs +++ b/tools/meta-mode/check-playbooks.mjs @@ -26,37 +26,71 @@ function readPlaybook(root, stem) { return existsSync(path) ? readFileSync(path, "utf8").replaceAll("\r\n", "\n") : null; } -export function checkPlaybooks(root, bundled = BUNDLED) { +function diagnostic(severity, message, code) { + return { severity, message, code }; +} + +export function checkPlaybooksDetailed(root, bundled = BUNDLED) { const directory = join(resolve(root), ".agents", "playbooks"); - if (!existsSync(directory)) return []; + if (!existsSync(directory)) return { diagnostics: [], errors: [], warnings: [] }; - 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 = readFileSync(join(directory, name), "utf8").replaceAll("\r\n", "\n"); 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) { @@ -65,7 +99,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`); @@ -93,18 +130,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; From 590d315318602b66e8e03b6038ddac98d2a9d9a4 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:25:06 +0800 Subject: [PATCH 08/13] chore(meta-mode): refresh integration baselines --- profiles/skill-manifest.json | 38 +++++++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 9 deletions(-) diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index bf5a656..5977965 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -271,13 +271,13 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/orchestrate.md", "sourceDigest": "d42b4375f1bd4ce23da0b575a198571a2117df7eea10c3c9fc6d92ae1053f7be", - "targetDigest": "c25dcc2662e15ccb91ff19fbe1a156b7fb97d7d49d0c3478dcd9f001e643e398" + "targetDigest": "eb7b5b4d8d3f6366546216d136afe6f3028cac3e31ac897a69af64aa463815b3" }, "skills/meta-mode/playbooks/pause-safely.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/pause-safely.md", "sourceDigest": "7d5fc10e144265f1b35f8157926541e738c1de64b6a85b3e61b51cf268c3dffd", - "targetDigest": "77968cfe5f4a71a8cd33e0ae8ffcdbd88dd693a212c240457773e6c40c96c21d" + "targetDigest": "0deee327d325a4c175614d3df581f388fbcbeb5c67dfc62a8ba517937a5b3338" }, "skills/meta-mode/playbooks/perf-issue.md": { "upstream": "pstack", @@ -307,13 +307,13 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/session-pickup.md", "sourceDigest": "1604fb463f7c7410d43fa024ca76c1c1824c166a01fd69d61f073d06fdca2a47", - "targetDigest": "97a3d7b0fcecf53c5c3fce9ec17f105196745086911c363d2dc7841bc56e40e2" + "targetDigest": "7ea205c7394469d61c9a4f234be7f7a426044412ecdb210bc08d43ef7cb554fe" }, "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", @@ -726,27 +726,27 @@ "tools/meta-mode/check-playbooks.mjs": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "847d83fe381880ec4d8a50b4fe942e503d7f611408343a8cd1f9207c45b45241" + "targetDigest": "f9d19c79e28ec3ca07626b2f963f2f2de8867c52ad2779f115c3620729a898cf" }, "tools/meta-mode/orch/orch.test.ts": { "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/orch.test.ts", "sourceDigest": "174b86d21ff51ccd28d1a8c938cb2e0c6fa287b209d4ca6c03932fcb1b53372e", - "targetDigest": "913d5c03ba456de547ec609458a2b14c5a3894d33385b0597ee44315252f870b", + "targetDigest": "c0077ee0b31d46a6a6b2b2f3a1391a947d44cd7d636f29969dd0c826fc79e722", "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": "9e2dbb4d8b41b004a50e2317e6c0fee13da366291821e517108cf323de4ddd65", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/package.json": { @@ -756,10 +756,30 @@ "targetDigest": "c7ac7fd69bb925415cdc15cd636567304ee40f8d665986e45ed8c0ee658f6caf", "reason": "Runtime tools are packaged separately from portable skill instructions." }, + "tools/meta-mode/pr-safety.mjs": { + "upstream": null, + "reason": "Portable meta-mode runtime tool and focused behavior tests.", + "targetDigest": "a5d4dc695f492ffe6526e367bcc81796e4549fc9e389752ac654aef68696a471" + }, + "tools/meta-mode/pr-safety.test.mjs": { + "upstream": null, + "reason": "Portable meta-mode runtime tool and focused behavior tests.", + "targetDigest": "b96660c5c672c882acbfa304ed20a5aa4250bc2fbabe345a9b71e6ab7fc41c53" + }, "tools/meta-mode/README.md": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "b7efedcdd8565664138d949b97042cad0ebab90da570c8d36976e2dd270d9797" + "targetDigest": "6957647e879f5cd85386a50fb4fa7fb9d83c6179f6e55d7b9e0d1a33f3912812" + }, + "tools/meta-mode/resume.mjs": { + "upstream": null, + "reason": "Portable meta-mode runtime tool and focused behavior tests.", + "targetDigest": "0a4b774716851e7d3f70fd2a62b38badcd027328647a68683f5e25bd0f421313" + }, + "tools/meta-mode/resume.test.mjs": { + "upstream": null, + "reason": "Portable meta-mode runtime tool and focused behavior tests.", + "targetDigest": "ff70ae1d2afcc6efe3866d66936cdb9991c70e50ce6c56cd6af3d1ff5b67b40a" }, "tools/meta-mode/watch-pr/cli.test.ts": { "upstream": "pstack", From ff718104e5775067a6ce54aa5b0c1c9d758dafec Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:37:02 +0800 Subject: [PATCH 09/13] fix(meta-mode): close hardening boundary gaps --- profiles/skill-manifest.json | 8 ++++---- tools/meta-mode/pr-safety.mjs | 25 +++++++++++++++++++++---- tools/meta-mode/pr-safety.test.mjs | 17 ++++++++++++++--- tools/meta-mode/resume.mjs | 19 ++++++++++++++----- tools/meta-mode/resume.test.mjs | 11 +++++++++++ 5 files changed, 64 insertions(+), 16 deletions(-) diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index 5977965..72d8621 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -759,12 +759,12 @@ "tools/meta-mode/pr-safety.mjs": { "upstream": null, "reason": "Portable meta-mode runtime tool and focused behavior tests.", - "targetDigest": "a5d4dc695f492ffe6526e367bcc81796e4549fc9e389752ac654aef68696a471" + "targetDigest": "12c7e37f6148146cf9e5e0cd647f363f01a57e5084627eec72b5e2170c743e77" }, "tools/meta-mode/pr-safety.test.mjs": { "upstream": null, "reason": "Portable meta-mode runtime tool and focused behavior tests.", - "targetDigest": "b96660c5c672c882acbfa304ed20a5aa4250bc2fbabe345a9b71e6ab7fc41c53" + "targetDigest": "c30334f2f837725fba92798d97250dea1dcab8846640a2da894e17135f2a2d33" }, "tools/meta-mode/README.md": { "upstream": null, @@ -774,12 +774,12 @@ "tools/meta-mode/resume.mjs": { "upstream": null, "reason": "Portable meta-mode runtime tool and focused behavior tests.", - "targetDigest": "0a4b774716851e7d3f70fd2a62b38badcd027328647a68683f5e25bd0f421313" + "targetDigest": "57c3b067f69ce36d30b9a6c5df0f8cbf2c0cef46777eb4d3c7e40b62f087ec72" }, "tools/meta-mode/resume.test.mjs": { "upstream": null, "reason": "Portable meta-mode runtime tool and focused behavior tests.", - "targetDigest": "ff70ae1d2afcc6efe3866d66936cdb9991c70e50ce6c56cd6af3d1ff5b67b40a" + "targetDigest": "2ed58fbbff1486f7ad0faf0550a825ad4648775b3a12743936247fdb2fbd54eb" }, "tools/meta-mode/watch-pr/cli.test.ts": { "upstream": "pstack", diff --git a/tools/meta-mode/pr-safety.mjs b/tools/meta-mode/pr-safety.mjs index 3d8774b..c06a3b8 100644 --- a/tools/meta-mode/pr-safety.mjs +++ b/tools/meta-mode/pr-safety.mjs @@ -20,12 +20,29 @@ export class WatchDeadline { assert() { if (this.remaining() === 0) throw new DeadlineExceeded(); } } +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, /^[0-9a-f]{40,64}$/i).toLowerCase(); +} + export function parseLandingRevision(value) { if (!value || typeof value !== "object" || Array.isArray(value)) throw new TypeError("landing revision must be an object"); - const required = ["owner", "repo", "number", "headRefOid", "baseRefName", "baseRefOid"]; - for (const key of required) if (typeof value[key] !== "string" && !(key === "number" && Number.isInteger(value[key]))) throw new TypeError(`invalid landing revision ${key}`); - if (value.number <= 0) throw new TypeError("invalid landing revision number"); - return { owner: value.owner, repo: value.repo, number: value.number, headRefOid: value.headRefOid, baseRefName: value.baseRefName, baseRefOid: value.baseRefOid }; + 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) { diff --git a/tools/meta-mode/pr-safety.test.mjs b/tools/meta-mode/pr-safety.test.mjs index 8a930d9..079c250 100644 --- a/tools/meta-mode/pr-safety.test.mjs +++ b/tools/meta-mode/pr-safety.test.mjs @@ -3,10 +3,21 @@ 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: "abc", baseRefName: "main", baseRefOid: "def" }); + 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: "new" }), false); - assert.throws(() => assertLandingRevision(revision, { ...revision, headRefOid: "new" }), /changed/); + 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", () => { diff --git a/tools/meta-mode/resume.mjs b/tools/meta-mode/resume.mjs index 37963e6..93aff12 100644 --- a/tools/meta-mode/resume.mjs +++ b/tools/meta-mode/resume.mjs @@ -48,6 +48,13 @@ function requireFile(path, label) { 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 }); @@ -68,8 +75,7 @@ function begin(repo, options) { requireFile(path, "Artifact"); return relative(repo.root, path).replaceAll("\\", "/"); }); - const id = options.id || `resume-${Date.now()}-${process.pid}`; - if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,96}$/.test(id)) throw new Error("Resume id must contain only letters, numbers, dot, underscore, or hyphen"); + 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}`); @@ -80,15 +86,16 @@ function begin(repo, options) { 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, `${options.id}.draft.json`); - if (!existsSync(draft)) throw new Error(`No draft resume found for id: ${options.id}`); + 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"); const published = { ...record, state: "published", publishedAt: new Date().toISOString() }; - const target = join(dir, `${options.id}.json`); + 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); @@ -98,11 +105,13 @@ function publish(repo, options) { function readResume(repo, options) { const dir = store(repo); let id = options.id; + if (id) id = resumeId(id); if (!id) { const entries = readdirSync(dir).filter((name) => name.endsWith(".json") && !name.endsWith(".draft.json")).sort(); id = entries.at(-1)?.slice(0, -5); } 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")); diff --git a/tools/meta-mode/resume.test.mjs b/tools/meta-mode/resume.test.mjs index f7f2641..d4d1389 100644 --- a/tools/meta-mode/resume.test.mjs +++ b/tools/meta-mode/resume.test.mjs @@ -55,6 +55,17 @@ test("rejects unsafe paths, missing identity, and unpublishable notes", (t) => { 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); From d3d18f93e95e78d27f9cc1f623afb03286e233f0 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:53:49 +0800 Subject: [PATCH 10/13] fix(meta-mode): store resume checkpoints in the common Git dir and verify files Checkpoints now live under the common Git directory keyed per worktree, so removing a linked worktree no longer deletes them and ids cannot collide. read picks the newest by publishedAt, hashes the referenced files and reports changed or missing ones, and only begin requires a Git identity. Playbooks and the guide call the tool through instead of a cwd-relative path. The test resolves the script from import.meta.url so it runs under bun test. Co-Authored-By: Claude Sonnet 5.5 --- docs/guide/03-understand.md | 2 +- skills/meta-mode/playbooks/pause-safely.md | 2 +- skills/meta-mode/playbooks/session-pickup.md | 2 +- tools/meta-mode/README.md | 19 +++- tools/meta-mode/resume.mjs | 75 ++++++++++++--- tools/meta-mode/resume.test.mjs | 99 ++++++++++++++++++-- 6 files changed, 170 insertions(+), 29 deletions(-) diff --git a/docs/guide/03-understand.md b/docs/guide/03-understand.md index 0e53119..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 first reads the latest project-local checkpoint with `node tools/meta-mode/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. +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/skills/meta-mode/playbooks/pause-safely.md b/skills/meta-mode/playbooks/pause-safely.md index 407b949..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. Store it in the repository worktree, then create and publish a checkpoint with `node tools/meta-mode/resume.mjs begin --project . --note --artifact ` and `node tools/meta-mode/resume.mjs publish --project . --id `. The checkpoint is durable under Git's private metadata, not a vendor-specific `/tmp` path. 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 f6224b0..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. First read the latest published checkpoint with `node tools/meta-mode/resume.mjs read --project .`, then follow its note and artifact paths. 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). +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/tools/meta-mode/README.md b/tools/meta-mode/README.md index f836797..7eee1cb 100644 --- a/tools/meta-mode/README.md +++ b/tools/meta-mode/README.md @@ -8,7 +8,7 @@ This directory contains the optional Bun tools that support `meta-mode`: - `check-playbooks.mjs` validates project playbook extensions against the bundled playbooks. - `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 `.git/mstack/resume`. +- `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 @@ -26,7 +26,16 @@ 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. The note and evidence paths -must be regular files inside the worktree. `begin` writes an unpublished draft; -`publish` validates those paths and atomically exposes the complete record; -`read` never returns drafts. Configure `user.name` and `user.email` before use. +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/resume.mjs b/tools/meta-mode/resume.mjs index 93aff12..e7ed311 100644 --- a/tools/meta-mode/resume.mjs +++ b/tools/meta-mode/resume.mjs @@ -1,6 +1,7 @@ #!/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"; @@ -11,7 +12,10 @@ const usage = `Usage: node tools/meta-mode/resume.mjs publish --project --id node tools/meta-mode/resume.mjs read --project [--id ] -A resume is drafted in .git/mstack/resume and becomes visible only after publish.`; +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 { @@ -26,13 +30,48 @@ function repository(project) { const input = resolve(project || process.cwd()); const root = resolve(git(input, ["rev-parse", "--show-toplevel"])); const worktree = root; - const gitDir = join(resolve(git(input, ["rev-parse", "--absolute-git-dir"])), "mstack", "resume"); + // 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(input, ["config", "--get", "user.name"]); } catch { /* report the combined identity error below */ } - try { email = git(input, ["config", "--get", "user.email"]); } catch { /* report the combined identity error below */ } + 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 { input, root, worktree, gitDir, identity: { name, email } }; + 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) { @@ -79,7 +118,8 @@ function begin(repo, options) { 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 record = { version: 1, id, state: "draft", project: repo.root, worktree: repo.worktree, identity: repo.identity, note: relative(repo.root, note).replaceAll("\\", "/"), artifacts, createdAt: new Date().toISOString() }; + 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 })); } @@ -94,7 +134,8 @@ function publish(repo, options) { 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"); - const published = { ...record, state: "published", publishedAt: new Date().toISOString() }; + // 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. @@ -103,12 +144,22 @@ function publish(repo, options) { } function readResume(repo, options) { - const dir = store(repo); + 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) { - const entries = readdirSync(dir).filter((name) => name.endsWith(".json") && !name.endsWith(".draft.json")).sort(); - id = entries.at(-1)?.slice(0, -5); + // 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); @@ -116,7 +167,9 @@ function readResume(repo, options) { 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"); - console.log(JSON.stringify({ ...record, path })); + 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) { diff --git a/tools/meta-mode/resume.test.mjs b/tools/meta-mode/resume.test.mjs index d4d1389..3740e1b 100644 --- a/tools/meta-mode/resume.test.mjs +++ b/tools/meta-mode/resume.test.mjs @@ -1,21 +1,34 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +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"; -const script = resolve("tools/meta-mode/resume.mjs"); +// 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 = mkdtempSync(join(tmpdir(), "mstack-resume-")); - execFileSync("git", ["-C", root, "init", "-q"]); - execFileSync("git", ["-C", root, "config", "user.name", "Test User"]); - execFileSync("git", ["-C", root, "config", "user.email", "test@example.test"]); + 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" }); } @@ -24,7 +37,7 @@ 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(root, ".git", "mstack", "resume", "handoff.draft.json"); + 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"); @@ -35,7 +48,10 @@ test("begin, publish, and read persist a useful checkpoint in Git storage", (t) assert.equal(record.note, "note.md"); const read = run(root, "read", "--id", "handoff"); assert.equal(read.status, 0, read.stderr); - assert.equal(JSON.parse(read.stdout).artifacts[0], "artifact.txt"); + 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) => { @@ -43,11 +59,11 @@ test("rejects unsafe paths, missing identity, and unpublishable notes", (t) => { let result = run(root, "begin", "--id", "bad", "--note", "../note.md"); assert.equal(result.status, 1); assert.match(result.stderr, /inside the project worktree/); - execFileSync("git", ["-C", root, "config", "user.email", ""]); + 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/); - execFileSync("git", ["-C", root, "config", "user.email", "test@example.test"]); + 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"); @@ -73,3 +89,66 @@ test("does not expose a draft through read", (t) => { 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/); +}); From 97c8aa88b62e9798dbafa0579628745cd97f8875 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:53:49 +0800 Subject: [PATCH 11/13] fix(meta-mode): fail closed in the worktree audit A failing git status now yields an explicit unknown dirty state classified as hold-unknown instead of clean. The transcript scan collapses runs of backslashes so JSONL-escaped Windows paths match. The test is replaced by an end-to-end run over a temporary repository, worktree, and fake transcripts. Co-Authored-By: Claude Sonnet 5.5 --- scripts/worktree-audit.test.mjs | 76 +++++++++++++++++-- .../meta-mode/playbooks/worktree-cleanup.md | 2 +- tools/meta-mode/worktree-audit.mjs | 28 +++++-- 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/scripts/worktree-audit.test.mjs b/scripts/worktree-audit.test.mjs index 50d314e..9d7e2b8 100644 --- a/scripts/worktree-audit.test.mjs +++ b/scripts/worktree-audit.test.mjs @@ -1,9 +1,41 @@ import assert from "node:assert/strict"; -import { mkdtempSync, writeFileSync } from "node:fs"; +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 { classify, parseWorktreePorcelain } from "../tools/meta-mode/worktree-audit.mjs"; +import { audit, classify, parseWorktreePorcelain } from "../tools/meta-mode/worktree-audit.mjs"; + +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([ @@ -17,6 +49,8 @@ test("parses NUL porcelain paths without shell or quote loss", () => { }); 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"); @@ -24,9 +58,37 @@ test("classification preserves safety gates", () => { assert.equal(classify({ dirty: "clean", pr: "-", recent: false, merged: false }), "review"); }); -test("transcript fixtures can contain Unicode and spaces", () => { - const root = mkdtempSync(join(tmpdir(), "mstack-audit-")); - const path = join(root, "session file.jsonl"); - writeFileSync(path, JSON.stringify({ cwd: "C:/Users/Test User/工作 tree/" })); - assert.match(path, /session file/); +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/worktree-cleanup.md b/skills/meta-mode/playbooks/worktree-cleanup.md index 889be27..ee5f0f4 100644 --- a/skills/meta-mode/playbooks/worktree-cleanup.md +++ b/skills/meta-mode/playbooks/worktree-cleanup.md @@ -5,7 +5,7 @@ 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/worktree-audit.mjs b/tools/meta-mode/worktree-audit.mjs index f67ac0e..64f2a69 100644 --- a/tools/meta-mode/worktree-audit.mjs +++ b/tools/meta-mode/worktree-audit.mjs @@ -9,6 +9,11 @@ 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. */ @@ -53,7 +58,9 @@ function humanSize(bytes) { } function age(timestamp, now) { return timestamp > 0 ? `${Math.max(0, Math.floor((now - timestamp) / 86400000))}d` : "?"; } function gitStatus(cwd) { - const output = runGit(["status", "--porcelain=v1", "-z"], 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; @@ -70,7 +77,9 @@ function remoteState(worktree, branch, head) { function latestTranscript(transcripts, worktree, now) { if (!transcripts || !existsSync(transcripts)) return { date: "-", recent: false }; let newest = 0; - const needle = worktree.replaceAll("\\", "/"); + // 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; } @@ -79,7 +88,7 @@ function latestTranscript(transcripts, worktree, now) { if (entry.isDirectory()) scan(path); else { try { - const text = readFileSync(path, "utf8").replaceAll("\\", "/"); + 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 */ } } @@ -92,6 +101,7 @@ function latestTranscript(transcripts, worktree, now) { } 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"; @@ -99,15 +109,19 @@ export function classify({ dirty, pr, recent, merged }) { return "review"; } -export function audit(repo = runGit(["rev-parse", "--show-toplevel"], process.cwd()).trim(), env = process.env) { +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 - let prs = []; - const gh = spawnSync("gh", ["pr", "list", "--author", "@me", "--state", "all", "--limit", "1000", "--json", "number,state,headRefName"], { cwd: repo, encoding: "utf8" }); - if (gh.status === 0) { try { prs = JSON.parse(gh.stdout); } catch { prs = []; } } + const prs = listPrs(repo); const now = Date.now(); const main = worktrees[0]?.path; return worktrees.slice(1).map((wt) => { From e8ee7aa335637b8af4ab3f5a8aef72876c8e32e7 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:53:49 +0800 Subject: [PATCH 12/13] fix(meta-mode): accept legacy ledgers and require a full head oid for merges orch reads a legacy ledger.tsv header as unbound rows (never verified) and migrates it on the next write. The orchestrate playbook now states the real (pr, head sha) key. guardedMergeArgs validates head as a full object id. Co-Authored-By: Claude Sonnet 5.5 --- skills/meta-mode/playbooks/orchestrate.md | 2 +- tools/meta-mode/orch/orch.test.ts | 35 +++++++++++++++++++++++ tools/meta-mode/orch/store.ts | 24 +++++++++++++--- tools/meta-mode/pr-safety.mjs | 11 ++++--- tools/meta-mode/pr-safety.test.mjs | 7 +++-- 5 files changed, 67 insertions(+), 12 deletions(-) diff --git a/skills/meta-mode/playbooks/orchestrate.md b/skills/meta-mode/playbooks/orchestrate.md index dea2696..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, base SHA, base branch, and stable patch-id: `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, base SHA, base branch, or patch-id voids the row, 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. +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/tools/meta-mode/orch/orch.test.ts b/tools/meta-mode/orch/orch.test.ts index 15f12ff..db60a0b 100644 --- a/tools/meta-mode/orch/orch.test.ts +++ b/tools/meta-mode/orch/orch.test.ts @@ -329,6 +329,41 @@ describe("Store", () => { ).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 () => { const { directory, store } = await initializedStore(); diff --git a/tools/meta-mode/orch/store.ts b/tools/meta-mode/orch/store.ts index 294968f..dc71fa6 100644 --- a/tools/meta-mode/orch/store.ts +++ b/tools/meta-mode/orch/store.ts @@ -16,6 +16,8 @@ import { basename, dirname, join, resolve } from "node:path"; const UNIT_HEADER = "id\ttrack\tstate\tbranch\tpr\tsha\tbrief"; 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 = @@ -582,8 +584,23 @@ async function saveUnits(store: string, rows: readonly Unit[]): Promise { } async function readLedger(store: string): Promise { - return (await readTsv(join(store, "ledger.tsv"), LEDGER_HEADER, 9)).map( - (row) => { + 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) { @@ -600,8 +617,7 @@ async function readLedger(store: string): Promise { verifier: row[7] ?? "", ts: row[8] ?? "", }; - } - ); + }); } function ledgerCells(row: LedgerEntry): readonly string[] { diff --git a/tools/meta-mode/pr-safety.mjs b/tools/meta-mode/pr-safety.mjs index c06a3b8..67848bd 100644 --- a/tools/meta-mode/pr-safety.mjs +++ b/tools/meta-mode/pr-safety.mjs @@ -20,6 +20,8 @@ export class WatchDeadline { 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}`); @@ -29,7 +31,7 @@ function requiredText(value, key, pattern = null) { } function oid(value, key) { - return requiredText(value, key, /^[0-9a-f]{40,64}$/i).toLowerCase(); + return requiredText(value, key, OID).toLowerCase(); } export function parseLandingRevision(value) { @@ -56,9 +58,10 @@ export function assertLandingRevision(expected, actual) { } export function guardedMergeArgs({ forge = "gh", pr, head }) { - if (!Number.isInteger(pr) || pr <= 0 || typeof head !== "string" || !head) throw new TypeError("merge requires a PR number and verified head SHA"); - if (forge === "gh") return ["pr", "merge", String(pr), "--match-head-commit", head]; - if (forge === "origin") return ["pr", "merge", String(pr), "--expected-head", 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}`); } diff --git a/tools/meta-mode/pr-safety.test.mjs b/tools/meta-mode/pr-safety.test.mjs index 079c250..896f369 100644 --- a/tools/meta-mode/pr-safety.test.mjs +++ b/tools/meta-mode/pr-safety.test.mjs @@ -21,9 +21,10 @@ test("rejects incomplete landing revisions at the boundary", () => { }); test("guarded merge always carries the verified head", () => { - assert.deepEqual(guardedMergeArgs({ pr: 7, head: "abc" }), ["pr", "merge", "7", "--match-head-commit", "abc"]); - assert.deepEqual(guardedMergeArgs({ forge: "origin", pr: 7, head: "abc" }), ["pr", "merge", "7", "--expected-head", "abc"]); - assert.throws(() => guardedMergeArgs({ pr: 7, head: "" }), /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", () => { From 26857039638e1a1bacaeb5e919a63dccec0436e6 Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:53:49 +0800 Subject: [PATCH 13/13] chore(meta-mode): refresh upstream and skill manifests Co-Authored-By: Claude Sonnet 5.5 --- profiles/skill-manifest.json | 43 ++++++++++++++++++--------------- profiles/upstream-manifest.json | 5 ++++ 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index bff1045..5977d7a 100644 --- a/profiles/skill-manifest.json +++ b/profiles/skill-manifest.json @@ -5,11 +5,6 @@ "mattpocock/skills": "6654f6b60cd9d5be8b54c6fafe44346dabeb3b76" }, "files": { - "scripts/worktree-audit.test.mjs": { - "upstream": null, - "reason": "Focused portability and safety behavior tests for the worktree audit.", - "targetDigest": "2b854de22c6029cd6746932b81be93d4eff23a29618d4a7624cf76b7f481b330" - }, "skills/architect/references/design-red-flags.md": { "upstream": "pstack", "source": "skills/architect/references/design-red-flags.md", @@ -271,13 +266,13 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/orchestrate.md", "sourceDigest": "d42b4375f1bd4ce23da0b575a198571a2117df7eea10c3c9fc6d92ae1053f7be", - "targetDigest": "eb7b5b4d8d3f6366546216d136afe6f3028cac3e31ac897a69af64aa463815b3" + "targetDigest": "01d014c9693e4214ef56ab95f0bbc864a47efddcdcb53eda629319cb63766c3f" }, "skills/meta-mode/playbooks/pause-safely.md": { "upstream": "pstack", "source": "skills/poteto-mode/playbooks/pause-safely.md", "sourceDigest": "7d5fc10e144265f1b35f8157926541e738c1de64b6a85b3e61b51cf268c3dffd", - "targetDigest": "0deee327d325a4c175614d3df581f388fbcbeb5c67dfc62a8ba517937a5b3338" + "targetDigest": "50f1fa76fcdbf92db5a772a0470a128850c960c9eb68156846cc4f5a503f863b" }, "skills/meta-mode/playbooks/perf-issue.md": { "upstream": "pstack", @@ -307,7 +302,7 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/session-pickup.md", "sourceDigest": "1604fb463f7c7410d43fa024ca76c1c1824c166a01fd69d61f073d06fdca2a47", - "targetDigest": "7ea205c7394469d61c9a4f234be7f7a426044412ecdb210bc08d43ef7cb554fe" + "targetDigest": "5bbf52a3e43524aca7f474962a9e9a6a25d8cd2cc96569dcddccd5bf35466ac8" }, "skills/meta-mode/playbooks/shipping.md": { "upstream": "pstack", @@ -331,7 +326,7 @@ "upstream": "pstack", "source": "skills/poteto-mode/playbooks/worktree-cleanup.md", "sourceDigest": "fc464dc4121926df1704f483c5feca83717c0a40b4651d3e11bd3aedd89441b6", - "targetDigest": "7963871a1ba7f31929502a864133fad4e20ac4ba62deafee38470f7a5e4e0332" + "targetDigest": "7a13c4a1e27e084f9bb9ce9f7c998d5584efeb3e4bfa15a6422f261f9db0bc77" }, "skills/meta-mode/references/bugbot-triage.md": { "upstream": "pstack", @@ -726,13 +721,13 @@ "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": "c0077ee0b31d46a6a6b2b2f3a1391a947d44cd7d636f29969dd0c826fc79e722", + "targetDigest": "821c2cd328e441590bb236733ca48a6f889e13c7c49447c5a0c170ac999af5a5", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/orch/orch.ts": { @@ -746,7 +741,7 @@ "upstream": "pstack", "source": "skills/poteto-mode/scripts/orch/store.ts", "sourceDigest": "0eae7cf69282e827b1227166f2e32cee3560f2f3e65b3f1f2faf6fb83d3b4c5b", - "targetDigest": "9e2dbb4d8b41b004a50e2317e6c0fee13da366291821e517108cf323de4ddd65", + "targetDigest": "c879408105d96814345f9e15f9a909505fec1374036c01e58820a55374a194bb", "reason": "Runtime tools are packaged separately from portable skill instructions." }, "tools/meta-mode/package.json": { @@ -758,18 +753,28 @@ }, "tools/meta-mode/pr-safety.mjs": { "upstream": null, - "reason": "Portable meta-mode runtime tool and focused behavior tests.", - "targetDigest": "12c7e37f6148146cf9e5e0cd647f363f01a57e5084627eec72b5e2170c743e77" + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "1ce77cecdcb4be6c9622257f2e2efac790e47fbd35b1fd8dd93a4d7ef123f2cf" }, "tools/meta-mode/pr-safety.test.mjs": { "upstream": null, - "reason": "Portable meta-mode runtime tool and focused behavior tests.", - "targetDigest": "c30334f2f837725fba92798d97250dea1dcab8846640a2da894e17135f2a2d33" + "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", @@ -857,8 +862,8 @@ }, "tools/meta-mode/worktree-audit.mjs": { "upstream": null, - "reason": "Local cross-platform implementation replacing shell-specific audit behavior.", - "targetDigest": "f87b0b438dcd6d23c29512b3cc70af1a35049db841d5069298456bf11818281d" + "reason": "Local portability guidance or implementation; review alongside its owning skill.", + "targetDigest": "ee7946ec0558ddeeb7466003d120124c6c884218f05d51e920c90d4fc010c20c" }, "tools/meta-mode/worktree-audit.sh": { "upstream": "pstack", 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",