Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ resolves the target repository from that checkout's GitHub remote.
| `plan.mjs claim <issue>` | Take single-writer ownership of an existing plan: assign the acting login, stay idempotent when it already owns the plan, and refuse when another login does. |
| `plan.mjs show <issue> [--body]` | Print the header strip, then `reviews: plan=<pass\|repair\|blocked\|none> code=<pass\|fixes-required\|blocked\|none>`. With `--body`, print only the body to stdout and send both metadata lines to stderr, header first. |
| `plan.mjs export <issue>` | Write the issue body verbatim to `plan-<issue>.md` inside the scratch directory `git rev-parse --git-path docks-review` resolves, creating it mode 0700 when missing, and print the absolute path. |
| `plan.mjs edit <issue> --file <path>` | Validate the file as the plan record, refuse on any failed check or any regression of a matching terminal step to a different non-terminal status, instruct re-export, replace the issue body, and print the header strip and the changed lines. |
| `plan.mjs edit <issue> --file <path>` | Validate the file as the plan record and enforce the irreversible execution-state freeze: existing step state is immutable after work starts, and new rows are append-only `planned` work on open plans. Refuse on any failed check, instruct re-export, replace the issue body, and print the header strip and changed lines. |
| `plan.mjs check <issue \| --file <path>>` | Validate a v3 record and print the pass result. |
| `plan.mjs status <issue> <status> [--reason <text>]` | Validate and apply one open-status transition, then replace all phase labels with the target phase label. Refuse closed issues. |
| `plan.mjs step <issue> <step-id> <status>` | Rewrite one Steps `Status` cell after checking dependencies; require an open `ongoing` plan, or a `finished` plan when the target status is terminal (`done` or `skipped`) for repair. |
Expand Down Expand Up @@ -271,9 +271,15 @@ re-export. The `claim`, `archive`, and `retire` commands do not rewrite body
bytes. A successful phase-only status change writes labels only and leaves the
body and digest valid. The guard compares body bytes, not the issue timestamp.

`edit` also refuses an incoming body that moves any matching step id from a
terminal remote status (`done` or `skipped`) to a different non-terminal
status. Re-export the current body and re-apply the intended edit.
Once work starts (irreversibly: when the current phase is neither `drafting` nor
`planned`, or label events show that `plan:ongoing` was ever applied), `edit`
preserves every existing Steps row's Status, Effect, Depends, display number,
and presence byte-for-byte; new rows must be appended after every existing row,
must start `planned`, and are refused on closed plans; after that boundary, only
`plan.mjs step` writes step state.
Before this boundary, a `drafting` or `planned` plan with no historical
`plan:ongoing` event may edit Steps freely. On a closed plan, post-merge step
mutation remains limited to terminal repair; new work requires a follow-up plan.

Re-export immediately before every body edit. Edit the export. Run
`plan.mjs check <issue>`. Delete the export and its `.origin` sidecar. Never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ user-invocable: true
metadata:
pattern: tool-wrapper
updated: "2026-08-25"
content_hash: "f51725f1a98263c28ae64f217751538a9fe841b27f464e4c67d785dc46e91dc9"
content_hash: "42379616a3df0c377616ebb2173a48095e07858c8c4633b7688587c5b44eb802"
---

