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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # @dreki-gg/pi-plan-mode
2
2
 
3
+ ## 0.25.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Add `@plan:<slug>` plan references with autocomplete. Type `@plan:` in any message to fuzzy-search and tag a plan (all plans listed, in-progress first), and on send the referenced plan's tasks + handoff are attached as context so the agent understands the reference. Resolution is context-only — it never switches execution mode, tools, or model — and follows first-wins when multiple tokens are present.
8
+
9
+ ## 0.24.1
10
+
11
+ ### Patch Changes
12
+
13
+ - Fix lost-update race on the plan/initiative registries and stop reconcile from regressing finished plans.
14
+
15
+ - **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.
16
+ - **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.
17
+
3
18
  ## 0.24.0
4
19
 
5
20
  ### 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
+ });
@@ -0,0 +1,55 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { filterPlanReferences, formatPlanSuggestion } from '../references/autocomplete.js';
3
+ import type { PlanListItem } from '../commands/list-plans.js';
4
+
5
+ function item(overrides: Partial<PlanListItem> & { name: string }): PlanListItem {
6
+ return {
7
+ title: `Title ${overrides.name}`,
8
+ status: 'in-progress',
9
+ created_at: '2026-01-01T00:00:00.000Z',
10
+ completed_at: null,
11
+ totalTasks: 3,
12
+ doneTasks: 1,
13
+ pendingTasks: 2,
14
+ ...overrides,
15
+ };
16
+ }
17
+
18
+ const items: PlanListItem[] = [
19
+ item({ name: 'done-plan', status: 'done' }),
20
+ item({ name: 'active-one', status: 'in-progress' }),
21
+ item({ name: 'abandoned-plan', status: 'abandoned' }),
22
+ item({ name: 'active-two', status: 'in-progress' }),
23
+ ];
24
+
25
+ describe('filterPlanReferences', () => {
26
+ test('empty query lists all plans, in-progress first', () => {
27
+ const result = filterPlanReferences(items, '');
28
+ expect(result.map((p) => p.name).slice(0, 2).sort()).toEqual(['active-one', 'active-two']);
29
+ expect(result).toHaveLength(4);
30
+ });
31
+
32
+ test('name-prefix matches win', () => {
33
+ const result = filterPlanReferences(items, 'active-t');
34
+ expect(result[0].name).toBe('active-two');
35
+ });
36
+
37
+ test('fuzzy fallback finds non-prefix matches', () => {
38
+ const result = filterPlanReferences(items, 'abandon');
39
+ expect(result.some((p) => p.name === 'abandoned-plan')).toBe(true);
40
+ });
41
+
42
+ test('returns empty array for no match', () => {
43
+ expect(filterPlanReferences(items, 'zzz-nope-nope')).toHaveLength(0);
44
+ });
45
+ });
46
+
47
+ describe('formatPlanSuggestion', () => {
48
+ test('builds a @plan: value with status + progress description', () => {
49
+ const suggestion = formatPlanSuggestion(item({ name: 'active-one', status: 'in-progress' }));
50
+ expect(suggestion.value).toBe('@plan:active-one');
51
+ expect(suggestion.label).toContain('Title active-one');
52
+ expect(suggestion.description).toContain('active-one');
53
+ expect(suggestion.description).toContain('1/3');
54
+ });
55
+ });
@@ -0,0 +1,86 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { chdir } from 'node:process';
3
+ import { mkdtemp, rm } from 'node:fs/promises';
4
+ import { join } from 'node:path';
5
+ import { tmpdir } from 'node:os';
6
+ import { makePlanRuntime } from '../effects/runtime.js';
7
+ import { upsertPlanEntry } from '../storage/plans-manifest.js';
8
+ import { writeTasksJsonl } from '../storage/task-storage.js';
9
+ import { saveHandoff } from '../storage/plan-storage.js';
10
+ import type { TaskMeta, TaskRecord } from '../types.js';
11
+ import { buildPlanContextPack, resolvePlanReference } from '../references/context.js';
12
+
13
+ const runPlanIO = makePlanRuntime();
14
+ const originalCwd = process.cwd();
15
+ let dir: string;
16
+
17
+ beforeEach(async () => {
18
+ dir = await mkdtemp(join(tmpdir(), 'plan-mode-refctx-'));
19
+ chdir(dir);
20
+ });
21
+
22
+ afterEach(async () => {
23
+ chdir(originalCwd);
24
+ await rm(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ const meta: TaskMeta = {
28
+ _type: 'meta',
29
+ title: 'Add Auth',
30
+ plan_name: 'add-auth',
31
+ created_at: '2026-01-01T00:00:00.000Z',
32
+ };
33
+
34
+ const tasks: TaskRecord[] = [
35
+ {
36
+ _type: 'task',
37
+ id: 't-001',
38
+ description: 'Write middleware',
39
+ status: 'done',
40
+ origin: 'plan',
41
+ created_at: '2026-01-01T00:00:00.000Z',
42
+ updated_at: '2026-01-01T00:00:00.000Z',
43
+ },
44
+ {
45
+ _type: 'task',
46
+ id: 't-002',
47
+ description: 'Wire routes',
48
+ status: 'pending',
49
+ origin: 'plan',
50
+ created_at: '2026-01-01T00:00:00.000Z',
51
+ updated_at: '2026-01-01T00:00:00.000Z',
52
+ },
53
+ ];
54
+
55
+ describe('buildPlanContextPack', () => {
56
+ test('includes title, status, tasks, and handoff', () => {
57
+ const pack = buildPlanContextPack('add-auth', 'Add Auth', 'in-progress', tasks, 'Handoff body');
58
+ expect(pack).toContain('Add Auth');
59
+ expect(pack).toContain('add-auth');
60
+ expect(pack).toContain('in-progress');
61
+ expect(pack).toContain('t-001');
62
+ expect(pack).toContain('Write middleware');
63
+ expect(pack).toContain('t-002');
64
+ expect(pack).toContain('Handoff body');
65
+ });
66
+ });
67
+
68
+ describe('resolvePlanReference', () => {
69
+ test('resolves a real plan on disk', async () => {
70
+ await runPlanIO(upsertPlanEntry('add-auth', { status: 'in-progress', title: 'Add Auth' }));
71
+ await runPlanIO(writeTasksJsonl('.plans/add-auth', meta, tasks));
72
+ await runPlanIO(saveHandoff('.plans/add-auth', 'Handoff body'));
73
+
74
+ const resolved = await runPlanIO(resolvePlanReference('add-auth'));
75
+ expect(resolved).toBeDefined();
76
+ expect(resolved?.title).toBe('Add Auth');
77
+ expect(resolved?.status).toBe('in-progress');
78
+ expect(resolved?.tasks).toHaveLength(2);
79
+ expect(resolved?.handoff).toBe('Handoff body');
80
+ });
81
+
82
+ test('returns undefined for an unknown slug', async () => {
83
+ const resolved = await runPlanIO(resolvePlanReference('nope'));
84
+ expect(resolved).toBeUndefined();
85
+ });
86
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ PLAN_TOKEN_PREFIX,
4
+ buildPlanToken,
5
+ parsePlanSlug,
6
+ findActivePlanToken,
7
+ extractPlanReferences,
8
+ firstPlanReference,
9
+ } from '../references/tokens.js';
10
+
11
+ describe('buildPlanToken / parsePlanSlug', () => {
12
+ test('round-trips a slug', () => {
13
+ const token = buildPlanToken('add-auth-middleware');
14
+ expect(token).toBe(`${PLAN_TOKEN_PREFIX}add-auth-middleware`);
15
+ expect(parsePlanSlug(token)).toBe('add-auth-middleware');
16
+ });
17
+
18
+ test('parsePlanSlug returns undefined for non-plan tokens', () => {
19
+ expect(parsePlanSlug('@chat:abc')).toBeUndefined();
20
+ expect(parsePlanSlug('@plan:')).toBeUndefined();
21
+ });
22
+ });
23
+
24
+ describe('findActivePlanToken', () => {
25
+ test('detects a token being typed at the cursor', () => {
26
+ const active = findActivePlanToken('start working on @plan:add-au');
27
+ expect(active?.query).toBe('add-au');
28
+ expect(active?.token).toBe('@plan:add-au');
29
+ });
30
+
31
+ test('detects an empty query right after the prefix', () => {
32
+ const active = findActivePlanToken('do @plan:');
33
+ expect(active?.query).toBe('');
34
+ });
35
+
36
+ test('returns undefined when there is no token at the cursor', () => {
37
+ expect(findActivePlanToken('just some text')).toBeUndefined();
38
+ expect(findActivePlanToken('email me@plan-b.com')).toBeUndefined();
39
+ });
40
+
41
+ test('does not match when the token is not adjacent to the cursor', () => {
42
+ expect(findActivePlanToken('@plan:foo and more text')).toBeUndefined();
43
+ });
44
+ });
45
+
46
+ describe('extractPlanReferences / firstPlanReference', () => {
47
+ test('extracts all tokens in order', () => {
48
+ const tokens = extractPlanReferences('work on @plan:alpha then @plan:beta');
49
+ expect(tokens).toEqual(['@plan:alpha', '@plan:beta']);
50
+ });
51
+
52
+ test('first-wins helper returns only the first slug', () => {
53
+ expect(firstPlanReference('work on @plan:alpha then @plan:beta')).toBe('alpha');
54
+ });
55
+
56
+ test('firstPlanReference returns undefined when no token present', () => {
57
+ expect(firstPlanReference('nothing here')).toBeUndefined();
58
+ });
59
+
60
+ test('does not treat email-like text as a token', () => {
61
+ expect(extractPlanReferences('reach me@plan-x.dev')).toEqual([]);
62
+ });
63
+ });
@@ -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', () => {
@@ -57,11 +57,16 @@ import { registerReconcilePlansTool } from './tools/reconcile-plans.js';
57
57
  import { isSafeCommand, isPlanPath } from './utils.js';
58
58
  import { handleListPlans } from './commands/list-plans.js';
59
59
  import { handleListInitiatives } from './commands/list-initiatives.js';
60
+ import { createPlanReferenceIndex } from './references/plan-index.js';
61
+ import { registerPlanReferenceAutocomplete } from './references/autocomplete.js';
62
+ import { registerPlanReferenceContext } from './references/context.js';
60
63
 
61
64
  export default function planMode(pi: ExtensionAPI): void {
62
65
  const state = new PlanModeState();
63
66
  // Build the live Effect runtime once; all storage I/O runs through this bridge.
64
67
  const runPlanIO = makePlanRuntime();
68
+ // Cached plan list for `@plan:<slug>` autocomplete; refreshed at session start.
69
+ const planReferenceIndex = createPlanReferenceIndex(runPlanIO);
65
70
 
66
71
  // ── Flag ──────────────────────────────────────────────────────────────────
67
72
  pi.registerFlag('plan', {
@@ -200,6 +205,9 @@ export default function planMode(pi: ExtensionAPI): void {
200
205
  registerInitiativeStatusTool(pi, runPlanIO);
201
206
  registerReconcilePlansTool(pi, runPlanIO);
202
207
 
208
+ // Attach a referenced plan (`@plan:<slug>`) as context when present in a prompt.
209
+ registerPlanReferenceContext(pi, runPlanIO);
210
+
203
211
  registerAddTaskTool(pi, {
204
212
  resolvePlan: (opts) => resolveActivePlan(state, pi, runPlanIO, opts),
205
213
  onTaskAdded: async (task) => {
@@ -602,6 +610,10 @@ export default function planMode(pi: ExtensionAPI): void {
602
610
  ctx.sessionManager.getEntries() as Array<{ type: string; customType?: string; data?: any }>,
603
611
  );
604
612
 
613
+ // Register `@plan:<slug>` autocomplete and warm its cache.
614
+ await planReferenceIndex.refresh();
615
+ registerPlanReferenceAutocomplete(ctx, planReferenceIndex);
616
+
605
617
  // Check for exec-pending handoff from planning session
606
618
  const pending = await runPlanIO(readAndClearExecPending());
607
619
  if (pending) {
@@ -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,120 @@
1
+ /**
2
+ * `@plan:` autocomplete provider — stacks on top of the built-in provider.
3
+ *
4
+ * Lists all plans, ranking in-progress first. Empty query keeps that ordering;
5
+ * a non-empty query prefers exact name-prefix matches and otherwise falls back
6
+ * to a fuzzy search over name + title.
7
+ */
8
+
9
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
10
+ import type {
11
+ AutocompleteItem,
12
+ AutocompleteProvider,
13
+ AutocompleteSuggestions,
14
+ } from '@earendil-works/pi-tui';
15
+ import { fuzzyFilter } from '@earendil-works/pi-tui';
16
+ import type { PlanListItem } from '../commands/list-plans.js';
17
+ import type { PlanStatus } from '../types.js';
18
+ import { buildPlanToken, findActivePlanToken } from './tokens.js';
19
+ import type { PlanReferenceIndex } from './plan-index.js';
20
+
21
+ const MAX_SUGGESTIONS = 20;
22
+
23
+ const STATUS_ICON: Record<PlanStatus, string> = {
24
+ 'in-progress': '🔵',
25
+ done: '✅',
26
+ superseded: '🔄',
27
+ abandoned: '❌',
28
+ };
29
+
30
+ const STATUS_RANK: Record<PlanStatus, number> = {
31
+ 'in-progress': 0,
32
+ done: 1,
33
+ superseded: 2,
34
+ abandoned: 3,
35
+ };
36
+
37
+ /** In-progress first, then newest plans within each status group. */
38
+ function byInProgressFirst(a: PlanListItem, b: PlanListItem): number {
39
+ const rank = STATUS_RANK[a.status] - STATUS_RANK[b.status];
40
+ if (rank !== 0) return rank;
41
+ return b.created_at.localeCompare(a.created_at);
42
+ }
43
+
44
+ export function filterPlanReferences(items: PlanListItem[], query: string): PlanListItem[] {
45
+ const trimmed = query.trim().toLowerCase();
46
+ if (!trimmed) {
47
+ return [...items].sort(byInProgressFirst).slice(0, MAX_SUGGESTIONS);
48
+ }
49
+
50
+ const prefixMatches = items
51
+ .filter((item) => item.name.toLowerCase().startsWith(trimmed))
52
+ .sort(byInProgressFirst);
53
+ if (prefixMatches.length) return prefixMatches.slice(0, MAX_SUGGESTIONS);
54
+
55
+ return fuzzyFilter(items, trimmed, (item) => `${item.name} ${item.title}`).slice(
56
+ 0,
57
+ MAX_SUGGESTIONS,
58
+ );
59
+ }
60
+
61
+ export function formatPlanSuggestion(item: PlanListItem): AutocompleteItem {
62
+ const progress = item.totalTasks > 0 ? `${item.doneTasks}/${item.totalTasks} tasks` : 'no tasks';
63
+ return {
64
+ value: buildPlanToken(item.name),
65
+ label: `${item.title}`,
66
+ description: `${STATUS_ICON[item.status]} ${item.name} • ${item.status} • ${progress}`,
67
+ };
68
+ }
69
+
70
+ export function createPlanReferenceAutocompleteProvider(
71
+ current: AutocompleteProvider,
72
+ index: PlanReferenceIndex,
73
+ ): AutocompleteProvider {
74
+ return {
75
+ async getSuggestions(
76
+ lines,
77
+ cursorLine,
78
+ cursorCol,
79
+ options,
80
+ ): Promise<AutocompleteSuggestions | null> {
81
+ const line = lines[cursorLine] ?? '';
82
+ const beforeCursor = line.slice(0, cursorCol);
83
+ const active = findActivePlanToken(beforeCursor);
84
+ if (!active) return current.getSuggestions(lines, cursorLine, cursorCol, options);
85
+
86
+ const items = await index.getItems();
87
+ if (options.signal.aborted) return null;
88
+
89
+ const matches = filterPlanReferences(items, active.query);
90
+ if (matches.length === 0) {
91
+ return current.getSuggestions(lines, cursorLine, cursorCol, options);
92
+ }
93
+
94
+ return {
95
+ prefix: active.token,
96
+ items: matches.map(formatPlanSuggestion),
97
+ };
98
+ },
99
+
100
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
101
+ return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
102
+ },
103
+
104
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
105
+ const line = lines[cursorLine] ?? '';
106
+ const beforeCursor = line.slice(0, cursorCol);
107
+ if (findActivePlanToken(beforeCursor)) return false;
108
+ return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
109
+ },
110
+ };
111
+ }
112
+
113
+ export function registerPlanReferenceAutocomplete(
114
+ ctx: ExtensionContext,
115
+ index: PlanReferenceIndex,
116
+ ): void {
117
+ ctx.ui.addAutocompleteProvider((current) =>
118
+ createPlanReferenceAutocompleteProvider(current, index),
119
+ );
120
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * `@plan:` context injection.
3
+ *
4
+ * When a submitted message references a plan with `@plan:<slug>`, attach that
5
+ * plan's tasks + handoff as a hidden context message so the agent understands
6
+ * the reference. First-wins: only the first token in the message is resolved.
7
+ *
8
+ * This is *context only* — it never switches execution mode, tools, or model.
9
+ */
10
+
11
+ import { Effect } from 'effect';
12
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
13
+ import type { FileSystem } from '../effects/filesystem.js';
14
+ import type { RunPlanIO } from '../effects/runtime.js';
15
+ import type { PlanStatus, TaskRecord, TaskStatus } from '../types.js';
16
+ import { readPlansManifest } from '../storage/plans-manifest.js';
17
+ import { readTasksJsonl } from '../storage/task-storage.js';
18
+ import { loadHandoff } from '../storage/plan-storage.js';
19
+ import { firstPlanReference } from './tokens.js';
20
+
21
+ export interface ResolvedPlanReference {
22
+ name: string;
23
+ title: string;
24
+ status: PlanStatus;
25
+ tasks: TaskRecord[];
26
+ handoff: string;
27
+ }
28
+
29
+ const TASK_ICON: Record<TaskStatus, string> = {
30
+ pending: '○',
31
+ done: '✓',
32
+ skipped: '⊘',
33
+ blocked: '✗',
34
+ deferred: '⏸',
35
+ };
36
+
37
+ /** Resolve a plan slug to its registry entry + tasks + handoff (Effect-based). */
38
+ export function resolvePlanReference(
39
+ slug: string,
40
+ ): Effect.Effect<ResolvedPlanReference | undefined, never, FileSystem> {
41
+ return Effect.gen(function* () {
42
+ const manifest = yield* Effect.orElseSucceed(readPlansManifest(), () => []);
43
+ const entry = manifest.find((candidate) => candidate.name === slug);
44
+ if (!entry) return undefined;
45
+
46
+ const dir = `.plans/${slug}`;
47
+ const snapshot = yield* Effect.orElseSucceed(readTasksJsonl(dir), () => undefined);
48
+ const handoff = yield* loadHandoff(dir);
49
+
50
+ return {
51
+ name: entry.name,
52
+ title: entry.title,
53
+ status: entry.status,
54
+ tasks: snapshot?.tasks ?? [],
55
+ handoff: handoff ?? '',
56
+ };
57
+ });
58
+ }
59
+
60
+ /** Build the markdown context pack injected for a resolved plan reference. */
61
+ export function buildPlanContextPack(
62
+ name: string,
63
+ title: string,
64
+ status: PlanStatus,
65
+ tasks: TaskRecord[],
66
+ handoff: string,
67
+ ): string {
68
+ const done = tasks.filter((t) => t.status === 'done' || t.status === 'skipped').length;
69
+ const taskLines = tasks.length
70
+ ? tasks
71
+ .map((t) => {
72
+ const marker = t.origin === 'discovered' ? ' (discovered)' : '';
73
+ const notes = t.notes ? `\n ${t.notes}` : '';
74
+ return `${t.id}. ${TASK_ICON[t.status]} ${t.description}${marker}${notes}`;
75
+ })
76
+ .join('\n')
77
+ : '_(no tasks)_';
78
+
79
+ return [
80
+ `# Referenced plan: ${title} (\`${name}\`)`,
81
+ `The user referenced this plan with \`@plan:${name}\`. Status: **${status}** — ${done}/${tasks.length} tasks done.`,
82
+ `Use this as context. The source of truth is \`.plans/${name}/\` (tasks.jsonl + HANDOFF.md) — inspect it with tools if you need more detail.`,
83
+ '',
84
+ '## Tasks',
85
+ '',
86
+ taskLines,
87
+ '',
88
+ '## Handoff',
89
+ '',
90
+ handoff.trim() || '_(no handoff recorded)_',
91
+ ].join('\n');
92
+ }
93
+
94
+ /**
95
+ * Register the `before_agent_start` handler that attaches a referenced plan as
96
+ * context. Chains alongside plan-mode's own `before_agent_start` handler.
97
+ */
98
+ export function registerPlanReferenceContext(pi: ExtensionAPI, runPlanIO: RunPlanIO): void {
99
+ pi.on('before_agent_start', async (event, ctx) => {
100
+ const slug = firstPlanReference(event.prompt);
101
+ if (!slug) return undefined;
102
+
103
+ const resolved = await runPlanIO(resolvePlanReference(slug));
104
+
105
+ if (!resolved) {
106
+ if (ctx.hasUI) {
107
+ ctx.ui.notify(`plan-mode: no plan named "${slug}" for @plan:${slug}`, 'warning');
108
+ }
109
+ return {
110
+ message: {
111
+ customType: 'plan-reference-context',
112
+ content: `The prompt referenced @plan:${slug}, but no plan with that name exists in .plans/plans.jsonl.`,
113
+ display: true,
114
+ details: { slug, resolved: false },
115
+ },
116
+ };
117
+ }
118
+
119
+ const content = buildPlanContextPack(
120
+ resolved.name,
121
+ resolved.title,
122
+ resolved.status,
123
+ resolved.tasks,
124
+ resolved.handoff,
125
+ );
126
+
127
+ if (ctx.hasUI) {
128
+ ctx.ui.notify(`Attached plan reference: ${resolved.title} (${resolved.name}).`, 'info');
129
+ }
130
+
131
+ return {
132
+ message: {
133
+ customType: 'plan-reference-context',
134
+ content,
135
+ display: false,
136
+ details: { slug, name: resolved.name, status: resolved.status, resolved: true },
137
+ },
138
+ };
139
+ });
140
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * In-memory cache of plan list items for `@plan:` autocomplete.
3
+ *
4
+ * Autocomplete `getSuggestions` runs on every keystroke, so it must be cheap.
5
+ * We cache the manifest-derived `PlanListItem[]` and refresh at most once per
6
+ * TTL window — fresh enough to surface plans submitted mid-session without a
7
+ * per-keystroke disk hit.
8
+ */
9
+
10
+ import type { RunPlanIO } from '../effects/runtime.js';
11
+ import { loadPlanListItems, type PlanListItem } from '../commands/list-plans.js';
12
+
13
+ const DEFAULT_TTL_MS = 2_000;
14
+
15
+ export interface PlanReferenceIndex {
16
+ /** Force a reload from disk. */
17
+ refresh(): Promise<void>;
18
+ /** Cached items, reloading first when the cache is stale. */
19
+ getItems(): Promise<PlanListItem[]>;
20
+ /** Cached items without triggering IO (may be stale or empty). */
21
+ peek(): PlanListItem[];
22
+ }
23
+
24
+ export function createPlanReferenceIndex(
25
+ runPlanIO: RunPlanIO,
26
+ ttlMs: number = DEFAULT_TTL_MS,
27
+ ): PlanReferenceIndex {
28
+ let items: PlanListItem[] = [];
29
+ let loadedAt = 0;
30
+ let inflight: Promise<void> | undefined;
31
+
32
+ const load = async (): Promise<void> => {
33
+ items = await runPlanIO(loadPlanListItems());
34
+ loadedAt = Date.now();
35
+ };
36
+
37
+ const refresh = async (): Promise<void> => {
38
+ // Coalesce concurrent refreshes so rapid keystrokes share one read.
39
+ if (!inflight) {
40
+ inflight = load().finally(() => {
41
+ inflight = undefined;
42
+ });
43
+ }
44
+ await inflight;
45
+ };
46
+
47
+ return {
48
+ refresh,
49
+ peek: () => items,
50
+ async getItems() {
51
+ if (Date.now() - loadedAt > ttlMs) await refresh();
52
+ return items;
53
+ },
54
+ };
55
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `@plan:<slug>` reference tokens.
3
+ *
4
+ * Lets a user reference a plan inside a normal chat message (e.g.
5
+ * "start working on @plan:add-auth"). Mirrors the past-chats `@chat:` /
6
+ * `@session:` token model: a cursor-anchored matcher drives autocomplete, and a
7
+ * global matcher extracts referenced slugs from a submitted prompt.
8
+ *
9
+ * Pure module — no IO, safe to unit test.
10
+ */
11
+
12
+ export const PLAN_TOKEN_PREFIX = '@plan:' as const;
13
+
14
+ /** Plan slugs are kebab-ish identifiers. */
15
+ const SLUG_CHARS = 'A-Za-z0-9_-';
16
+
17
+ export interface ActivePlanToken {
18
+ /** The query typed after the prefix (may be empty). */
19
+ query: string;
20
+ /** The full token under the cursor, including the prefix. */
21
+ token: string;
22
+ /** Offset in the line where the token starts. */
23
+ tokenStart: number;
24
+ }
25
+
26
+ export function buildPlanToken(slug: string): string {
27
+ return `${PLAN_TOKEN_PREFIX}${slug}`;
28
+ }
29
+
30
+ /** Extract the slug from a `@plan:<slug>` token, or undefined when invalid. */
31
+ export function parsePlanSlug(token: string): string | undefined {
32
+ if (!token.startsWith(PLAN_TOKEN_PREFIX)) return undefined;
33
+ const slug = token.slice(PLAN_TOKEN_PREFIX.length).trim();
34
+ return slug || undefined;
35
+ }
36
+
37
+ // Token must be at a word boundary: start of line or after whitespace / open
38
+ // bracket. Prevents matching inside emails like `me@plan-x.dev`.
39
+ const ACTIVE_RE = new RegExp(`(?:^|[\\s([{])(${PLAN_TOKEN_PREFIX}([${SLUG_CHARS}]*))$`);
40
+ const GLOBAL_RE = new RegExp(
41
+ `(?:^|[\\s([{])(${PLAN_TOKEN_PREFIX}([${SLUG_CHARS}]+))`,
42
+ 'g',
43
+ );
44
+
45
+ /** Detect a `@plan:` token being typed immediately before the cursor. */
46
+ export function findActivePlanToken(textBeforeCursor: string): ActivePlanToken | undefined {
47
+ const match = textBeforeCursor.match(ACTIVE_RE);
48
+ if (!match) return undefined;
49
+ const token = match[1] ?? '';
50
+ return {
51
+ query: match[2] ?? '',
52
+ token,
53
+ tokenStart: textBeforeCursor.length - token.length,
54
+ };
55
+ }
56
+
57
+ /** All `@plan:<slug>` tokens in a message, in order, de-duplicated by slug. */
58
+ export function extractPlanReferences(text: string): string[] {
59
+ const tokens: string[] = [];
60
+ const seen = new Set<string>();
61
+ for (const match of text.matchAll(GLOBAL_RE)) {
62
+ const token = match[1];
63
+ const slug = token ? parsePlanSlug(token) : undefined;
64
+ if (token && slug && !seen.has(slug)) {
65
+ seen.add(slug);
66
+ tokens.push(token);
67
+ }
68
+ }
69
+ return tokens;
70
+ }
71
+
72
+ /** First referenced slug in a message (first-wins), or undefined. */
73
+ export function firstPlanReference(text: string): string | undefined {
74
+ const [first] = extractPlanReferences(text);
75
+ return first ? parsePlanSlug(first) : undefined;
76
+ }
@@ -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.25.0",
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"