@dreki-gg/pi-plan-mode 0.21.0 → 0.24.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.
Files changed (41) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +48 -5
  3. package/bin/clean-plans.js +67 -49
  4. package/extensions/plan-mode/__tests__/html-render.test.ts +37 -23
  5. package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +159 -0
  6. package/extensions/plan-mode/__tests__/initiative-status.test.ts +98 -0
  7. package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +89 -0
  8. package/extensions/plan-mode/__tests__/list-initiatives.test.ts +59 -0
  9. package/extensions/plan-mode/__tests__/list-plans.test.ts +253 -0
  10. package/extensions/plan-mode/__tests__/reconcile.test.ts +38 -1
  11. package/extensions/plan-mode/__tests__/revise-plan.test.ts +38 -0
  12. package/extensions/plan-mode/__tests__/schema.test.ts +88 -0
  13. package/extensions/plan-mode/__tests__/submit-initiative.test.ts +58 -0
  14. package/extensions/plan-mode/__tests__/submit-plan.test.ts +97 -0
  15. package/extensions/plan-mode/__tests__/update-initiative.test.ts +72 -0
  16. package/extensions/plan-mode/commands/list-initiatives.ts +148 -0
  17. package/extensions/plan-mode/commands/list-plans.ts +229 -0
  18. package/extensions/plan-mode/constants.ts +5 -0
  19. package/extensions/plan-mode/html/render.ts +41 -18
  20. package/extensions/plan-mode/index.ts +31 -0
  21. package/extensions/plan-mode/initiative.ts +168 -0
  22. package/extensions/plan-mode/prompts.ts +5 -1
  23. package/extensions/plan-mode/reconcile.ts +65 -0
  24. package/extensions/plan-mode/schema.ts +28 -0
  25. package/extensions/plan-mode/storage/initiatives-manifest.ts +112 -0
  26. package/extensions/plan-mode/storage/plan-storage.ts +11 -0
  27. package/extensions/plan-mode/storage/plans-manifest.ts +16 -1
  28. package/extensions/plan-mode/tools/initiative-status.ts +191 -0
  29. package/extensions/plan-mode/tools/preview-prototype.ts +14 -9
  30. package/extensions/plan-mode/tools/reconcile-plans.ts +33 -6
  31. package/extensions/plan-mode/tools/revise-plan.ts +22 -0
  32. package/extensions/plan-mode/tools/submit-initiative.ts +86 -0
  33. package/extensions/plan-mode/tools/submit-plan.ts +37 -4
  34. package/extensions/plan-mode/tools/update-initiative.ts +120 -0
  35. package/extensions/plan-mode/tools/update-plan.ts +3 -0
  36. package/extensions/plan-mode/types.ts +3 -0
  37. package/package.json +2 -3
  38. package/skills/planning-context/SKILL.md +11 -0
  39. package/skills/visual-prototype/SKILL.md +4 -2
  40. package/extensions/plan-mode/html/templates/prototype.pug +0 -25
  41. package/extensions/plan-mode/pug.d.ts +0 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.24.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add an **initiatives** layer above plans for decomposing large work (beads-style grouping + cross-plan dependencies).
