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
18 changes: 14 additions & 4 deletions docs/PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,8 @@ The Steps table uses this exact header:

Every Steps row must be terminal before the closing pull request merges. Once
that merge closes the issue as completed, the derived state is `finished` and
step mutation is no longer legal. Post-merge work belongs to a named follow-up
plan.
step mutation is limited to terminal repair of a closed-but-unarchived record.
Post-merge work belongs to a named follow-up plan.

No Steps `Files` cell names the plan's own issue reference. Writing lifecycle
state into the record is the CLI's job, not an implementation step.
Expand Down Expand Up @@ -196,10 +196,10 @@ 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, 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, 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 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 the plan state and dependencies. |
| `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. |
| `plan.mjs list [--status <s>]` | Print `<status>\t#<issue>\t<title>` for every issue labelled `plan`, deriving `unlabelled`, `finished`, `retired`, and `duplicate` rather than reading them from the body. |
| `plan.mjs next` | Print startable open plans, using the queue when it is present and valid. |
| `plan.mjs archive <issue>` | Verify terminal steps, the latest trusted code-review result (with legacy body fallback only when no trusted comment record exists), completed closure, and an eligible merged closing pull request; strip stale phase labels and write no status. |
Expand Down Expand Up @@ -249,6 +249,12 @@ remaining window but does not close it, because the read and the edit are
separate API calls. A conflict is not an error to retry blindly: re-read the
record, re-apply the intent, and run `plan.mjs check <issue>` before continuing.

`step` requires an open `ongoing` plan, except that a `finished` plan accepts a
step mutation when the target status is terminal (`done` or `skipped`). This
repairs a closed-but-unarchived record without creating a new closure event.
Never reopen the issue for this repair: `archive` trusts only the latest
closure, so reopening would discard the eligible closure proof.

An export copy is a snapshot of one body revision, not a live view. The `step`
and `edit` commands rewrite body bytes. A status change also rewrites body bytes
when it adds or clears the blocked reason. These body writes supersede every
Expand All @@ -265,6 +271,10 @@ 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.

Re-export immediately before every body edit. Edit the export. Run
`plan.mjs check <issue>`. Delete the export and its `.origin` sidecar. Never
carry an edit across an intervening body write.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ description: "Use when a goal may need the six-phase plan flow: decide, draft, r
user-invocable: true
metadata:
pattern: tool-wrapper
updated: "2026-08-24"
content_hash: "9ba61ce6b78db45126838e43e8dc1c4033f0c6aad29b8eb559e6371ad5a31f18"
updated: "2026-08-25"
content_hash: "f51725f1a98263c28ae64f217751538a9fe841b27f464e4c67d785dc46e91dc9"
---

# Plan Manager
Expand Down Expand Up @@ -225,10 +225,10 @@ 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. It refreshes the digest before the remote body write. It then replaces the body. | header strip, then `changed: <k> line(s)` and the changed lines as `-old` / `+new` |
| `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` |
| `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 | `plan #<n> step <id>: <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>` |
| `list [--status <s>]` | list plan issues and derive status from phase label for open work or from `state` + `stateReason` when closed; open issues first, then closed; each group sorted by ascending number | `<status>\t#<n>\t<title>` per line |
| `next` | queue-aware startable plans from `docs/PLAN-QUEUE.md` (`Plan` cell holds the issue number); falls back to every `planned` plan on a missing or malformed queue, warning on stderr | `#<n>` per line |
| `archive <issue>` | require completed closure, terminal steps, a trusted latest `Code-review: pass` comment (or legacy body pass only when no trusted code record exists), and a merged closing pull request into the target repository's default branch; remove any stale phase label without writing status | `plan #<n> finished (closed by <pr-url>)` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ template placeholder `_Not researched yet._`.
to a declared id. A bare `step 3` is invalid.

Every Steps row must be terminal before the pull request carrying
`Closes #<issue>` merges. Once that merge closes the issue, step mutation is no
longer available. Post-merge work belongs to a named follow-up plan.
`Closes #<issue>` merges. After that merge closes the issue, step mutation is
limited to terminal repair of a closed-but-unarchived record. Post-merge work
belongs to a named follow-up plan.

## Acceptance table - exact header

Expand Down Expand Up @@ -271,6 +272,11 @@ it never enters the export/edit body cycle.

A conflict is not permission to retry blindly. Re-read the issue, re-apply the
intended change, and run `plan.mjs check <issue>` before continuing.
`step` requires an open `ongoing` plan, except that a `finished` plan accepts a
step mutation when the target status is terminal (`done` or `skipped`). This
repairs a closed-but-unarchived record without creating a new closure event.
Never reopen the issue for this repair: `archive` trusts only the latest
closure, so reopening would discard the eligible closure proof.

