@dreki-gg/pi-plan-mode 0.17.1 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +45 -9
- package/bin/clean-plans.js +92 -66
- package/extensions/plan-mode/__tests__/add-task.test.ts +16 -8
- package/extensions/plan-mode/__tests__/plan-status.test.ts +94 -0
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +46 -0
- package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +83 -0
- package/extensions/plan-mode/__tests__/reconcile.test.ts +114 -0
- package/extensions/plan-mode/__tests__/resolve-plan.test.ts +154 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +19 -0
- package/extensions/plan-mode/__tests__/update-plan.test.ts +69 -0
- package/extensions/plan-mode/__tests__/update-task.test.ts +106 -0
- package/extensions/plan-mode/constants.ts +14 -1
- package/extensions/plan-mode/index.ts +75 -5
- package/extensions/plan-mode/reconcile.ts +141 -0
- package/extensions/plan-mode/resolve-plan.ts +118 -0
- package/extensions/plan-mode/schema.ts +13 -1
- package/extensions/plan-mode/storage/plans-manifest.ts +42 -3
- package/extensions/plan-mode/tools/add-task.ts +26 -4
- package/extensions/plan-mode/tools/plan-status.ts +155 -0
- package/extensions/plan-mode/tools/reconcile-plans.ts +118 -0
- package/extensions/plan-mode/tools/update-plan.ts +123 -0
- package/extensions/plan-mode/tools/update-task.ts +50 -10
- package/extensions/plan-mode/types.ts +6 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 48cee24: fix(plan-mode): multi-plan / cross-session drift + silent wrong-plan writes
|
|
8
|
+
|
|
9
|
+
Real-world report from a repo with many simultaneously-in-progress plans surfaced two trust-breakers plus several rough edges (see `FEEDBACK.md`). All addressed:
|
|
10
|
+
|
|
11
|
+
- **Registry status is now a projection of task state** (🔴 #1). Plan completion was coupled to a formal in-session execution run (`state.executing`), so a plan driven to all-tasks-`done` via `update_task` in another session/model stayed `in-progress` forever. `reconcilePlanStatus` now re-derives `plans.jsonl` status from `tasks.jsonl` on every task write (in `update_task` **and** `add_task`), decoupling completion from execution mode.
|
|
12
|
+
- **Explicit `plan` hint always wins** (🔴 #7). `resolveActivePlan` returned the in-memory `state.plan` before ever consulting an explicit `{ plan: "<name>" }` argument, so once a plan was submitted in a session every `update_task` / `add_task` silently pinned to it — landing writes in the wrong `tasks.jsonl`. The hint is now resolved **before** the in-memory short-circuit and re-attaches the named plan from disk.
|
|
13
|
+
- **New `update_plan` tool** (#2/#3): close or reopen a plan (`done` / `superseded` / `abandoned` / `in-progress`) with a `reason`, instead of hand-editing the registry or smuggling status into the title.
|
|
14
|
+
- **Widened plan status** (#3): `PlanManifestEntry.status` gains `superseded` and `abandoned`, plus an optional `reason`. Only `in-progress` is active; terminal statuses drop out of resolution and are never auto-overridden by reconciliation.
|
|
15
|
+
- **New `reconcile_plans` tool** (#6): walks every plan, reports drift (registry vs. derived task status), orphan task dirs, and registry-only plans; `apply: true` repairs safe `in-progress` ⇄ `done` drift.
|
|
16
|
+
- **`clean` archives instead of deletes** (#4): closed-plan directories move to `.plans/.archive/<name>/` by default (preserving HANDOFF.md + tasks.jsonl); true deletion is gated behind `--purge`. The CLI now reads `plans.jsonl` (was `plans.json`).
|
|
17
|
+
- **Multi-plan UX** (#5): `/plan focus <name>` pins the active plan so tracking calls default to it; `plan_status` with no arg and multiple in-progress plans renders a progress table (`7/17`, `8/8 ⚠ done?`) that surfaces reconcile candidates at a glance.
|
|
18
|
+
|
|
19
|
+
## 0.18.0
|
|
20
|
+
|
|
21
|
+
### Minor Changes
|
|
22
|
+
|
|
23
|
+
- Make `update_task` / `add_task` usable across sessions and resilient for autonomous agents.
|
|
24
|
+
|
|
25
|
+
The active plan was session-scoped: `update_task` / `add_task` only worked when a plan was submitted in the same session, restored from its entries, or handed off via the one-shot exec-pending marker. An agent executing an existing `.plans/<name>/` in a fresh session (the common plan-here / execute-there flow) hit a hard `No active plan` throw.
|
|
26
|
+
|
|
27
|
+
- **Disk-backed resolution** (`resolveActivePlan`): when nothing is attached in memory, the active plan is resolved from `.plans/plans.jsonl` — the sole in-progress plan auto-attaches (data only; does NOT enter execution mode / change tools / model). Wired into `session_start` and both tracking tools.
|
|
28
|
+
- **No hard throws on tracking calls**: `update_task` / `add_task` now return soft, non-terminating results (no active plan, unknown task id, already-resolved task) so a tracking miss never derails the real work. `update_task` is idempotent — re-marking the same status is a no-op success.
|
|
29
|
+
- **`plan` parameter**: both tools accept an optional `plan` (name or `.plans/<name>`) to disambiguate without the interactive `/plan resume` when multiple plans are in-progress.
|
|
30
|
+
- **`update_task` corrections**: a different status on an already-resolved task now applies as a correction (e.g. `done`→`skipped`, or `blocked`→`done` to unblock) and is reported as such, instead of being refused.
|
|
31
|
+
- **New `plan_status` tool**: read-only snapshot of the active plan — progress counts + every task id/status — so an agent can check what's active and which ids are valid (disk-backed; works in a fresh execution session) instead of probing with a failing `update_task`. Added to both tool sets.
|
|
32
|
+
|
|
3
33
|
## 0.17.1
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -22,8 +22,37 @@ pi install npm:@dreki-gg/pi-questionnaire
|
|
|
22
22
|
| -------- | -------------- | ---------------------------------------------- |
|
|
23
23
|
| Flag | `--plan` | Start pi in plan mode |
|
|
24
24
|
| Command | `/plan [prompt]` | Enter plan mode, optionally with a starting prompt |
|
|
25
|
+
| Command | `/plan resume` | Pick up an in-progress plan from disk |
|
|
26
|
+
| Command | `/plan focus <name>` | Pin a plan so tracking calls default to it (multi-plan repos) |
|
|
25
27
|
| Command | `/todos` | Show current plan progress |
|
|
26
28
|
| Shortcut | `Ctrl+Alt+P` | Toggle plan mode |
|
|
29
|
+
| Tool | `update_task` | Mark a task done / skipped / blocked |
|
|
30
|
+
| Tool | `add_task` | Capture a discovered follow-up (deferred) |
|
|
31
|
+
| Tool | `plan_status` | Read-only snapshot; progress table when many plans are active |
|
|
32
|
+
| Tool | `update_plan` | Close/reopen a plan: done, superseded, abandoned, in-progress |
|
|
33
|
+
| Tool | `reconcile_plans` | Detect & repair drift between tasks.jsonl and the registry |
|
|
34
|
+
|
|
35
|
+
### Plan lifecycle status
|
|
36
|
+
|
|
37
|
+
The registry (`.plans/plans.jsonl`) `status` is a **projection of task state**, not a
|
|
38
|
+
hand-maintained flag. Marking every task `done`/`skipped` (via `update_task`, in any
|
|
39
|
+
session or model) automatically flips the plan to `done` — completion is no longer
|
|
40
|
+
coupled to a formal in-session execution run.
|
|
41
|
+
|
|
42
|
+
| Status | Meaning | Active? |
|
|
43
|
+
| ------------- | -------------------------------------------------- | ------- |
|
|
44
|
+
| `in-progress` | Active, tracked, eligible for auto-resolution | ✅ |
|
|
45
|
+
| `done` | All tasks resolved | — |
|
|
46
|
+
| `superseded` | Another plan absorbed the work | — |
|
|
47
|
+
| `abandoned` | Won't do / rejected | — |
|
|
48
|
+
|
|
49
|
+
`superseded` / `abandoned` are set explicitly via `update_plan` (with a `reason`) and are
|
|
50
|
+
never auto-overridden by task reconciliation. Only `in-progress` plans participate in
|
|
51
|
+
active-plan resolution.
|
|
52
|
+
|
|
53
|
+
In repos with **many** in-progress plans, an explicit `{ plan: "<name>" }` on
|
|
54
|
+
`update_task` / `add_task` / `plan_status` **always** targets that plan — it is never
|
|
55
|
+
silently overridden by whatever plan was last submitted in the session.
|
|
27
56
|
|
|
28
57
|
## Workflow
|
|
29
58
|
|
|
@@ -99,17 +128,23 @@ Plans start as `"in-progress"` when created and are marked `"done"` when all exe
|
|
|
99
128
|
|
|
100
129
|
## Cleaning completed plans
|
|
101
130
|
|
|
102
|
-
Use the CLI to
|
|
131
|
+
Use the CLI to clean closed plans (`done` / `superseded` / `abandoned`). By default it
|
|
132
|
+
**archives** plan directories to `.plans/.archive/<name>/` — keeping HANDOFF.md and
|
|
133
|
+
tasks.jsonl as a record — rather than deleting them:
|
|
103
134
|
|
|
104
135
|
```bash
|
|
105
|
-
# Preview what would be
|
|
136
|
+
# Preview what would be cleaned (no changes)
|
|
106
137
|
npx @dreki-gg/pi-plan-mode clean --dry-run
|
|
107
138
|
|
|
108
|
-
#
|
|
139
|
+
# Archive closed plans to .plans/.archive/ and update plans.jsonl
|
|
109
140
|
npx @dreki-gg/pi-plan-mode clean
|
|
141
|
+
|
|
142
|
+
# Permanently delete instead of archiving
|
|
143
|
+
npx @dreki-gg/pi-plan-mode clean --purge
|
|
110
144
|
```
|
|
111
145
|
|
|
112
|
-
In-flight plans (`"status": "in-progress"`) are never touched.
|
|
146
|
+
In-flight plans (`"status": "in-progress"`) are never touched. Archiving is the default so
|
|
147
|
+
that closing out a finished plan never silently destroys its handoff + task ledger.
|
|
113
148
|
|
|
114
149
|
### GitHub Actions
|
|
115
150
|
|
|
@@ -157,10 +192,11 @@ In plan mode, bash is restricted to read-only commands (ls, grep, git status, ca
|
|
|
157
192
|
## CLI reference
|
|
158
193
|
|
|
159
194
|
```
|
|
160
|
-
pi-plan-mode clean [--dry-run]
|
|
195
|
+
pi-plan-mode clean [--dry-run] [--purge]
|
|
161
196
|
```
|
|
162
197
|
|
|
163
|
-
| Option | Description
|
|
164
|
-
| ----------- |
|
|
165
|
-
| `clean` |
|
|
166
|
-
| `--dry-run` | Show what would be
|
|
198
|
+
| Option | Description |
|
|
199
|
+
| ----------- | ----------------------------------------------------------------- |
|
|
200
|
+
| `clean` | Archive closed plan directories to `.plans/.archive/`, update manifest |
|
|
201
|
+
| `--dry-run` | Show what would be cleaned without changing anything |
|
|
202
|
+
| `--purge` | Permanently delete instead of archiving |
|
package/bin/clean-plans.js
CHANGED
|
@@ -1,117 +1,143 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* CLI to clean
|
|
3
|
+
* CLI to clean closed plans from `.plans/`.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* npx @dreki-gg/pi-plan-mode clean [--dry-run]
|
|
6
|
+
* npx @dreki-gg/pi-plan-mode clean [--dry-run] [--purge]
|
|
7
7
|
*
|
|
8
|
-
* Reads `.plans/plans.
|
|
9
|
-
*
|
|
8
|
+
* Reads `.plans/plans.jsonl`, and for every plan whose status is terminal
|
|
9
|
+
* (done / superseded / abandoned):
|
|
10
|
+
* - default: ARCHIVES the plan directory to `.plans/.archive/<name>/`
|
|
11
|
+
* (non-destructive — keeps HANDOFF.md + tasks.jsonl as a record)
|
|
12
|
+
* - --purge: permanently deletes the plan directory
|
|
13
|
+
* In both cases the entry is removed from the active `plans.jsonl` registry.
|
|
10
14
|
*
|
|
11
15
|
* Designed for use in GitHub Actions after merge — similar to changesets.
|
|
16
|
+
* History-preserving by default (FEEDBACK #4): closing out a finished plan must
|
|
17
|
+
* not silently destroy its handoff + task ledger.
|
|
12
18
|
*/
|
|
13
19
|
|
|
14
|
-
import { readFileSync, writeFileSync, rmSync,
|
|
20
|
+
import { readFileSync, writeFileSync, rmSync, renameSync, mkdirSync, existsSync } from 'node:fs';
|
|
15
21
|
import { resolve, join } from 'node:path';
|
|
16
22
|
|
|
17
23
|
const PLANS_DIR = '.plans';
|
|
18
|
-
const
|
|
24
|
+
const ARCHIVE_DIR = join(PLANS_DIR, '.archive');
|
|
25
|
+
const MANIFEST_PATH = join(PLANS_DIR, 'plans.jsonl');
|
|
26
|
+
|
|
27
|
+
const TERMINAL_STATUSES = new Set(['done', 'superseded', 'abandoned']);
|
|
28
|
+
|
|
29
|
+
/** Parse `.plans/plans.jsonl` into an array of plan entries. */
|
|
30
|
+
function readManifest(path) {
|
|
31
|
+
const text = readFileSync(path, 'utf-8');
|
|
32
|
+
const entries = [];
|
|
33
|
+
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
34
|
+
if (!raw.trim()) continue;
|
|
35
|
+
try {
|
|
36
|
+
entries.push(JSON.parse(raw));
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error(`Failed to parse ${MANIFEST_PATH} at line ${index + 1}:`, err);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return entries;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function writeManifest(path, entries) {
|
|
46
|
+
const content =
|
|
47
|
+
entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
|
|
48
|
+
writeFileSync(path, content);
|
|
49
|
+
}
|
|
19
50
|
|
|
20
51
|
function main() {
|
|
21
52
|
const args = process.argv.slice(2);
|
|
22
53
|
const command = args[0];
|
|
23
54
|
|
|
24
55
|
if (command !== 'clean') {
|
|
25
|
-
console.error('Usage: pi-plan-mode clean [--dry-run]\n');
|
|
56
|
+
console.error('Usage: pi-plan-mode clean [--dry-run] [--purge]\n');
|
|
26
57
|
console.error('Commands:');
|
|
27
|
-
console.error(' clean
|
|
58
|
+
console.error(' clean Archive closed plan directories and update plans.jsonl\n');
|
|
28
59
|
console.error('Options:');
|
|
29
|
-
console.error(' --dry-run Show what would be
|
|
60
|
+
console.error(' --dry-run Show what would be cleaned without changing anything');
|
|
61
|
+
console.error(' --purge Permanently delete instead of archiving to .plans/.archive/');
|
|
30
62
|
process.exit(1);
|
|
31
63
|
}
|
|
32
64
|
|
|
33
65
|
const dryRun = args.includes('--dry-run');
|
|
34
|
-
const
|
|
66
|
+
const purge = args.includes('--purge');
|
|
67
|
+
const manifestPath = resolve(MANIFEST_PATH);
|
|
35
68
|
|
|
36
|
-
if (!existsSync(
|
|
37
|
-
console.log(
|
|
69
|
+
if (!existsSync(manifestPath)) {
|
|
70
|
+
console.log(`No ${MANIFEST_PATH} found — nothing to clean.`);
|
|
38
71
|
process.exit(0);
|
|
39
72
|
}
|
|
40
73
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
manifest = JSON.parse(readFileSync(plansJsonPath, 'utf-8'));
|
|
45
|
-
} catch (err) {
|
|
46
|
-
console.error(`Failed to parse ${PLANS_JSON}:`, err);
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const donePlans = Object.entries(manifest).filter(([, entry]) => entry.status === 'done');
|
|
51
|
-
const inFlightPlans = Object.entries(manifest).filter(
|
|
52
|
-
([, entry]) => entry.status === 'in-progress',
|
|
53
|
-
);
|
|
74
|
+
const entries = readManifest(manifestPath);
|
|
75
|
+
const closed = entries.filter((entry) => TERMINAL_STATUSES.has(entry.status));
|
|
76
|
+
const inFlight = entries.filter((entry) => entry.status === 'in-progress');
|
|
54
77
|
|
|
55
|
-
if (
|
|
56
|
-
console.log('No
|
|
57
|
-
if (
|
|
58
|
-
console.log(`\n${
|
|
59
|
-
for (const
|
|
60
|
-
console.log(` ○ ${name} — ${entry.title}`);
|
|
61
|
-
}
|
|
78
|
+
if (closed.length === 0) {
|
|
79
|
+
console.log('No closed plans to clean.');
|
|
80
|
+
if (inFlight.length > 0) {
|
|
81
|
+
console.log(`\n${inFlight.length} plan(s) still in progress:`);
|
|
82
|
+
for (const entry of inFlight) console.log(` ○ ${entry.name} — ${entry.title}`);
|
|
62
83
|
}
|
|
63
84
|
process.exit(0);
|
|
64
85
|
}
|
|
65
86
|
|
|
66
|
-
|
|
87
|
+
const verb = purge ? 'delete' : 'archive';
|
|
88
|
+
console.log(
|
|
89
|
+
dryRun ? `Dry run — would ${verb}:\n` : `${purge ? 'Deleting' : 'Archiving'} closed plans:\n`,
|
|
90
|
+
);
|
|
67
91
|
|
|
92
|
+
if (!dryRun && !purge && !existsSync(resolve(ARCHIVE_DIR))) {
|
|
93
|
+
mkdirSync(resolve(ARCHIVE_DIR), { recursive: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const remaining = [...inFlight];
|
|
68
97
|
let cleaned = 0;
|
|
69
|
-
for (const
|
|
70
|
-
const planPath = resolve(join(PLANS_DIR, name));
|
|
98
|
+
for (const entry of closed) {
|
|
99
|
+
const planPath = resolve(join(PLANS_DIR, entry.name));
|
|
71
100
|
const exists = existsSync(planPath);
|
|
101
|
+
const label = `${entry.name} — ${entry.title} [${entry.status}]`;
|
|
72
102
|
|
|
73
103
|
if (dryRun) {
|
|
74
|
-
console.log(` ✓ ${
|
|
75
|
-
|
|
76
|
-
|
|
104
|
+
console.log(` ✓ ${label}${exists ? '' : ' (directory already missing)'}`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (exists) {
|
|
109
|
+
if (purge) {
|
|
77
110
|
rmSync(planPath, { recursive: true, force: true });
|
|
78
|
-
console.log(` ✓ Deleted ${PLANS_DIR}/${name} — ${
|
|
111
|
+
console.log(` ✓ Deleted ${PLANS_DIR}/${entry.name} — ${label}`);
|
|
79
112
|
} else {
|
|
80
|
-
|
|
113
|
+
const dest = resolve(join(ARCHIVE_DIR, entry.name));
|
|
114
|
+
rmSync(dest, { recursive: true, force: true }); // replace any stale archive
|
|
115
|
+
renameSync(planPath, dest);
|
|
116
|
+
console.log(` ✓ Archived ${PLANS_DIR}/${entry.name} → ${ARCHIVE_DIR}/${entry.name}`);
|
|
81
117
|
}
|
|
82
|
-
|
|
83
|
-
|
|
118
|
+
} else {
|
|
119
|
+
console.log(` ✓ ${entry.name} — directory already missing, removing from manifest`);
|
|
84
120
|
}
|
|
121
|
+
cleaned++;
|
|
85
122
|
}
|
|
86
123
|
|
|
87
|
-
if (
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
|
|
91
|
-
// Remove .plans/ if completely empty
|
|
92
|
-
const plansDir = resolve(PLANS_DIR);
|
|
93
|
-
try {
|
|
94
|
-
if (existsSync(plansDir) && readdirSync(plansDir).length === 0) {
|
|
95
|
-
rmSync(plansDir, { recursive: true, force: true });
|
|
96
|
-
console.log(`\nRemoved empty ${PLANS_DIR}/`);
|
|
97
|
-
}
|
|
98
|
-
} catch {
|
|
99
|
-
// Directory might have other contents
|
|
100
|
-
}
|
|
101
|
-
} else {
|
|
102
|
-
writeFileSync(plansJsonPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
124
|
+
if (dryRun) {
|
|
125
|
+
console.log(`\n${closed.length} plan(s) would be cleaned.`);
|
|
126
|
+
if (inFlight.length > 0) {
|
|
127
|
+
console.log(`${inFlight.length} plan(s) still in progress (will be kept).`);
|
|
103
128
|
}
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
104
131
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
console.log(`${remaining} plan(s) still in progress.`);
|
|
108
|
-
}
|
|
132
|
+
if (remaining.length === 0) {
|
|
133
|
+
rmSync(manifestPath, { force: true });
|
|
109
134
|
} else {
|
|
110
|
-
|
|
111
|
-
if (inFlightPlans.length > 0) {
|
|
112
|
-
console.log(`${inFlightPlans.length} plan(s) still in progress (will be kept).`);
|
|
113
|
-
}
|
|
135
|
+
writeManifest(manifestPath, remaining);
|
|
114
136
|
}
|
|
137
|
+
|
|
138
|
+
console.log(`\nCleaned ${cleaned} plan(s).`);
|
|
139
|
+
if (remaining.length > 0) console.log(`${remaining.length} plan(s) still in progress.`);
|
|
140
|
+
if (!purge) console.log(`Archived plans kept in ${ARCHIVE_DIR}/ (use --purge to delete).`);
|
|
115
141
|
}
|
|
116
142
|
|
|
117
143
|
main();
|
|
@@ -7,8 +7,14 @@ const now = '2026-05-27T12:00:00.000Z';
|
|
|
7
7
|
interface CapturedTool {
|
|
8
8
|
execute: (
|
|
9
9
|
id: string,
|
|
10
|
-
params: {
|
|
11
|
-
|
|
10
|
+
params: {
|
|
11
|
+
description: string;
|
|
12
|
+
reason: string;
|
|
13
|
+
details?: string;
|
|
14
|
+
depends_on?: string[];
|
|
15
|
+
plan?: string;
|
|
16
|
+
},
|
|
17
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
12
18
|
}
|
|
13
19
|
|
|
14
20
|
function setup(plan: PlanData | undefined) {
|
|
@@ -21,7 +27,8 @@ function setup(plan: PlanData | undefined) {
|
|
|
21
27
|
} as unknown as Parameters<typeof registerAddTaskTool>[0];
|
|
22
28
|
|
|
23
29
|
registerAddTaskTool(pi, {
|
|
24
|
-
|
|
30
|
+
// Mirrors index.ts: in-memory plan wins; disk fallback returns candidates.
|
|
31
|
+
resolvePlan: async () => ({ plan, candidates: [] }),
|
|
25
32
|
onTaskAdded: (task) => {
|
|
26
33
|
added.push(task);
|
|
27
34
|
},
|
|
@@ -66,10 +73,11 @@ describe('add_task tool', () => {
|
|
|
66
73
|
expect(task.description).toBe('Extract shared helper');
|
|
67
74
|
});
|
|
68
75
|
|
|
69
|
-
test('
|
|
70
|
-
const { tool } = setup(undefined);
|
|
71
|
-
await
|
|
72
|
-
|
|
73
|
-
);
|
|
76
|
+
test('soft-skips (does not throw) when there is no active plan', async () => {
|
|
77
|
+
const { tool, added } = setup(undefined);
|
|
78
|
+
const result = await tool.execute('call-1', { description: 'x', reason: 'y' });
|
|
79
|
+
expect((result.details as { skipped?: boolean }).skipped).toBe(true);
|
|
80
|
+
expect(result.content?.[0]?.text).toMatch(/no active plan/i);
|
|
81
|
+
expect(added).toHaveLength(0);
|
|
74
82
|
});
|
|
75
83
|
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { registerPlanStatusTool } from '../tools/plan-status.js';
|
|
3
|
+
import type { PlanData, TaskRecord, TaskStatus } from '../types.js';
|
|
4
|
+
|
|
5
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
6
|
+
|
|
7
|
+
interface CapturedTool {
|
|
8
|
+
execute: (
|
|
9
|
+
id: string,
|
|
10
|
+
params: { plan?: string },
|
|
11
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function setup(
|
|
15
|
+
plan: PlanData | undefined,
|
|
16
|
+
candidates: string[] = [],
|
|
17
|
+
inProgress?: Array<{ name: string; title: string; resolved: number; total: number }>,
|
|
18
|
+
) {
|
|
19
|
+
let tool: CapturedTool | undefined;
|
|
20
|
+
const pi = {
|
|
21
|
+
registerTool: (config: CapturedTool) => {
|
|
22
|
+
tool = config;
|
|
23
|
+
},
|
|
24
|
+
} as unknown as Parameters<typeof registerPlanStatusTool>[0];
|
|
25
|
+
registerPlanStatusTool(pi, {
|
|
26
|
+
resolvePlan: async () => ({ plan, candidates }),
|
|
27
|
+
listInProgress: inProgress ? async () => inProgress : undefined,
|
|
28
|
+
});
|
|
29
|
+
return tool!;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const planTask = (id: string, status: TaskStatus): TaskRecord => ({
|
|
33
|
+
_type: 'task',
|
|
34
|
+
id,
|
|
35
|
+
description: `task ${id}`,
|
|
36
|
+
status,
|
|
37
|
+
origin: 'plan',
|
|
38
|
+
created_at: now,
|
|
39
|
+
updated_at: now,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('plan_status tool', () => {
|
|
43
|
+
test('summarizes progress + lists task ids and statuses', async () => {
|
|
44
|
+
const plan: PlanData = {
|
|
45
|
+
title: 'My Plan',
|
|
46
|
+
planName: 'my-plan',
|
|
47
|
+
handoff: '',
|
|
48
|
+
tasks: [
|
|
49
|
+
planTask('t-001', 'done'),
|
|
50
|
+
planTask('t-002', 'pending'),
|
|
51
|
+
planTask('t-003', 'blocked'),
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
const tool = setup(plan);
|
|
55
|
+
const result = await tool.execute('c', {});
|
|
56
|
+
|
|
57
|
+
const text = result.content?.[0]?.text ?? '';
|
|
58
|
+
expect(text).toMatch(/My Plan \(my-plan\)/);
|
|
59
|
+
expect(text).toMatch(/1\/3 resolved/);
|
|
60
|
+
expect(text).toMatch(/blocked 1/);
|
|
61
|
+
expect(text).toMatch(/t-002 \[pending\]/);
|
|
62
|
+
|
|
63
|
+
const details = result.details as { active: boolean; task_ids: string[]; total: number };
|
|
64
|
+
expect(details.active).toBe(true);
|
|
65
|
+
expect(details.task_ids).toEqual(['t-001', 't-002', 't-003']);
|
|
66
|
+
expect(details.total).toBe(3);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('reports no active plan (read-only, no throw) with candidates', async () => {
|
|
70
|
+
const tool = setup(undefined, ['alpha', 'beta']);
|
|
71
|
+
const result = await tool.execute('c', {});
|
|
72
|
+
expect((result.details as { active: boolean }).active).toBe(false);
|
|
73
|
+
expect(result.content?.[0]?.text).toMatch(/alpha, beta/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('renders a progress table when multiple plans are in-progress (FEEDBACK #5)', async () => {
|
|
77
|
+
const tool = setup(
|
|
78
|
+
undefined,
|
|
79
|
+
['alpha', 'beta'],
|
|
80
|
+
[
|
|
81
|
+
{ name: 'alpha', title: 'Alpha', resolved: 7, total: 17 },
|
|
82
|
+
{ name: 'beta', title: 'Beta', resolved: 8, total: 8 },
|
|
83
|
+
],
|
|
84
|
+
);
|
|
85
|
+
const result = await tool.execute('c', {});
|
|
86
|
+
const text = result.content?.[0]?.text ?? '';
|
|
87
|
+
expect(text).toMatch(/2 in-progress/);
|
|
88
|
+
expect(text).toMatch(/7\/17 alpha/);
|
|
89
|
+
// A fully-resolved plan still listed in-progress is flagged as a reconcile cue.
|
|
90
|
+
expect(text).toMatch(/8\/8 beta — Beta ⚠ done\?/);
|
|
91
|
+
const details = result.details as { in_progress?: unknown[] };
|
|
92
|
+
expect(details.in_progress).toHaveLength(2);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -7,6 +7,7 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
8
8
|
import {
|
|
9
9
|
readPlansManifest,
|
|
10
|
+
reconcilePlanStatus,
|
|
10
11
|
upsertPlanEntry,
|
|
11
12
|
writePlansManifest,
|
|
12
13
|
} from '../storage/plans-manifest.js';
|
|
@@ -71,4 +72,49 @@ describe('plans.jsonl manifest', () => {
|
|
|
71
72
|
expect(entry.status).toBe('done');
|
|
72
73
|
expect(entry.completed_at).toBeString();
|
|
73
74
|
});
|
|
75
|
+
|
|
76
|
+
test('records a reason for terminal statuses and clears completed_at on reopen', async () => {
|
|
77
|
+
await run(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
|
|
78
|
+
await run(upsertPlanEntry('p', { status: 'superseded', reason: 'absorbed by q' }));
|
|
79
|
+
let [entry] = await run(readPlansManifest());
|
|
80
|
+
expect(entry.status).toBe('superseded');
|
|
81
|
+
expect(entry.reason).toBe('absorbed by q');
|
|
82
|
+
expect(entry.completed_at).toBeString();
|
|
83
|
+
|
|
84
|
+
// Reopening clears completed_at.
|
|
85
|
+
await run(upsertPlanEntry('p', { status: 'in-progress' }));
|
|
86
|
+
[entry] = await run(readPlansManifest());
|
|
87
|
+
expect(entry.status).toBe('in-progress');
|
|
88
|
+
expect(entry.completed_at).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('reconcilePlanStatus (registry as a projection of task state)', () => {
|
|
93
|
+
test('flips in-progress → done when finalizable', async () => {
|
|
94
|
+
await run(upsertPlanEntry('p', { status: 'in-progress', title: 'P' }));
|
|
95
|
+
await run(reconcilePlanStatus('p', true, 'P'));
|
|
96
|
+
const [entry] = await run(readPlansManifest());
|
|
97
|
+
expect(entry.status).toBe('done');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('reopens done → in-progress when no longer finalizable', async () => {
|
|
101
|
+
await run(upsertPlanEntry('p', { status: 'done', title: 'P' }));
|
|
102
|
+
await run(reconcilePlanStatus('p', false, 'P'));
|
|
103
|
+
const [entry] = await run(readPlansManifest());
|
|
104
|
+
expect(entry.status).toBe('in-progress');
|
|
105
|
+
expect(entry.completed_at).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('never overrides a manually-closed terminal status', async () => {
|
|
109
|
+
await run(upsertPlanEntry('p', { status: 'abandoned', title: 'P', reason: 'rejected' }));
|
|
110
|
+
await run(reconcilePlanStatus('p', true, 'P'));
|
|
111
|
+
const [entry] = await run(readPlansManifest());
|
|
112
|
+
expect(entry.status).toBe('abandoned');
|
|
113
|
+
expect(entry.reason).toBe('rejected');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('is a no-op for an unknown plan (nothing created)', async () => {
|
|
117
|
+
await run(reconcilePlanStatus('ghost', true));
|
|
118
|
+
await expect(run(readPlansManifest())).resolves.toEqual([]);
|
|
119
|
+
});
|
|
74
120
|
});
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { chdir } from 'node:process';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { makePlanRuntime } from '../effects/runtime.js';
|
|
7
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
8
|
+
import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
9
|
+
import { registerReconcilePlansTool } from '../tools/reconcile-plans.js';
|
|
10
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
11
|
+
|
|
12
|
+
const runPlanIO = makePlanRuntime();
|
|
13
|
+
const now = '2026-05-27T12:00:00.000Z';
|
|
14
|
+
|
|
15
|
+
const meta = (name: string): TaskMeta => ({
|
|
16
|
+
_type: 'meta',
|
|
17
|
+
title: `Title ${name}`,
|
|
18
|
+
plan_name: name,
|
|
19
|
+
created_at: now,
|
|
20
|
+
});
|
|
21
|
+
const doneTask = (id: string): TaskRecord => ({
|
|
22
|
+
_type: 'task',
|
|
23
|
+
id,
|
|
24
|
+
description: `task ${id}`,
|
|
25
|
+
status: 'done',
|
|
26
|
+
origin: 'plan',
|
|
27
|
+
created_at: now,
|
|
28
|
+
updated_at: now,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
interface CapturedTool {
|
|
32
|
+
execute: (
|
|
33
|
+
id: string,
|
|
34
|
+
params: { apply?: boolean },
|
|
35
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setup(): CapturedTool {
|
|
39
|
+
let tool: CapturedTool | undefined;
|
|
40
|
+
const pi = {
|
|
41
|
+
registerTool: (config: CapturedTool) => {
|
|
42
|
+
tool = config;
|
|
43
|
+
},
|
|
44
|
+
} as unknown as Parameters<typeof registerReconcilePlansTool>[0];
|
|
45
|
+
registerReconcilePlansTool(pi, runPlanIO);
|
|
46
|
+
return tool!;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const originalCwd = process.cwd();
|
|
50
|
+
let dir: string;
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-reconcile-tool-'));
|
|
53
|
+
chdir(dir);
|
|
54
|
+
});
|
|
55
|
+
afterEach(async () => {
|
|
56
|
+
chdir(originalCwd);
|
|
57
|
+
await rm(dir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('reconcile_plans tool', () => {
|
|
61
|
+
test('reports drift read-only without changing the registry', async () => {
|
|
62
|
+
await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [doneTask('t-001')]));
|
|
63
|
+
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
|
|
64
|
+
|
|
65
|
+
const tool = setup();
|
|
66
|
+
const result = await tool.execute('c', {});
|
|
67
|
+
expect(result.content?.[0]?.text).toMatch(/drift issue/);
|
|
68
|
+
// Read-only: registry unchanged.
|
|
69
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
70
|
+
expect(entry.status).toBe('in-progress');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('apply:true repairs status drift', async () => {
|
|
74
|
+
await runPlanIO(writeTasksJsonl('.plans/alpha', meta('alpha'), [doneTask('t-001')]));
|
|
75
|
+
await runPlanIO(upsertPlanEntry('alpha', { status: 'in-progress', title: 'Title alpha' }));
|
|
76
|
+
|
|
77
|
+
const tool = setup();
|
|
78
|
+
const result = await tool.execute('c', { apply: true });
|
|
79
|
+
expect((result.details as { repaired?: string[] }).repaired).toEqual(['alpha']);
|
|
80
|
+
const [entry] = await runPlanIO(readPlansManifest());
|
|
81
|
+
expect(entry.status).toBe('done');
|
|
82
|
+
});
|
|
83
|
+
});
|