From 8ecde50312fe2edf975e9e0a3d4aa4174d4f29c7 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 1/2] 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 a6c1b917320a4939791ac2d163831ae4d035ca1c Mon Sep 17 00:00:00 2001 From: 3metaJun <251347867+3metaJun@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:42:54 +0800 Subject: [PATCH 2/2] fix(meta-mode): harden playbook checker and align its docs - Fail on a missing project root or bundled directory instead of passing. - Accept BOM and unterminated frontmatter; resolve extends stems case-sensitively and only inside the bundled directory. - Use the placeholder and --bundled in the skill command so it works with an artifact destination override; document list-item changes. - Resolve the test script from the file location, skip the symlink test when symlinks need privileges, and cover the default bundled path. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 6 ++-- profiles/skill-manifest.json | 4 +-- profiles/upstream-manifest.json | 2 +- scripts/check-playbooks.test.mjs | 53 +++++++++++++++++++++++++---- skills/meta-mode/SKILL.md | 4 +-- tools/meta-mode/check-playbooks.mjs | 17 ++++++--- 6 files changed, 66 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 2da941c..4fec6ae 100644 --- a/README.md +++ b/README.md @@ -469,9 +469,9 @@ 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. +must be list items that 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: diff --git a/profiles/skill-manifest.json b/profiles/skill-manifest.json index e5ab41c..c434ea2 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": "7b536c7484e19cf426e3f382ee58143678bb8c94c1bab33b0b89f1f9b2d62d96" + "targetDigest": "32363f67837dfe03bc5fd1b58ba940f2cfde2dfe46bce808416ad7cd4f63fd90" }, "skills/no-comments/SKILL.md": { "upstream": "pstack", @@ -721,7 +721,7 @@ "tools/meta-mode/check-playbooks.mjs": { "upstream": null, "reason": "Local portability guidance or implementation; review alongside its owning skill.", - "targetDigest": "847d83fe381880ec4d8a50b4fe942e503d7f611408343a8cd1f9207c45b45241" + "targetDigest": "3a753d840c391cbf93979d6d474bc3965b387bc507de6925420ca78ff5db6aaf" }, "tools/meta-mode/orch/orch.test.ts": { "upstream": "pstack", diff --git a/profiles/upstream-manifest.json b/profiles/upstream-manifest.json index dd047fe..80755ef 100644 --- a/profiles/upstream-manifest.json +++ b/profiles/upstream-manifest.json @@ -109,7 +109,7 @@ }, "meta-mode": { "source": "d5817d996f6da294c86cacb2bd1b0c40c85968844a6afd194fe69efa8654878f", - "target": "7b536c7484e19cf426e3f382ee58143678bb8c94c1bab33b0b89f1f9b2d62d96" + "target": "32363f67837dfe03bc5fd1b58ba940f2cfde2dfe46bce808416ad7cd4f63fd90" }, "no-comments": { "source": "5c5b0882297d704c3a9720c52b7a793c68b013eaf717989f0945624efdfe2b05", diff --git a/scripts/check-playbooks.test.mjs b/scripts/check-playbooks.test.mjs index f0bd4f8..2bbc48e 100644 --- a/scripts/check-playbooks.test.mjs +++ b/scripts/check-playbooks.test.mjs @@ -2,11 +2,13 @@ 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 { dirname, join, resolve } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { checkPlaybooks } from "../tools/meta-mode/check-playbooks.mjs"; -const script = resolve("tools/meta-mode/check-playbooks.mjs"); +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const script = join(repoRoot, "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-")); @@ -47,19 +49,56 @@ test("reports missing bases, stale anchors, missing when fields, and unquoted ch assert.match(result.stderr, /broken\.md: a change has no straight-quoted step text/); }); -test("runs through a symlink and rejects unknown options", (t) => { +test("runs through a symlink", (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/); + try { + symlinkSync(script, entry); + } catch (error) { + if (error.code === "EPERM") return t.skip("creating symlinks needs privileges on this system"); + throw error; + } + const result = spawnSync(process.execPath, [entry, root, "--bundled", bundledRoot], { encoding: "utf8" }); + assert.equal(result.status, 1); + assert.match(result.stderr, /shipping-v2.*no playbook/); +}); +test("rejects unknown options and a missing project root", (t) => { + const { root, bundledRoot } = fixture(t, {}); const unknown = run(root, bundledRoot, "--unknown"); assert.equal(unknown.status, 1); assert.match(unknown.stderr, /Unknown option: --unknown/); + + const missing = run(join(root, "typo"), bundledRoot); + assert.equal(missing.status, 1); + assert.match(missing.stderr, /Project root is not a directory/); +}); + +test("parses BOM and unterminated frontmatter and keeps bases inside the bundled directory", (t) => { + const { root, bundledRoot } = fixture(t, { + "bom.md": "---\nwhen: Use it.\n---\nbody\n", + "bare.md": "---\nwhen: Use it.\n---", + "case.md": "---\nextends: Bug-Fix\nwhen: Use it.\n---\n", + "escape.md": "---\nextends: ../.agents/playbooks/bom\nwhen: Use it.\n---\n", + }); + assert.deepEqual(checkPlaybooks(root, bundledRoot), [ + ".agents/playbooks/case.md: extends `Bug-Fix`, which this mstack has no playbook for", + ".agents/playbooks/escape.md: extends `../.agents/playbooks/bom`, which this mstack has no playbook for", + ]); +}); + +test("validates against the playbooks bundled with this checkout by default", (t) => { + const root = mkdtempSync(join(tmpdir(), "mstack-check-playbooks-bundled-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + mkdirSync(join(root, ".agents", "playbooks"), { recursive: true }); + writeFileSync( + join(root, ".agents", "playbooks", "bug-fix.md"), + '---\nextends: bug-fix\nwhen: Use it for bugs.\n---\n- **After** "Binary-search the cause." run the profiler.\n', + ); + const result = spawnSync(process.execPath, [script, root], { encoding: "utf8" }); + assert.equal(result.status, 0, result.stderr); }); test("leaves a repository without project playbooks valid", (t) => { diff --git a/skills/meta-mode/SKILL.md b/skills/meta-mode/SKILL.md index 4332cce..4e60127 100644 --- a/skills/meta-mode/SKILL.md +++ b/skills/meta-mode/SKILL.md @@ -167,9 +167,9 @@ 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. +**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 is a list item that 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. +Before using a project playbook, run `node "/check-playbooks.mjs" --bundled "/meta-mode/playbooks" `. 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. diff --git a/tools/meta-mode/check-playbooks.mjs b/tools/meta-mode/check-playbooks.mjs index 2920ce6..4993bf4 100644 --- a/tools/meta-mode/check-playbooks.mjs +++ b/tools/meta-mode/check-playbooks.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -14,26 +14,33 @@ function flat(text) { } function frontmatter(text) { - return text.match(/^---\n([\s\S]*?)\n---\n/)?.[1] ?? ""; + return text.match(/^---\n([\s\S]*?)\n---(?:\n|$)/)?.[1] ?? ""; +} + +function readText(path) { + return readFileSync(path, "utf8").replace(/^/, "").replaceAll("\r\n", "\n"); } function field(text, key) { return frontmatter(text).match(new RegExp(`^${key}:[ \\t]*(.*)$`, "m"))?.[1].trim() ?? ""; } +// Match against the directory listing so a stem resolves the same on case-insensitive and case-sensitive filesystems and cannot escape the directory. function readPlaybook(root, stem) { - const path = join(root, `${stem}.md`); - return existsSync(path) ? readFileSync(path, "utf8").replaceAll("\r\n", "\n") : null; + const file = `${stem}.md`; + return readdirSync(root).includes(file) ? readText(join(root, file)) : null; } export function checkPlaybooks(root, bundled = BUNDLED) { + if (!existsSync(root) || !statSync(root).isDirectory()) throw new Error(`Project root is not a directory: ${root}`); const directory = join(resolve(root), ".agents", "playbooks"); if (!existsSync(directory)) return []; + if (!existsSync(bundled) || !statSync(bundled).isDirectory()) throw new Error(`Bundled playbooks directory not found: ${bundled}`); 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 text = readText(join(directory, name)); const when = field(text, "when"); if (!when) problems.push(`${relativePath}: its frontmatter needs a "when:" line`);