@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.
@@ -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 };