# Plan Manager
Expand Down Expand Up @@ -225,7 +225,7 @@ Archive proof rules live in the contract reference.
| `claim <issue>` | resolve the acting login, `gh issue edit <n> --add-assignee @me` when unassigned; idempotent for the owner, refuses a foreign owner without writing | `plan #<n> claimed: <login>` |
| `show <issue> [--body]` | header strip, then per-kind verdicts from latest trusted comments with legacy fallback only when none exists; `--body` puts the record alone on stdout and both metadata lines on stderr | header strip, then `reviews: plan=<pass\|repair\|blocked\|none> code=<pass\|fixes-required\|blocked\|none>` |
| `export <issue>` | `export` writes the body to the worktree-aware `docks-review` directory. It writes its SHA-256 digest to `<file>.origin` with mode `0600`. | the absolute export path |
| `edit <issue> --file <path>` | `edit` runs 13 checks. It requires provenance for the current body and refuses a file that moves any matching step id from a terminal remote status (`done` or `skipped`) to a different non-terminal status; re-export and re-apply the edit. It refreshes the digest before the remote body write, then replaces the body. | header strip, then `changed: <k> line(s)` and the changed lines as `-old` / `+new` |
| `edit <issue> --file <path>` | Once work starts (irreversibly: when the current phase is neither `drafting` nor `planned`, or label events show that `plan:ongoing` was ever applied), `edit` preserves every existing Steps row's Status, Effect, Depends, display number, and presence byte-for-byte; new rows must be appended after every existing row, must start `planned`, and are refused on closed plans; after that boundary, only `plan.mjs step` writes step state. It requires provenance for the current body, refreshes the digest before the remote body write, then replaces the body. | header strip, then `changed: <k> line(s)` and the changed lines as `-old` / `+new` |
| `check <issue \| --file <path>>` | 13 checks | `plan check passed: #<n>` or `plan check passed: <path>` |
| `status <issue> <status> [--reason <text>]` | `status` requires an open issue. It validates and updates its phase label. It keeps a leading `Blocked:` line only for blocked status. | `plan #<n> status: <old> -> <new>` |
| `step <issue> <step-id> <status>` | rewrite one Steps `Status` cell; requires an open `ongoing` plan, or a `finished` plan when the target status is terminal (`done` or `skipped`) for repair | `plan #<n> step <id>: <old> -> <new>` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,9 +287,15 @@ sidecar, an unreadable digest, or a digest from a superseded body revision.
After validation, `edit` refreshes the digest before the remote body write.
A local sidecar failure fails closed and requires one re-export.
A phase-only status change leaves the body and sidecar valid.
`edit` also refuses an incoming body that moves any matching step id from a
terminal remote status (`done` or `skipped`) to a different non-terminal
status. Re-export the current body and re-apply the intended edit.
Once work starts (irreversibly: when the current phase is neither `drafting` nor
`planned`, or label events show that `plan:ongoing` was ever applied), `edit`
preserves every existing Steps row's Status, Effect, Depends, display number,
and presence byte-for-byte; new rows must be appended after every existing row,
must start `planned`, and are refused on closed plans; after that boundary, only
`plan.mjs step` writes step state.
Before this boundary, a `drafting` or `planned` plan with no historical
`plan:ongoing` event may edit Steps freely. On a closed plan, post-merge step
mutation remains limited to terminal repair; new work requires a follow-up plan.

For every body edit, export the record. Edit the export. Run
`plan.mjs check <issue>`. Delete the export and its `.origin` sidecar.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,23 @@ function issueComments(number) {
return pages.flat();
}

function planWorkStarted(issue, recordStatus) {
// "Started" must be irreversible: `blocked -> planned` and `blocked -> drafting` are legal transitions,
// so a started plan can legally return to an editable label. Label events are GitHub-owned timeline
// facts no later transition or body write can erase; the query runs only when the current label alone
// does not already prove the plan started.
if (recordStatus !== 'drafting' && recordStatus !== 'planned') return true;
const { owner, name } = repositoryCoordinates();
const pages = parseJson(
runGh(['api', `repos/${owner}/${name}/issues/${issue.number}/events`, '--paginate', '--slurp']),
'gh api issue events',
);
if (!Array.isArray(pages) || pages.some((page) => !Array.isArray(page))) {
fail('gh api issue events returned malformed events');
}
return pages.flat().some((event) => event?.event === 'labeled' && event?.label?.name === 'plan:ongoing');
}

