@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
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `.plans/initiatives.jsonl` registry — the initiative-level sibling of
|
|
3
|
+
* `plans-manifest.ts`.
|
|
4
|
+
*
|
|
5
|
+
* An initiative groups multiple plans. Its `status` is a PROJECTION of its
|
|
6
|
+
* member plans' statuses (see `reconcileInitiativeStatus` in `../initiative.ts`
|
|
7
|
+
* for the projection wiring): `done` when every member plan is terminal,
|
|
8
|
+
* `in-progress` otherwise. Manually-set terminal statuses (`superseded` /
|
|
9
|
+
* `abandoned` via `update_initiative`) are never auto-overridden.
|
|
10
|
+
*
|
|
11
|
+
* This module is intentionally dependency-light: it knows how to read/write the
|
|
12
|
+
* registry. The projection (which must read the PLANS manifest) lives in
|
|
13
|
+
* `../initiative.ts` to keep the dependency direction one-way and cycle-free.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Effect, Either, Option } from 'effect';
|
|
17
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
18
|
+
import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
|
|
19
|
+
import { decodeInitiativeManifestEntry } from '../schema.js';
|
|
20
|
+
import type { InitiativeStatus } from '../types.js';
|
|
21
|
+
import { withFileLock } from './file-lock.js';
|
|
22
|
+
|
|
23
|
+
const MANIFEST_DIR = '.plans';
|
|
24
|
+
const MANIFEST_PATH = '.plans/initiatives.jsonl';
|
|
25
|
+
|
|
26
|
+
export interface InitiativeManifestEntry {
|
|
27
|
+
_type: 'initiative';
|
|
28
|
+
name: string;
|
|
29
|
+
status: InitiativeStatus;
|
|
30
|
+
title: string;
|
|
31
|
+
created_at: string;
|
|
32
|
+
completed_at: string | null;
|
|
33
|
+
reason?: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A status is terminal (closed) when it is anything other than in-progress. */
|
|
37
|
+
export function isTerminalStatus(status: InitiativeStatus): boolean {
|
|
38
|
+
return status !== 'in-progress';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type ReadError = JsonlParseError | JsonlValidationError;
|
|
42
|
+
|
|
43
|
+
export function readInitiativesManifest(): Effect.Effect<
|
|
44
|
+
InitiativeManifestEntry[],
|
|
45
|
+
ReadError,
|
|
46
|
+
FileSystem
|
|
47
|
+
> {
|
|
48
|
+
return Effect.gen(function* () {
|
|
49
|
+
const fs = yield* FileSystem;
|
|
50
|
+
// A missing or unreadable manifest is treated as "no initiatives".
|
|
51
|
+
const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH));
|
|
52
|
+
if (Option.isNone(maybeText)) return [];
|
|
53
|
+
|
|
54
|
+
const entries: InitiativeManifestEntry[] = [];
|
|
55
|
+
for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
|
|
56
|
+
if (!raw.trim()) continue;
|
|
57
|
+
const line = index + 1;
|
|
58
|
+
|
|
59
|
+
let parsed: unknown;
|
|
60
|
+
try {
|
|
61
|
+
parsed = JSON.parse(raw);
|
|
62
|
+
} catch (cause) {
|
|
63
|
+
return yield* Effect.fail(new JsonlParseError({ path: MANIFEST_PATH, line, cause }));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const decoded = decodeInitiativeManifestEntry(parsed);
|
|
67
|
+
if (Either.isLeft(decoded)) {
|
|
68
|
+
return yield* Effect.fail(
|
|
69
|
+
new JsonlValidationError({ path: MANIFEST_PATH, line, reason: decoded.left.message }),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
entries.push(decoded.right);
|
|
73
|
+
}
|
|
74
|
+
return entries;
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeInitiativesManifest(
|
|
79
|
+
entries: InitiativeManifestEntry[],
|
|
80
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
81
|
+
return Effect.gen(function* () {
|
|
82
|
+
const fs = yield* FileSystem;
|
|
83
|
+
yield* fs.makeDir(MANIFEST_DIR);
|
|
84
|
+
const content =
|
|
85
|
+
entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
|
|
86
|
+
yield* fs.writeFileAtomic(MANIFEST_PATH, content);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface InitiativeUpsert {
|
|
91
|
+
status: InitiativeStatus;
|
|
92
|
+
title?: string;
|
|
93
|
+
reason?: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Pure transform: upsert `name` into the in-memory `entries` array, preserving
|
|
98
|
+
* created_at from any existing entry. No IO — shared by the locked
|
|
99
|
+
* `upsertInitiativeEntry` and `reconcileInitiativeStatus` so both flow through
|
|
100
|
+
* one serialized read-modify-write and never nest locks.
|
|
101
|
+
*/
|
|
102
|
+
export function applyInitiativeUpsert(
|
|
103
|
+
entries: InitiativeManifestEntry[],
|
|
104
|
+
name: string,
|
|
105
|
+
updates: InitiativeUpsert,
|
|
106
|
+
): void {
|
|
107
|
+
const now = new Date().toISOString();
|
|
108
|
+
const index = entries.findIndex((entry) => entry.name === name);
|
|
109
|
+
const existing = index === -1 ? undefined : entries[index];
|
|
110
|
+
const entry: InitiativeManifestEntry = {
|
|
111
|
+
_type: 'initiative',
|
|
112
|
+
name,
|
|
113
|
+
status: updates.status,
|
|
114
|
+
title: updates.title ?? existing?.title ?? 'Untitled initiative',
|
|
115
|
+
created_at: existing?.created_at ?? now,
|
|
116
|
+
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
117
|
+
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
118
|
+
reason: updates.reason ?? existing?.reason,
|
|
119
|
+
};
|
|
120
|
+
if (index === -1) entries.push(entry);
|
|
121
|
+
else entries[index] = entry;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Serialized read-modify-write of the initiatives registry. Holds a
|
|
126
|
+
* process-wide lock on the manifest path across the whole read → transform →
|
|
127
|
+
* write so concurrent tool calls cannot clobber each other. `transform` may run
|
|
128
|
+
* IO (e.g. read the plans manifest to project status) and mutates the entries
|
|
129
|
+
* array in place, returning `true` when it changed something.
|
|
130
|
+
*/
|
|
131
|
+
export function mutateInitiativesManifest<E, R>(
|
|
132
|
+
transform: (entries: InitiativeManifestEntry[]) => Effect.Effect<boolean, E, R>,
|
|
133
|
+
): Effect.Effect<void, ReadError | PlanWriteError | E, FileSystem | R> {
|
|
134
|
+
return withFileLock(
|
|
135
|
+
MANIFEST_PATH,
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const entries = yield* readInitiativesManifest();
|
|
138
|
+
const changed = yield* transform(entries);
|
|
139
|
+
if (changed) yield* writeInitiativesManifest(entries);
|
|
140
|
+
}),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function upsertInitiativeEntry(
|
|
145
|
+
name: string,
|
|
146
|
+
updates: InitiativeUpsert,
|
|
147
|
+
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
148
|
+
return mutateInitiativesManifest((entries) =>
|
|
149
|
+
Effect.sync(() => {
|
|
150
|
+
applyInitiativeUpsert(entries, name, updates);
|
|
151
|
+
return true;
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -75,3 +75,14 @@ export function loadHandoff(planDir: string): Effect.Effect<string | undefined,
|
|
|
75
75
|
return Option.getOrUndefined(maybeText);
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
+
|
|
79
|
+
export function saveInitiative(
|
|
80
|
+
initiativeDir: string,
|
|
81
|
+
content: string,
|
|
82
|
+
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
83
|
+
return Effect.gen(function* () {
|
|
84
|
+
const fs = yield* FileSystem;
|
|
85
|
+
yield* fs.makeDir(initiativeDir);
|
|
86
|
+
yield* fs.writeFileString(`${initiativeDir}/INITIATIVE.md`, content);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -3,6 +3,7 @@ import { FileSystem } from '../effects/filesystem.js';
|
|
|
3
3
|
import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
|
|
4
4
|
import { decodePlanManifestEntry } from '../schema.js';
|
|
5
5
|
import type { PlanStatus } from '../types.js';
|
|
6
|
+
import { withFileLock } from './file-lock.js';
|
|
6
7
|
|
|
7
8
|
const MANIFEST_DIR = '.plans';
|
|
8
9
|
const MANIFEST_PATH = '.plans/plans.jsonl';
|
|
@@ -15,6 +16,10 @@ export interface PlanManifestEntry {
|
|
|
15
16
|
created_at: string;
|
|
16
17
|
completed_at: string | null;
|
|
17
18
|
reason?: string;
|
|
19
|
+
/** Parent initiative name (kebab). Absent = standalone flat plan. */
|
|
20
|
+
initiative?: string;
|
|
21
|
+
/** Plan-level dependencies (plan names). Cross-initiative allowed. */
|
|
22
|
+
depends_on?: string[];
|
|
18
23
|
}
|
|
19
24
|
|
|
20
25
|
/** A status is terminal (closed) when it is anything other than in-progress. */
|
|
@@ -67,28 +72,74 @@ export function writePlansManifest(
|
|
|
67
72
|
});
|
|
68
73
|
}
|
|
69
74
|
|
|
75
|
+
export interface PlanUpsert {
|
|
76
|
+
status: PlanStatus;
|
|
77
|
+
title?: string;
|
|
78
|
+
reason?: string;
|
|
79
|
+
/** Parent initiative name; preserved when omitted. */
|
|
80
|
+
initiative?: string;
|
|
81
|
+
/** Plan-level dependencies (plan names); preserved when omitted. */
|
|
82
|
+
depends_on?: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Pure transform: upsert `name` into the in-memory `entries` array, preserving
|
|
87
|
+
* created_at / membership / deps from any existing entry. No IO — shared by the
|
|
88
|
+
* locked `upsertPlanEntry` and `reconcilePlanStatus` so both flow through one
|
|
89
|
+
* serialized read-modify-write and never nest locks.
|
|
90
|
+
*/
|
|
91
|
+
export function applyPlanUpsert(
|
|
92
|
+
entries: PlanManifestEntry[],
|
|
93
|
+
name: string,
|
|
94
|
+
updates: PlanUpsert,
|
|
95
|
+
): void {
|
|
96
|
+
const now = new Date().toISOString();
|
|
97
|
+
const index = entries.findIndex((entry) => entry.name === name);
|
|
98
|
+
const existing = index === -1 ? undefined : entries[index];
|
|
99
|
+
const entry: PlanManifestEntry = {
|
|
100
|
+
_type: 'plan',
|
|
101
|
+
name,
|
|
102
|
+
status: updates.status,
|
|
103
|
+
title: updates.title ?? existing?.title ?? 'Untitled plan',
|
|
104
|
+
created_at: existing?.created_at ?? now,
|
|
105
|
+
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
106
|
+
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
107
|
+
reason: updates.reason ?? existing?.reason,
|
|
108
|
+
// Membership + plan-level deps are preserved across status-only upserts.
|
|
109
|
+
initiative: updates.initiative ?? existing?.initiative,
|
|
110
|
+
depends_on: updates.depends_on ?? existing?.depends_on,
|
|
111
|
+
};
|
|
112
|
+
if (index === -1) entries.push(entry);
|
|
113
|
+
else entries[index] = entry;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Serialized read-modify-write of the plans registry. Holds a process-wide lock
|
|
118
|
+
* on the manifest path across the whole read → transform → write so concurrent
|
|
119
|
+
* tool calls cannot clobber each other (lost-update race). `transform` mutates
|
|
120
|
+
* the entries array in place and returns `true` when it changed something
|
|
121
|
+
* (return `false` to skip the rewrite).
|
|
122
|
+
*/
|
|
123
|
+
export function mutatePlansManifest(
|
|
124
|
+
transform: (entries: PlanManifestEntry[]) => boolean,
|
|
125
|
+
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
126
|
+
return withFileLock(
|
|
127
|
+
MANIFEST_PATH,
|
|
128
|
+
Effect.gen(function* () {
|
|
129
|
+
const entries = yield* readPlansManifest();
|
|
130
|
+
const changed = transform(entries);
|
|
131
|
+
if (changed) yield* writePlansManifest(entries);
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
70
136
|
export function upsertPlanEntry(
|
|
71
137
|
name: string,
|
|
72
|
-
updates:
|
|
138
|
+
updates: PlanUpsert,
|
|
73
139
|
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
74
|
-
return
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const index = entries.findIndex((entry) => entry.name === name);
|
|
78
|
-
const existing = index === -1 ? undefined : entries[index];
|
|
79
|
-
const entry: PlanManifestEntry = {
|
|
80
|
-
_type: 'plan',
|
|
81
|
-
name,
|
|
82
|
-
status: updates.status,
|
|
83
|
-
title: updates.title ?? existing?.title ?? 'Untitled plan',
|
|
84
|
-
created_at: existing?.created_at ?? now,
|
|
85
|
-
// Terminal statuses record a completion timestamp; reopening clears it.
|
|
86
|
-
completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
|
|
87
|
-
reason: updates.reason ?? existing?.reason,
|
|
88
|
-
};
|
|
89
|
-
if (index === -1) entries.push(entry);
|
|
90
|
-
else entries[index] = entry;
|
|
91
|
-
yield* writePlansManifest(entries);
|
|
140
|
+
return mutatePlansManifest((entries) => {
|
|
141
|
+
applyPlanUpsert(entries, name, updates);
|
|
142
|
+
return true;
|
|
92
143
|
});
|
|
93
144
|
}
|
|
94
145
|
|
|
@@ -108,16 +159,16 @@ export function reconcilePlanStatus(
|
|
|
108
159
|
finalizable: boolean,
|
|
109
160
|
title?: string,
|
|
110
161
|
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
111
|
-
return
|
|
112
|
-
const entries = yield* readPlansManifest();
|
|
162
|
+
return mutatePlansManifest((entries) => {
|
|
113
163
|
const existing = entries.find((entry) => entry.name === name);
|
|
114
164
|
// Reconcile only reflects task state for KNOWN plans; never conjure an
|
|
115
165
|
// entry for an unregistered plan (orphans are surfaced, not auto-created).
|
|
116
|
-
if (!existing) return;
|
|
166
|
+
if (!existing) return false;
|
|
117
167
|
// Do not resurrect / clobber an explicitly closed plan.
|
|
118
|
-
if (existing.status === 'superseded' || existing.status === 'abandoned') return;
|
|
168
|
+
if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
|
|
119
169
|
const status: PlanStatus = finalizable ? 'done' : 'in-progress';
|
|
120
|
-
if (existing.status === status) return; // no change
|
|
121
|
-
|
|
170
|
+
if (existing.status === status) return false; // no change
|
|
171
|
+
applyPlanUpsert(entries, name, { status, title });
|
|
172
|
+
return true;
|
|
122
173
|
});
|
|
123
174
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from '../errors.js';
|
|
12
12
|
import { decodeTasksLine } from '../schema.js';
|
|
13
13
|
import type { TaskMeta, TaskRecord } from '../types.js';
|
|
14
|
+
import { withFileLock } from './file-lock.js';
|
|
14
15
|
|
|
15
16
|
const TASKS_FILE = 'tasks.jsonl';
|
|
16
17
|
|
|
@@ -86,20 +87,25 @@ export function updateTask(
|
|
|
86
87
|
ReadError | PlanWriteError | TasksFileNotFound | TaskNotFound,
|
|
87
88
|
FileSystem
|
|
88
89
|
> {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
// Serialize the read-modify-write so concurrent task updates to the same plan
|
|
91
|
+
// (e.g. parallel update_task / update_tasks calls) cannot clobber each other.
|
|
92
|
+
return withFileLock(
|
|
93
|
+
join(planDir, TASKS_FILE),
|
|
94
|
+
Effect.gen(function* () {
|
|
95
|
+
const snapshot = yield* readTasksJsonl(planDir);
|
|
96
|
+
if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
|
|
92
97
|
|
|
93
|
-
|
|
94
|
-
|
|
98
|
+
const index = snapshot.tasks.findIndex((task) => task.id === taskId);
|
|
99
|
+
if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
|
|
95
100
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
101
|
+
const updated: TaskRecord = {
|
|
102
|
+
...snapshot.tasks[index],
|
|
103
|
+
...updates,
|
|
104
|
+
updated_at: new Date().toISOString(),
|
|
105
|
+
};
|
|
106
|
+
snapshot.tasks[index] = updated;
|
|
107
|
+
yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
|
|
108
|
+
return updated;
|
|
109
|
+
}),
|
|
110
|
+
);
|
|
105
111
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* initiative_status tool — read-only snapshot of an initiative.
|
|
3
|
+
*
|
|
4
|
+
* The initiative sibling of plan_status. Shows each member plan with its task
|
|
5
|
+
* progress (resolved/total), lifecycle status, and ready/blocked-by readiness
|
|
6
|
+
* derived from plan-level dependencies. This is the at-a-glance view for
|
|
7
|
+
* splitting an initiative's work across sessions or subagents: "ready" plans
|
|
8
|
+
* have all dependencies satisfied and can be picked up now.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import { Text } from '@earendil-works/pi-tui';
|
|
13
|
+
import { Type } from 'typebox';
|
|
14
|
+
import { Effect } from 'effect';
|
|
15
|
+
import type { RunPlanIO } from '../effects/runtime.js';
|
|
16
|
+
import { FileSystem } from '../effects/filesystem.js';
|
|
17
|
+
import { readPlansManifest, type PlanManifestEntry } from '../storage/plans-manifest.js';
|
|
18
|
+
import { readInitiativesManifest } from '../storage/initiatives-manifest.js';
|
|
19
|
+
import { readTasksJsonl } from '../storage/task-storage.js';
|
|
20
|
+
import { initiativeRollup, membersOf, type InitiativeRollup } from '../initiative.js';
|
|
21
|
+
|
|
22
|
+
/** Normalize an initiative hint (`x` or `.plans/x`) to a bare name. */
|
|
23
|
+
function normalizeName(hint: string): string {
|
|
24
|
+
return hint
|
|
25
|
+
.replace(/^\.plans\//, '')
|
|
26
|
+
.replace(/\/+$/, '')
|
|
27
|
+
.trim();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface TaskCount {
|
|
31
|
+
resolved: number;
|
|
32
|
+
total: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Load member task counts for a set of plans (resolved = done + skipped). */
|
|
36
|
+
function loadTaskCounts(
|
|
37
|
+
plans: readonly PlanManifestEntry[],
|
|
38
|
+
): Effect.Effect<Map<string, TaskCount>, never, FileSystem> {
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
const counts = new Map<string, TaskCount>();
|
|
41
|
+
for (const plan of plans) {
|
|
42
|
+
const snapshot = yield* Effect.orElseSucceed(
|
|
43
|
+
readTasksJsonl(`.plans/${plan.name}`),
|
|
44
|
+
() => undefined,
|
|
45
|
+
);
|
|
46
|
+
const total = snapshot?.tasks.length ?? 0;
|
|
47
|
+
const resolved =
|
|
48
|
+
snapshot?.tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length ?? 0;
|
|
49
|
+
counts.set(plan.name, { resolved, total });
|
|
50
|
+
}
|
|
51
|
+
return counts;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const STATUS_GLYPH: Record<string, string> = {
|
|
56
|
+
'in-progress': '○',
|
|
57
|
+
done: '✓',
|
|
58
|
+
superseded: '🔄',
|
|
59
|
+
abandoned: '✗',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export function registerInitiativeStatusTool(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
|
|
63
|
+
pi.registerTool({
|
|
64
|
+
name: 'initiative_status',
|
|
65
|
+
label: 'Initiative Status',
|
|
66
|
+
description:
|
|
67
|
+
'Read-only snapshot of an initiative: member plans with task progress, status, and ready/blocked-by. Use to see what work is unblocked and can be picked up next.',
|
|
68
|
+
promptSnippet: 'Show an initiative: member plans, progress, and ready/blocked work',
|
|
69
|
+
promptGuidelines: [
|
|
70
|
+
'Call initiative_status to see which member plans are ready (dependencies satisfied) before dispatching work across sessions or subagents.',
|
|
71
|
+
'It is read-only and never mutates state.',
|
|
72
|
+
],
|
|
73
|
+
parameters: Type.Object({
|
|
74
|
+
initiative: Type.Optional(
|
|
75
|
+
Type.String({
|
|
76
|
+
description:
|
|
77
|
+
'Initiative name (or .plans/<name>). Omit to use the sole in-progress initiative.',
|
|
78
|
+
}),
|
|
79
|
+
),
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
async execute(_toolCallId, params) {
|
|
83
|
+
const hint = params.initiative ? normalizeName(params.initiative) : undefined;
|
|
84
|
+
|
|
85
|
+
const snapshot = await runPlanIO(
|
|
86
|
+
Effect.gen(function* () {
|
|
87
|
+
const initiatives = yield* readInitiativesManifest();
|
|
88
|
+
const plans = yield* readPlansManifest();
|
|
89
|
+
return { initiatives, plans };
|
|
90
|
+
}),
|
|
91
|
+
);
|
|
92
|
+
const { initiatives, plans } = snapshot;
|
|
93
|
+
|
|
94
|
+
// Resolve the target initiative.
|
|
95
|
+
let targetName = hint;
|
|
96
|
+
if (!targetName) {
|
|
97
|
+
const inProgress = initiatives.filter((entry) => entry.status === 'in-progress');
|
|
98
|
+
if (inProgress.length === 1) targetName = inProgress[0]!.name;
|
|
99
|
+
else {
|
|
100
|
+
// Zero or many: list in-progress initiatives with rollup progress.
|
|
101
|
+
const rows = inProgress.map((entry) => {
|
|
102
|
+
const r = initiativeRollup(entry.name, plans);
|
|
103
|
+
return ` ${r.done}/${r.total} plans ${entry.name} — ${entry.title} (ready ${r.ready}, blocked ${r.blocked})`;
|
|
104
|
+
});
|
|
105
|
+
const text = inProgress.length
|
|
106
|
+
? `No single active initiative — ${inProgress.length} in-progress. Pass { initiative: "<name>" }.\n${rows.join('\n')}`
|
|
107
|
+
: 'No in-progress initiative found in .plans/initiatives.jsonl.';
|
|
108
|
+
return {
|
|
109
|
+
content: [{ type: 'text' as const, text }],
|
|
110
|
+
details: {
|
|
111
|
+
active: false,
|
|
112
|
+
in_progress: inProgress.map((e) => e.name),
|
|
113
|
+
} as Record<string, unknown>,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const entry = initiatives.find((e) => e.name === targetName);
|
|
119
|
+
if (!entry) {
|
|
120
|
+
const names = initiatives.map((e) => e.name).join(', ');
|
|
121
|
+
return {
|
|
122
|
+
content: [
|
|
123
|
+
{
|
|
124
|
+
type: 'text' as const,
|
|
125
|
+
text: `Initiative not found: ${targetName}. Known: ${names || '(none)'}`,
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
details: {
|
|
129
|
+
active: false,
|
|
130
|
+
error: 'not_found',
|
|
131
|
+
initiative: targetName,
|
|
132
|
+
} as Record<string, unknown>,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const rollup: InitiativeRollup = initiativeRollup(entry.name, plans);
|
|
137
|
+
const taskCounts = await runPlanIO(loadTaskCounts(membersOf(entry.name, plans)));
|
|
138
|
+
|
|
139
|
+
const memberLines = rollup.members.map((m) => {
|
|
140
|
+
const glyph = STATUS_GLYPH[m.status] ?? '○';
|
|
141
|
+
const tc = taskCounts.get(m.name);
|
|
142
|
+
const progress = tc && tc.total > 0 ? ` ${tc.resolved}/${tc.total} tasks` : '';
|
|
143
|
+
let readiness = '';
|
|
144
|
+
if (m.status === 'in-progress') {
|
|
145
|
+
readiness = m.ready ? ' [ready]' : ` [blocked by ${m.blockedBy?.join(', ')}]`;
|
|
146
|
+
}
|
|
147
|
+
return ` ${glyph} ${m.name} [${m.status}]${progress}${readiness}`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
const text =
|
|
151
|
+
`Initiative: ${entry.title} (${entry.name}) — ${entry.status}\n` +
|
|
152
|
+
`Plans: ${rollup.done}/${rollup.total} done — in-progress ${rollup.inProgress} (ready ${rollup.ready}, blocked ${rollup.blocked})` +
|
|
153
|
+
(rollup.closed ? `, closed ${rollup.closed}` : '') +
|
|
154
|
+
'\n' +
|
|
155
|
+
(memberLines.length ? `Member plans:\n${memberLines.join('\n')}` : 'No member plans yet.');
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
content: [{ type: 'text' as const, text }],
|
|
159
|
+
details: {
|
|
160
|
+
active: true,
|
|
161
|
+
initiative: entry.name,
|
|
162
|
+
status: entry.status,
|
|
163
|
+
rollup,
|
|
164
|
+
ready_plans: rollup.members.filter((m) => m.ready).map((m) => m.name),
|
|
165
|
+
} as Record<string, unknown>,
|
|
166
|
+
};
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
renderCall(args, theme) {
|
|
170
|
+
const name = (args as { initiative?: string }).initiative;
|
|
171
|
+
let content = theme.fg('toolTitle', theme.bold('initiative_status'));
|
|
172
|
+
if (name) content += ' ' + theme.fg('muted', name);
|
|
173
|
+
return new Text(content, 0, 0);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
renderResult(result, _options, theme) {
|
|
177
|
+
const details = result.details as
|
|
178
|
+
| { active?: boolean; initiative?: string; rollup?: InitiativeRollup }
|
|
179
|
+
| undefined;
|
|
180
|
+
if (!details?.active || !details.rollup)
|
|
181
|
+
return new Text(theme.fg('dim', 'No active initiative'), 0, 0);
|
|
182
|
+
const r = details.rollup;
|
|
183
|
+
return new Text(
|
|
184
|
+
theme.fg('toolTitle', `${details.initiative ?? 'initiative'} `) +
|
|
185
|
+
theme.fg('muted', `${r.done}/${r.total} plans — ready ${r.ready}, blocked ${r.blocked}`),
|
|
186
|
+
0,
|
|
187
|
+
0,
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
});
|
|
191
|
+
}
|
|
@@ -11,7 +11,14 @@ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
|
|
|
11
11
|
import { Text } from '@earendil-works/pi-tui';
|
|
12
12
|
import { Type } from 'typebox';
|
|
13
13
|
import type { RunPlanIO } from '../effects/runtime.js';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
applyInitiativeReconcile,
|
|
16
|
+
applyReconcile,
|
|
17
|
+
collectInitiativeDrift,
|
|
18
|
+
collectPlanDrift,
|
|
19
|
+
type InitiativeDriftRow,
|
|
20
|
+
type PlanDriftRow,
|
|
21
|
+
} from '../reconcile.js';
|
|
15
22
|
|
|
16
23
|
function describeRow(row: PlanDriftRow): string {
|
|
17
24
|
const progress = row.hasTasks ? ` (${row.resolved}/${row.total})` : '';
|
|
@@ -22,6 +29,10 @@ function describeRow(row: PlanDriftRow): string {
|
|
|
22
29
|
return ` ⚠ ${row.name}${progress} — tasks.jsonl with no registry entry (orphan)`;
|
|
23
30
|
}
|
|
24
31
|
if (row.drift === 'status') {
|
|
32
|
+
if (row.direction === 'downgrade') {
|
|
33
|
+
// Wrong-direction projection: do NOT auto-repair. Mark tasks done instead.
|
|
34
|
+
return ` ⚠ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus} (likely merged; mark tasks done — not auto-repaired)`;
|
|
35
|
+
}
|
|
25
36
|
return ` ✗ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus}`;
|
|
26
37
|
}
|
|
27
38
|
return ` ✓ ${row.name}${progress} — ${row.registryStatus} (in sync)`;
|
|
@@ -37,6 +48,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
37
48
|
promptGuidelines: [
|
|
38
49
|
'Use reconcile_plans to audit .plans/ when registry status looks stale (e.g. a fully-done plan still in-progress).',
|
|
39
50
|
'Run it read-only first; pass apply:true once you have reviewed the reported drift.',
|
|
51
|
+
'apply:true only records completion (in-progress→done). It never regresses a done plan back to in-progress — if a done plan shows incomplete tasks (work merged but tasks not marked), mark those tasks done instead.',
|
|
40
52
|
],
|
|
41
53
|
parameters: Type.Object({
|
|
42
54
|
apply: Type.Optional(
|
|
@@ -48,38 +60,64 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
|
|
|
48
60
|
|
|
49
61
|
async execute(_toolCallId, params) {
|
|
50
62
|
const rows = await runPlanIO(collectPlanDrift());
|
|
63
|
+
const initiativeRows = await runPlanIO(collectInitiativeDrift());
|
|
51
64
|
const drifted = rows.filter((row) => row.drift);
|
|
65
|
+
const initiativeDrifted = initiativeRows.filter((row) => row.drift);
|
|
52
66
|
|
|
53
67
|
let repaired: PlanDriftRow[] = [];
|
|
68
|
+
let initiativeRepaired: InitiativeDriftRow[] = [];
|
|
54
69
|
if (params.apply) {
|
|
55
70
|
repaired = await runPlanIO(applyReconcile(rows));
|
|
71
|
+
// Re-collect after plan repairs so initiative projection sees fresh state.
|
|
72
|
+
initiativeRepaired = await runPlanIO(
|
|
73
|
+
applyInitiativeReconcile(await runPlanIO(collectInitiativeDrift())),
|
|
74
|
+
);
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
const lines = rows.map(describeRow);
|
|
59
|
-
const
|
|
78
|
+
const initiativeLines = initiativeRows.map(
|
|
79
|
+
(row) =>
|
|
80
|
+
row.drift === 'status'
|
|
81
|
+
? ` ✗ ${row.name} (initiative, ${row.members} plans) — registry ${row.registryStatus}, plans say ${row.derivedStatus}`
|
|
82
|
+
: ` ✓ ${row.name} (initiative, ${row.members} plans) — ${row.registryStatus} (in sync)`,
|
|
83
|
+
);
|
|
84
|
+
const statusDrift = drifted.filter(
|
|
85
|
+
(r) => r.drift === 'status' && r.direction === 'upgrade',
|
|
86
|
+
).length;
|
|
87
|
+
const downgrades = drifted.filter(
|
|
88
|
+
(r) => r.drift === 'status' && r.direction === 'downgrade',
|
|
89
|
+
).length;
|
|
60
90
|
const orphans = drifted.filter((r) => r.drift === 'orphan').length;
|
|
61
91
|
const registryOnly = drifted.filter((r) => r.drift === 'registry-only').length;
|
|
62
92
|
|
|
93
|
+
const totalDrift = drifted.length + initiativeDrifted.length;
|
|
63
94
|
const header = params.apply
|
|
64
|
-
? `Reconciled ${repaired.length} plan(s).`
|
|
65
|
-
:
|
|
66
|
-
? 'All plans in sync.'
|
|
67
|
-
: `${
|
|
95
|
+
? `Reconciled ${repaired.length} plan(s) + ${initiativeRepaired.length} initiative(s).`
|
|
96
|
+
: totalDrift === 0
|
|
97
|
+
? 'All plans and initiatives in sync.'
|
|
98
|
+
: `${totalDrift} drift issue(s) found (run with apply:true to repair status drift).`;
|
|
68
99
|
|
|
69
100
|
const summary = [
|
|
70
101
|
`status-drift ${statusDrift}`,
|
|
102
|
+
`needs-tasks-done ${downgrades}`,
|
|
71
103
|
`orphan ${orphans}`,
|
|
72
104
|
`registry-only ${registryOnly}`,
|
|
73
105
|
].join(', ');
|
|
74
106
|
|
|
75
|
-
const
|
|
107
|
+
const initiativeBlock = initiativeLines.length
|
|
108
|
+
? `\nInitiatives:\n${initiativeLines.join('\n')}`
|
|
109
|
+
: '';
|
|
110
|
+
const text = `${header}\n${summary}\n${lines.join('\n')}${initiativeBlock}`;
|
|
76
111
|
return {
|
|
77
112
|
content: [{ type: 'text' as const, text }],
|
|
78
113
|
details: {
|
|
79
114
|
applied: Boolean(params.apply),
|
|
80
115
|
repaired: repaired.map((r) => r.name),
|
|
81
|
-
drift: drifted.map((r) => ({ name: r.name, kind: r.drift })),
|
|
116
|
+
drift: drifted.map((r) => ({ name: r.name, kind: r.drift, direction: r.direction })),
|
|
82
117
|
total: rows.length,
|
|
118
|
+
initiative_repaired: initiativeRepaired.map((r) => r.name),
|
|
119
|
+
initiative_drift: initiativeDrifted.map((r) => ({ name: r.name, kind: r.drift })),
|
|
120
|
+
initiative_total: initiativeRows.length,
|
|
83
121
|
},
|
|
84
122
|
};
|
|
85
123
|
},
|