@dreki-gg/taskman 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/cli.d.mts +7 -0
- package/dist/cli.mjs +287 -0
- package/dist/index.d.mts +682 -0
- package/dist/index.mjs +2 -0
- package/dist/initiatives-Ij_teFl_.mjs +1253 -0
- package/package.json +51 -0
|
@@ -0,0 +1,1253 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-CfYAbeIz.mjs";
|
|
2
|
+
import { Context, Data, Effect, Either, Layer, Option, Schema } from "effect";
|
|
3
|
+
import { mkdir, open, readFile, readdir, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createWriteStream } from "node:fs";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { randomUUID } from "node:crypto";
|
|
7
|
+
//#region src/errors.ts
|
|
8
|
+
/**
|
|
9
|
+
* Tagged errors for plan-mode disk I/O and JSONL validation.
|
|
10
|
+
*
|
|
11
|
+
* These replace ad-hoc `throw new Error(...)` so storage programs surface
|
|
12
|
+
* typed, inspectable failures. They are mapped back to user-facing strings at
|
|
13
|
+
* the tool boundary via `errorMessage`.
|
|
14
|
+
*/
|
|
15
|
+
var PlanReadError = class extends Data.TaggedError("PlanReadError") {
|
|
16
|
+
get message() {
|
|
17
|
+
return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
var PlanWriteError = class extends Data.TaggedError("PlanWriteError") {
|
|
21
|
+
get message() {
|
|
22
|
+
return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
var JsonlParseError = class extends Data.TaggedError("JsonlParseError") {
|
|
26
|
+
get message() {
|
|
27
|
+
return `Invalid JSONL in ${this.path} at line ${this.line}: ${causeMessage(this.cause)}`;
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
var JsonlValidationError = class extends Data.TaggedError("JsonlValidationError") {
|
|
31
|
+
get message() {
|
|
32
|
+
return `Invalid record in ${this.path} at line ${this.line}: ${this.reason}`;
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
var MissingMetaRecord = class extends Data.TaggedError("MissingMetaRecord") {
|
|
36
|
+
get message() {
|
|
37
|
+
return `${this.path} is missing meta record`;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
var TaskNotFound = class extends Data.TaggedError("TaskNotFound") {
|
|
41
|
+
get message() {
|
|
42
|
+
return `Task not found: ${this.taskId}`;
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
var TasksFileNotFound = class extends Data.TaggedError("TasksFileNotFound") {
|
|
46
|
+
get message() {
|
|
47
|
+
return `No tasks.jsonl found in ${this.planDir}`;
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
function causeMessage(cause) {
|
|
51
|
+
if (cause instanceof Error) return cause.message;
|
|
52
|
+
return String(cause);
|
|
53
|
+
}
|
|
54
|
+
function errorMessage(error) {
|
|
55
|
+
if (error instanceof Error) return error.message;
|
|
56
|
+
if (typeof error === "object" && error !== null && "message" in error) {
|
|
57
|
+
const message = error.message;
|
|
58
|
+
if (typeof message === "string") return message;
|
|
59
|
+
}
|
|
60
|
+
return String(error);
|
|
61
|
+
}
|
|
62
|
+
/** Convert any error (including tagged errors) into a native Error for the tool boundary. */
|
|
63
|
+
function toNativeError(error) {
|
|
64
|
+
if (error instanceof Error) return error;
|
|
65
|
+
const native = new Error(errorMessage(error));
|
|
66
|
+
if (typeof error === "object" && error !== null && "_tag" in error) native.name = String(error._tag);
|
|
67
|
+
return native;
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/schema.ts
|
|
71
|
+
/**
|
|
72
|
+
* Effect Schema definitions for plan-mode persisted records.
|
|
73
|
+
*
|
|
74
|
+
* These replace the hand-rolled type guards. Schemas are the single source of
|
|
75
|
+
* truth for record shape; the mutable TS interfaces in `types.ts` are kept for
|
|
76
|
+
* the imperative orchestration code (which mutates tasks in place) and are
|
|
77
|
+
* structurally compatible with the decoded values.
|
|
78
|
+
*/
|
|
79
|
+
const TaskStatusSchema = Schema.Literal("pending", "done", "skipped", "blocked", "deferred");
|
|
80
|
+
const TaskOriginSchema = Schema.Literal("plan", "discovered");
|
|
81
|
+
const TaskRecordSchema = Schema.Struct({
|
|
82
|
+
_type: Schema.Literal("task"),
|
|
83
|
+
id: Schema.String,
|
|
84
|
+
description: Schema.String,
|
|
85
|
+
details: Schema.optional(Schema.String),
|
|
86
|
+
status: TaskStatusSchema,
|
|
87
|
+
origin: Schema.optional(TaskOriginSchema),
|
|
88
|
+
depends_on: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
|
89
|
+
notes: Schema.optional(Schema.String),
|
|
90
|
+
created_at: Schema.String,
|
|
91
|
+
updated_at: Schema.String
|
|
92
|
+
});
|
|
93
|
+
const TaskMetaSchema = Schema.Struct({
|
|
94
|
+
_type: Schema.Literal("meta"),
|
|
95
|
+
title: Schema.String,
|
|
96
|
+
plan_name: Schema.String,
|
|
97
|
+
created_at: Schema.String,
|
|
98
|
+
/** Optional git commit the plan was written against (back-compat: absent on older plans). */
|
|
99
|
+
base_commit: Schema.optional(Schema.String)
|
|
100
|
+
});
|
|
101
|
+
/** A single tasks.jsonl line is either the meta record or a task record. */
|
|
102
|
+
const TasksLineSchema = Schema.Union(TaskMetaSchema, TaskRecordSchema);
|
|
103
|
+
/**
|
|
104
|
+
* Plan lifecycle statuses.
|
|
105
|
+
* - in-progress: active, tracked, eligible for auto-resolution
|
|
106
|
+
* - done: completed (all tasks resolved)
|
|
107
|
+
* - superseded: closed because another plan absorbed the work
|
|
108
|
+
* - abandoned: closed without shipping (rejected / won't do)
|
|
109
|
+
* Only `in-progress` is treated as active; the rest are terminal.
|
|
110
|
+
*/
|
|
111
|
+
const PlanStatusSchema = Schema.Literal("in-progress", "done", "superseded", "abandoned");
|
|
112
|
+
const PlanManifestEntrySchema = Schema.Struct({
|
|
113
|
+
_type: Schema.Literal("plan"),
|
|
114
|
+
name: Schema.String,
|
|
115
|
+
status: PlanStatusSchema,
|
|
116
|
+
title: Schema.String,
|
|
117
|
+
created_at: Schema.String,
|
|
118
|
+
completed_at: Schema.NullOr(Schema.String),
|
|
119
|
+
/** Optional human-readable reason, used for terminal statuses. */
|
|
120
|
+
reason: Schema.optional(Schema.String),
|
|
121
|
+
/** Parent initiative name (kebab). Absent = standalone flat plan. */
|
|
122
|
+
initiative: Schema.optional(Schema.String),
|
|
123
|
+
/**
|
|
124
|
+
* Plan-level dependencies: names of plans this plan depends on. Distinct from
|
|
125
|
+
* the task-level `depends_on` above. Cross-initiative references are allowed.
|
|
126
|
+
*/
|
|
127
|
+
depends_on: Schema.optional(Schema.mutable(Schema.Array(Schema.String)))
|
|
128
|
+
});
|
|
129
|
+
/**
|
|
130
|
+
* Initiative lifecycle statuses reuse the plan lifecycle literals. An
|
|
131
|
+
* initiative's status is a projection of its member plans' statuses, with the
|
|
132
|
+
* same terminal-guard semantics as plans.
|
|
133
|
+
*/
|
|
134
|
+
const InitiativeStatusSchema = PlanStatusSchema;
|
|
135
|
+
const InitiativeManifestEntrySchema = Schema.Struct({
|
|
136
|
+
_type: Schema.Literal("initiative"),
|
|
137
|
+
name: Schema.String,
|
|
138
|
+
status: InitiativeStatusSchema,
|
|
139
|
+
title: Schema.String,
|
|
140
|
+
created_at: Schema.String,
|
|
141
|
+
completed_at: Schema.NullOr(Schema.String),
|
|
142
|
+
/** Optional human-readable reason, used for terminal statuses. */
|
|
143
|
+
reason: Schema.optional(Schema.String)
|
|
144
|
+
});
|
|
145
|
+
const ExecPendingConfigSchema = Schema.Struct({
|
|
146
|
+
model: Schema.Struct({
|
|
147
|
+
provider: Schema.String,
|
|
148
|
+
id: Schema.String
|
|
149
|
+
}),
|
|
150
|
+
thinking: Schema.String
|
|
151
|
+
});
|
|
152
|
+
const decodeTaskRecord = Schema.decodeUnknownEither(TaskRecordSchema);
|
|
153
|
+
const decodeTaskMeta = Schema.decodeUnknownEither(TaskMetaSchema);
|
|
154
|
+
const decodeTasksLine = Schema.decodeUnknownEither(TasksLineSchema);
|
|
155
|
+
const decodePlanManifestEntry = Schema.decodeUnknownEither(PlanManifestEntrySchema);
|
|
156
|
+
const decodeInitiativeManifestEntry = Schema.decodeUnknownEither(InitiativeManifestEntrySchema);
|
|
157
|
+
const decodeExecPendingConfig = Schema.decodeUnknownEither(ExecPendingConfigSchema);
|
|
158
|
+
//#endregion
|
|
159
|
+
//#region src/storage/atomic-write.ts
|
|
160
|
+
/**
|
|
161
|
+
* Atomically write `data` to `path`: write to a temp file, fsync, rename into
|
|
162
|
+
* place, then best-effort fsync the directory. Failures surface as
|
|
163
|
+
* `PlanWriteError`.
|
|
164
|
+
*/
|
|
165
|
+
function writeFileAtomic(path, data, options = {}) {
|
|
166
|
+
return Effect.tryPromise({
|
|
167
|
+
try: () => writeFileAtomicPromise(path, data, options),
|
|
168
|
+
catch: (cause) => new PlanWriteError({
|
|
169
|
+
path,
|
|
170
|
+
cause
|
|
171
|
+
})
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async function writeFileAtomicPromise(path, data, options) {
|
|
175
|
+
const dir = dirname(path);
|
|
176
|
+
const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
|
|
177
|
+
let completed = false;
|
|
178
|
+
try {
|
|
179
|
+
await writeAndSync(tempPath, data, options.mode);
|
|
180
|
+
await rename(tempPath, path);
|
|
181
|
+
completed = true;
|
|
182
|
+
await syncDirectory(dir);
|
|
183
|
+
} finally {
|
|
184
|
+
if (!completed) await rm(tempPath, { force: true }).catch(() => void 0);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async function writeAndSync(path, data, mode) {
|
|
188
|
+
await new Promise((resolve, reject) => {
|
|
189
|
+
const stream = createWriteStream(path, {
|
|
190
|
+
flags: "wx",
|
|
191
|
+
mode
|
|
192
|
+
});
|
|
193
|
+
stream.once("error", reject);
|
|
194
|
+
stream.once("finish", resolve);
|
|
195
|
+
stream.end(data);
|
|
196
|
+
});
|
|
197
|
+
const handle = await open(path, "r+");
|
|
198
|
+
try {
|
|
199
|
+
await handle.sync();
|
|
200
|
+
} finally {
|
|
201
|
+
await handle.close();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async function syncDirectory(dir) {
|
|
205
|
+
const handle = await open(dir, "r").catch(() => void 0);
|
|
206
|
+
if (!handle) return;
|
|
207
|
+
try {
|
|
208
|
+
await handle.sync().catch(() => void 0);
|
|
209
|
+
} finally {
|
|
210
|
+
await handle.close().catch(() => void 0);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/effects/filesystem.ts
|
|
215
|
+
/**
|
|
216
|
+
* FileSystem service — the single seam for plan-mode disk I/O.
|
|
217
|
+
*
|
|
218
|
+
* Storage programs depend on this `Context.Tag` rather than touching
|
|
219
|
+
* `node:fs/promises` directly, which makes them trivially testable and keeps
|
|
220
|
+
* all failure modes typed (`PlanReadError` / `PlanWriteError`).
|
|
221
|
+
*/
|
|
222
|
+
var FileSystem = class extends Context.Tag("PlanMode/FileSystem")() {};
|
|
223
|
+
const nodeFileSystemService = {
|
|
224
|
+
readFileString: (path) => Effect.tryPromise({
|
|
225
|
+
try: () => readFile(path, "utf-8"),
|
|
226
|
+
catch: (cause) => new PlanReadError({
|
|
227
|
+
path,
|
|
228
|
+
cause
|
|
229
|
+
})
|
|
230
|
+
}),
|
|
231
|
+
writeFileString: (path, data) => Effect.tryPromise({
|
|
232
|
+
try: () => writeFile(path, data, "utf-8"),
|
|
233
|
+
catch: (cause) => new PlanWriteError({
|
|
234
|
+
path,
|
|
235
|
+
cause
|
|
236
|
+
})
|
|
237
|
+
}),
|
|
238
|
+
writeFileAtomic: (path, data) => writeFileAtomic(path, data),
|
|
239
|
+
makeDir: (path) => Effect.tryPromise({
|
|
240
|
+
try: async () => {
|
|
241
|
+
await mkdir(path, { recursive: true });
|
|
242
|
+
},
|
|
243
|
+
catch: (cause) => new PlanWriteError({
|
|
244
|
+
path,
|
|
245
|
+
cause
|
|
246
|
+
})
|
|
247
|
+
}),
|
|
248
|
+
listDirectories: (path) => Effect.tryPromise({
|
|
249
|
+
try: async () => {
|
|
250
|
+
return (await readdir(path, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
251
|
+
},
|
|
252
|
+
catch: (cause) => new PlanReadError({
|
|
253
|
+
path,
|
|
254
|
+
cause
|
|
255
|
+
})
|
|
256
|
+
}),
|
|
257
|
+
removeFile: (path) => Effect.tryPromise({
|
|
258
|
+
try: () => unlink(path),
|
|
259
|
+
catch: (cause) => new PlanWriteError({
|
|
260
|
+
path,
|
|
261
|
+
cause
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
};
|
|
265
|
+
//#endregion
|
|
266
|
+
//#region src/effects/runtime.ts
|
|
267
|
+
/**
|
|
268
|
+
* Live Effect runtime for the plan-mode extension.
|
|
269
|
+
*
|
|
270
|
+
* Build the layer once inside the extension entry and run storage programs
|
|
271
|
+
* through the `runPlanIO` bridge so the imperative pi event handlers keep their
|
|
272
|
+
* `await fn(...)` shape.
|
|
273
|
+
*/
|
|
274
|
+
function makeRuntimeLayer() {
|
|
275
|
+
return Layer.succeed(FileSystem, nodeFileSystemService);
|
|
276
|
+
}
|
|
277
|
+
/** Build a bridge that runs storage programs against the live filesystem layer. */
|
|
278
|
+
function makePlanRuntime() {
|
|
279
|
+
const layer = makeRuntimeLayer();
|
|
280
|
+
return function runPlanIO(program) {
|
|
281
|
+
return Effect.runPromise(program.pipe(Effect.provide(layer)));
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
//#endregion
|
|
285
|
+
//#region src/storage/file-lock.ts
|
|
286
|
+
/**
|
|
287
|
+
* Process-wide keyed mutex for serializing read-modify-write on shared files.
|
|
288
|
+
*
|
|
289
|
+
* Pi runs every tool call in a single Node process. When several tool calls run
|
|
290
|
+
* in one block (e.g. three `submit_initiative` calls, or concurrent
|
|
291
|
+
* `submit_plan` / `revise_plan`), each does an independent
|
|
292
|
+
* read → modify → write against the same registry file. Without serialization
|
|
293
|
+
* their reads all observe the same starting state and the last write clobbers
|
|
294
|
+
* the rest — a classic lost-update race.
|
|
295
|
+
*
|
|
296
|
+
* `withFileLock` wraps a read-modify-write critical section so only one runs at
|
|
297
|
+
* a time per `key` (the registry path). The semaphore is created eagerly with
|
|
298
|
+
* `unsafeMakeSemaphore` and cached per key, so its permit count lives in plain
|
|
299
|
+
* shared memory and serializes correctly even across independent
|
|
300
|
+
* `Effect.runPromise` invocations (separate tool executes).
|
|
301
|
+
*
|
|
302
|
+
* NOTE: this guards against in-process concurrency only. Atomic writes
|
|
303
|
+
* (`writeFileAtomic`) still protect against torn files from other processes,
|
|
304
|
+
* but cross-process registry coordination is out of scope.
|
|
305
|
+
*/
|
|
306
|
+
const locks = /* @__PURE__ */ new Map();
|
|
307
|
+
function lockFor(key) {
|
|
308
|
+
let lock = locks.get(key);
|
|
309
|
+
if (!lock) {
|
|
310
|
+
lock = Effect.unsafeMakeSemaphore(1);
|
|
311
|
+
locks.set(key, lock);
|
|
312
|
+
}
|
|
313
|
+
return lock;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Run `effect` while holding the single permit for `key`. Concurrent callers
|
|
317
|
+
* with the same key queue and run one at a time; the permit is always released,
|
|
318
|
+
* even on failure or interruption.
|
|
319
|
+
*
|
|
320
|
+
* Do NOT nest `withFileLock` for the same key inside another — the permit is
|
|
321
|
+
* not reentrant and would deadlock. Express composite read-modify-write as one
|
|
322
|
+
* locked section instead.
|
|
323
|
+
*/
|
|
324
|
+
function withFileLock(key, effect) {
|
|
325
|
+
return Effect.suspend(() => lockFor(key).withPermits(1)(effect));
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/storage/task-storage.ts
|
|
329
|
+
const TASKS_FILE = "tasks.jsonl";
|
|
330
|
+
function readTasksJsonl(planDir) {
|
|
331
|
+
const path = join(planDir, TASKS_FILE);
|
|
332
|
+
return Effect.gen(function* () {
|
|
333
|
+
const fs = yield* FileSystem;
|
|
334
|
+
const maybeText = yield* Effect.option(fs.readFileString(path));
|
|
335
|
+
if (Option.isNone(maybeText)) return void 0;
|
|
336
|
+
const text = maybeText.value;
|
|
337
|
+
if (!text.trim()) return yield* Effect.fail(new MissingMetaRecord({ path }));
|
|
338
|
+
let meta;
|
|
339
|
+
const tasks = [];
|
|
340
|
+
for (const [index, raw] of text.split(/\r?\n/).entries()) {
|
|
341
|
+
if (!raw.trim()) continue;
|
|
342
|
+
const line = index + 1;
|
|
343
|
+
let parsed;
|
|
344
|
+
try {
|
|
345
|
+
parsed = JSON.parse(raw);
|
|
346
|
+
} catch (cause) {
|
|
347
|
+
return yield* Effect.fail(new JsonlParseError({
|
|
348
|
+
path,
|
|
349
|
+
line,
|
|
350
|
+
cause
|
|
351
|
+
}));
|
|
352
|
+
}
|
|
353
|
+
const decoded = decodeTasksLine(parsed);
|
|
354
|
+
if (Either.isLeft(decoded)) return yield* Effect.fail(new JsonlValidationError({
|
|
355
|
+
path,
|
|
356
|
+
line,
|
|
357
|
+
reason: decoded.left.message
|
|
358
|
+
}));
|
|
359
|
+
const record = decoded.right;
|
|
360
|
+
if (record._type === "meta") meta = record;
|
|
361
|
+
else tasks.push(record);
|
|
362
|
+
}
|
|
363
|
+
if (!meta) return yield* Effect.fail(new MissingMetaRecord({ path }));
|
|
364
|
+
return {
|
|
365
|
+
meta,
|
|
366
|
+
tasks
|
|
367
|
+
};
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
function writeTasksJsonl(planDir, meta, tasks) {
|
|
371
|
+
return Effect.gen(function* () {
|
|
372
|
+
const fs = yield* FileSystem;
|
|
373
|
+
yield* fs.makeDir(planDir);
|
|
374
|
+
const content = [meta, ...tasks].map((record) => JSON.stringify(record)).join("\n") + "\n";
|
|
375
|
+
yield* fs.writeFileAtomic(join(planDir, TASKS_FILE), content);
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
function updateTask(planDir, taskId, updates) {
|
|
379
|
+
return withFileLock(join(planDir, TASKS_FILE), Effect.gen(function* () {
|
|
380
|
+
const snapshot = yield* readTasksJsonl(planDir);
|
|
381
|
+
if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
|
|
382
|
+
const index = snapshot.tasks.findIndex((task) => task.id === taskId);
|
|
383
|
+
if (index === -1) return yield* Effect.fail(new TaskNotFound({
|
|
384
|
+
planDir,
|
|
385
|
+
taskId
|
|
386
|
+
}));
|
|
387
|
+
const updated = {
|
|
388
|
+
...snapshot.tasks[index],
|
|
389
|
+
...updates,
|
|
390
|
+
updated_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
391
|
+
};
|
|
392
|
+
snapshot.tasks[index] = updated;
|
|
393
|
+
yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
|
|
394
|
+
return updated;
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
//#endregion
|
|
398
|
+
//#region src/storage/plan-storage.ts
|
|
399
|
+
/**
|
|
400
|
+
* Plan document I/O — handoff and initiative markdown documents.
|
|
401
|
+
*
|
|
402
|
+
* The pi-specific exec-pending marker helpers live in the pi extension; this
|
|
403
|
+
* engine package only needs the durable plan documents.
|
|
404
|
+
*/
|
|
405
|
+
function saveHandoff(planDir, content) {
|
|
406
|
+
return Effect.gen(function* () {
|
|
407
|
+
const fs = yield* FileSystem;
|
|
408
|
+
yield* fs.makeDir(planDir);
|
|
409
|
+
yield* fs.writeFileString(`${planDir}/HANDOFF.md`, content);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
function loadHandoff(planDir) {
|
|
413
|
+
return Effect.gen(function* () {
|
|
414
|
+
const fs = yield* FileSystem;
|
|
415
|
+
const maybeText = yield* Effect.option(fs.readFileString(`${planDir}/HANDOFF.md`));
|
|
416
|
+
return Option.getOrUndefined(maybeText);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
function saveInitiative(initiativeDir, content) {
|
|
420
|
+
return Effect.gen(function* () {
|
|
421
|
+
const fs = yield* FileSystem;
|
|
422
|
+
yield* fs.makeDir(initiativeDir);
|
|
423
|
+
yield* fs.writeFileString(`${initiativeDir}/INITIATIVE.md`, content);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
//#endregion
|
|
427
|
+
//#region src/storage/plans-manifest.ts
|
|
428
|
+
const MANIFEST_DIR$1 = ".plans";
|
|
429
|
+
const MANIFEST_PATH$1 = ".plans/plans.jsonl";
|
|
430
|
+
/** A status is terminal (closed) when it is anything other than in-progress. */
|
|
431
|
+
function isTerminalStatus$1(status) {
|
|
432
|
+
return status !== "in-progress";
|
|
433
|
+
}
|
|
434
|
+
function readPlansManifest() {
|
|
435
|
+
return Effect.gen(function* () {
|
|
436
|
+
const fs = yield* FileSystem;
|
|
437
|
+
const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH$1));
|
|
438
|
+
if (Option.isNone(maybeText)) return [];
|
|
439
|
+
const entries = [];
|
|
440
|
+
for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
|
|
441
|
+
if (!raw.trim()) continue;
|
|
442
|
+
const line = index + 1;
|
|
443
|
+
let parsed;
|
|
444
|
+
try {
|
|
445
|
+
parsed = JSON.parse(raw);
|
|
446
|
+
} catch (cause) {
|
|
447
|
+
return yield* Effect.fail(new JsonlParseError({
|
|
448
|
+
path: MANIFEST_PATH$1,
|
|
449
|
+
line,
|
|
450
|
+
cause
|
|
451
|
+
}));
|
|
452
|
+
}
|
|
453
|
+
const decoded = decodePlanManifestEntry(parsed);
|
|
454
|
+
if (Either.isLeft(decoded)) return yield* Effect.fail(new JsonlValidationError({
|
|
455
|
+
path: MANIFEST_PATH$1,
|
|
456
|
+
line,
|
|
457
|
+
reason: decoded.left.message
|
|
458
|
+
}));
|
|
459
|
+
entries.push(decoded.right);
|
|
460
|
+
}
|
|
461
|
+
return entries;
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
function writePlansManifest(entries) {
|
|
465
|
+
return Effect.gen(function* () {
|
|
466
|
+
const fs = yield* FileSystem;
|
|
467
|
+
yield* fs.makeDir(MANIFEST_DIR$1);
|
|
468
|
+
const content = entries.map((entry) => JSON.stringify(entry)).join("\n") + (entries.length ? "\n" : "");
|
|
469
|
+
yield* fs.writeFileAtomic(MANIFEST_PATH$1, content);
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Pure transform: upsert `name` into the in-memory `entries` array, preserving
|
|
474
|
+
* created_at / membership / deps from any existing entry. No IO — shared by the
|
|
475
|
+
* locked `upsertPlanEntry` and `reconcilePlanStatus` so both flow through one
|
|
476
|
+
* serialized read-modify-write and never nest locks.
|
|
477
|
+
*/
|
|
478
|
+
function applyPlanUpsert(entries, name, updates) {
|
|
479
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
480
|
+
const index = entries.findIndex((entry) => entry.name === name);
|
|
481
|
+
const existing = index === -1 ? void 0 : entries[index];
|
|
482
|
+
const entry = {
|
|
483
|
+
_type: "plan",
|
|
484
|
+
name,
|
|
485
|
+
status: updates.status,
|
|
486
|
+
title: updates.title ?? existing?.title ?? "Untitled plan",
|
|
487
|
+
created_at: existing?.created_at ?? now,
|
|
488
|
+
completed_at: isTerminalStatus$1(updates.status) ? existing?.completed_at ?? now : null,
|
|
489
|
+
reason: updates.reason ?? existing?.reason,
|
|
490
|
+
initiative: updates.initiative ?? existing?.initiative,
|
|
491
|
+
depends_on: updates.depends_on ?? existing?.depends_on
|
|
492
|
+
};
|
|
493
|
+
if (index === -1) entries.push(entry);
|
|
494
|
+
else entries[index] = entry;
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* Serialized read-modify-write of the plans registry. Holds a process-wide lock
|
|
498
|
+
* on the manifest path across the whole read → transform → write so concurrent
|
|
499
|
+
* tool calls cannot clobber each other (lost-update race). `transform` mutates
|
|
500
|
+
* the entries array in place and returns `true` when it changed something
|
|
501
|
+
* (return `false` to skip the rewrite).
|
|
502
|
+
*/
|
|
503
|
+
function mutatePlansManifest(transform) {
|
|
504
|
+
return withFileLock(MANIFEST_PATH$1, Effect.gen(function* () {
|
|
505
|
+
const entries = yield* readPlansManifest();
|
|
506
|
+
if (transform(entries)) yield* writePlansManifest(entries);
|
|
507
|
+
}));
|
|
508
|
+
}
|
|
509
|
+
function upsertPlanEntry(name, updates) {
|
|
510
|
+
return mutatePlansManifest((entries) => {
|
|
511
|
+
applyPlanUpsert(entries, name, updates);
|
|
512
|
+
return true;
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Reconcile a plan's registry status from its task state.
|
|
517
|
+
*
|
|
518
|
+
* The registry `status` is a PROJECTION of task state, not a parallel flag.
|
|
519
|
+
* Call this wherever tasks are written so completion is never coupled to a
|
|
520
|
+
* formal in-session execution run (see FEEDBACK #1). `finalizable` means every
|
|
521
|
+
* active task is resolved AND no deferred follow-ups remain.
|
|
522
|
+
*
|
|
523
|
+
* Guard: a manually-set terminal status (`superseded` / `abandoned`) is never
|
|
524
|
+
* auto-overridden — only `in-progress` ⇄ `done` is derived from tasks.
|
|
525
|
+
*/
|
|
526
|
+
function reconcilePlanStatus(name, finalizable, title) {
|
|
527
|
+
return mutatePlansManifest((entries) => {
|
|
528
|
+
const existing = entries.find((entry) => entry.name === name);
|
|
529
|
+
if (!existing) return false;
|
|
530
|
+
if (existing.status === "superseded" || existing.status === "abandoned") return false;
|
|
531
|
+
const status = finalizable ? "done" : "in-progress";
|
|
532
|
+
if (existing.status === status) return false;
|
|
533
|
+
applyPlanUpsert(entries, name, {
|
|
534
|
+
status,
|
|
535
|
+
title
|
|
536
|
+
});
|
|
537
|
+
return true;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
//#endregion
|
|
541
|
+
//#region src/storage/initiatives-manifest.ts
|
|
542
|
+
/**
|
|
543
|
+
* `.plans/initiatives.jsonl` registry — the initiative-level sibling of
|
|
544
|
+
* `plans-manifest.ts`.
|
|
545
|
+
*
|
|
546
|
+
* An initiative groups multiple plans. Its `status` is a PROJECTION of its
|
|
547
|
+
* member plans' statuses (see `reconcileInitiativeStatus` in `../initiative.ts`
|
|
548
|
+
* for the projection wiring): `done` when every member plan is terminal,
|
|
549
|
+
* `in-progress` otherwise. Manually-set terminal statuses (`superseded` /
|
|
550
|
+
* `abandoned` via `update_initiative`) are never auto-overridden.
|
|
551
|
+
*
|
|
552
|
+
* This module is intentionally dependency-light: it knows how to read/write the
|
|
553
|
+
* registry. The projection (which must read the PLANS manifest) lives in
|
|
554
|
+
* `../initiative.ts` to keep the dependency direction one-way and cycle-free.
|
|
555
|
+
*/
|
|
556
|
+
const MANIFEST_DIR = ".plans";
|
|
557
|
+
const MANIFEST_PATH = ".plans/initiatives.jsonl";
|
|
558
|
+
/** A status is terminal (closed) when it is anything other than in-progress. */
|
|
559
|
+
function isTerminalStatus(status) {
|
|
560
|
+
return status !== "in-progress";
|
|
561
|
+
}
|
|
562
|
+
function readInitiativesManifest() {
|
|
563
|
+
return Effect.gen(function* () {
|
|
564
|
+
const fs = yield* FileSystem;
|
|
565
|
+
const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH));
|
|
566
|
+
if (Option.isNone(maybeText)) return [];
|
|
567
|
+
const entries = [];
|
|
568
|
+
for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
|
|
569
|
+
if (!raw.trim()) continue;
|
|
570
|
+
const line = index + 1;
|
|
571
|
+
let parsed;
|
|
572
|
+
try {
|
|
573
|
+
parsed = JSON.parse(raw);
|
|
574
|
+
} catch (cause) {
|
|
575
|
+
return yield* Effect.fail(new JsonlParseError({
|
|
576
|
+
path: MANIFEST_PATH,
|
|
577
|
+
line,
|
|
578
|
+
cause
|
|
579
|
+
}));
|
|
580
|
+
}
|
|
581
|
+
const decoded = decodeInitiativeManifestEntry(parsed);
|
|
582
|
+
if (Either.isLeft(decoded)) return yield* Effect.fail(new JsonlValidationError({
|
|
583
|
+
path: MANIFEST_PATH,
|
|
584
|
+
line,
|
|
585
|
+
reason: decoded.left.message
|
|
586
|
+
}));
|
|
587
|
+
entries.push(decoded.right);
|
|
588
|
+
}
|
|
589
|
+
return entries;
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
function writeInitiativesManifest(entries) {
|
|
593
|
+
return Effect.gen(function* () {
|
|
594
|
+
const fs = yield* FileSystem;
|
|
595
|
+
yield* fs.makeDir(MANIFEST_DIR);
|
|
596
|
+
const content = entries.map((entry) => JSON.stringify(entry)).join("\n") + (entries.length ? "\n" : "");
|
|
597
|
+
yield* fs.writeFileAtomic(MANIFEST_PATH, content);
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Pure transform: upsert `name` into the in-memory `entries` array, preserving
|
|
602
|
+
* created_at from any existing entry. No IO — shared by the locked
|
|
603
|
+
* `upsertInitiativeEntry` and `reconcileInitiativeStatus` so both flow through
|
|
604
|
+
* one serialized read-modify-write and never nest locks.
|
|
605
|
+
*/
|
|
606
|
+
function applyInitiativeUpsert(entries, name, updates) {
|
|
607
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
608
|
+
const index = entries.findIndex((entry) => entry.name === name);
|
|
609
|
+
const existing = index === -1 ? void 0 : entries[index];
|
|
610
|
+
const entry = {
|
|
611
|
+
_type: "initiative",
|
|
612
|
+
name,
|
|
613
|
+
status: updates.status,
|
|
614
|
+
title: updates.title ?? existing?.title ?? "Untitled initiative",
|
|
615
|
+
created_at: existing?.created_at ?? now,
|
|
616
|
+
completed_at: isTerminalStatus(updates.status) ? existing?.completed_at ?? now : null,
|
|
617
|
+
reason: updates.reason ?? existing?.reason
|
|
618
|
+
};
|
|
619
|
+
if (index === -1) entries.push(entry);
|
|
620
|
+
else entries[index] = entry;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* Serialized read-modify-write of the initiatives registry. Holds a
|
|
624
|
+
* process-wide lock on the manifest path across the whole read → transform →
|
|
625
|
+
* write so concurrent tool calls cannot clobber each other. `transform` may run
|
|
626
|
+
* IO (e.g. read the plans manifest to project status) and mutates the entries
|
|
627
|
+
* array in place, returning `true` when it changed something.
|
|
628
|
+
*/
|
|
629
|
+
function mutateInitiativesManifest(transform) {
|
|
630
|
+
return withFileLock(MANIFEST_PATH, Effect.gen(function* () {
|
|
631
|
+
const entries = yield* readInitiativesManifest();
|
|
632
|
+
if (yield* transform(entries)) yield* writeInitiativesManifest(entries);
|
|
633
|
+
}));
|
|
634
|
+
}
|
|
635
|
+
function upsertInitiativeEntry(name, updates) {
|
|
636
|
+
return mutateInitiativesManifest((entries) => Effect.sync(() => {
|
|
637
|
+
applyInitiativeUpsert(entries, name, updates);
|
|
638
|
+
return true;
|
|
639
|
+
}));
|
|
640
|
+
}
|
|
641
|
+
//#endregion
|
|
642
|
+
//#region src/task-status.ts
|
|
643
|
+
function deferredTasks(tasks) {
|
|
644
|
+
return tasks.filter((task) => task.status === "deferred");
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* True when no active work remains — every task is done, skipped, or deferred
|
|
648
|
+
* (nothing pending or blocked).
|
|
649
|
+
*/
|
|
650
|
+
function activeTasksResolved(tasks) {
|
|
651
|
+
return tasks.every((task) => task.status === "done" || task.status === "skipped" || task.status === "deferred");
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* True when the plan can be marked complete: active work is resolved AND there
|
|
655
|
+
* are no deferred follow-ups awaiting the user's decision.
|
|
656
|
+
*/
|
|
657
|
+
function isPlanFinalizable(tasks) {
|
|
658
|
+
return activeTasksResolved(tasks) && !tasks.some((task) => task.status === "deferred");
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Reactivate tasks for a resumed run: blocked tasks and deferred follow-ups
|
|
662
|
+
* become pending (mutated in place). Returns true if anything changed.
|
|
663
|
+
*/
|
|
664
|
+
function reactivateForExecution(tasks, timestamp) {
|
|
665
|
+
let changed = false;
|
|
666
|
+
for (const task of tasks) if (task.status === "blocked" || task.status === "deferred") {
|
|
667
|
+
task.status = "pending";
|
|
668
|
+
task.updated_at = timestamp;
|
|
669
|
+
changed = true;
|
|
670
|
+
}
|
|
671
|
+
return changed;
|
|
672
|
+
}
|
|
673
|
+
//#endregion
|
|
674
|
+
//#region src/initiative.ts
|
|
675
|
+
/**
|
|
676
|
+
* Initiative logic — ready-work computation and the initiative→plan projection.
|
|
677
|
+
*
|
|
678
|
+
* Two layers live here:
|
|
679
|
+
* - PURE: `computePlanReadiness`, `isInitiativeFinalizable`, `initiativeRollup`
|
|
680
|
+
* reason over a plans-manifest snapshot with no IO. They are the basis for
|
|
681
|
+
* "what work is unblocked right now" — the foundation for phase-2 subagent
|
|
682
|
+
* fan-out.
|
|
683
|
+
* - IO: `reconcileInitiativeStatus` / `reconcileInitiativeForPlan` keep an
|
|
684
|
+
* initiative's registry status a PROJECTION of its member plans, mirroring
|
|
685
|
+
* `reconcilePlanStatus` one level up. They read the PLANS manifest, so they
|
|
686
|
+
* live here (not in `initiatives-manifest.ts`) to keep the dependency
|
|
687
|
+
* direction one-way: initiative.ts → {plans-manifest, initiatives-manifest}.
|
|
688
|
+
*/
|
|
689
|
+
/**
|
|
690
|
+
* For each `in-progress` plan, whether all of its plan-level dependencies are
|
|
691
|
+
* `done`. Only a `done` dependency unblocks — a missing, in-progress, or
|
|
692
|
+
* terminally-closed (superseded/abandoned) dependency keeps a plan blocked.
|
|
693
|
+
*/
|
|
694
|
+
function computePlanReadiness(plans) {
|
|
695
|
+
const statusByName = new Map(plans.map((plan) => [plan.name, plan.status]));
|
|
696
|
+
return plans.filter((plan) => plan.status === "in-progress").map((plan) => {
|
|
697
|
+
const blockedBy = (plan.depends_on ?? []).filter((dep) => statusByName.get(dep) !== "done");
|
|
698
|
+
return {
|
|
699
|
+
name: plan.name,
|
|
700
|
+
ready: blockedBy.length === 0,
|
|
701
|
+
blockedBy
|
|
702
|
+
};
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
/** Member plans of an initiative (linked by name in the plans manifest). */
|
|
706
|
+
function membersOf(initiative, plans) {
|
|
707
|
+
return plans.filter((plan) => plan.initiative === initiative);
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* An initiative is finalizable (`done`) when it has ≥1 member plan AND every
|
|
711
|
+
* member is terminal (no member is `in-progress`). Mirrors the plan-level rule
|
|
712
|
+
* one level up.
|
|
713
|
+
*/
|
|
714
|
+
function isInitiativeFinalizable(initiative, plans) {
|
|
715
|
+
const members = membersOf(initiative, plans);
|
|
716
|
+
if (members.length === 0) return false;
|
|
717
|
+
return members.every((plan) => plan.status !== "in-progress");
|
|
718
|
+
}
|
|
719
|
+
/** Aggregate an initiative's member plans into counts + per-member readiness. */
|
|
720
|
+
function initiativeRollup(initiative, plans) {
|
|
721
|
+
const members = membersOf(initiative, plans);
|
|
722
|
+
const readiness = new Map(computePlanReadiness(plans).map((row) => [row.name, row]));
|
|
723
|
+
let done = 0;
|
|
724
|
+
let closed = 0;
|
|
725
|
+
let inProgress = 0;
|
|
726
|
+
let ready = 0;
|
|
727
|
+
let blocked = 0;
|
|
728
|
+
const rows = members.map((plan) => {
|
|
729
|
+
if (plan.status === "done") done += 1;
|
|
730
|
+
else if (plan.status === "in-progress") inProgress += 1;
|
|
731
|
+
else closed += 1;
|
|
732
|
+
const row = {
|
|
733
|
+
name: plan.name,
|
|
734
|
+
title: plan.title,
|
|
735
|
+
status: plan.status
|
|
736
|
+
};
|
|
737
|
+
if (plan.status === "in-progress") {
|
|
738
|
+
const r = readiness.get(plan.name);
|
|
739
|
+
row.ready = r?.ready ?? true;
|
|
740
|
+
row.blockedBy = r?.blockedBy ?? [];
|
|
741
|
+
if (row.ready) ready += 1;
|
|
742
|
+
else blocked += 1;
|
|
743
|
+
}
|
|
744
|
+
return row;
|
|
745
|
+
});
|
|
746
|
+
return {
|
|
747
|
+
name: initiative,
|
|
748
|
+
total: members.length,
|
|
749
|
+
done,
|
|
750
|
+
closed,
|
|
751
|
+
inProgress,
|
|
752
|
+
ready,
|
|
753
|
+
blocked,
|
|
754
|
+
members: rows
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Re-derive an initiative's registry status from its member plans.
|
|
759
|
+
*
|
|
760
|
+
* Like `reconcilePlanStatus`: only reflects state for a KNOWN initiative (never
|
|
761
|
+
* conjures an entry), and never clobbers a manually-set terminal status
|
|
762
|
+
* (`superseded` / `abandoned`). Only `in-progress` ⇄ `done` is derived.
|
|
763
|
+
*/
|
|
764
|
+
function reconcileInitiativeStatus(name) {
|
|
765
|
+
return mutateInitiativesManifest((initiatives) => Effect.gen(function* () {
|
|
766
|
+
const existing = initiatives.find((entry) => entry.name === name);
|
|
767
|
+
if (!existing) return false;
|
|
768
|
+
if (existing.status === "superseded" || existing.status === "abandoned") return false;
|
|
769
|
+
const status = isInitiativeFinalizable(name, yield* readPlansManifest()) ? "done" : "in-progress";
|
|
770
|
+
if (existing.status === status) return false;
|
|
771
|
+
applyInitiativeUpsert(initiatives, name, {
|
|
772
|
+
status,
|
|
773
|
+
title: existing.title
|
|
774
|
+
});
|
|
775
|
+
return true;
|
|
776
|
+
}));
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* Reconcile the initiative that a given plan belongs to (no-op when the plan is
|
|
780
|
+
* standalone). Call this after any plan-status write so the initiative level
|
|
781
|
+
* stays in sync without callers needing to know the parent name.
|
|
782
|
+
*/
|
|
783
|
+
function reconcileInitiativeForPlan(planName) {
|
|
784
|
+
return Effect.gen(function* () {
|
|
785
|
+
const plan = (yield* readPlansManifest()).find((entry) => entry.name === planName);
|
|
786
|
+
if (!plan?.initiative) return;
|
|
787
|
+
yield* reconcileInitiativeStatus(plan.initiative);
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
//#endregion
|
|
791
|
+
//#region src/reconcile.ts
|
|
792
|
+
/**
|
|
793
|
+
* Drift detection + repair between `tasks.jsonl` reality and registry status.
|
|
794
|
+
*
|
|
795
|
+
* Drift happens in both directions (FEEDBACK #6):
|
|
796
|
+
* - tasks all done but registry `in-progress` (completion never recorded), and
|
|
797
|
+
* - registry `in-progress`/`done` disagreeing with task state generally.
|
|
798
|
+
*
|
|
799
|
+
* It also surfaces two un-trackable classes:
|
|
800
|
+
* - registry-only plans (an entry with no `tasks.jsonl` directory), and
|
|
801
|
+
* - orphan task dirs (a `tasks.jsonl` with no registry entry).
|
|
802
|
+
*
|
|
803
|
+
* `collectPlanDrift` is a pure read; `applyReconcile` repairs only the safe
|
|
804
|
+
* `in-progress` ⇄ `done` projection and never touches terminal statuses.
|
|
805
|
+
*/
|
|
806
|
+
const PLANS_DIR = ".plans";
|
|
807
|
+
/** Walk every plan (registry + task dirs) and classify drift. Pure read. */
|
|
808
|
+
function collectPlanDrift() {
|
|
809
|
+
return Effect.gen(function* () {
|
|
810
|
+
const fs = yield* FileSystem;
|
|
811
|
+
const manifest = yield* readPlansManifest();
|
|
812
|
+
const dirs = yield* Effect.orElseSucceed(fs.listDirectories(PLANS_DIR), () => []);
|
|
813
|
+
const taskDirs = new Set(dirs.filter((name) => !name.startsWith(".")));
|
|
814
|
+
const rows = [];
|
|
815
|
+
const seen = /* @__PURE__ */ new Set();
|
|
816
|
+
for (const entry of manifest) {
|
|
817
|
+
seen.add(entry.name);
|
|
818
|
+
const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${entry.name}`);
|
|
819
|
+
if (!snapshot) {
|
|
820
|
+
rows.push({
|
|
821
|
+
name: entry.name,
|
|
822
|
+
registryStatus: entry.status,
|
|
823
|
+
title: entry.title,
|
|
824
|
+
hasTasks: false,
|
|
825
|
+
drift: "registry-only"
|
|
826
|
+
});
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
const total = snapshot.tasks.length;
|
|
830
|
+
const resolved = snapshot.tasks.filter((t) => t.status === "done" || t.status === "skipped").length;
|
|
831
|
+
const derivedStatus = isPlanFinalizable(snapshot.tasks) ? "done" : "in-progress";
|
|
832
|
+
const drift = !(entry.status === "superseded" || entry.status === "abandoned") && entry.status !== derivedStatus ? "status" : void 0;
|
|
833
|
+
const direction = drift === "status" ? derivedStatus === "done" ? "upgrade" : "downgrade" : void 0;
|
|
834
|
+
rows.push({
|
|
835
|
+
name: entry.name,
|
|
836
|
+
registryStatus: entry.status,
|
|
837
|
+
title: entry.title,
|
|
838
|
+
derivedStatus,
|
|
839
|
+
resolved,
|
|
840
|
+
total,
|
|
841
|
+
hasTasks: true,
|
|
842
|
+
drift,
|
|
843
|
+
direction
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
for (const name of taskDirs) {
|
|
847
|
+
if (seen.has(name)) continue;
|
|
848
|
+
const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${name}`);
|
|
849
|
+
if (!snapshot) continue;
|
|
850
|
+
const total = snapshot.tasks.length;
|
|
851
|
+
const resolved = snapshot.tasks.filter((t) => t.status === "done" || t.status === "skipped").length;
|
|
852
|
+
rows.push({
|
|
853
|
+
name,
|
|
854
|
+
title: snapshot.meta.title,
|
|
855
|
+
derivedStatus: isPlanFinalizable(snapshot.tasks) ? "done" : "in-progress",
|
|
856
|
+
resolved,
|
|
857
|
+
total,
|
|
858
|
+
hasTasks: true,
|
|
859
|
+
drift: "orphan"
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
return rows;
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
/** Compare each initiative's registry status against its member-plan projection. */
|
|
866
|
+
function collectInitiativeDrift() {
|
|
867
|
+
return Effect.gen(function* () {
|
|
868
|
+
const initiatives = yield* readInitiativesManifest();
|
|
869
|
+
const plans = yield* readPlansManifest();
|
|
870
|
+
return initiatives.map((entry) => {
|
|
871
|
+
const derivedStatus = isInitiativeFinalizable(entry.name, plans) ? "done" : "in-progress";
|
|
872
|
+
const drift = !(entry.status === "superseded" || entry.status === "abandoned") && entry.status !== derivedStatus ? "status" : void 0;
|
|
873
|
+
return {
|
|
874
|
+
name: entry.name,
|
|
875
|
+
registryStatus: entry.status,
|
|
876
|
+
title: entry.title,
|
|
877
|
+
derivedStatus,
|
|
878
|
+
members: membersOf(entry.name, plans).length,
|
|
879
|
+
drift
|
|
880
|
+
};
|
|
881
|
+
});
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
/** Repair `status`-class initiative drift by re-projecting from member plans. */
|
|
885
|
+
function applyInitiativeReconcile(rows) {
|
|
886
|
+
return Effect.gen(function* () {
|
|
887
|
+
const repaired = [];
|
|
888
|
+
for (const row of rows) {
|
|
889
|
+
if (row.drift !== "status") continue;
|
|
890
|
+
yield* reconcileInitiativeStatus(row.name);
|
|
891
|
+
repaired.push(row);
|
|
892
|
+
}
|
|
893
|
+
return repaired;
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Repair `status`-class drift by projecting derived status into the registry.
|
|
898
|
+
*
|
|
899
|
+
* Safety: only `upgrade` drift (registry `in-progress` → tasks `done`) is
|
|
900
|
+
* auto-repaired. A `downgrade` (registry `done` → tasks `in-progress`) is
|
|
901
|
+
* reported but NEVER auto-applied — it almost always means work merged without
|
|
902
|
+
* marking tasks done, and projecting tasks→registry there would regress a
|
|
903
|
+
* finished plan. The human resolves it by marking the tasks done instead.
|
|
904
|
+
*
|
|
905
|
+
* Orphans and registry-only rows are likewise reported but not auto-fixed.
|
|
906
|
+
* Returns the rows that were repaired.
|
|
907
|
+
*/
|
|
908
|
+
function applyReconcile(rows) {
|
|
909
|
+
return Effect.gen(function* () {
|
|
910
|
+
const repaired = [];
|
|
911
|
+
for (const row of rows) {
|
|
912
|
+
if (row.drift !== "status" || !row.derivedStatus) continue;
|
|
913
|
+
if (row.direction === "downgrade") continue;
|
|
914
|
+
yield* reconcilePlanStatus(row.name, row.derivedStatus === "done", row.title);
|
|
915
|
+
yield* reconcileInitiativeForPlan(row.name);
|
|
916
|
+
repaired.push(row);
|
|
917
|
+
}
|
|
918
|
+
return repaired;
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
//#endregion
|
|
922
|
+
//#region src/resolve.ts
|
|
923
|
+
/**
|
|
924
|
+
* Stateless, disk-backed plan resolution.
|
|
925
|
+
*
|
|
926
|
+
* Unlike the pi extension's `resolve-plan.ts` (which also juggles session
|
|
927
|
+
* `state`), this is pure manifest + tasks-file resolution: given an optional
|
|
928
|
+
* `name` hint, return the resolved plan name or the in-progress candidates so a
|
|
929
|
+
* caller (CLI, automation) can act without any session.
|
|
930
|
+
*
|
|
931
|
+
* Order: explicit `name` hint → the single in-progress plan in
|
|
932
|
+
* `.plans/plans.jsonl`. Ambiguous (multiple in-progress, no hint) returns
|
|
933
|
+
* `{ planName: undefined, candidates }`.
|
|
934
|
+
*/
|
|
935
|
+
/** Normalize a plan hint (`my-plan` or `.plans/my-plan`) to a bare name. */
|
|
936
|
+
function normalizePlanName(hint) {
|
|
937
|
+
return hint.replace(/^\.plans\//, "").replace(/\/+$/, "").trim();
|
|
938
|
+
}
|
|
939
|
+
function resolvePlanByName(opts = {}) {
|
|
940
|
+
return Effect.gen(function* () {
|
|
941
|
+
const manifest = yield* readPlansManifest();
|
|
942
|
+
if (opts.name) {
|
|
943
|
+
const hint = normalizePlanName(opts.name);
|
|
944
|
+
const match = manifest.find((entry) => entry.name === hint);
|
|
945
|
+
if (match) return {
|
|
946
|
+
planName: match.name,
|
|
947
|
+
planDir: `.plans/${match.name}`,
|
|
948
|
+
candidates: []
|
|
949
|
+
};
|
|
950
|
+
return {
|
|
951
|
+
planName: void 0,
|
|
952
|
+
candidates: manifest.filter((entry) => entry.status === "in-progress").map((entry) => entry.name)
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
const inProgress = manifest.filter((entry) => entry.status === "in-progress");
|
|
956
|
+
if (inProgress.length === 1) {
|
|
957
|
+
const name = inProgress[0].name;
|
|
958
|
+
return {
|
|
959
|
+
planName: name,
|
|
960
|
+
planDir: `.plans/${name}`,
|
|
961
|
+
candidates: []
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
planName: void 0,
|
|
966
|
+
candidates: inProgress.map((entry) => entry.name)
|
|
967
|
+
};
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
/** Build full plan data (`title, planName, handoff, tasks, base_commit`) from disk. */
|
|
971
|
+
function loadPlanData(planDir) {
|
|
972
|
+
return Effect.gen(function* () {
|
|
973
|
+
const snapshot = yield* readTasksJsonl(planDir);
|
|
974
|
+
if (!snapshot) return void 0;
|
|
975
|
+
const handoff = yield* loadHandoff(planDir);
|
|
976
|
+
return {
|
|
977
|
+
title: snapshot.meta.title,
|
|
978
|
+
planName: snapshot.meta.plan_name,
|
|
979
|
+
handoff: handoff ?? "",
|
|
980
|
+
tasks: snapshot.tasks,
|
|
981
|
+
base_commit: snapshot.meta.base_commit
|
|
982
|
+
};
|
|
983
|
+
});
|
|
984
|
+
}
|
|
985
|
+
//#endregion
|
|
986
|
+
//#region src/ids.ts
|
|
987
|
+
/**
|
|
988
|
+
* Pure id / name helpers shared by the engine and its consumers.
|
|
989
|
+
*/
|
|
990
|
+
function toKebabCase(name) {
|
|
991
|
+
return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Generate the next sequential task id (`t-NNN`) given existing ids.
|
|
995
|
+
*
|
|
996
|
+
* Uses the max numeric suffix of `t-<digits>` ids + 1, zero-padded to 3.
|
|
997
|
+
* Falls back to `t-<count+1>` when no ids match the pattern.
|
|
998
|
+
*/
|
|
999
|
+
function nextTaskId(existingIds) {
|
|
1000
|
+
let max = 0;
|
|
1001
|
+
let matched = false;
|
|
1002
|
+
for (const id of existingIds) {
|
|
1003
|
+
const m = /^t-(\d+)$/.exec(id);
|
|
1004
|
+
if (!m) continue;
|
|
1005
|
+
matched = true;
|
|
1006
|
+
const n = Number.parseInt(m[1], 10);
|
|
1007
|
+
if (n > max) max = n;
|
|
1008
|
+
}
|
|
1009
|
+
const next = matched ? max + 1 : existingIds.length + 1;
|
|
1010
|
+
return `t-${String(next).padStart(3, "0")}`;
|
|
1011
|
+
}
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/engine.ts
|
|
1014
|
+
/**
|
|
1015
|
+
* High-level task-management operations that compose storage writes with the
|
|
1016
|
+
* registry/initiative status projection — the same flow the pi extension runs
|
|
1017
|
+
* inline on every task write. Consumers (CLI, automation) should call these
|
|
1018
|
+
* rather than re-implementing the write→reconcile sequence.
|
|
1019
|
+
*/
|
|
1020
|
+
/** Re-derive plan + parent-initiative registry status from current task state. */
|
|
1021
|
+
function reconcileFromTasks(planName, tasks, title) {
|
|
1022
|
+
return Effect.gen(function* () {
|
|
1023
|
+
yield* reconcilePlanStatus(planName, isPlanFinalizable(tasks), title);
|
|
1024
|
+
yield* reconcileInitiativeForPlan(planName);
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
/**
|
|
1028
|
+
* Set a task's status (and optional notes), persist, then re-project registry
|
|
1029
|
+
* status. Mirrors the extension's `onTaskUpdated`.
|
|
1030
|
+
*/
|
|
1031
|
+
function setTaskStatus(planDir, taskId, status, notes) {
|
|
1032
|
+
return Effect.gen(function* () {
|
|
1033
|
+
const snapshot = yield* readTasksJsonl(planDir);
|
|
1034
|
+
if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
|
|
1035
|
+
const task = snapshot.tasks.find((t) => t.id === taskId);
|
|
1036
|
+
if (!task) return yield* Effect.fail(new TaskNotFound({
|
|
1037
|
+
planDir,
|
|
1038
|
+
taskId
|
|
1039
|
+
}));
|
|
1040
|
+
task.status = status;
|
|
1041
|
+
task.updated_at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1042
|
+
if (notes) task.notes = notes;
|
|
1043
|
+
yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
|
|
1044
|
+
yield* reconcileFromTasks(snapshot.meta.plan_name, snapshot.tasks, snapshot.meta.title);
|
|
1045
|
+
return {
|
|
1046
|
+
task,
|
|
1047
|
+
finalizable: isPlanFinalizable(snapshot.tasks)
|
|
1048
|
+
};
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Append a discovered follow-up as a `deferred` task, persist, then re-project
|
|
1053
|
+
* registry status (a new deferred task can re-open a done plan). Mirrors the
|
|
1054
|
+
* extension's `add_task` + `onTaskAdded`.
|
|
1055
|
+
*/
|
|
1056
|
+
function appendDeferredTask(planDir, input) {
|
|
1057
|
+
return Effect.gen(function* () {
|
|
1058
|
+
const snapshot = yield* readTasksJsonl(planDir);
|
|
1059
|
+
if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
|
|
1060
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1061
|
+
const task = {
|
|
1062
|
+
_type: "task",
|
|
1063
|
+
id: nextTaskId(snapshot.tasks.map((t) => t.id)),
|
|
1064
|
+
description: input.description.slice(0, 60),
|
|
1065
|
+
details: input.details ?? "",
|
|
1066
|
+
status: "deferred",
|
|
1067
|
+
origin: "discovered",
|
|
1068
|
+
depends_on: input.depends_on,
|
|
1069
|
+
notes: input.reason,
|
|
1070
|
+
created_at: now,
|
|
1071
|
+
updated_at: now
|
|
1072
|
+
};
|
|
1073
|
+
snapshot.tasks.push(task);
|
|
1074
|
+
yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
|
|
1075
|
+
yield* reconcileFromTasks(snapshot.meta.plan_name, snapshot.tasks, snapshot.meta.title);
|
|
1076
|
+
return task;
|
|
1077
|
+
});
|
|
1078
|
+
}
|
|
1079
|
+
//#endregion
|
|
1080
|
+
//#region src/listing/plans.ts
|
|
1081
|
+
/**
|
|
1082
|
+
* Pure + Effect helpers for listing plans (the engine half of the pi `/plans`
|
|
1083
|
+
* command). The interactive pi handler lives in the extension.
|
|
1084
|
+
*/
|
|
1085
|
+
var plans_exports = /* @__PURE__ */ __exportAll({
|
|
1086
|
+
filterPlans: () => filterPlans,
|
|
1087
|
+
formatPlanList: () => formatPlanList,
|
|
1088
|
+
loadPlanListItems: () => loadPlanListItems,
|
|
1089
|
+
parseListArgs: () => parseListArgs,
|
|
1090
|
+
sortPlans: () => sortPlans
|
|
1091
|
+
});
|
|
1092
|
+
function filterPlans(plans, filter) {
|
|
1093
|
+
if (filter === "all") return plans;
|
|
1094
|
+
return plans.filter((p) => p.status === filter);
|
|
1095
|
+
}
|
|
1096
|
+
function sortPlans(plans, sort) {
|
|
1097
|
+
const sorted = [...plans];
|
|
1098
|
+
switch (sort) {
|
|
1099
|
+
case "name":
|
|
1100
|
+
sorted.sort((a, b) => a.name.localeCompare(b.name));
|
|
1101
|
+
break;
|
|
1102
|
+
case "date-asc":
|
|
1103
|
+
sorted.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
1104
|
+
break;
|
|
1105
|
+
case "date-desc":
|
|
1106
|
+
sorted.sort((a, b) => b.created_at.localeCompare(a.created_at));
|
|
1107
|
+
break;
|
|
1108
|
+
case "tasks":
|
|
1109
|
+
sorted.sort((a, b) => b.totalTasks - a.totalTasks);
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
return sorted;
|
|
1113
|
+
}
|
|
1114
|
+
const STATUS_ICON$1 = {
|
|
1115
|
+
"in-progress": "🔵",
|
|
1116
|
+
done: "✅",
|
|
1117
|
+
superseded: "🔄",
|
|
1118
|
+
abandoned: "❌"
|
|
1119
|
+
};
|
|
1120
|
+
function formatPlanList(plans, filter, sort) {
|
|
1121
|
+
if (plans.length === 0) return filter === "all" ? "No plans found in .plans/plans.jsonl" : `No plans with status "${filter}"`;
|
|
1122
|
+
const sortLabel = {
|
|
1123
|
+
name: "name",
|
|
1124
|
+
"date-asc": "oldest first",
|
|
1125
|
+
"date-desc": "newest first",
|
|
1126
|
+
tasks: "most tasks first"
|
|
1127
|
+
};
|
|
1128
|
+
return `${filter === "all" ? `All plans (${plans.length}) — sorted by ${sortLabel[sort]}` : `Plans: ${filter} (${plans.length}) — sorted by ${sortLabel[sort]}`}\n${plans.map((p) => {
|
|
1129
|
+
const icon = STATUS_ICON$1[p.status];
|
|
1130
|
+
const progress = p.totalTasks > 0 ? ` [${p.doneTasks}/${p.totalTasks} tasks]` : " [no tasks]";
|
|
1131
|
+
const date = p.created_at.slice(0, 10);
|
|
1132
|
+
return ` ${icon} ${p.name} — ${p.title}${progress} (${date})`;
|
|
1133
|
+
}).join("\n")}`;
|
|
1134
|
+
}
|
|
1135
|
+
function loadPlanListItems() {
|
|
1136
|
+
return Effect.gen(function* () {
|
|
1137
|
+
const manifest = yield* Effect.orElseSucceed(readPlansManifest(), () => []);
|
|
1138
|
+
const items = [];
|
|
1139
|
+
for (const entry of manifest) {
|
|
1140
|
+
const dir = `.plans/${entry.name}`;
|
|
1141
|
+
const snapshot = yield* Effect.orElseSucceed(readTasksJsonl(dir), () => void 0);
|
|
1142
|
+
const totalTasks = snapshot?.tasks.length ?? 0;
|
|
1143
|
+
const doneTasks = snapshot?.tasks.filter((t) => t.status === "done" || t.status === "skipped").length ?? 0;
|
|
1144
|
+
const pendingTasks = snapshot?.tasks.filter((t) => t.status === "pending").length ?? 0;
|
|
1145
|
+
items.push({
|
|
1146
|
+
name: entry.name,
|
|
1147
|
+
title: entry.title,
|
|
1148
|
+
status: entry.status,
|
|
1149
|
+
created_at: entry.created_at,
|
|
1150
|
+
completed_at: entry.completed_at,
|
|
1151
|
+
totalTasks,
|
|
1152
|
+
doneTasks,
|
|
1153
|
+
pendingTasks
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
return items;
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
const FILTER_ALIASES$1 = {
|
|
1160
|
+
all: "all",
|
|
1161
|
+
"in-progress": "in-progress",
|
|
1162
|
+
pending: "in-progress",
|
|
1163
|
+
active: "in-progress",
|
|
1164
|
+
done: "done",
|
|
1165
|
+
completed: "done",
|
|
1166
|
+
superseded: "superseded",
|
|
1167
|
+
abandoned: "abandoned"
|
|
1168
|
+
};
|
|
1169
|
+
const SORT_ALIASES = {
|
|
1170
|
+
name: "name",
|
|
1171
|
+
"date-asc": "date-asc",
|
|
1172
|
+
oldest: "date-asc",
|
|
1173
|
+
"date-desc": "date-desc",
|
|
1174
|
+
newest: "date-desc",
|
|
1175
|
+
tasks: "tasks",
|
|
1176
|
+
"task-count": "tasks"
|
|
1177
|
+
};
|
|
1178
|
+
function parseListArgs(raw) {
|
|
1179
|
+
const tokens = raw.toLowerCase().split(/\s+/);
|
|
1180
|
+
let filter = "all";
|
|
1181
|
+
let sort = "date-desc";
|
|
1182
|
+
for (const token of tokens) if (FILTER_ALIASES$1[token]) filter = FILTER_ALIASES$1[token];
|
|
1183
|
+
else if (SORT_ALIASES[token]) sort = SORT_ALIASES[token];
|
|
1184
|
+
return {
|
|
1185
|
+
filter,
|
|
1186
|
+
sort
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
//#endregion
|
|
1190
|
+
//#region src/listing/initiatives.ts
|
|
1191
|
+
/**
|
|
1192
|
+
* Pure + Effect helpers for listing initiatives (the engine half of the pi
|
|
1193
|
+
* `/initiatives` command). The interactive pi handler lives in the extension.
|
|
1194
|
+
*/
|
|
1195
|
+
var initiatives_exports = /* @__PURE__ */ __exportAll({
|
|
1196
|
+
filterInitiatives: () => filterInitiatives,
|
|
1197
|
+
formatInitiativeList: () => formatInitiativeList,
|
|
1198
|
+
loadInitiativeListItems: () => loadInitiativeListItems,
|
|
1199
|
+
parseInitiativeFilter: () => parseInitiativeFilter
|
|
1200
|
+
});
|
|
1201
|
+
function filterInitiatives(items, filter) {
|
|
1202
|
+
if (filter === "all") return items;
|
|
1203
|
+
return items.filter((i) => i.status === filter);
|
|
1204
|
+
}
|
|
1205
|
+
const STATUS_ICON = {
|
|
1206
|
+
"in-progress": "🔵",
|
|
1207
|
+
done: "✅",
|
|
1208
|
+
superseded: "🔄",
|
|
1209
|
+
abandoned: "❌"
|
|
1210
|
+
};
|
|
1211
|
+
function formatInitiativeList(items, filter) {
|
|
1212
|
+
if (items.length === 0) return filter === "all" ? "No initiatives found in .plans/initiatives.jsonl" : `No initiatives with status "${filter}"`;
|
|
1213
|
+
return `${filter === "all" ? `All initiatives (${items.length})` : `Initiatives: ${filter} (${items.length})`}\n${items.map((i) => {
|
|
1214
|
+
const icon = STATUS_ICON[i.status];
|
|
1215
|
+
const progress = i.totalPlans > 0 ? ` [${i.donePlans}/${i.totalPlans} plans, ready ${i.ready}, blocked ${i.blocked}]` : " [no plans]";
|
|
1216
|
+
const date = i.created_at.slice(0, 10);
|
|
1217
|
+
return ` ${icon} ${i.name} — ${i.title}${progress} (${date})`;
|
|
1218
|
+
}).join("\n")}`;
|
|
1219
|
+
}
|
|
1220
|
+
function loadInitiativeListItems() {
|
|
1221
|
+
return Effect.gen(function* () {
|
|
1222
|
+
const initiatives = yield* Effect.orElseSucceed(readInitiativesManifest(), () => []);
|
|
1223
|
+
const plans = yield* Effect.orElseSucceed(readPlansManifest(), () => []);
|
|
1224
|
+
return initiatives.map((entry) => {
|
|
1225
|
+
const r = initiativeRollup(entry.name, plans);
|
|
1226
|
+
return {
|
|
1227
|
+
name: entry.name,
|
|
1228
|
+
title: entry.title,
|
|
1229
|
+
status: entry.status,
|
|
1230
|
+
created_at: entry.created_at,
|
|
1231
|
+
totalPlans: r.total,
|
|
1232
|
+
donePlans: r.done,
|
|
1233
|
+
ready: r.ready,
|
|
1234
|
+
blocked: r.blocked
|
|
1235
|
+
};
|
|
1236
|
+
});
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
const FILTER_ALIASES = {
|
|
1240
|
+
all: "all",
|
|
1241
|
+
"in-progress": "in-progress",
|
|
1242
|
+
active: "in-progress",
|
|
1243
|
+
done: "done",
|
|
1244
|
+
completed: "done",
|
|
1245
|
+
superseded: "superseded",
|
|
1246
|
+
abandoned: "abandoned"
|
|
1247
|
+
};
|
|
1248
|
+
function parseInitiativeFilter(raw) {
|
|
1249
|
+
for (const token of raw.toLowerCase().split(/\s+/)) if (FILTER_ALIASES[token]) return FILTER_ALIASES[token];
|
|
1250
|
+
return "all";
|
|
1251
|
+
}
|
|
1252
|
+
//#endregion
|
|
1253
|
+
export { nodeFileSystemService as $, reactivateForExecution as A, reconcilePlanStatus as B, isInitiativeFinalizable as C, TasksFileNotFound as Ct, activeTasksResolved as D, reconcileInitiativeStatus as E, toNativeError as Et, writeInitiativesManifest as F, saveInitiative as G, writePlansManifest as H, applyPlanUpsert as I, writeTasksJsonl as J, readTasksJsonl as K, isTerminalStatus$1 as L, mutateInitiativesManifest as M, readInitiativesManifest as N, deferredTasks as O, upsertInitiativeEntry as P, FileSystem as Q, mutatePlansManifest as R, initiativeRollup as S, TaskNotFound as St, reconcileInitiativeForPlan as T, errorMessage as Tt, loadHandoff as U, upsertPlanEntry as V, saveHandoff as W, makePlanRuntime as X, withFileLock as Y, makeRuntimeLayer as Z, applyInitiativeReconcile as _, JsonlParseError as _t, filterPlans as a, PlanStatusSchema as at, collectPlanDrift as b, PlanReadError as bt, plans_exports as c, TaskRecordSchema as ct, setTaskStatus as d, decodeExecPendingConfig as dt, writeFileAtomic as et, nextTaskId as f, decodeInitiativeManifestEntry as ft, resolvePlanByName as g, decodeTasksLine as gt, normalizePlanName as h, decodeTaskRecord as ht, loadInitiativeListItems as i, PlanManifestEntrySchema as it, applyInitiativeUpsert as j, isPlanFinalizable as k, sortPlans as l, TaskStatusSchema as lt, loadPlanData as m, decodeTaskMeta as mt, formatInitiativeList as n, InitiativeManifestEntrySchema as nt, formatPlanList as o, TaskMetaSchema as ot, toKebabCase as p, decodePlanManifestEntry as pt, updateTask as q, initiatives_exports as r, InitiativeStatusSchema as rt, loadPlanListItems as s, TaskOriginSchema as st, filterInitiatives as t, ExecPendingConfigSchema as tt, appendDeferredTask as u, TasksLineSchema as ut, applyReconcile as v, JsonlValidationError as vt, membersOf as w, causeMessage as wt, computePlanReadiness as x, PlanWriteError as xt, collectInitiativeDrift as y, MissingMetaRecord as yt, readPlansManifest as z };
|