@dreki-gg/pi-plan-mode 0.24.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.24.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Fix lost-update race on the plan/initiative registries and stop reconcile from regressing finished plans.
8
+
9
+ - **Serialized registry writes:** concurrent tool calls in one block (e.g. several `submit_initiative` / `submit_plan` / `revise_plan` at once) previously ran an unguarded read-modify-write against `plans.jsonl` / `initiatives.jsonl`, so only the last write survived. A process-wide per-file lock (`withFileLock`) now serializes the read→modify→write of both registries and per-plan `tasks.jsonl`, eliminating the lost updates. Atomic writes already guarded against torn files; this closes the in-process concurrency gap.
10
+ - **No wrong-direction reconcile:** `reconcile_plans --apply` now only records completion (`in-progress → done`) and never auto-downgrades a `done` plan back to `in-progress`. A done plan with incomplete tasks (work merged but tasks never marked done) is surfaced as `needs-tasks-done` for a human to resolve by marking the tasks done, instead of being silently regressed.
11
+
3
12
  ## 0.24.0
4
13
 
5
14
  ### Minor Changes
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Regression: concurrent read-modify-write on the registries must not lose
3
+ * updates. Each `Effect.runPromise` mimics a separate tool execute running in
4
+ * the same block (e.g. three `submit_initiative` calls). Before the file-lock
5
+ * fix, their reads all saw the same starting file and the last write clobbered
6
+ * the rest — only one entry survived.
7
+ */
8
+
9
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
10
+ import { Effect } from 'effect';
11
+ import { chdir } from 'node:process';
12
+ import { mkdtemp, rm } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ import { tmpdir } from 'node:os';
15
+ import { FileSystem, nodeFileSystemService } from '../effects/filesystem.js';
16
+ import { readPlansManifest, upsertPlanEntry } from '../storage/plans-manifest.js';
17
+ import {
18
+ readInitiativesManifest,
19
+ upsertInitiativeEntry,
20
+ } from '../storage/initiatives-manifest.js';
21
+ import { writeTasksJsonl, readTasksJsonl, updateTask } from '../storage/task-storage.js';
22
+ import type { TaskMeta, TaskRecord } from '../types.js';
23
+
24
+ // Each call gets its OWN runPromise so the writes are genuinely concurrent —
25
+ // the same shape as independent tool executes sharing one Node process.
26
+ const run = <A, E>(program: Effect.Effect<A, E, FileSystem>): Promise<A> =>
27
+ Effect.runPromise(program.pipe(Effect.provideService(FileSystem, nodeFileSystemService)));
28
+
29
+ const originalCwd = process.cwd();
30
+ let dir: string;
31
+
32
+ beforeEach(async () => {
33
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-concurrent-'));
34
+ chdir(dir);
35
+ });
36
+ afterEach(async () => {
37
+ chdir(originalCwd);
38
+ await rm(dir, { recursive: true, force: true });
39
+ });
40
+
41
+ describe('concurrent registry writes', () => {
42
+ test('5 concurrent plan upserts all persist (no lost updates)', async () => {
43
+ const names = ['a', 'b', 'c', 'd', 'e'];
44
+ await Promise.all(
45
+ names.map((n) => run(upsertPlanEntry(n, { status: 'in-progress', title: n.toUpperCase() }))),
46
+ );
47
+ const entries = await run(readPlansManifest());
48
+ expect(entries.map((e) => e.name).sort()).toEqual(names);
49
+ });
50
+
51
+ test('3 concurrent initiative upserts all persist (no lost updates)', async () => {
52
+ const names = ['one', 'two', 'three'];
53
+ await Promise.all(
54
+ names.map((n) => run(upsertInitiativeEntry(n, { status: 'in-progress', title: n }))),
55
+ );
56
+ const entries = await run(readInitiativesManifest());
57
+ expect(entries.map((e) => e.name).sort()).toEqual([...names].sort());
58
+ });
59
+
60
+ test('concurrent task updates to the same plan all persist', async () => {
61
+ const planDir = join('.plans', 'p');
62
+ const meta: TaskMeta = {
63
+ _type: 'meta',
64
+ plan_name: 'p',
65
+ title: 'P',
66
+ created_at: 'now',
67
+ };
68
+ const tasks: TaskRecord[] = ['t-001', 't-002', 't-003'].map((id) => ({
69
+ _type: 'task',
70
+ id,
71
+ description: id,
72
+ status: 'pending',
73
+ created_at: 'now',
74
+ updated_at: 'now',
75
+ }));
76
+ await run(writeTasksJsonl(planDir, meta, tasks));
77
+
78
+ await Promise.all(
79
+ tasks.map((t) => run(updateTask(planDir, t.id, { status: 'done' }))),
80
+ );
81
+
82
+ const snapshot = await run(readTasksJsonl(planDir));
83
+ expect(snapshot?.tasks.every((t) => t.status === 'done')).toBe(true);
84
+ });
85
+ });
@@ -117,6 +117,43 @@ describe('applyReconcile', () => {
117
117
  const [entry] = await runPlanIO(readPlansManifest());
118
118
  expect(entry.status).toBe('done');
119
119
  });
