From 3ec1a64eba34943ca77756a62d9abf550c95b009 Mon Sep 17 00:00:00 2001 From: Rakvadium Date: Fri, 16 Oct 2026 15:39:51 -0600 Subject: [PATCH] feat(odd-mode): keep the todo list in .odd-todo.md without a todo tool Threads in harnesses with no todo tool kept the playbook list in chat, where nothing checked it. Without a todo tool the list now lives in an untracked .odd-todo.md, and check-todo.mjs runs before every PR. It fails when the file is missing or tracked, or when a playbook step is neither done nor skipped with a reason. --- skills/odd-mode/SKILL.md | 2 +- skills/odd-mode/playbooks/opening-a-pr.md | 2 + skills/odd-mode/scripts/check-todo.mjs | 75 ++++++++++++++++ skills/odd-mode/scripts/check-todo.test.mjs | 97 +++++++++++++++++++++ 4 files changed, 175 insertions(+), 1 deletion(-) create mode 100644 skills/odd-mode/scripts/check-todo.mjs create mode 100644 skills/odd-mode/scripts/check-todo.test.mjs diff --git a/skills/odd-mode/SKILL.md b/skills/odd-mode/SKILL.md index 681ea21..5794c28 100644 --- a/skills/odd-mode/SKILL.md +++ b/skills/odd-mode/SKILL.md @@ -125,7 +125,7 @@ Comments follow the same rule as the reply. Write them clean as you go. Keep a c ## Playbooks -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. +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. Use the harness's todo tool when it has one. Without one, keep the list in `.odd-todo.md` at the worktree root and never commit it. Add `.odd-todo.md` to the file `git rev-parse --git-path info/exclude` prints. Start the file with one `Playbook: playbooks/.md` line per matched playbook, then a markdown checklist. Copy each step's first line as `- [ ] `, with or without its number. Mark a done step `- [x]`. A skipped step keeps its line and ends with `skip: `. 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/skills/odd-mode/playbooks/opening-a-pr.md b/skills/odd-mode/playbooks/opening-a-pr.md index b3b393d..39232c9 100644 --- a/skills/odd-mode/playbooks/opening-a-pr.md +++ b/skills/odd-mode/playbooks/opening-a-pr.md @@ -4,6 +4,8 @@ Invoked at the end of every other playbook. **Worktree.** Work from a git worktree off main. Subagents inherit it. Multiple `Task` calls on the same branch each get their own worktree, or `git fetch && git reset --hard origin/` between them. Dirty branch with unrelated work: patch out, fresh worktree, apply. Snarled worktree: reset from main, redo minimally. +**Todo file.** When the thread keeps its list in `.odd-todo.md`, run `node /scripts/check-todo.mjs` from the worktree before you create the PR. Fix every line it prints first. + **Commits.** Commit liberally. Rebase into small, ordered commits before opening PRs. Each commit is a future PR: landable, ordered to tell the story. Amend when the fix belongs in a just-made commit. New commit when separable. **Branches.** Push only the PR's head branch. Never push backup, scratch, wip, or delegate branches. Delegate worktree branches stay local. The lead integrates their commits. diff --git a/skills/odd-mode/scripts/check-todo.mjs b/skills/odd-mode/scripts/check-todo.mjs new file mode 100644 index 0000000..356bff3 --- /dev/null +++ b/skills/odd-mode/scripts/check-todo.mjs @@ -0,0 +1,75 @@ +#!/usr/bin/env node +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; + +const SKILL_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const STEP = /^(\d+)\. (.*)$/; +const ITEM = /^\s*- \[([ x])\] (?:\d+\. )?(.*)$/; +// The check runs inside Opening a PR, so any step that runs it is still open. +const EXEMPT = "Run **Opening a PR**"; + +const git = (args, cwd) => spawnSync("git", args, { cwd, encoding: "utf8" }); + +let file = process.argv[2]; +if (!file) { + const top = git(["rev-parse", "--show-toplevel"], process.cwd()); + if (top.status !== 0) { + console.error("check-todo: not in a git repository and no file given"); + process.exit(1); + } + file = path.join(top.stdout.trim(), ".odd-todo.md"); +} +const display = path.relative(process.cwd(), path.resolve(file)) || file; + +if (!fs.existsSync(file)) { + console.error(`${display}: missing`); + process.exit(1); +} + +const lines = fs.readFileSync(file, "utf8").split(/\r?\n/); +const problems = []; + +const fileDir = path.dirname(path.resolve(file)); +if (git(["ls-files", "--error-unmatch", "--", path.basename(file)], fileDir).status === 0) { + problems.push(`${display}: tracked by git, untrack it and add it to info/exclude`); +} + +const playbooks = lines.map((line) => /^Playbook:\s*(\S.*?)\s*$/.exec(line)?.[1]).filter(Boolean); +if (playbooks.length === 0) problems.push(`${display}: no "Playbook:" line`); + +const items = lines.map((line) => ITEM.exec(line)).filter(Boolean).map((m) => ({ done: m[1] === "x", text: m[2] })); +const short = (text) => (text.length > 30 ? `${text.slice(0, 30).trimEnd()}...` : text); + +let checked = 0; +for (const playbook of playbooks) { + const playbookPath = path.resolve(SKILL_DIR, playbook); + if (!fs.existsSync(playbookPath)) { + problems.push(`${display}: ${playbook} does not exist in ${SKILL_DIR}`); + continue; + } + const steps = fs + .readFileSync(playbookPath, "utf8") + .split(/\r?\n/) + .map((line) => STEP.exec(line)) + .filter(Boolean) + .map((m) => ({ n: m[1], text: m[2].trim() })) + .filter((step) => !step.text.includes(EXEMPT)); + for (const step of steps) { + checked++; + const matches = items.filter((item) => item.text.startsWith(step.text)); + const where = `${display}: ${playbook} step ${step.n}`; + if (matches.length === 0) { + problems.push(`${where} has no checklist item: "${short(step.text)}"`); + } else if (!matches.some((item) => item.done || /skip:\s*\S/.test(item.text.slice(step.text.length)))) { + problems.push(`${where} not done or skipped: "${short(step.text)}"`); + } + } +} + +for (const p of problems) console.error(p); +if (problems.length) process.exit(1); + +console.log(`${display}: ${checked} playbook steps done or skipped`); diff --git a/skills/odd-mode/scripts/check-todo.test.mjs b/skills/odd-mode/scripts/check-todo.test.mjs new file mode 100644 index 0000000..6400723 --- /dev/null +++ b/skills/odd-mode/scripts/check-todo.test.mjs @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { after, test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const script = path.join(here, "check-todo.mjs"); +const readSteps = (playbook) => + fs + .readFileSync(path.join(here, "..", "playbooks", playbook), "utf8") + .split(/\r?\n/) + .filter((line) => /^\d+\. /.test(line)); +const steps = readSteps("authoring-a-skill.md"); +const opening = steps.findIndex((step) => step.includes("Run **Opening a PR**")); +const summary = (checked) => `.odd-todo.md: ${checked} playbook steps done or skipped`; + +const dirs = []; +after(() => { + for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 }); +}); + +const run = (lines, { commit = false } = {}) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "check-todo-")); + dirs.push(dir); + execFileSync("git", ["init", "-q"], { cwd: dir }); + if (lines) fs.writeFileSync(path.join(dir, ".odd-todo.md"), lines.join("\r\n")); + if (commit) { + execFileSync("git", ["add", ".odd-todo.md"], { cwd: dir }); + execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "todo"], { cwd: dir }); + } + const result = spawnSync(process.execPath, [script], { cwd: dir, encoding: "utf8" }); + return { code: result.status, out: result.stdout + result.stderr }; +}; + +const todo = (mark, playbook = "authoring-a-skill.md", list = steps) => [ + `Playbook: playbooks/${playbook}`, + "", + ...list.map((step, i) => mark(step, i)), +]; +const allDone = todo((step) => `- [x] ${step}`); + +test("missing file fails", () => { + const { code, out } = run(null); + assert.equal(code, 1); + assert.match(out, /\.odd-todo\.md: missing/); +}); + +test("unaccounted step fails and names it", () => { + const { code, out } = run(todo((step, i) => (i === 1 ? "- [x] something else" : `- [x] ${step}`))); + assert.equal(code, 1); + assert.ok(out.includes("playbooks/authoring-a-skill.md step 2 has no checklist item"), out); +}); + +test("skip with a reason passes", () => { + const { code, out } = run(todo((step, i) => (i === 0 ? `- [ ] ${step} skip: not installed` : `- [x] ${step}`))); + assert.equal(code, 0, out); + assert.ok(out.includes(summary(steps.length - 1)), out); +}); + +test("skip with an empty reason fails", () => { + const { code, out } = run(todo((step, i) => (i === 0 ? `- [ ] ${step} skip: ` : `- [x] ${step}`))); + assert.equal(code, 1); + assert.ok(out.includes("step 1 not done or skipped"), out); +}); + +test("all done passes", () => { + const { code, out } = run(allDone); + assert.equal(code, 0, out); + assert.ok(out.includes(summary(steps.length - 1)), out); +}); + +test("tracked file fails", () => { + const { code, out } = run(allDone, { commit: true }); + assert.equal(code, 1); + assert.ok(out.includes("tracked by git"), out); +}); + +test("unchecked Opening a PR step passes", () => { + assert.notEqual(opening, -1); + const { code, out } = run(todo((step, i) => `- [${i === opening ? " " : "x"}] ${step}`)); + assert.equal(code, 0, out); + assert.ok(out.includes(summary(steps.length - 1)), out); +}); + +test("unchecked step that runs Opening a PR mid-step passes", () => { + const refactoring = readSteps("refactoring.md"); + const last = refactoring.length - 1; + assert.ok(refactoring[last].startsWith("8. ") && refactoring[last].includes("Run **Opening a PR**")); + const { code, out } = run( + todo((step, i) => `- [${i === last ? " " : "x"}] ${step}`, "refactoring.md", refactoring), + ); + assert.equal(code, 0, out); + assert.ok(out.includes(summary(refactoring.length - 1)), out); +});