@dreki-gg/pi-plan-mode 0.26.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/extensions/plan-mode/__tests__/initiative-status.test.ts +5 -5
  3. package/extensions/plan-mode/__tests__/plan-references-context.test.ts +4 -4
  4. package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +3 -3
  5. package/extensions/plan-mode/__tests__/resolve-plan.test.ts +4 -4
  6. package/extensions/plan-mode/__tests__/revise-plan.test.ts +4 -4
  7. package/extensions/plan-mode/__tests__/submit-initiative.test.ts +2 -2
  8. package/extensions/plan-mode/__tests__/submit-plan.test.ts +3 -3
  9. package/extensions/plan-mode/__tests__/update-initiative.test.ts +2 -2
  10. package/extensions/plan-mode/__tests__/update-plan.test.ts +2 -2
  11. package/extensions/plan-mode/__tests__/utils.test.ts +2 -1
  12. package/extensions/plan-mode/commands/list-initiatives.ts +19 -109
  13. package/extensions/plan-mode/commands/list-plans.ts +21 -168
  14. package/extensions/plan-mode/exec-pending.ts +59 -0
  15. package/extensions/plan-mode/index.ts +8 -7
  16. package/extensions/plan-mode/references/context.ts +5 -5
  17. package/extensions/plan-mode/references/plan-index.ts +1 -1
  18. package/extensions/plan-mode/resolve-plan.ts +4 -4
  19. package/extensions/plan-mode/resume.ts +6 -5
  20. package/extensions/plan-mode/tools/add-task.ts +1 -1
  21. package/extensions/plan-mode/tools/initiative-status.ts +6 -6
  22. package/extensions/plan-mode/tools/preview-prototype.ts +3 -3
  23. package/extensions/plan-mode/tools/reconcile-plans.ts +2 -2
  24. package/extensions/plan-mode/tools/revise-plan.ts +7 -7
  25. package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
  26. package/extensions/plan-mode/tools/submit-plan.ts +7 -7
  27. package/extensions/plan-mode/tools/update-initiative.ts +2 -2
  28. package/extensions/plan-mode/tools/update-plan.ts +3 -3
  29. package/extensions/plan-mode/types.ts +17 -59
  30. package/extensions/plan-mode/utils.ts +2 -29
  31. package/package.json +2 -1
  32. package/extensions/plan-mode/__tests__/atomic-write.test.ts +0 -45
  33. package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +0 -85
  34. package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +0 -159
  35. package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +0 -89
  36. package/extensions/plan-mode/__tests__/list-initiatives.test.ts +0 -59
  37. package/extensions/plan-mode/__tests__/list-plans.test.ts +0 -253
  38. package/extensions/plan-mode/__tests__/plan-storage.test.ts +0 -57
  39. package/extensions/plan-mode/__tests__/plans-manifest.test.ts +0 -120
  40. package/extensions/plan-mode/__tests__/reconcile.test.ts +0 -188
  41. package/extensions/plan-mode/__tests__/schema.test.ts +0 -334
  42. package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
  43. package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -101
  44. package/extensions/plan-mode/effects/filesystem.ts +0 -65
  45. package/extensions/plan-mode/effects/runtime.ts +0 -24
  46. package/extensions/plan-mode/errors.ts +0 -105
  47. package/extensions/plan-mode/initiative.ts +0 -172
  48. package/extensions/plan-mode/plan-storage.ts +0 -6
  49. package/extensions/plan-mode/reconcile.ts +0 -235
  50. package/extensions/plan-mode/schema.ts +0 -99
  51. package/extensions/plan-mode/storage/atomic-write.ts +0 -75
  52. package/extensions/plan-mode/storage/file-lock.ts +0 -49
  53. package/extensions/plan-mode/storage/initiatives-manifest.ts +0 -154
  54. package/extensions/plan-mode/storage/plan-storage.ts +0 -88
  55. package/extensions/plan-mode/storage/plans-manifest.ts +0 -174
  56. package/extensions/plan-mode/storage/task-storage.ts +0 -111
  57. package/extensions/plan-mode/task-status.ts +0 -46