120
+
121
+ test('classifies an upgrade (in-progress registry, done tasks) as direction:upgrade', async () => {
122
+ await runPlanIO(writeTasksJsonl('.plans/up', meta('up'), [task('t-001', 'done')]));
123
+ await runPlanIO(upsertPlanEntry('up', { status: 'in-progress', title: 'Up' }));
124
+ const rows = await runPlanIO(collectPlanDrift());
125
+ expect(rows.find((r) => r.name === 'up')!.direction).toBe('upgrade');
126
+ });
127
+
128
+ test('a done plan with incomplete tasks is flagged downgrade and NOT auto-repaired', async () => {
129
+ // Work merged but tasks never marked done: registry done, tasks in-progress.
130
+ await runPlanIO(
131
+ writeTasksJsonl('.plans/merged', meta('merged'), [task('t-001', 'pending')]),
132
+ );
133
+ await runPlanIO(
134
+ writePlansManifest([
135
+ {
136
+ _type: 'plan',
137
+ name: 'merged',
138
+ status: 'done',
139
+ title: 'Merged',
140
+ created_at: now,
141
+ completed_at: now,
142
+ },
143
+ ]),
144
+ );
145
+
146
+ const rows = await runPlanIO(collectPlanDrift());
147
+ const merged = rows.find((r) => r.name === 'merged')!;
148
+ expect(merged.drift).toBe('status');
149
+ expect(merged.direction).toBe('downgrade');
150
+
151
+ // --apply must NOT regress it back to in-progress.
152
+ const repaired = await runPlanIO(applyReconcile(rows));
153
+ expect(repaired.map((r) => r.name)).not.toContain('merged');
154
+ const [entry] = await runPlanIO(readPlansManifest());
155
+ expect(entry.status).toBe('done');
156
+ });
120
157
  });
121
158
 
122
159
  describe('initiative drift', () => {
@@ -18,8 +18,8 @@ import { FileSystem } from './effects/filesystem.js';
18
18
  import type { JsonlParseError, JsonlValidationError, PlanWriteError } from './errors.js';
19
19
  import { readPlansManifest, type PlanManifestEntry } from './storage/plans-manifest.js';
20
20
  import {
21
- readInitiativesManifest,
22
- upsertInitiativeEntry,
21
+ applyInitiativeUpsert,
22
+ mutateInitiativesManifest,
23
23
  } from './storage/initiatives-manifest.js';
24
24
  import type { PlanStatus } from './types.js';
25
25
 
@@ -139,16 +139,20 @@ type ReconcileError = JsonlParseError | JsonlValidationError | PlanWriteError;
139
139
  export function reconcileInitiativeStatus(
140
140
  name: string,
141
141
  ): Effect.Effect<void, ReconcileError, FileSystem> {
142
- return Effect.gen(function* () {
143
- const initiatives = yield* readInitiativesManifest();
144
- const existing = initiatives.find((entry) => entry.name === name);
145
- if (!existing) return;
146
- if (existing.status === 'superseded' || existing.status === 'abandoned') return;
147
- const plans = yield* readPlansManifest();
148
- const status: PlanStatus = isInitiativeFinalizable(name, plans) ? 'done' : 'in-progress';
149
- if (existing.status === status) return;
150
- yield* upsertInitiativeEntry(name, { status, title: existing.title });
151
- });
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
+ );
152
156
  }
153
157
 
154
158
  /**
@@ -55,6 +55,18 @@ export interface PlanDriftRow {
55
55
  * - undefined : in sync
56
56
  */
57
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';
58
70
  }