`export` writes the body to `<git-dir>/docks-review/plan-<n>.md`. It writes the
body digest to `plan-<n>.md.origin` as one lowercase SHA-256 line. The sidecar
Expand All @@ -281,6 +287,9 @@ 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.

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 @@ -825,7 +825,7 @@ function editPlan(args) {
if (!value) fail('edit requires an issue and --file');
const options = parseOptions(flags, new Set(['--file']));
if (!options['--file']) fail('edit requires an issue and --file');
const { issue, record } = readPlanIssue(value, true);
const { issue, parsed, record } = readPlanIssue(value, true);
const file = options['--file'];
const after = fs.readFileSync(file, 'utf8');
// Every body edit runs export, edit, check, delete, so a file with no sidecar was derived from bytes this
Expand All @@ -842,6 +842,24 @@ 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`);
}
const changes = changedLines(issue.body, after);
// Stage provenance before the remote write. A failed sidecar write then costs one re-export; the reverse
// order would leave a local digest naming a body GitHub already replaced, which reads as a valid export.
Expand Down Expand Up @@ -902,7 +920,12 @@ function setStepStatus(args) {
if (!STEP_STATUSES.has(target)) fail(`unknown step status: ${target}`);
const { issue, parsed, record } = readPlanIssue(value, true);
requireValidPlanIssue(issue);
if (record.status !== 'ongoing') fail(`plan status is ${record.status}; expected ongoing`);
// A plan whose issue closed with a step still non-terminal deadlocks: `step` wanted `ongoing` while
// `archive` wants every step terminal. Reopening to repair destroys the closure proof (only the latest
// closure event counts), so a `finished` plan accepts exactly the terminal repairs archive needs - a body
// edit on the closed issue, which creates no closure event and never touches GitHub state.
const terminalRepair = record.status === 'finished' && new Set(['done', 'skipped']).has(target);
if (record.status !== 'ongoing' && !terminalRepair) fail(`plan status is ${record.status}; expected ongoing`);
const { sections } = sectionMap(parsed.body);
const table = parseRows(sections.get('Steps') ?? '', STEPS_HEADER, STEPS_SEPARATOR, 8);
const row = table.rows.find(({ cells }) => cells[1] === stepId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ description: "Use when bootstrapping, migrating, auditing, or explicitly refresh
user-invocable: true
metadata:
pattern: tool-wrapper
updated: "2026-08-24"
content_hash: "49b0f7d76b40059a871d7807f9b51f6322879a392f287d351d1c64669403f5f8"
updated: "2026-08-25"
content_hash: "457e5d91cb38f0cc95ceb4c40947f6065e2ff017a46c08e7c813cf0d3c63f4d3"
---

# Plans Workspace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,8 @@ The Steps table uses this exact header:

Every Steps row must be terminal before the closing pull request merges. Once
that merge closes the issue as completed, the derived state is `finished` and
step mutation is no longer legal. Post-merge work belongs to a named follow-up
plan.
step mutation is limited to terminal repair of a closed-but-unarchived record.
Post-merge work belongs to a named follow-up plan.

No Steps `Files` cell names the plan's own issue reference. Writing lifecycle
state into the record is the CLI's job, not an implementation step.
Expand Down Expand Up @@ -200,10 +200,10 @@ 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, 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, 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 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 the plan state and dependencies. |
| `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. |
| `plan.mjs list [--status <s>]` | Print `<status>\t#<issue>\t<title>` for every issue labelled `plan`, deriving `unlabelled`, `finished`, `retired`, and `duplicate` rather than reading them from the body. |
| `plan.mjs next` | Print startable open plans, using the queue when it is present and valid. |
| `plan.mjs archive <issue>` | Verify terminal steps, the latest trusted code-review result (with legacy body fallback only when no trusted comment record exists), completed closure, and an eligible merged closing pull request; strip stale phase labels and write no status. |
Expand Down Expand Up @@ -253,6 +253,12 @@ remaining window but does not close it, because the read and the edit are
separate API calls. A conflict is not an error to retry blindly: re-read the
record, re-apply the intent, and run `plan.mjs check <issue>` before continuing.

`step` requires an open `ongoing` plan, except that a `finished` plan accepts a
step mutation when the target status is terminal (`done` or `skipped`). This
repairs a closed-but-unarchived record without creating a new closure event.
Never reopen the issue for this repair: `archive` trusts only the latest
closure, so reopening would discard the eligible closure proof.

An export copy is a snapshot of one body revision, not a live view. The `step`
and `edit` commands rewrite body bytes. A status change also rewrites body bytes
when it adds or clears the blocked reason. These body writes supersede every
Expand All @@ -269,6 +275,10 @@ 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.

Re-export immediately before every body edit. Edit the export. Run
`plan.mjs check <issue>`. Delete the export and its `.origin` sidecar. Never
carry an edit across an intervening body write.
Expand Down
Loading