function parseReviewComment(body) {
if (typeof body !== 'string') return undefined;
if (body.includes('\u2014')) return undefined;
Expand Down Expand Up @@ -842,23 +859,56 @@ function editPlan(args) {
}
const failures = checkPlan(after, issue);
if (failures.length > 0) fail(failures.map((message) => `${file}: ${message}`).join('\n'));
// The provenance digest proves the remote body is the one exported from, not that the FILE reflects it:
// an intervening export refreshes the sidecar, so a stale draft can pass the digest check and silently
// revert step statuses the CLI advanced in between. A terminal cell never legally regresses
// (STEP_TRANSITIONS closes `done` and `skipped`), so refuse any matching step id moving away from one.
const stepStatuses = (text) => {
const table = parseRows(sectionMap(text).sections.get('Steps') ?? '', STEPS_HEADER, STEPS_SEPARATOR, 8);
return new Map(table.rows.map(({ cells }) => [cells[1], unquoteCode(cells[6])]));
};
const remoteSteps = stepStatuses(parsed.body);
const regressed = [...stepStatuses(after)]
.filter(([id, status]) => {
const remote = remoteSteps.get(id);
return new Set(['done', 'skipped']).has(remote) && status !== remote && !new Set(['done', 'skipped']).has(status);
})
.map(([id, status]) => `${id} (${remoteSteps.get(id)} -> ${status})`);
if (regressed.length > 0) {
fail(`status regression: step ${regressed.join(', ')} is terminal on #${issue.number} but the file reverts it; re-export and re-apply the edit`);
// Once work starts, `plan.mjs step` is the only writer of step state. A body edit could otherwise
// bypass STEP_TRANSITIONS (`planned -> done`), fake completion by removing a non-terminal row (archive
// requires every step terminal), reorder execution by rewriting Depends or display numbers, or suppress
// the pre-run `ask` by downgrading a non-local Effect. Task, Files, and Done-when stay editable as prose.
if (planWorkStarted(issue, record.status)) {
const stepRows = (text) => {
const table = parseRows(sectionMap(text).sections.get('Steps') ?? '', STEPS_HEADER, STEPS_SEPARATOR, 8);
return table.rows.map(({ cells }) => ({
display: cells[0],
id: cells[1],
depends: cells[4],
effect: cells[5],
status: unquoteCode(cells[6]),
}));
};
const remoteRows = stepRows(parsed.body);
const incomingRows = stepRows(after);
const incomingById = new Map(incomingRows.map((row) => [row.id, row]));
const remoteIds = new Set(remoteRows.map((row) => row.id));
const violations = [];
for (const row of remoteRows) {
const next = incomingById.get(row.id);
if (!next) {
violations.push(`step ${row.id} removed (retire it with \`step ${issue.number} ${row.id} skipped\`)`);
continue;
}
if (next.status !== row.status) violations.push(`step ${row.id} Status ${row.status} -> ${next.status}`);
if (next.effect !== row.effect) violations.push(`step ${row.id} Effect ${row.effect} -> ${next.effect}`);
if (next.depends !== row.depends) violations.push(`step ${row.id} Depends ${row.depends} -> ${next.depends}`);
if (next.display !== row.display) violations.push(`step ${row.id} display ${row.display} -> ${next.display}`);
}
// A closed record accepts no new rows: post-merge mutation is terminal repair only, and new work
// belongs to a follow-up plan - an appended planned row plus finished-plan `step ... done` would
// smuggle unreviewed work into a merged record. Open started plans append planned-born rows only.
const closed = String(issue.state).toUpperCase() === 'CLOSED';
const lastRemotePosition = incomingRows.reduce(
(last, row, index) => (remoteIds.has(row.id) ? index : last),
-1,
);
incomingRows.forEach((row, index) => {
if (remoteIds.has(row.id)) return;
if (closed) violations.push(`step ${row.id} added to a closed plan (new work takes a follow-up plan)`);
else if (row.status !== 'planned') violations.push(`new step ${row.id} born ${row.status}; new rows start planned`);
else if (index < lastRemotePosition) violations.push(`new step ${row.id} inserted between existing rows; new rows append at the end`);
});
if (violations.length > 0) {
fail(
`step state is frozen once work starts: ${violations.join('; ')}. Use \`plan.mjs step\` for status changes, then re-export and re-apply the edit`,
);
}
}
const changes = changedLines(issue.body, after);
// Stage provenance before the remote write. A failed sidecar write then costs one re-export; the reverse
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ user-invocable: true
metadata:
pattern: tool-wrapper
updated: "2026-08-25"
content_hash: "457e5d91cb38f0cc95ceb4c40947f6065e2ff017a46c08e7c813cf0d3c63f4d3"
content_hash: "51cc06588adf95d6d6f784d4eaabebea901347d06a676bd1f8e0ab82cea57324"
---

# Plans Workspace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ resolves the target repository from that checkout's GitHub remote.
| `plan.mjs claim <issue>` | Take single-writer ownership of an existing plan: assign the acting login, stay idempotent when it already owns the plan, and refuse when another login does. |
| `plan.mjs show <issue> [--body]` | Print the header strip, then `reviews: plan=<pass\|repair\|blocked\|none> code=<pass\|fixes-required\|blocked\|none>`. With `--body`, print only the body to stdout and send both metadata lines to stderr, header first. |
| `plan.mjs export <issue>` | Write the issue body verbatim to `plan-<issue>.md` inside the scratch directory `git rev-parse --git-path docks-review` resolves, creating it mode 0700 when missing, and print the absolute path. |
| `plan.mjs edit <issue> --file <path>` | Validate the file as the plan record, refuse on any failed check or any regression of a matching terminal step to a different non-terminal status, instruct re-export, replace the issue body, and print the header strip and the changed lines. |
| `plan.mjs edit <issue> --file <path>` | Validate the file as the plan record and enforce the irreversible execution-state freeze: existing step state is immutable after work starts, and new rows are append-only `planned` work on open plans. Refuse on any failed check, instruct re-export, replace the issue body, and print the header strip and changed lines. |
| `plan.mjs check <issue \| --file <path>>` | Validate a v3 record and print the pass result. |
| `plan.mjs status <issue> <status> [--reason <text>]` | Validate and apply one open-status transition, then replace all phase labels with the target phase label. Refuse closed issues. |
| `plan.mjs step <issue> <step-id> <status>` | Rewrite one Steps `Status` cell after checking dependencies; require an open `ongoing` plan, or a `finished` plan when the target status is terminal (`done` or `skipped`) for repair. |
Expand Down Expand Up @@ -275,9 +275,15 @@ re-export. The `claim`, `archive`, and `retire` commands do not rewrite body
bytes. A successful phase-only status change writes labels only and leaves the
body and digest valid. The guard compares body bytes, not the issue timestamp.

`edit` also refuses an incoming body that moves any matching step id from a
terminal remote status (`done` or `skipped`) to a different non-terminal
status. Re-export the current body and re-apply the intended edit.
Once work starts (irreversibly: when the current phase is neither `drafting` nor
`planned`, or label events show that `plan:ongoing` was ever applied), `edit`
preserves every existing Steps row's Status, Effect, Depends, display number,
and presence byte-for-byte; new rows must be appended after every existing row,
must start `planned`, and are refused on closed plans; after that boundary, only
`plan.mjs step` writes step state.
Before this boundary, a `drafting` or `planned` plan with no historical
`plan:ongoing` event may edit Steps freely. On a closed plan, post-merge step
mutation remains limited to terminal repair; new work requires a follow-up plan.

Re-export immediately before every body edit. Edit the export. Run
`plan.mjs check <issue>`. Delete the export and its `.origin` sidecar. Never
Expand Down
16 changes: 16 additions & 0 deletions scripts/tests/fixtures/gh-stub/gh
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,21 @@ if (group === 'api' && commentsEndpoint) {
process.exit(0);
}

const eventsEndpoint = /^repos\/([^/]+)\/([^/]+)\/issues\/([1-9]\d*)\/events$/.exec(`${command ?? ''}`);
if (group === 'api' && eventsEndpoint) {
if (!args.includes('--paginate') || !args.includes('--slurp')) die('issue events api requires --paginate and --slurp');
const repo = `${eventsEndpoint[1]}/${eventsEndpoint[2]}`;
if (repo !== state.repo.nameWithOwner) die(`repository not found: ${repo}`);
const issue = issueByNumber(Number(eventsEndpoint[3]));
const events = issue.events ?? [];
const pages = [];
for (let index = 0; index < events.length; index += 100) pages.push(events.slice(index, index + 100));
if (pages.length === 0) pages.push([]);
console.log(JSON.stringify(pages));
save();
process.exit(0);
}

if (group === 'api' && command === 'graphql') {
const query = formFields('-f').query;
if (!query) die('graphql requires -f query=<query>');
Expand Down Expand Up @@ -366,6 +381,7 @@ if (group === 'issue' && command === 'create') {
updatedAt: now,
comments: [],
closedByPullRequestsReferences: [],
events: [],
};
state.issues.push(issue);
console.log(issue.url);
Expand Down
Loading