59
71
 
60
72
  type CollectError = JsonlParseError | JsonlValidationError | MissingMetaRecord;
@@ -92,6 +104,12 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
92
104
  // Terminal statuses (superseded/abandoned) are intentional — never drift.
93
105
  const isTerminalManual = entry.status === 'superseded' || entry.status === 'abandoned';
94
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;
95
113
  rows.push({
96
114
  name: entry.name,
97
115
  registryStatus: entry.status,
@@ -101,6 +119,7 @@ export function collectPlanDrift(): Effect.Effect<PlanDriftRow[], CollectError,
101
119
  total,
102
120
  hasTasks: true,
103
121
  drift,
122
+ direction,
104
123
  });
105
124
  }
106
125
 
@@ -186,8 +205,15 @@ export function applyInitiativeReconcile(
186
205
 
187
206
  /**
188
207
  * Repair `status`-class drift by projecting derived status into the registry.
189
- * Orphans and registry-only rows are reported but not auto-fixed (they need a
190
- * human decision). Returns the rows that were repaired.
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.
191
217
  */
192
218
  export function applyReconcile(
193
219
  rows: PlanDriftRow[],
@@ -196,6 +222,9 @@ export function applyReconcile(
196
222
  const repaired: PlanDriftRow[] = [];
197
223
  for (const row of rows) {
198
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;
199
228
  yield* reconcilePlanStatus(row.name, row.derivedStatus === 'done', row.title);
200
229
  // Repairing a plan's status can flip its parent initiative's projection.
201
230
  yield* reconcileInitiativeForPlan(row.name);
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Process-wide keyed mutex for serializing read-modify-write on shared files.
3
+ *
4
+ * Pi runs every tool call in a single Node process. When several tool calls run
5
+ * in one block (e.g. three `submit_initiative` calls, or concurrent
6
+ * `submit_plan` / `revise_plan`), each does an independent
7
+ * read → modify → write against the same registry file. Without serialization
8
+ * their reads all observe the same starting state and the last write clobbers
9
+ * the rest — a classic lost-update race.
10
+ *
11
+ * `withFileLock` wraps a read-modify-write critical section so only one runs at
12
+ * a time per `key` (the registry path). The semaphore is created eagerly with
13
+ * `unsafeMakeSemaphore` and cached per key, so its permit count lives in plain
14
+ * shared memory and serializes correctly even across independent
15
+ * `Effect.runPromise` invocations (separate tool executes).
16
+ *
17
+ * NOTE: this guards against in-process concurrency only. Atomic writes
18
+ * (`writeFileAtomic`) still protect against torn files from other processes,
19
+ * but cross-process registry coordination is out of scope.
20
+ */
21
+
22
+ import { Effect } from 'effect';
23
+
24
+ const locks = new Map<string, Effect.Semaphore>();
25
+
26
+ function lockFor(key: string): Effect.Semaphore {
27
+ let lock = locks.get(key);
28
+ if (!lock) {
29
+ lock = Effect.unsafeMakeSemaphore(1);
30
+ locks.set(key, lock);
31
+ }
32
+ return lock;
33
+ }
34
+
35
+ /**
36
+ * Run `effect` while holding the single permit for `key`. Concurrent callers
37
+ * with the same key queue and run one at a time; the permit is always released,
38
+ * even on failure or interruption.
39
+ *
40
+ * Do NOT nest `withFileLock` for the same key inside another — the permit is
41
+ * not reentrant and would deadlock. Express composite read-modify-write as one
42
+ * locked section instead.
43
+ */
44
+ export function withFileLock<A, E, R>(
45
+ key: string,
46
+ effect: Effect.Effect<A, E, R>,
47
+ ): Effect.Effect<A, E, R> {
48
+ return Effect.suspend(() => lockFor(key).withPermits(1)(effect));
49
+ }
@@ -18,6 +18,7 @@ import { FileSystem } from '../effects/filesystem.js';
18
18
  import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
19
19
  import { decodeInitiativeManifestEntry } from '../schema.js';
20
20
  import type { InitiativeStatus } from '../types.js';
21
+ import { withFileLock } from './file-lock.js';
21
22
 
22
23
  const MANIFEST_DIR = '.plans';
23
24
  const MANIFEST_PATH = '.plans/initiatives.jsonl';
@@ -86,27 +87,68 @@ export function writeInitiativesManifest(
86
87
  });
87
88
  }
88
89
 
90
+ export interface InitiativeUpsert {
91
+ status: InitiativeStatus;
92
+ title?: string;
93
+ reason?: string;
94
+ }
95
+
96
+ /**
97
+ * Pure transform: upsert `name` into the in-memory `entries` array, preserving
98
+ * created_at from any existing entry. No IO — shared by the locked
99
+ * `upsertInitiativeEntry` and `reconcileInitiativeStatus` so both flow through
100
+ * one serialized read-modify-write and never nest locks.
101
+ */
102
+ export function applyInitiativeUpsert(
103
+ entries: InitiativeManifestEntry[],
104
+ name: string,
105
+ updates: InitiativeUpsert,
106
+ ): void {
107
+ const now = new Date().toISOString();
108
+ const index = entries.findIndex((entry) => entry.name === name);
109
+ const existing = index === -1 ? undefined : entries[index];
110
+ const entry: InitiativeManifestEntry = {
111
+ _type: 'initiative',
112
+ name,
113
+ status: updates.status,
114
+ title: updates.title ?? existing?.title ?? 'Untitled initiative',
115
+ created_at: existing?.created_at ?? now,
116
+ // Terminal statuses record a completion timestamp; reopening clears it.
117
+ completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
118
+ reason: updates.reason ?? existing?.reason,
119
+ };
120
+ if (index === -1) entries.push(entry);
121
+ else entries[index] = entry;
122
+ }
123
+
124
+ /**
125
+ * Serialized read-modify-write of the initiatives registry. Holds a
126
+ * process-wide lock on the manifest path across the whole read → transform →
127
+ * write so concurrent tool calls cannot clobber each other. `transform` may run
128
+ * IO (e.g. read the plans manifest to project status) and mutates the entries
129
+ * array in place, returning `true` when it changed something.
130
+ */
131
+ export function mutateInitiativesManifest<E, R>(
132
+ transform: (entries: InitiativeManifestEntry[]) => Effect.Effect<boolean, E, R>,
133
+ ): Effect.Effect<void, ReadError | PlanWriteError | E, FileSystem | R> {
134
+ return withFileLock(
135
+ MANIFEST_PATH,
136
+ Effect.gen(function* () {
137
+ const entries = yield* readInitiativesManifest();
138
+ const changed = yield* transform(entries);
139
+ if (changed) yield* writeInitiativesManifest(entries);
140
+ }),
141
+ );
142
+ }
143
+
89
144
  export function upsertInitiativeEntry(
90
145
  name: string,
91
- updates: { status: InitiativeStatus; title?: string; reason?: string },
146
+ updates: InitiativeUpsert,
92
147
  ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
93
- return Effect.gen(function* () {
94
- const entries = yield* readInitiativesManifest();
95
- const now = new Date().toISOString();
96
- const index = entries.findIndex((entry) => entry.name === name);
97
- const existing = index === -1 ? undefined : entries[index];
98
- const entry: InitiativeManifestEntry = {
99
- _type: 'initiative',
100
- name,
101
- status: updates.status,
102
- title: updates.title ?? existing?.title ?? 'Untitled initiative',
103
- created_at: existing?.created_at ?? now,
104
- // Terminal statuses record a completion timestamp; reopening clears it.
105
- completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
106
- reason: updates.reason ?? existing?.reason,
107
- };
108
- if (index === -1) entries.push(entry);
109
- else entries[index] = entry;
110
- yield* writeInitiativesManifest(entries);
111
- });
148
+ return mutateInitiativesManifest((entries) =>
149
+ Effect.sync(() => {
150
+ applyInitiativeUpsert(entries, name, updates);
151
+ return true;
152
+ }),
153
+ );
112
154
  }
@@ -3,6 +3,7 @@ import { FileSystem } from '../effects/filesystem.js';
3
3
  import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
4
4
  import { decodePlanManifestEntry } from '../schema.js';
5
5
  import type { PlanStatus } from '../types.js';
6
+ import { withFileLock } from './file-lock.js';
6
7
 
7
8
  const MANIFEST_DIR = '.plans';
8
9
  const MANIFEST_PATH = '.plans/plans.jsonl';
@@ -71,39 +72,74 @@ export function writePlansManifest(
71
72
  });
72
73
  }