@@ -1,65 +0,0 @@
1
- /**
2
- * FileSystem service — the single seam for plan-mode disk I/O.
3
- *
4
- * Storage programs depend on this `Context.Tag` rather than touching
5
- * `node:fs/promises` directly, which makes them trivially testable and keeps
6
- * all failure modes typed (`PlanReadError` / `PlanWriteError`).
7
- */
8
-
9
- import { Context, Effect } from 'effect';
10
- import { mkdir, readFile, readdir, unlink, writeFile } from 'node:fs/promises';
11
- import { PlanReadError, PlanWriteError } from '../errors.js';
12
- import { writeFileAtomic } from '../storage/atomic-write.js';
13
-
14
- export interface FileSystemService {
15
- readonly readFileString: (path: string) => Effect.Effect<string, PlanReadError>;
16
- readonly writeFileString: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
17
- readonly writeFileAtomic: (path: string, data: string) => Effect.Effect<void, PlanWriteError>;
18
- readonly makeDir: (path: string) => Effect.Effect<void, PlanWriteError>;
19
- readonly listDirectories: (path: string) => Effect.Effect<string[], PlanReadError>;
20
- readonly removeFile: (path: string) => Effect.Effect<void, PlanWriteError>;
21
- }
22
-
23
- export class FileSystem extends Context.Tag('PlanMode/FileSystem')<
24
- FileSystem,
25
- FileSystemService
26
- >() {}
27
-
28
- export const nodeFileSystemService: FileSystemService = {
29
- readFileString: (path) =>
30
- Effect.tryPromise({
31
- try: () => readFile(path, 'utf-8'),
32
- catch: (cause) => new PlanReadError({ path, cause }),
33
- }),
34
-
35
- writeFileString: (path, data) =>
36
- Effect.tryPromise({
37
- try: () => writeFile(path, data, 'utf-8'),
38
- catch: (cause) => new PlanWriteError({ path, cause }),
39
- }),
40
-
41
- writeFileAtomic: (path, data) => writeFileAtomic(path, data),
42
-
43
- makeDir: (path) =>
44
- Effect.tryPromise({
45
- try: async () => {
46
- await mkdir(path, { recursive: true });
47
- },
48
- catch: (cause) => new PlanWriteError({ path, cause }),
49
- }),
50
-
51
- listDirectories: (path) =>
52
- Effect.tryPromise({
53
- try: async () => {
54
- const entries = await readdir(path, { withFileTypes: true });
55
- return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
56
- },
57
- catch: (cause) => new PlanReadError({ path, cause }),
58
- }),
59
-
60
- removeFile: (path) =>
61
- Effect.tryPromise({
62
- try: () => unlink(path),
63
- catch: (cause) => new PlanWriteError({ path, cause }),
64
- }),
65
- };
@@ -1,24 +0,0 @@
1
- /**
2
- * Live Effect runtime for the plan-mode extension.
3
- *
4
- * Build the layer once inside the extension entry and run storage programs
5
- * through the `runPlanIO` bridge so the imperative pi event handlers keep their
6
- * `await fn(...)` shape.
7
- */
8
-
9
- import { Effect, Layer } from 'effect';
10
- import { FileSystem, nodeFileSystemService } from './filesystem.js';
11
-
12
- export function makeRuntimeLayer(): Layer.Layer<FileSystem> {
13
- return Layer.succeed(FileSystem, nodeFileSystemService);
14
- }
15
-
16
- /** Build a bridge that runs storage programs against the live filesystem layer. */
17
- export function makePlanRuntime() {
18
- const layer = makeRuntimeLayer();
19
- return function runPlanIO<A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> {
20
- return Effect.runPromise(program.pipe(Effect.provide(layer)));
21
- };
22
- }
23
-
24
- export type RunPlanIO = ReturnType<typeof makePlanRuntime>;
@@ -1,105 +0,0 @@
1
- /**
2
- * Tagged errors for plan-mode disk I/O and JSONL validation.
3
- *
4
- * These replace ad-hoc `throw new Error(...)` so storage programs surface
5
- * typed, inspectable failures. They are mapped back to user-facing strings at
6
- * the tool boundary via `errorMessage`.
7
- */
8
-
9
- import { Data } from 'effect';
10
-
11
- export class PlanReadError extends Data.TaggedError('PlanReadError')<{
12
- readonly path: string;
13
- readonly cause: unknown;
14
- }> {
15
- get message(): string {
16
- return `Failed to read ${this.path}: ${causeMessage(this.cause)}`;
17
- }
18
- }
19
-
20
- export class PlanWriteError extends Data.TaggedError('PlanWriteError')<{
21
- readonly path: string;
22
- readonly cause: unknown;
23
- }> {
24
- get message(): string {
25
- return `Failed to write ${this.path}: ${causeMessage(this.cause)}`;
26
- }
27
- }
28
-
29
- export class JsonlParseError extends Data.TaggedError('JsonlParseError')<{
30
- readonly path: string;
31
- readonly line: number;
32
- readonly cause?: unknown;
33
- }> {
34
- get message(): string {
35
- return `Invalid JSONL in ${this.path} at line ${this.line}: ${causeMessage(this.cause)}`;
36
- }
37
- }
38
-
39
- export class JsonlValidationError extends Data.TaggedError('JsonlValidationError')<{
40
- readonly path: string;
41
- readonly line: number;
42
- readonly reason: string;
43
- }> {
44
- get message(): string {
45
- return `Invalid record in ${this.path} at line ${this.line}: ${this.reason}`;
46
- }
47
- }
48
-
49
- export class MissingMetaRecord extends Data.TaggedError('MissingMetaRecord')<{
50
- readonly path: string;
51
- }> {
52
- get message(): string {
53
- return `${this.path} is missing meta record`;
54
- }
55
- }
56
-
57
- export class TaskNotFound extends Data.TaggedError('TaskNotFound')<{
58
- readonly planDir: string;
59
- readonly taskId: string;
60
- }> {
61
- get message(): string {
62
- return `Task not found: ${this.taskId}`;
63
- }
64
- }
65
-
66
- export class TasksFileNotFound extends Data.TaggedError('TasksFileNotFound')<{
67
- readonly planDir: string;
68
- }> {
69
- get message(): string {
70
- return `No tasks.jsonl found in ${this.planDir}`;
71
- }
72
- }
73
-
74
- export type PlanStorageError =
75
- | PlanReadError
76
- | PlanWriteError
77
- | JsonlParseError
78
- | JsonlValidationError
79
- | MissingMetaRecord
80
- | TaskNotFound
81
- | TasksFileNotFound;
82
-
83
- export function causeMessage(cause: unknown): string {
84
- if (cause instanceof Error) return cause.message;
85
- return String(cause);
86
- }
87
-
88
- export function errorMessage(error: unknown): string {
89
- if (error instanceof Error) return error.message;
90
- if (typeof error === 'object' && error !== null && 'message' in error) {
91
- const message = (error as { message?: unknown }).message;
92
- if (typeof message === 'string') return message;
93
- }
94
- return String(error);
95
- }
96
-
97
- /** Convert any error (including tagged errors) into a native Error for the tool boundary. */
98
- export function toNativeError(error: unknown): Error {
99
- if (error instanceof Error) return error;
100
- const native = new Error(errorMessage(error));
101
- if (typeof error === 'object' && error !== null && '_tag' in error) {
102
- native.name = String((error as { _tag: unknown })._tag);
103
- }
104
- return native;
105
- }
@@ -1,172 +0,0 @@
1
- /**
2
- * Initiative logic — ready-work computation and the initiative→plan projection.
3
- *
4
- * Two layers live here:
5
- * - PURE: `computePlanReadiness`, `isInitiativeFinalizable`, `initiativeRollup`
6
- * reason over a plans-manifest snapshot with no IO. They are the basis for
7
- * "what work is unblocked right now" — the foundation for phase-2 subagent
8
- * fan-out.
9
- * - IO: `reconcileInitiativeStatus` / `reconcileInitiativeForPlan` keep an
10
- * initiative's registry status a PROJECTION of its member plans, mirroring
11
- * `reconcilePlanStatus` one level up. They read the PLANS manifest, so they
12
- * live here (not in `initiatives-manifest.ts`) to keep the dependency
13
- * direction one-way: initiative.ts → {plans-manifest, initiatives-manifest}.
14
- */
15
-
16
- import { Effect } from 'effect';
17
- import { FileSystem } from './effects/filesystem.js';
18
- import type { JsonlParseError, JsonlValidationError, PlanWriteError } from './errors.js';
19
- import { readPlansManifest, type PlanManifestEntry } from './storage/plans-manifest.js';
20
- import {
21
- applyInitiativeUpsert,
22
- mutateInitiativesManifest,
23
- } from './storage/initiatives-manifest.js';
24
- import type { PlanStatus } from './types.js';
25
-
26
- // ── Pure: readiness + projection rules ───────────────────────────────────────
27
-
28
- export interface PlanReadiness {
29
- name: string;
30
- /** True when every plan in `depends_on` is `done`. */
31
- ready: boolean;
32
- /** Dependency plan names that are not yet `done` (unknown deps count too). */
33
- blockedBy: string[];
34
- }
35
-
36
- /**
37
- * For each `in-progress` plan, whether all of its plan-level dependencies are
38
- * `done`. Only a `done` dependency unblocks — a missing, in-progress, or
39
- * terminally-closed (superseded/abandoned) dependency keeps a plan blocked.
40
- */
41
- export function computePlanReadiness(plans: readonly PlanManifestEntry[]): PlanReadiness[] {
42
- const statusByName = new Map(plans.map((plan) => [plan.name, plan.status]));
43
- return plans
44
- .filter((plan) => plan.status === 'in-progress')
45
- .map((plan) => {
46
- const deps = plan.depends_on ?? [];
47
- const blockedBy = deps.filter((dep) => statusByName.get(dep) !== 'done');
48
- return { name: plan.name, ready: blockedBy.length === 0, blockedBy };
49
- });
50
- }
51
-
52
- /** Member plans of an initiative (linked by name in the plans manifest). */
53
- export function membersOf(
54
- initiative: string,
55
- plans: readonly PlanManifestEntry[],
56
- ): PlanManifestEntry[] {
57
- return plans.filter((plan) => plan.initiative === initiative);
58
- }
59
-
60
- /**
61
- * An initiative is finalizable (`done`) when it has ≥1 member plan AND every
62
- * member is terminal (no member is `in-progress`). Mirrors the plan-level rule
63
- * one level up.
64
- */
65
- export function isInitiativeFinalizable(
66
- initiative: string,
67
- plans: readonly PlanManifestEntry[],
68
- ): boolean {
69
- const members = membersOf(initiative, plans);
70
- if (members.length === 0) return false;
71
- return members.every((plan) => plan.status !== 'in-progress');
72
- }
73
-
74
- export interface InitiativeMemberRow {
75
- name: string;
76
- title: string;
77
- status: PlanStatus;
78
- /** Present for in-progress members. */
79
- ready?: boolean;
80
- blockedBy?: string[];
81
- }
82
-
83
- export interface InitiativeRollup {
84
- name: string;
85
- total: number;
86
- done: number;
87
- /** Terminal but not done (superseded / abandoned). */
88
- closed: number;
89
- inProgress: number;
90
- ready: number;
91
- blocked: number;
92
- members: InitiativeMemberRow[];
93
- }
94
-
95
- /** Aggregate an initiative's member plans into counts + per-member readiness. */
96
- export function initiativeRollup(
97
- initiative: string,
98
- plans: readonly PlanManifestEntry[],
99
- ): InitiativeRollup {
100
- const members = membersOf(initiative, plans);
101
- const readiness = new Map(computePlanReadiness(plans).map((row) => [row.name, row]));
102
-
103
- let done = 0;
104
- let closed = 0;
105
- let inProgress = 0;
106
- let ready = 0;
107
- let blocked = 0;
108
-
109
- const rows: InitiativeMemberRow[] = members.map((plan) => {
110
- if (plan.status === 'done') done += 1;
111
- else if (plan.status === 'in-progress') inProgress += 1;
112
- else closed += 1; // superseded / abandoned
113
-
114
- const row: InitiativeMemberRow = { name: plan.name, title: plan.title, status: plan.status };
115
- if (plan.status === 'in-progress') {
116
- const r = readiness.get(plan.name);
117
- row.ready = r?.ready ?? true;
118
- row.blockedBy = r?.blockedBy ?? [];
119
- if (row.ready) ready += 1;
120
- else blocked += 1;
121
- }
122
- return row;
123
- });
124
-
125
- return { name: initiative, total: members.length, done, closed, inProgress, ready, blocked, members: rows };
126
- }
127
-
128
- // ── IO: keep initiative registry status a projection of member plans ─────────
129
-
130
- type ReconcileError = JsonlParseError | JsonlValidationError | PlanWriteError;
131
-
132
- /**
133
- * Re-derive an initiative's registry status from its member plans.
134
- *
135
- * Like `reconcilePlanStatus`: only reflects state for a KNOWN initiative (never
136
- * conjures an entry), and never clobbers a manually-set terminal status
137
- * (`superseded` / `abandoned`). Only `in-progress` ⇄ `done` is derived.
138
- */
139
- export function reconcileInitiativeStatus(
140
- name: string,
141
- ): Effect.Effect<void, ReconcileError, FileSystem> {
142
- // The whole read-decide-write runs under the initiatives lock so a concurrent
143
- // writer cannot slip a status change between our read and our write.
144
- return mutateInitiativesManifest((initiatives) =>
145
- Effect.gen(function* () {
146
- const existing = initiatives.find((entry) => entry.name === name);
147
- if (!existing) return false;
148
- if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
149
- const plans = yield* readPlansManifest();
150
- const status: PlanStatus = isInitiativeFinalizable(name, plans) ? 'done' : 'in-progress';
151
- if (existing.status === status) return false;
152
- applyInitiativeUpsert(initiatives, name, { status, title: existing.title });
153
- return true;
154
- }),
155
- );
156
- }
157
-
158
- /**
159
- * Reconcile the initiative that a given plan belongs to (no-op when the plan is
160
- * standalone). Call this after any plan-status write so the initiative level
161
- * stays in sync without callers needing to know the parent name.
162
- */
163
- export function reconcileInitiativeForPlan(
164
- planName: string,
165
- ): Effect.Effect<void, ReconcileError, FileSystem> {
166
- return Effect.gen(function* () {
167
- const plans = yield* readPlansManifest();
168
- const plan = plans.find((entry) => entry.name === planName);
169
- if (!plan?.initiative) return;
170
- yield* reconcileInitiativeStatus(plan.initiative);
171
- });
172
- }
@@ -1,6 +0,0 @@
1
- export {
2
- loadHandoff,
3
- readAndClearExecPending,
4
- saveHandoff,
5
- writeExecPending,
6
- } from './storage/plan-storage.js';
@@ -1,235 +0,0 @@
1
- /**
2
- * Drift detection + repair between `tasks.jsonl` reality and registry status.
3
- *
4
- * Drift happens in both directions (FEEDBACK #6):
5
- * - tasks all done but registry `in-progress` (completion never recorded), and
6
- * - registry `in-progress`/`done` disagreeing with task state generally.
7
- *
8
- * It also surfaces two un-trackable classes:
9
- * - registry-only plans (an entry with no `tasks.jsonl` directory), and
10
- * - orphan task dirs (a `tasks.jsonl` with no registry entry).
11
- *
12
- * `collectPlanDrift` is a pure read; `applyReconcile` repairs only the safe
13
- * `in-progress` ⇄ `done` projection and never touches terminal statuses.
14
- */
15
-
16
- import { Effect } from 'effect';
17
- import { FileSystem } from './effects/filesystem.js';
18
- import type {
19
- JsonlParseError,
20
- JsonlValidationError,
21
- MissingMetaRecord,
22
- PlanWriteError,
23
- } from './errors.js';
24
- import { readPlansManifest, reconcilePlanStatus } from './storage/plans-manifest.js';
25
- import { readInitiativesManifest } from './storage/initiatives-manifest.js';
26
- import {
27
- isInitiativeFinalizable,
28
- membersOf,
29
- reconcileInitiativeForPlan,
30
- reconcileInitiativeStatus,
31
- } from './initiative.js';
32
- import { readTasksJsonl } from './storage/task-storage.js';
33
- import { isPlanFinalizable } from './task-status.js';
34
- import type { PlanStatus } from './types.js';
35
-
36
- const PLANS_DIR = '.plans';
37
-
38
- export interface PlanDriftRow {
39
- name: string;
40
- /** Registry status, or `undefined` when there is a task dir but no entry. */
41
- registryStatus?: PlanStatus;
42
- title?: string;
43
- /** Derived from tasks: `done` when finalizable, else `in-progress`. */
44
- derivedStatus?: 'in-progress' | 'done';
45
- /** Resolved/total task counts when a tasks.jsonl exists. */
46
- resolved?: number;
47
- total?: number;
48
- /** True when a `tasks.jsonl` snapshot was found for this plan. */
49
- hasTasks: boolean;
50
- /**
51
- * Drift class:
52
- * - 'status' : registry status disagrees with derived task status
53
- * - 'registry-only' : registry entry but no tasks.jsonl dir
54
- * - 'orphan' : tasks.jsonl dir but no registry entry
55
- * - undefined : in sync
56
- */
57
- drift?: 'status' | 'registry-only' | 'orphan';
58
- /**
59
- * For `status` drift, the direction the registry would move if projected from
60
- * tasks:
61
- * - 'upgrade' : registry `in-progress` → tasks `done` (safe; auto-repaired)
62
- * - 'downgrade' : registry `done` → tasks `in-progress` (NOT auto-repaired)
63
- *
64
- * A downgrade almost always means "work merged but tasks were never marked
65
- * done" — auto-projecting tasks→registry there would REGRESS a finished plan
66
- * back to in-progress (the wrong direction). We surface it for a human to
67
- * resolve by marking the tasks done instead.
68
- */
69
- direction?: 'upgrade' | 'downgrade';
70
- }
71
-
72
- type CollectError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
73
-
74
- /** Walk every plan (registry + task dirs) and classify drift. Pure read. */
75
- export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError, FileSystem> {
76
- return Effect.gen(function* () {
77
- const fs = yield* FileSystem;
78
- const manifest = yield* readPlansManifest();
79
- const dirs = yield* Effect.orElseSucceed(fs.listDirectories(PLANS_DIR), () => [] as string[]);
80
- // Ignore dotfile dirs like `.archive`.
81
- const taskDirs = new Set(dirs.filter((name) => !name.startsWith('.')));
82
-
83
- const rows: PlanDriftRow[] = [];
84
- const seen = new Set<string>();
85
-
86
- for (const entry of manifest) {
87
- seen.add(entry.name);
88
- const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${entry.name}`);
89
- if (!snapshot) {
90
- rows.push({
91
- name: entry.name,
92
- registryStatus: entry.status,
93
- title: entry.title,
94
- hasTasks: false,
95
- drift: 'registry-only',
96
- });
97
- continue;
98
- }
99
- const total = snapshot.tasks.length;
100
- const resolved = snapshot.tasks.filter(
101
- (t) => t.status === 'done' || t.status === 'skipped',
102
- ).length;
103
- const derivedStatus = isPlanFinalizable(snapshot.tasks) ? 'done' : 'in-progress';
104
- // Terminal statuses (superseded/abandoned) are intentional — never drift.
105
- const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
106
- const drift = !isTerminalManual && entry.status !== derivedStatus ? 'status' : undefined;
107
- const direction =
108
- drift === 'status'
109
- ? derivedStatus === 'done'
110
- ? ('upgrade' as const)
111
- : ('downgrade' as const)
112
- : undefined;
113
- rows.push({
114
- name: entry.name,
115
- registryStatus: entry.status,
116
- title: entry.title,
117
- derivedStatus,
118
- resolved,
119
- total,
120
- hasTasks: true,
121
- drift,
122
- direction,
123
- });
124
- }
125
-
126
- // Orphan task dirs: have tasks.jsonl but no registry entry.
127
- for (const name of taskDirs) {
128
- if (seen.has(name)) continue;
129
- const snapshot = yield* readTasksJsonl(`${PLANS_DIR}/${name}`);
130
- if (!snapshot) continue;
131
- const total = snapshot.tasks.length;
132
- const resolved = snapshot.tasks.filter(
133
- (t) => t.status === 'done' || t.status === 'skipped',
134
- ).length;
135
- rows.push({
136
- name,
137
- title: snapshot.meta.title,
138
- derivedStatus: isPlanFinalizable(snapshot.tasks) ? 'done' : 'in-progress',
139
- resolved,
140
- total,
141
- hasTasks: true,
142
- drift: 'orphan',
143
- });
144
- }
145
-
146
- return rows;
147
- });
148
- }
149
-
150
- // ── Initiative-level drift ───────────────────────────────────────────────────
151
-
152
- export interface InitiativeDriftRow {
153
- name: string;
154
- registryStatus: PlanStatus;
155
- title: string;
156
- /** Projected from member plans: `done` when finalizable, else `in-progress`. */
157
- derivedStatus: 'in-progress' | 'done';
158
- members: number;
159
- /** 'status' when the registry status disagrees with the projection. */
160
- drift?: 'status';
161
- }
162
-
163
- /** Compare each initiative's registry status against its member-plan projection. */
164
- export function collectInitiativeDrift(): Effect.Effect<
165
- InitiativeDriftRow[],
166
- CollectError,
167
- FileSystem
168
- > {
169
- return Effect.gen(function* () {
170
- const initiatives = yield* readInitiativesManifest();
171
- const plans = yield* readPlansManifest();
172
- return initiatives.map((entry) => {
173
- const derivedStatus: 'in-progress' | 'done' = isInitiativeFinalizable(entry.name, plans)
174
- ? 'done'
175
- : 'in-progress';
176
- // Terminal statuses (superseded/abandoned) are intentional — never drift.
177
- const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
178
- const drift = !isTerminalManual && entry.status !== derivedStatus ? ('status' as const) : undefined;
179
- return {
180
- name: entry.name,
181
- registryStatus: entry.status,
182
- title: entry.title,
183
- derivedStatus,
184
- members: membersOf(entry.name, plans).length,
185
- drift,
186
- };
187
- });
188
- });
189
- }
190
-
191
- /** Repair `status`-class initiative drift by re-projecting from member plans. */
192
- export function applyInitiativeReconcile(
193
- rows: InitiativeDriftRow[],
194
- ): Effect.Effect<InitiativeDriftRow[], CollectError | PlanWriteError, FileSystem> {
195
- return Effect.gen(function* () {
196
- const repaired: InitiativeDriftRow[] = [];
197
- for (const row of rows) {
198
- if (row.drift !== 'status') continue;
199
- yield* reconcileInitiativeStatus(row.name);
200
- repaired.push(row);
201
- }
202
- return repaired;
203
- });
204
- }
205
-
206
- /**
207
- * Repair `status`-class drift by projecting derived status into the registry.
208
- *
209
- * Safety: only `upgrade` drift (registry `in-progress` → tasks `done`) is
210
- * auto-repaired. A `downgrade` (registry `done` → tasks `in-progress`) is
211
- * reported but NEVER auto-applied — it almost always means work merged without
212
- * marking tasks done, and projecting tasks→registry there would regress a
213
- * finished plan. The human resolves it by marking the tasks done instead.
214
- *
215
- * Orphans and registry-only rows are likewise reported but not auto-fixed.
216
- * Returns the rows that were repaired.
217
- */
218
- export function applyReconcile(
219
- rows: PlanDriftRow[],
220
- ): Effect.Effect<PlanDriftRow[], CollectError | PlanWriteError, FileSystem> {
221
- return Effect.gen(function* () {
222
- const repaired: PlanDriftRow[] = [];
223
- for (const row of rows) {
224
- if (row.drift !== 'status' || !row.derivedStatus) continue;
225
- // Guard against the wrong-direction projection: never auto-regress a
226
- // `done` plan back to `in-progress`.
227
- if (row.direction === 'downgrade') continue;
228
- yield* reconcilePlanStatus(row.name, row.derivedStatus === 'done', row.title);
229
- // Repairing a plan's status can flip its parent initiative's projection.
230
- yield* reconcileInitiativeForPlan(row.name);
231
- repaired.push(row);
232
- }
233
- return repaired;
234
- });
235
- }