8
+
9
+ - New `submit_initiative`, `update_initiative`, and `initiative_status` tools, plus a `/initiatives` command.
10
+ - `submit_plan` / `revise_plan` gain optional `initiative` and `depends_on_plans` (plan-level dependencies; cross-initiative allowed).
11
+ - 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.
12
+ - `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.
13
+ - `reconcile_plans` now also detects/repairs initiative-level status drift; the `clean` CLI archives closed initiatives alongside closed plans.
14
+ - New `.plans/initiatives.jsonl` registry and `.plans/<initiative>/INITIATIVE.md` overview. All new fields are optional — existing flat plans are unaffected.
15
+
16
+ ## 0.23.0
17
+
18
+ ### Minor Changes
19
+
20
+ - `preview_prototype` is now freeform. The tool dropped its Pug dependency and the imposed wrapper template (fixed dark theme, badge header, purple-accent panel) that made every prototype look the same. It now takes a `html` parameter — a complete, self-contained HTML document the agent authors with full freedom over markup, fonts, colors, layout, and inline scripts — and only persists and opens it. A bare fragment is tolerated and wrapped in a minimal unstyled shell. The `visual-prototype` skill and plan-mode prompt now steer the agent toward product-fitting designs and toward delegating the markup to the `ux-designer` subagent for real design taste, instead of generic boilerplate.
21
+
22
+ ## 0.22.0
23
+
24
+ ### Minor Changes
25
+
26
+ - Add `/plans` command to list, filter, and sort plans interactively. Supports filtering by status (in-progress, done, superseded, abandoned) and sorting by date, task count, or name. Works both interactively and with inline args (e.g. `/plans done oldest`).
27
+
3
28
  ## 0.21.0
4
29
 
5
30
  ### 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 | `reconcile_plans` | Detect & repair drift between tasks.jsonl and the registry |
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.json # Tracking manifest — plan status lifecycle
103
- └── add-auth-middleware/
104
- ├── PLAN.md # Numbered plan with context
105
- ├── START-PROMPT.md # Self-contained executor handoff prompt
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
 
@@ -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 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.
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 MANIFEST_PATH = join(PLANS_DIR, 'plans.jsonl');
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 `.plans/plans.jsonl` into an array of plan entries. */
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 ${MANIFEST_PATH} at line ${index + 1}:`, err);
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
- function main() {
52
- const args = process.argv.slice(2);
53
- const command = args[0];
54
-
55
- if (command !== 'clean') {
56
- console.error('Usage: pi-plan-mode clean [--dry-run] [--purge]\n');
57
- console.error('Commands:');
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('No closed plans to clean.');
65
+ console.log(`No closed ${noun}s to clean.`);
80
66
  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}`);
67
+ console.log(` ${inFlight.length} ${noun}(s) still in progress.`);
83
68
  }
84
- process.exit(0);
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 ? `Dry run — would ${verb}:\n` : `${purge ? 'Deleting' : 'Archiving'} closed plans:\n`,
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 planPath = resolve(join(PLANS_DIR, entry.name));
100
- const exists = existsSync(planPath);
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(planPath, { recursive: true, force: true });
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(planPath, dest);
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} plan(s) would be cleaned.`);
112
+ console.log(`\n${closed.length} ${noun}(s) would be cleaned.`);
126
113
  if (inFlight.length > 0) {
127
- console.log(`${inFlight.length} plan(s) still in progress (will be kept).`);
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} 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).`);
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();
@@ -1,34 +1,48 @@
1
1
  import { describe, expect, test } from 'bun:test';
2
- import { renderPrototypeHtml } from '../html/render.js';
3
-
4
- describe('renderPrototypeHtml', () => {
5
- test('renders title and intent in a minimal header', () => {
6
- const html = renderPrototypeHtml('Sidebar redesign', 'Left-aligned nav with icons', '.nav Nav');
7
-
8
- expect(html).toContain('Sidebar redesign');
9
- expect(html).toContain('Left-aligned nav with icons');
10
- expect(html).toContain('Prototype');
2
+ import { buildPrototypeDocument } from '../html/render.js';
3
+
4
+ describe('buildPrototypeDocument', () => {
5
+ test('returns a full HTML document untouched, with no imposed wrapper', () => {
6
+ const doc = `<!doctype html>
7
+ <html lang="en">
8
+ <head><meta charset="utf-8"><title>My own design</title></head>
9
+ <body style="background: salmon"><h1>Hand-crafted</h1></body>
10
+ </html>`;
11
+
12
+ const html = buildPrototypeDocument('Sidebar redesign', doc);
13
+
14
+ // Author's document is preserved verbatim — no badge, no theme, no panel.
15
+ expect(html).toBe(doc.trim());
16
+ expect(html).not.toContain('Prototype ·');
17
+ expect(html).not.toContain('class="prototype"');
18
+ expect(html).not.toContain('Inter');
11
19
  });
12
20
 
13
- test('renders the Pug body markup', () => {
14
- const html = renderPrototypeHtml('Card', 'A product card', '.card Product card');
15
-
16
- expect(html).toContain('class="card"');
17
- expect(html).toContain('Product card');
21
+ test('recognizes a full document via <html> even without a doctype', () => {
22
+ const doc = '<html><body><main>Hello</main></body></html>';
23
+ const html = buildPrototypeDocument('Card', doc);
24
+ expect(html).toBe(doc);
18
25
  });
19
26
 
20
- test('does not render tasks or handoff sections', () => {
21
- const html = renderPrototypeHtml('Card', 'A product card', '.card Hello');
27
+ test('wraps a bare fragment in a minimal, unstyled shell', () => {
28
+ const html = buildPrototypeDocument('Card', '<div class="card">Product card</div>');
22
29
 
23
- expect(html).not.toContain('Handoff');
24
- expect(html).not.toContain('Tasks');
25
- expect(html).not.toContain('Depends on');
30
+ expect(html).toMatch(/^<!doctype html>/i);
31
+ expect(html).toContain('<title>Card</title>');
32
+ expect(html).toContain('<div class="card">Product card</div>');
33
+ // The shell imposes no theme of its own.
34
+ expect(html).not.toContain('background');
35
+ expect(html).not.toContain('Inter');
26
36
  });
27
37
 
28
- test('handles an empty prototype body without throwing', () => {
29
- const html = renderPrototypeHtml('Empty', 'Nothing yet', '');
38
+ test('escapes the title when used in the fallback shell', () => {
39
+ const html = buildPrototypeDocument('A & B <script>', '<p>hi</p>');
40
+ expect(html).toContain('<title>A &amp; B &lt;script&gt;</title>');
41
+ });
30
42
 
31
- expect(html).toContain('Empty');
32
- expect(html).toContain('Nothing yet');
43
+ test('handles an empty body without throwing', () => {
44
+ const html = buildPrototypeDocument('Empty', '');
45
+ expect(html).toMatch(/^<!doctype html>/i);
46
+ expect(html).toContain('<title>Empty</title>');
33
47
  });
34
48
  });
@@ -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
+ });