73
74
 
75
+ export interface PlanUpsert {
76
+ status: PlanStatus;
77
+ title?: string;
78
+ reason?: string;
79
+ /** Parent initiative name; preserved when omitted. */
80
+ initiative?: string;
81
+ /** Plan-level dependencies (plan names); preserved when omitted. */
82
+ depends_on?: string[];
83
+ }
84
+
85
+ /**
86
+ * Pure transform: upsert `name` into the in-memory `entries` array, preserving
87
+ * created_at / membership / deps from any existing entry. No IO — shared by the
88
+ * locked `upsertPlanEntry` and `reconcilePlanStatus` so both flow through one
89
+ * serialized read-modify-write and never nest locks.
90
+ */
91
+ export function applyPlanUpsert(
92
+ entries: PlanManifestEntry[],
93
+ name: string,
94
+ updates: PlanUpsert,
95
+ ): void {
96
+ const now = new Date().toISOString();
97
+ const index = entries.findIndex((entry) => entry.name === name);
98
+ const existing = index === -1 ? undefined : entries[index];
99
+ const entry: PlanManifestEntry = {
100
+ _type: 'plan',
101
+ name,
102
+ status: updates.status,
103
+ title: updates.title ?? existing?.title ?? 'Untitled plan',
104
+ created_at: existing?.created_at ?? now,
105
+ // Terminal statuses record a completion timestamp; reopening clears it.
106
+ completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
107
+ reason: updates.reason ?? existing?.reason,
108
+ // Membership + plan-level deps are preserved across status-only upserts.
109
+ initiative: updates.initiative ?? existing?.initiative,
110
+ depends_on: updates.depends_on ?? existing?.depends_on,
111
+ };
112
+ if (index === -1) entries.push(entry);
113
+ else entries[index] = entry;
114
+ }
115
+
116
+ /**
117
+ * Serialized read-modify-write of the plans registry. Holds a process-wide lock
118
+ * on the manifest path across the whole read → transform → write so concurrent
119
+ * tool calls cannot clobber each other (lost-update race). `transform` mutates
120
+ * the entries array in place and returns `true` when it changed something
121
+ * (return `false` to skip the rewrite).
122
+ */
123
+ export function mutatePlansManifest(
124
+ transform: (entries: PlanManifestEntry[]) => boolean,
125
+ ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
126
+ return withFileLock(
127
+ MANIFEST_PATH,
128
+ Effect.gen(function* () {
129
+ const entries = yield* readPlansManifest();
130
+ const changed = transform(entries);
131
+ if (changed) yield* writePlansManifest(entries);
132
+ }),
133
+ );
134
+ }
135
+
74
136
  export function upsertPlanEntry(
75
137
  name: string,
76
- updates: {
77
- status: PlanStatus;
78
- title?: string;
79
- reason?: string;
80
- /** Parent initiative name; preserved when omitted. */
81
- initiative?: string;
82
- /** Plan-level dependencies (plan names); preserved when omitted. */
83
- depends_on?: string[];
84
- },
138
+ updates: PlanUpsert,
85
139
  ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
86
- return Effect.gen(function* () {
87
- const entries = yield* readPlansManifest();
88
- const now = new Date().toISOString();
89
- const index = entries.findIndex((entry) => entry.name === name);
90
- const existing = index === -1 ? undefined : entries[index];
91
- const entry: PlanManifestEntry = {
92
- _type: 'plan',
93
- name,
94
- status: updates.status,
95
- title: updates.title ?? existing?.title ?? 'Untitled plan',
96
- created_at: existing?.created_at ?? now,
97
- // Terminal statuses record a completion timestamp; reopening clears it.
98
- completed_at: isTerminalStatus(updates.status) ? (existing?.completed_at ?? now) : null,
99
- reason: updates.reason ?? existing?.reason,
100
- // Membership + plan-level deps are preserved across status-only upserts.
101
- initiative: updates.initiative ?? existing?.initiative,
102
- depends_on: updates.depends_on ?? existing?.depends_on,
103
- };
104
- if (index === -1) entries.push(entry);
105
- else entries[index] = entry;
106
- yield* writePlansManifest(entries);
140
+ return mutatePlansManifest((entries) => {
141
+ applyPlanUpsert(entries, name, updates);
142
+ return true;
107
143
  });
108
144
  }
109
145
 
@@ -123,16 +159,16 @@ export function reconcilePlanStatus(
123
159
  finalizable: boolean,
124
160
  title?: string,
125
161
  ): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
126
- return Effect.gen(function* () {
127
- const entries = yield* readPlansManifest();
162
+ return mutatePlansManifest((entries) => {
128
163
  const existing = entries.find((entry) => entry.name === name);
129
164
  // Reconcile only reflects task state for KNOWN plans; never conjure an
130
165
  // entry for an unregistered plan (orphans are surfaced, not auto-created).
131
- if (!existing) return;
166
+ if (!existing) return false;
132
167
  // Do not resurrect / clobber an explicitly closed plan.
133
- if (existing.status === 'superseded' || existing.status === 'abandoned') return;
168
+ if (existing.status === 'superseded' || existing.status === 'abandoned') return false;
134
169
  const status: PlanStatus = finalizable ? 'done' : 'in-progress';
135
- if (existing.status === status) return; // no change
136
- yield* upsertPlanEntry(name, { status, title });
170
+ if (existing.status === status) return false; // no change
171
+ applyPlanUpsert(entries, name, { status, title });
172
+ return true;
137
173
  });
138
174
  }
@@ -11,6 +11,7 @@ import {
11
11
  } from '../errors.js';
12
12
  import { decodeTasksLine } from '../schema.js';
13
13
  import type { TaskMeta, TaskRecord } from '../types.js';
14
+ import { withFileLock } from './file-lock.js';
14
15
 
15
16
  const TASKS_FILE = 'tasks.jsonl';
16
17
 
@@ -86,20 +87,25 @@ export function updateTask(
86
87
  ReadError | PlanWriteError | TasksFileNotFound | TaskNotFound,
87
88
  FileSystem
88
89
  > {
89
- return Effect.gen(function* () {
90
- const snapshot = yield* readTasksJsonl(planDir);
91
- if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
90
+ // Serialize the read-modify-write so concurrent task updates to the same plan
91
+ // (e.g. parallel update_task / update_tasks calls) cannot clobber each other.
92
+ return withFileLock(
93
+ join(planDir, TASKS_FILE),
94
+ Effect.gen(function* () {
95
+ const snapshot = yield* readTasksJsonl(planDir);
96
+ if (!snapshot) return yield* Effect.fail(new TasksFileNotFound({ planDir }));
92
97
 
93
- const index = snapshot.tasks.findIndex((task) => task.id === taskId);
94
- if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
98
+ const index = snapshot.tasks.findIndex((task) => task.id === taskId);
99
+ if (index === -1) return yield* Effect.fail(new TaskNotFound({ planDir, taskId }));
95
100
 
96
- const updated: TaskRecord = {
97
- ...snapshot.tasks[index],
98
- ...updates,
99
- updated_at: new Date().toISOString(),
100
- };
101
- snapshot.tasks[index] = updated;
102
- yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
103
- return updated;
104
- });
101
+ const updated: TaskRecord = {
102
+ ...snapshot.tasks[index],
103
+ ...updates,
104
+ updated_at: new Date().toISOString(),
105
+ };
106
+ snapshot.tasks[index] = updated;
107
+ yield* writeTasksJsonl(planDir, snapshot.meta, snapshot.tasks);
108
+ return updated;
109
+ }),
110
+ );
105
111
  }
@@ -29,6 +29,10 @@ function describeRow(row: PlanDriftRow): string {
29
29
  return ` ⚠ ${row.name}${progress} — tasks.jsonl with no registry entry (orphan)`;
30
30
  }
31
31
  if (row.drift === 'status') {
32
+ if (row.direction === 'downgrade') {
33
+ // Wrong-direction projection: do NOT auto-repair. Mark tasks done instead.
34
+ return ` ⚠ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus} (likely merged; mark tasks done — not auto-repaired)`;
35
+ }
32
36
  return ` ✗ ${row.name}${progress} — registry ${row.registryStatus}, tasks say ${row.derivedStatus}`;
33
37
  }
34
38
  return ` ✓ ${row.name}${progress} — ${row.registryStatus} (in sync)`;
@@ -44,6 +48,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
44
48
  promptGuidelines: [
45
49
  'Use reconcile_plans to audit .plans/ when registry status looks stale (e.g. a fully-done plan still in-progress).',
46
50
  'Run it read-only first; pass apply:true once you have reviewed the reported drift.',
51
+ 'apply:true only records completion (in-progress→done). It never regresses a done plan back to in-progress — if a done plan shows incomplete tasks (work merged but tasks not marked), mark those tasks done instead.',
47
52
  ],
48
53
  parameters: Type.Object({
49
54
  apply: Type.Optional(
@@ -76,7 +81,12 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
76
81
  ? ` ✗ ${row.name} (initiative, ${row.members} plans) — registry ${row.registryStatus}, plans say ${row.derivedStatus}`
77
82
  : ` ✓ ${row.name} (initiative, ${row.members} plans) — ${row.registryStatus} (in sync)`,
78
83
  );
79
- const statusDrift = drifted.filter((r) => r.drift === 'status').length;
84
+ const statusDrift = drifted.filter(
85
+ (r) => r.drift === 'status' && r.direction === 'upgrade',
86
+ ).length;
87
+ const downgrades = drifted.filter(
88
+ (r) => r.drift === 'status' && r.direction === 'downgrade',
89
+ ).length;
80
90
  const orphans = drifted.filter((r) => r.drift === 'orphan').length;
81
91
  const registryOnly = drifted.filter((r) => r.drift === 'registry-only').length;
82
92
 
@@ -89,6 +99,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
89
99
 
90
100
  const summary = [
91
101
  `status-drift ${statusDrift}`,
102
+ `needs-tasks-done ${downgrades}`,
92
103
  `orphan ${orphans}`,
93
104
  `registry-only ${registryOnly}`,
94
105
  ].join(', ');
@@ -102,7 +113,7 @@ export function registerReconcilePlansTool(pi: ExtensionAPI, runPlanIO: RunPlanI
102
113
  details: {
103
114
  applied: Boolean(params.apply),
104
115
  repaired: repaired.map((r) => r.name),
105
- drift: drifted.map((r) => ({ name: r.name, kind: r.drift })),
116
+ drift: drifted.map((r) => ({ name: r.name, kind: r.drift, direction: r.direction })),
106
117
  total: rows.length,
107
118
  initiative_repaired: initiativeRepaired.map((r) => r.name),
108
119
  initiative_drift: initiativeDrifted.map((r) => ({ name: r.name, kind: r.drift })),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dreki-gg/pi-plan-mode",
3
- "version": "0.24.0",
3
+ "version": "0.24.1",
4
4
  "description": "Two-phase planning workflow for pi — plan with claude-opus-4-6:medium, execute with gpt-5.5:low, with .plans/ file-based handoff",
5
5
  "keywords": [
6
6
  "pi-package"