@dreki-gg/pi-plan-mode 0.23.0 → 0.24.1
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 +22 -0
- package/README.md +48 -5
- package/bin/clean-plans.js +67 -49
- package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +85 -0
- package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +159 -0
- package/extensions/plan-mode/__tests__/initiative-status.test.ts +98 -0
- package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +89 -0
- package/extensions/plan-mode/__tests__/list-initiatives.test.ts +59 -0
- package/extensions/plan-mode/__tests__/reconcile.test.ts +75 -1
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
- package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
- package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
- package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
- package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
- package/extensions/plan-mode/constants.ts +5 -0
- package/extensions/plan-mode/index.ts +22 -0
- package/extensions/plan-mode/initiative.ts +172 -0
- package/extensions/plan-mode/prompts.ts +4 -0
- package/extensions/plan-mode/reconcile.ts +96 -2
- package/extensions/plan-mode/schema.ts +28 -0
- package/extensions/plan-mode/storage/file-lock.ts +49 -0
- package/extensions/plan-mode/storage/initiatives-manifest.ts +154 -0
- package/extensions/plan-mode/storage/plan-storage.ts +11 -0
- package/extensions/plan-mode/storage/plans-manifest.ts +76 -25
- package/extensions/plan-mode/storage/task-storage.ts +20 -14
- package/extensions/plan-mode/tools/initiative-status.ts +191 -0
- package/extensions/plan-mode/tools/reconcile-plans.ts +46 -8
- package/extensions/plan-mode/tools/revise-plan.ts +22 -0
- package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
- package/extensions/plan-mode/tools/submit-plan.ts +37 -4
- package/extensions/plan-mode/tools/update-initiative.ts +120 -0
- package/extensions/plan-mode/tools/update-plan.ts +3 -0
- package/extensions/plan-mode/types.ts +3 -0
- package/package.json +1 -1
- package/skills/planning-context/SKILL.md +11 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# @dreki-gg/pi-plan-mode
|
|
2
2
|
|
|
3
|
+
## 0.24.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Fix lost-update race on the plan/initiative registries and stop reconcile from regressing finished plans.
|
|
8
|
+
|
|
9
|
+
- **Serialized registry writes:** concurrent tool calls in one block (e.g. several `submit_initiative` / `submit_plan` / `revise_plan` at once) previously ran an unguarded read-modify-write against `plans.jsonl` / `initiatives.jsonl`, so only the last write survived. A process-wide per-file lock (`withFileLock`) now serializes the read→modify→write of both registries and per-plan `tasks.jsonl`, eliminating the lost updates. Atomic writes already guarded against torn files; this closes the in-process concurrency gap.
|
|
10
|
+
- **No wrong-direction reconcile:** `reconcile_plans --apply` now only records completion (`in-progress → done`) and never auto-downgrades a `done` plan back to `in-progress`. A done plan with incomplete tasks (work merged but tasks never marked done) is surfaced as `needs-tasks-done` for a human to resolve by marking the tasks done, instead of being silently regressed.
|
|
11
|
+
|
|
12
|
+
## 0.24.0
|
|
13
|
+
|
|
14
|
+
### Minor Changes
|
|
15
|
+
|
|
16
|
+
- Add an **initiatives** layer above plans for decomposing large work (beads-style grouping + cross-plan dependencies).
|
|
17
|
+
|
|
18
|
+
- New `submit_initiative`, `update_initiative`, and `initiative_status` tools, plus a `/initiatives` command.
|
|
19
|
+
- `submit_plan` / `revise_plan` gain optional `initiative` and `depends_on_plans` (plan-level dependencies; cross-initiative allowed).
|
|
20
|
+
- An initiative's status is a projection of its member plans (mirroring how a plan's status projects from its tasks); `done` is derived automatically while `superseded` / `abandoned` stay explicit.
|
|
21
|
+
- `initiative_status` computes ready-work — which member plans are unblocked (all dependencies `done`) vs blocked-by — so work can be split across sessions or subagents.
|
|
22
|
+
- `reconcile_plans` now also detects/repairs initiative-level status drift; the `clean` CLI archives closed initiatives alongside closed plans.
|
|
23
|
+
- New `.plans/initiatives.jsonl` registry and `.plans/<initiative>/INITIATIVE.md` overview. All new fields are optional — existing flat plans are unaffected.
|
|
24
|
+
|
|
3
25
|
## 0.23.0
|
|
4
26
|
|
|
5
27
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -24,6 +24,8 @@ pi install npm:@dreki-gg/pi-questionnaire
|
|
|
24
24
|
| Command | `/plan [prompt]` | Enter plan mode, optionally with a starting prompt |
|
|
25
25
|
| Command | `/plan resume` | Pick up an in-progress plan from disk |
|
|
26
26
|
| Command | `/plan focus <name>` | Pin a plan so tracking calls default to it (multi-plan repos) |
|
|
27
|
+
| Command | `/plans` | List/filter/sort plans |
|
|
28
|
+
| Command | `/initiatives` | List initiatives with member-plan rollup |
|
|
27
29
|
| Command | `/todos` | Show current plan progress |
|
|
28
30
|
| Shortcut | `Ctrl+Alt+P` | Toggle plan mode |
|
|
29
31
|
| Tool | `revise_plan` | Rewrite an existing plan in place (title/handoff/tasks) |
|
|
@@ -33,7 +35,45 @@ pi install npm:@dreki-gg/pi-questionnaire
|
|
|
33
35
|
| Tool | `plan_status` | Read-only snapshot; progress table when many plans are active |
|
|
34
36
|
| Tool | `set_active_plan` | Pin a plan as active (tool form of `/plan focus`) so tracking calls target it |
|
|
35
37
|
| Tool | `update_plan` | Close/reopen a plan: done, superseded, abandoned, in-progress |
|
|
36
|
-
| Tool | `
|
|
38
|
+
| Tool | `submit_initiative` | Create an initiative that groups multiple plans |
|
|
39
|
+
| Tool | `update_initiative` | Close/reopen an initiative: done, superseded, abandoned, in-progress |
|
|
40
|
+
| Tool | `initiative_status` | Snapshot an initiative: member plans, progress, ready/blocked |
|
|
41
|
+
| Tool | `reconcile_plans` | Detect & repair drift between tasks.jsonl and the registry (plans **and** initiatives) |
|
|
42
|
+
|
|
43
|
+
## Initiatives — grouping large work
|
|
44
|
+
|
|
45
|
+
When a body of work is too large for a single plan, group it under an **initiative**.
|
|
46
|
+
An initiative is one level above a plan; the same projection rule applies one level up:
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Initiative status = projection of its member plans' statuses
|
|
50
|
+
Plan status = projection of its tasks' statuses
|
|
51
|
+
Task base state
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
An initiative is `done` when it has ≥1 member plan and every member is terminal
|
|
55
|
+
(`done` / `superseded` / `abandoned`). Member plans link to the initiative by name and
|
|
56
|
+
carry **plan-level** `depends_on` (cross-initiative allowed), so the extension can compute
|
|
57
|
+
*ready work* — plans whose dependencies are all `done`. `initiative_status` surfaces, per
|
|
58
|
+
member plan, whether it is **ready** or **blocked by** which plans — the view you want when
|
|
59
|
+
splitting an initiative across sessions or subagents.
|
|
60
|
+
|
|
61
|
+
```text
|
|
62
|
+
# 1. Create the initiative
|
|
63
|
+
submit_initiative(name: "auth-overhaul", title: "Auth Overhaul", overview: "...")
|
|
64
|
+
|
|
65
|
+
# 2. Submit member plans linked + ordered
|
|
66
|
+
submit_plan(name: "auth-schema", initiative: "auth-overhaul")
|
|
67
|
+
submit_plan(name: "auth-jwt", initiative: "auth-overhaul", depends_on_plans: ["auth-schema"])
|
|
68
|
+
submit_plan(name: "auth-ui", initiative: "auth-overhaul", depends_on_plans: ["auth-jwt"])
|
|
69
|
+
|
|
70
|
+
# 3. See what's ready to pick up
|
|
71
|
+
initiative_status(initiative: "auth-overhaul")
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Initiative lifecycle mirrors plans: `done` is projected automatically, while `superseded` /
|
|
75
|
+
`abandoned` (and reopen) are explicit via `update_initiative` with a `reason`. The `clean`
|
|
76
|
+
CLI archives closed initiatives the same way it archives closed plans.
|
|
37
77
|
|
|
38
78
|
### Plan lifecycle status
|
|
39
79
|
|
|
@@ -99,10 +139,13 @@ When **Execute Plan** is selected:
|
|
|
99
139
|
|
|
100
140
|
```
|
|
101
141
|
.plans/
|
|
102
|
-
├── plans.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
142
|
+
├── plans.jsonl # Plan registry — plan status lifecycle
|
|
143
|
+
├── initiatives.jsonl # Initiative registry — groups member plans
|
|
144
|
+
├── auth-overhaul/ # An initiative directory
|
|
145
|
+
│ └── INITIATIVE.md # Initiative overview + plan breakdown
|
|
146
|
+
└── auth-jwt/ # A member plan (linked by name in the registry)
|
|
147
|
+
├── HANDOFF.md # Self-contained executor handoff
|
|
148
|
+
├── tasks.jsonl # Tasks (gains optional initiative + plan-level depends_on)
|
|
106
149
|
└── ... # Optional supporting files
|
|
107
150
|
```
|
|
108
151
|
|
package/bin/clean-plans.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* CLI to clean closed plans from `.plans/`.
|
|
3
|
+
* CLI to clean closed plans (and initiatives) from `.plans/`.
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
6
|
* npx @dreki-gg/pi-plan-mode clean [--dry-run] [--purge]
|
|
7
7
|
*
|
|
8
|
-
* Reads `.plans/plans.jsonl`, and for every
|
|
9
|
-
* (done / superseded / abandoned):
|
|
10
|
-
* - default: ARCHIVES the
|
|
11
|
-
* (non-destructive — keeps HANDOFF.md
|
|
12
|
-
* - --purge: permanently deletes the
|
|
13
|
-
* In both cases the entry is removed from
|
|
8
|
+
* Reads `.plans/plans.jsonl` and `.plans/initiatives.jsonl`, and for every
|
|
9
|
+
* entry whose status is terminal (done / superseded / abandoned):
|
|
10
|
+
* - default: ARCHIVES the directory to `.plans/.archive/<name>/`
|
|
11
|
+
* (non-destructive — keeps HANDOFF.md / INITIATIVE.md as a record)
|
|
12
|
+
* - --purge: permanently deletes the directory
|
|
13
|
+
* In both cases the entry is removed from its active registry.
|
|
14
14
|
*
|
|
15
15
|
* Designed for use in GitHub Actions after merge — similar to changesets.
|
|
16
16
|
* History-preserving by default (FEEDBACK #4): closing out a finished plan must
|
|
@@ -22,12 +22,13 @@ import { resolve, join } from 'node:path';
|
|
|
22
22
|
|
|
23
23
|
const PLANS_DIR = '.plans';
|
|
24
24
|
const ARCHIVE_DIR = join(PLANS_DIR, '.archive');
|
|
25
|
-
const
|
|
25
|
+
const PLANS_MANIFEST = join(PLANS_DIR, 'plans.jsonl');
|
|
26
|
+
const INITIATIVES_MANIFEST = join(PLANS_DIR, 'initiatives.jsonl');
|
|
26
27
|
|
|
27
28
|
const TERMINAL_STATUSES = new Set(['done', 'superseded', 'abandoned']);
|
|
28
29
|
|
|
29
|
-
/** Parse `.
|
|
30
|
-
function readManifest(path) {
|
|
30
|
+
/** Parse a `.jsonl` registry into an array of entries. */
|
|
31
|
+
function readManifest(path, label) {
|
|
31
32
|
const text = readFileSync(path, 'utf-8');
|
|
32
33
|
const entries = [];
|
|
33
34
|
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
@@ -35,7 +36,7 @@ function readManifest(path) {
|
|
|
35
36
|
try {
|
|
36
37
|
entries.push(JSON.parse(raw));
|
|
37
38
|
} catch (err) {
|
|
38
|
-
console.error(`Failed to parse ${
|
|
39
|
+
console.error(`Failed to parse ${label} at line ${index + 1}:`, err);
|
|
39
40
|
process.exit(1);
|
|
40
41
|
}
|
|
41
42
|
}
|
|
@@ -48,45 +49,31 @@ function writeManifest(path, entries) {
|
|
|
48
49
|
writeFileSync(path, content);
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
console.error(' clean Archive closed plan directories and update plans.jsonl\n');
|
|
59
|
-
console.error('Options:');
|
|
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/');
|
|
62
|
-
process.exit(1);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const dryRun = args.includes('--dry-run');
|
|
66
|
-
const purge = args.includes('--purge');
|
|
67
|
-
const manifestPath = resolve(MANIFEST_PATH);
|
|
68
|
-
|
|
69
|
-
if (!existsSync(manifestPath)) {
|
|
70
|
-
console.log(`No ${MANIFEST_PATH} found — nothing to clean.`);
|
|
71
|
-
process.exit(0);
|
|
72
|
-
}
|
|
52
|
+
/**
|
|
53
|
+
* Clean one registry. `noun` is "plan" / "initiative" for messaging. Returns
|
|
54
|
+
* the number cleaned and how many remain in progress.
|
|
55
|
+
*/
|
|
56
|
+
function cleanRegistry(manifestRelPath, noun, { dryRun, purge }) {
|
|
57
|
+
const manifestPath = resolve(manifestRelPath);
|
|
58
|
+
if (!existsSync(manifestPath)) return { cleaned: 0, inFlight: 0, closed: 0 };
|
|
73
59
|
|
|
74
|
-
const entries = readManifest(manifestPath);
|
|
60
|
+
const entries = readManifest(manifestPath, manifestRelPath);
|
|
75
61
|
const closed = entries.filter((entry) => TERMINAL_STATUSES.has(entry.status));
|
|
76
62
|
const inFlight = entries.filter((entry) => entry.status === 'in-progress');
|
|
77
63
|
|
|
78
64
|
if (closed.length === 0) {
|
|
79
|
-
console.log(
|
|
65
|
+
console.log(`No closed ${noun}s to clean.`);
|
|
80
66
|
if (inFlight.length > 0) {
|
|
81
|
-
console.log(
|
|
82
|
-
for (const entry of inFlight) console.log(` ○ ${entry.name} — ${entry.title}`);
|
|
67
|
+
console.log(` ${inFlight.length} ${noun}(s) still in progress.`);
|
|
83
68
|
}
|
|
84
|
-
|
|
69
|
+
return { cleaned: 0, inFlight: inFlight.length, closed: 0 };
|
|
85
70
|
}
|
|
86
71
|
|
|
87
72
|
const verb = purge ? 'delete' : 'archive';
|
|
88
73
|
console.log(
|
|
89
|
-
dryRun
|
|
74
|
+
dryRun
|
|
75
|
+
? `Dry run — would ${verb} closed ${noun}s:\n`
|
|
76
|
+
: `${purge ? 'Deleting' : 'Archiving'} closed ${noun}s:\n`,
|
|
90
77
|
);
|
|
91
78
|
|
|
92
79
|
if (!dryRun && !purge && !existsSync(resolve(ARCHIVE_DIR))) {
|
|
@@ -96,8 +83,8 @@ function main() {
|
|
|
96
83
|
const remaining = [...inFlight];
|
|
97
84
|
let cleaned = 0;
|
|
98
85
|
for (const entry of closed) {
|
|
99
|
-
const
|
|
100
|
-
const exists = existsSync(
|
|
86
|
+
const dirPath = resolve(join(PLANS_DIR, entry.name));
|
|
87
|
+
const exists = existsSync(dirPath);
|
|
101
88
|
const label = `${entry.name} — ${entry.title} [${entry.status}]`;
|
|
102
89
|
|
|
103
90
|
if (dryRun) {
|
|
@@ -107,12 +94,12 @@ function main() {
|
|
|
107
94
|
|
|
108
95
|
if (exists) {
|
|
109
96
|
if (purge) {
|
|
110
|
-
rmSync(
|
|
97
|
+
rmSync(dirPath, { recursive: true, force: true });
|
|
111
98
|
console.log(` ✓ Deleted ${PLANS_DIR}/${entry.name} — ${label}`);
|
|
112
99
|
} else {
|
|
113
100
|
const dest = resolve(join(ARCHIVE_DIR, entry.name));
|
|
114
101
|
rmSync(dest, { recursive: true, force: true }); // replace any stale archive
|
|
115
|
-
renameSync(
|
|
102
|
+
renameSync(dirPath, dest);
|
|
116
103
|
console.log(` ✓ Archived ${PLANS_DIR}/${entry.name} → ${ARCHIVE_DIR}/${entry.name}`);
|
|
117
104
|
}
|
|
118
105
|
} else {
|
|
@@ -122,11 +109,11 @@ function main() {
|
|
|
122
109
|
}
|
|
123
110
|
|
|
124
111
|
if (dryRun) {
|
|
125
|
-
console.log(`\n${closed.length}
|
|
112
|
+
console.log(`\n${closed.length} ${noun}(s) would be cleaned.`);
|
|
126
113
|
if (inFlight.length > 0) {
|
|
127
|
-
console.log(`${inFlight.length}
|
|
114
|
+
console.log(`${inFlight.length} ${noun}(s) still in progress (will be kept).`);
|
|
128
115
|
}
|
|
129
|
-
return;
|
|
116
|
+
return { cleaned: 0, inFlight: inFlight.length, closed: closed.length };
|
|
130
117
|
}
|
|
131
118
|
|
|
132
119
|
if (remaining.length === 0) {
|
|
@@ -135,9 +122,40 @@ function main() {
|
|
|
135
122
|
writeManifest(manifestPath, remaining);
|
|
136
123
|
}
|
|
137
124
|
|
|
138
|
-
console.log(`\nCleaned ${cleaned}
|
|
139
|
-
if (remaining.length > 0) console.log(`${remaining.length}
|
|
140
|
-
|
|
125
|
+
console.log(`\nCleaned ${cleaned} ${noun}(s).`);
|
|
126
|
+
if (remaining.length > 0) console.log(`${remaining.length} ${noun}(s) still in progress.`);
|
|
127
|
+
return { cleaned, inFlight: remaining.length, closed: closed.length };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function main() {
|
|
131
|
+
const args = process.argv.slice(2);
|
|
132
|
+
const command = args[0];
|
|
133
|
+
|
|
134
|
+
if (command !== 'clean') {
|
|
135
|
+
console.error('Usage: pi-plan-mode clean [--dry-run] [--purge]\n');
|
|
136
|
+
console.error('Commands:');
|
|
137
|
+
console.error(' clean Archive closed plan + initiative directories and update registries\n');
|
|
138
|
+
console.error('Options:');
|
|
139
|
+
console.error(' --dry-run Show what would be cleaned without changing anything');
|
|
140
|
+
console.error(' --purge Permanently delete instead of archiving to .plans/.archive/');
|
|
141
|
+
process.exit(1);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const dryRun = args.includes('--dry-run');
|
|
145
|
+
const purge = args.includes('--purge');
|
|
146
|
+
|
|
147
|
+
if (!existsSync(resolve(PLANS_MANIFEST)) && !existsSync(resolve(INITIATIVES_MANIFEST))) {
|
|
148
|
+
console.log(`No ${PLANS_MANIFEST} or ${INITIATIVES_MANIFEST} found — nothing to clean.`);
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const plans = cleanRegistry(PLANS_MANIFEST, 'plan', { dryRun, purge });
|
|
153
|
+
console.log('');
|
|
154
|
+
const initiatives = cleanRegistry(INITIATIVES_MANIFEST, 'initiative', { dryRun, purge });
|
|
155
|
+
|
|
156
|
+
if (!dryRun && !purge && (plans.cleaned > 0 || initiatives.cleaned > 0)) {
|
|
157
|
+
console.log(`\nArchived items kept in ${ARCHIVE_DIR}/ (use --purge to delete).`);
|
|
158
|
+
}
|
|
141
159
|
}
|
|
142
160
|
|
|
143
161
|
main();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression: concurrent read-modify-write on the registries must not lose
|
|
3
|
+
* updates. Each `Effect.runPromise` mimics a separate tool execute running in
|
|
4
|
+
* the same block (e.g. three `submit_initiative` calls). Before the file-lock
|
|
5
|
+
* fix, their reads all saw the same starting file and the last write clobbered
|
|
6
|
+
* the rest — only one entry survived.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
10
|
+
import { Effect } from 'effect';
|
|
11
|
+
import { chdir } from 'node:process';
|
|
12
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
16
|
+
import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
17
|
+
import {
|
|
18
|
+
readInitiativesManifest,
|
|
19
|
+
upsertInitiativeEntry,
|
|
20
|
+
} from '../storage/initiatives-manifest.js';
|
|
21
|
+
import { writeTasksJsonl, readTasksJsonl, updateTask } from '../storage/task-storage.js';
|
|
22
|
+
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
23
|
+
|
|
24
|
+
// Each call gets its OWN runPromise so the writes are genuinely concurrent —
|
|
25
|
+
// the same shape as independent tool executes sharing one Node process.
|
|
26
|
+
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
27
|
+
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
28
|
+
|
|
29
|
+
const originalCwd = process.cwd();
|
|
30
|
+
let dir: string;
|
|
31
|
+
|
|
32
|
+
beforeEach(async () => {
|
|
33
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-concurrent-'));
|
|
34
|
+
chdir(dir);
|
|
35
|
+
});
|
|
36
|
+
afterEach(async () => {
|
|
37
|
+
chdir(originalCwd);
|
|
38
|
+
await rm(dir, { recursive: true, force: true });
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe('concurrent registry writes', () => {
|
|
42
|
+
test('5 concurrent plan upserts all persist (no lost updates)', async () => {
|
|
43
|
+
const names = ['a', 'b', 'c', 'd', 'e'];
|
|
44
|
+
await Promise.all(
|
|
45
|
+
names.map((n) => run(upsertPlanEntry(n, { status: 'in-progress', title: n.toUpperCase() }))),
|
|
46
|
+
);
|
|
47
|
+
const entries = await run(readPlansManifest());
|
|
48
|
+
expect(entries.map((e) => e.name).sort()).toEqual(names);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('3 concurrent initiative upserts all persist (no lost updates)', async () => {
|
|
52
|
+
const names = ['one', 'two', 'three'];
|
|
53
|
+
await Promise.all(
|
|
54
|
+
names.map((n) => run(upsertInitiativeEntry(n, { status: 'in-progress', title: n }))),
|
|
55
|
+
);
|
|
56
|
+
const entries = await run(readInitiativesManifest());
|
|
57
|
+
expect(entries.map((e) => e.name).sort()).toEqual([...names].sort());
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('concurrent task updates to the same plan all persist', async () => {
|
|
61
|
+
const planDir = join('.plans', 'p');
|
|
62
|
+
const meta: TaskMeta = {
|
|
63
|
+
_type: 'meta',
|
|
64
|
+
plan_name: 'p',
|
|
65
|
+
title: 'P',
|
|
66
|
+
created_at: 'now',
|
|
67
|
+
};
|
|
68
|
+
const tasks: TaskRecord[] = ['t-001', 't-002', 't-003'].map((id) => ({
|
|
69
|
+
_type: 'task',
|
|
70
|
+
id,
|
|
71
|
+
description: id,
|
|
72
|
+
status: 'pending',
|
|
73
|
+
created_at: 'now',
|
|
74
|
+
updated_at: 'now',
|
|
75
|
+
}));
|
|
76
|
+
await run(writeTasksJsonl(planDir, meta, tasks));
|
|
77
|
+
|
|
78
|
+
await Promise.all(
|
|
79
|
+
tasks.map((t) => run(updateTask(planDir, t.id, { status: 'done' }))),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const snapshot = await run(readTasksJsonl(planDir));
|
|
83
|
+
expect(snapshot?.tasks.every((t) => t.status === 'done')).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { chdir } from 'node:process';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
8
|
+
import {
|
|
9
|
+
computePlanReadiness,
|
|
10
|
+
initiativeRollup,
|
|
11
|
+
isInitiativeFinalizable,
|
|
12
|
+
reconcileInitiativeForPlan,
|
|
13
|
+
reconcileInitiativeStatus,
|
|
14
|
+
} from '../initiative.js';
|
|
15
|
+
import { upsertPlanEntry, type PlanManifestEntry } from '../storage/plans-manifest.js';
|
|
16
|
+
import {
|
|
17
|
+
readInitiativesManifest,
|
|
18
|
+
upsertInitiativeEntry,
|
|
19
|
+
} from '../storage/initiatives-manifest.js';
|
|
20
|
+
|
|
21
|
+
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
22
|
+
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
23
|
+
|
|
24
|
+
function plan(partial: Partial<PlanManifestEntry> & { name: string }): PlanManifestEntry {
|
|
25
|
+
return {
|
|
26
|
+
_type: 'plan',
|
|
27
|
+
status: 'in-progress',
|
|
28
|
+
title: partial.name,
|
|
29
|
+
created_at: 'now',
|
|
30
|
+
completed_at: null,
|
|
31
|
+
...partial,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('computePlanReadiness (pure)', () => {
|
|
36
|
+
test('a plan with no deps is ready', () => {
|
|
37
|
+
const rows = computePlanReadiness([plan({ name: 'a' })]);
|
|
38
|
+
expect(rows).toEqual([{ name: 'a', ready: true, blockedBy: [] }]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a plan whose dep is done is ready; otherwise blocked', () => {
|
|
42
|
+
const plans = [
|
|
43
|
+
plan({ name: 'schema', status: 'done' }),
|
|
44
|
+
plan({ name: 'api', depends_on: ['schema'] }),
|
|
45
|
+
plan({ name: 'ui', depends_on: ['api'] }),
|
|
46
|
+
];
|
|
47
|
+
const byName = new Map(computePlanReadiness(plans).map((r) => [r.name, r]));
|
|
48
|
+
expect(byName.get('api')?.ready).toBe(true);
|
|
49
|
+
expect(byName.get('ui')?.ready).toBe(false);
|
|
50
|
+
expect(byName.get('ui')?.blockedBy).toEqual(['api']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('only in-progress plans are reported (done dep itself is not a row)', () => {
|
|
54
|
+
const rows = computePlanReadiness([
|
|
55
|
+
plan({ name: 'schema', status: 'done' }),
|
|
56
|
+
plan({ name: 'api', depends_on: ['schema'] }),
|
|
57
|
+
]);
|
|
58
|
+
expect(rows.map((r) => r.name)).toEqual(['api']);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('an unknown or terminally-closed dep keeps a plan blocked', () => {
|
|
62
|
+
const rows = computePlanReadiness([
|
|
63
|
+
plan({ name: 'ghost-dep', status: 'abandoned' }),
|
|
64
|
+
plan({ name: 'x', depends_on: ['ghost-dep', 'missing'] }),
|
|
65
|
+
]);
|
|
66
|
+
const x = rows.find((r) => r.name === 'x');
|
|
67
|
+
expect(x?.ready).toBe(false);
|
|
68
|
+
expect(x?.blockedBy.sort()).toEqual(['ghost-dep', 'missing']);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('isInitiativeFinalizable / initiativeRollup (pure)', () => {
|
|
73
|
+
const plans = [
|
|
74
|
+
plan({ name: 'a', initiative: 'big', status: 'done' }),
|
|
75
|
+
plan({ name: 'b', initiative: 'big', status: 'in-progress', depends_on: ['a'] }),
|
|
76
|
+
plan({ name: 'c', initiative: 'big', status: 'in-progress', depends_on: ['b'] }),
|
|
77
|
+
plan({ name: 'solo' }), // not a member
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
test('not finalizable while a member is in-progress', () => {
|
|
81
|
+
expect(isInitiativeFinalizable('big', plans)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('finalizable when all members are terminal', () => {
|
|
85
|
+
const allClosed = [
|
|
86
|
+
plan({ name: 'a', initiative: 'big', status: 'done' }),
|
|
87
|
+
plan({ name: 'b', initiative: 'big', status: 'superseded' }),
|
|
88
|
+
];
|
|
89
|
+
expect(isInitiativeFinalizable('big', allClosed)).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test('an empty initiative is never finalizable', () => {
|
|
93
|
+
expect(isInitiativeFinalizable('nope', plans)).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('rollup counts members, progress, and readiness', () => {
|
|
97
|
+
const r = initiativeRollup('big', plans);
|
|
98
|
+
expect(r.total).toBe(3);
|
|
99
|
+
expect(r.done).toBe(1);
|
|
100
|
+
expect(r.inProgress).toBe(2);
|
|
101
|
+
expect(r.ready).toBe(1); // b (dep a is done)
|
|
102
|
+
expect(r.blocked).toBe(1); // c (dep b is in-progress)
|
|
103
|
+
const b = r.members.find((m) => m.name === 'b');
|
|
104
|
+
expect(b?.ready).toBe(true);
|
|
105
|
+
const c = r.members.find((m) => m.name === 'c');
|
|
106
|
+
expect(c?.blockedBy).toEqual(['b']);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('reconcileInitiativeStatus / reconcileInitiativeForPlan (IO projection)', () => {
|
|
111
|
+
const originalCwd = process.cwd();
|
|
112
|
+
let dir: string;
|
|
113
|
+
|
|
114
|
+
beforeEach(async () => {
|
|
115
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-init-proj-'));
|
|
116
|
+
chdir(dir);
|
|
117
|
+
});
|
|
118
|
+
afterEach(async () => {
|
|
119
|
+
chdir(originalCwd);
|
|
120
|
+
await rm(dir, { recursive: true, force: true });
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('flips an initiative to done when its last in-progress member completes', async () => {
|
|
124
|
+
await run(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
|
|
125
|
+
await run(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
|
|
126
|
+
await run(upsertPlanEntry('b', { status: 'in-progress', title: 'B', initiative: 'big' }));
|
|
127
|
+
|
|
128
|
+
await run(reconcileInitiativeStatus('big'));
|
|
129
|
+
let [entry] = await run(readInitiativesManifest());
|
|
130
|
+
expect(entry.status).toBe('in-progress'); // b still open
|
|
131
|
+
|
|
132
|
+
await run(upsertPlanEntry('b', { status: 'done', title: 'B', initiative: 'big' }));
|
|
133
|
+
await run(reconcileInitiativeForPlan('b'));
|
|
134
|
+
[entry] = await run(readInitiativesManifest());
|
|
135
|
+
expect(entry.status).toBe('done');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('reopens a done initiative when a member goes back in-progress', async () => {
|
|
139
|
+
await run(upsertInitiativeEntry('big', { status: 'done', title: 'Big' }));
|
|
140
|
+
await run(upsertPlanEntry('a', { status: 'in-progress', title: 'A', initiative: 'big' }));
|
|
141
|
+
await run(reconcileInitiativeStatus('big'));
|
|
142
|
+
const [entry] = await run(readInitiativesManifest());
|
|
143
|
+
expect(entry.status).toBe('in-progress');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('never overrides a manually-closed (superseded) initiative', async () => {
|
|
147
|
+
await run(upsertInitiativeEntry('big', { status: 'superseded', title: 'Big', reason: 'x' }));
|
|
148
|
+
await run(upsertPlanEntry('a', { status: 'done', title: 'A', initiative: 'big' }));
|
|
149
|
+
await run(reconcileInitiativeStatus('big'));
|
|
150
|
+
const [entry] = await run(readInitiativesManifest());
|
|
151
|
+
expect(entry.status).toBe('superseded');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('reconcileInitiativeForPlan is a no-op for a standalone plan', async () => {
|
|
155
|
+
await run(upsertPlanEntry('solo', { status: 'done', title: 'Solo' }));
|
|
156
|
+
await run(reconcileInitiativeForPlan('solo'));
|
|
157
|
+
await expect(run(readInitiativesManifest())).resolves.toEqual([]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { Effect } from 'effect';
|
|
3
|
+
import { chdir } from 'node:process';
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
|
|
8
|
+
import { makePlanRuntime } from '../effects/runtime.js';
|
|
9
|
+
import { upsertPlanEntry } from '../storage/plans-manifest.js';
|
|
10
|
+
import { upsertInitiativeEntry } from '../storage/initiatives-manifest.js';
|
|
11
|
+
import { writeTasksJsonl } from '../storage/task-storage.js';
|
|
12
|
+
import type { TaskRecord } from '../types.js';
|
|
13
|
+
import { registerInitiativeStatusTool } from '../tools/initiative-status.js';
|
|
14
|
+
|
|
15
|
+
const runPlanIO = makePlanRuntime();
|
|
16
|
+
const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
|
|
17
|
+
Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
|
|
18
|
+
|
|
19
|
+
interface CapturedTool {
|
|
20
|
+
execute: (
|
|
21
|
+
id: string,
|
|
22
|
+
params: { initiative?: string },
|
|
23
|
+
) => Promise<{ content?: Array<{ text: string }>; details?: unknown }>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function setup(): CapturedTool {
|
|
27
|
+
let tool: CapturedTool | undefined;
|
|
28
|
+
const pi = {
|
|
29
|
+
registerTool: (config: CapturedTool) => {
|
|
30
|
+
tool = config;
|
|
31
|
+
},
|
|
32
|
+
} as unknown as Parameters<typeof registerInitiativeStatusTool>[0];
|
|
33
|
+
registerInitiativeStatusTool(pi, runPlanIO);
|
|
34
|
+
return tool!;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function task(id: string, status: TaskRecord['status']): TaskRecord {
|
|
38
|
+
return { _type: 'task', id, description: id, status, created_at: 'n', updated_at: 'n' };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const originalCwd = process.cwd();
|
|
42
|
+
let dir: string;
|
|
43
|
+
beforeEach(async () => {
|
|
44
|
+
dir = await mkdtemp(join(tmpdir(), 'plan-mode-init-status-'));
|
|
45
|
+
chdir(dir);
|
|
46
|
+
});
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
chdir(originalCwd);
|
|
49
|
+
await rm(dir, { recursive: true, force: true });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('initiative_status tool', () => {
|
|
53
|
+
async function seed() {
|
|
54
|
+
await runPlanIO(upsertInitiativeEntry('big', { status: 'in-progress', title: 'Big' }));
|
|
55
|
+
await runPlanIO(upsertPlanEntry('schema', { status: 'done', title: 'Schema', initiative: 'big' }));
|
|
56
|
+
await runPlanIO(
|
|
57
|
+
upsertPlanEntry('api', { status: 'in-progress', title: 'API', initiative: 'big', depends_on: ['schema'] }),
|
|
58
|
+
);
|
|
59
|
+
await runPlanIO(
|
|
60
|
+
upsertPlanEntry('ui', { status: 'in-progress', title: 'UI', initiative: 'big', depends_on: ['api'] }),
|
|
61
|
+
);
|
|
62
|
+
await run(
|
|
63
|
+
writeTasksJsonl(
|
|
64
|
+
'.plans/api',
|
|
65
|
+
{ _type: 'meta', title: 'API', plan_name: 'api', created_at: 'n' },
|
|
66
|
+
[task('t-001', 'done'), task('t-002', 'pending')],
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
test('renders member plans with progress + ready/blocked', async () => {
|
|
72
|
+
await seed();
|
|
73
|
+
const result = await setup().execute('c', { initiative: 'big' });
|
|
74
|
+
const text = result.content?.[0]?.text ?? '';
|
|
75
|
+
expect(text).toMatch(/Initiative: Big \(big\) — in-progress/);
|
|
76
|
+
expect(text).toMatch(/api \[in-progress\] 1\/2 tasks \[ready\]/);
|
|
77
|
+
expect(text).toMatch(/ui \[in-progress\].*\[blocked by api\]/);
|
|
78
|
+
const details = result.details as { ready_plans?: string[] };
|
|
79
|
+
expect(details.ready_plans).toContain('api');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('auto-selects the sole in-progress initiative when none is passed', async () => {
|
|
83
|
+
await seed();
|
|
84
|
+
const result = await setup().execute('c', {});
|
|
85
|
+
expect((result.details as { active?: boolean }).active).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('reports not_found for an unknown initiative', async () => {
|
|
89
|
+
await seed();
|
|
90
|
+
const result = await setup().execute('c', { initiative: 'ghost' });
|
|
91
|
+
expect((result.details as { error?: string }).error).toBe('not_found');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('no in-progress initiative → inactive snapshot', async () => {
|
|
95
|
+
const result = await setup().execute('c', {});
|
|
96
|
+
expect((result.details as { active?: boolean }).active).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
});
|