@dreki-gg/pi-plan-mode 0.25.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.
- package/CHANGELOG.md +25 -0
- package/extensions/plan-mode/__tests__/git.test.ts +17 -0
- package/extensions/plan-mode/__tests__/initiative-status.test.ts +5 -5
- package/extensions/plan-mode/__tests__/plan-references-context.test.ts +4 -4
- package/extensions/plan-mode/__tests__/prompts.test.ts +23 -0
- package/extensions/plan-mode/__tests__/reconcile-plans.test.ts +3 -3
- package/extensions/plan-mode/__tests__/resolve-plan.test.ts +4 -4
- package/extensions/plan-mode/__tests__/revise-plan.test.ts +4 -4
- package/extensions/plan-mode/__tests__/submit-initiative.test.ts +2 -2
- package/extensions/plan-mode/__tests__/submit-plan.test.ts +3 -3
- package/extensions/plan-mode/__tests__/update-initiative.test.ts +2 -2
- package/extensions/plan-mode/__tests__/update-plan.test.ts +2 -2
- package/extensions/plan-mode/__tests__/utils.test.ts +2 -1
- package/extensions/plan-mode/commands/list-initiatives.ts +19 -109
- package/extensions/plan-mode/commands/list-plans.ts +21 -168
- package/extensions/plan-mode/exec-pending.ts +59 -0
- package/extensions/plan-mode/git.ts +21 -0
- package/extensions/plan-mode/index.ts +9 -7
- package/extensions/plan-mode/prompts.ts +6 -1
- package/extensions/plan-mode/references/context.ts +5 -5
- package/extensions/plan-mode/references/plan-index.ts +1 -1
- package/extensions/plan-mode/resolve-plan.ts +5 -4
- package/extensions/plan-mode/resume.ts +7 -5
- package/extensions/plan-mode/tools/add-task.ts +1 -1
- package/extensions/plan-mode/tools/initiative-status.ts +6 -6
- package/extensions/plan-mode/tools/preview-prototype.ts +3 -3
- package/extensions/plan-mode/tools/reconcile-plans.ts +2 -2
- package/extensions/plan-mode/tools/revise-plan.ts +9 -7
- package/extensions/plan-mode/tools/submit-initiative.ts +4 -4
- package/extensions/plan-mode/tools/submit-plan.ts +18 -8
- package/extensions/plan-mode/tools/update-initiative.ts +2 -2
- package/extensions/plan-mode/tools/update-plan.ts +3 -3
- package/extensions/plan-mode/types.ts +17 -51
- package/extensions/plan-mode/utils.ts +2 -29
- package/package.json +2 -1
- package/extensions/plan-mode/__tests__/atomic-write.test.ts +0 -45
- package/extensions/plan-mode/__tests__/concurrent-writes.test.ts +0 -85
- package/extensions/plan-mode/__tests__/initiative-readiness.test.ts +0 -159
- package/extensions/plan-mode/__tests__/initiatives-manifest.test.ts +0 -89
- package/extensions/plan-mode/__tests__/list-initiatives.test.ts +0 -59
- package/extensions/plan-mode/__tests__/list-plans.test.ts +0 -253
- package/extensions/plan-mode/__tests__/plan-storage.test.ts +0 -57
- package/extensions/plan-mode/__tests__/plans-manifest.test.ts +0 -120
- package/extensions/plan-mode/__tests__/reconcile.test.ts +0 -188
- package/extensions/plan-mode/__tests__/schema.test.ts +0 -319
- package/extensions/plan-mode/__tests__/task-status.test.ts +0 -74
- package/extensions/plan-mode/__tests__/task-storage.test.ts +0 -94
- package/extensions/plan-mode/effects/filesystem.ts +0 -65
- package/extensions/plan-mode/effects/runtime.ts +0 -24
- package/extensions/plan-mode/errors.ts +0 -105
- package/extensions/plan-mode/initiative.ts +0 -172
- package/extensions/plan-mode/plan-storage.ts +0 -6
- package/extensions/plan-mode/reconcile.ts +0 -235
- package/extensions/plan-mode/schema.ts +0 -97
- package/extensions/plan-mode/storage/atomic-write.ts +0 -75
- package/extensions/plan-mode/storage/file-lock.ts +0 -49
- package/extensions/plan-mode/storage/initiatives-manifest.ts +0 -154
- package/extensions/plan-mode/storage/plan-storage.ts +0 -88
- package/extensions/plan-mode/storage/plans-manifest.ts +0 -174
- package/extensions/plan-mode/storage/task-storage.ts +0 -111
- package/extensions/plan-mode/task-status.ts +0 -46
|
@@ -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
|
-
}
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Effect Schema definitions for plan-mode persisted records.
|
|
3
|
-
*
|
|
4
|
-
* These replace the hand-rolled type guards. Schemas are the single source of
|
|
5
|
-
* truth for record shape; the mutable TS interfaces in `types.ts` are kept for
|
|
6
|
-
* the imperative orchestration code (which mutates tasks in place) and are
|
|
7
|
-
* structurally compatible with the decoded values.
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { Schema } from 'effect';
|
|
11
|
-
|
|
12
|
-
export const TaskStatusSchema = Schema.Literal('pending', 'done', 'skipped', 'blocked', 'deferred');
|
|
13
|
-
|
|
14
|
-
export const TaskOriginSchema = Schema.Literal('plan', 'discovered');
|
|
15
|
-
|
|
16
|
-
export const TaskRecordSchema = Schema.Struct({
|
|
17
|
-
_type: Schema.Literal('task'),
|
|
18
|
-
id: Schema.String,
|
|
19
|
-
description: Schema.String,
|
|
20
|
-
details: Schema.optional(Schema.String),
|
|
21
|
-
status: TaskStatusSchema,
|
|
22
|
-
origin: Schema.optional(TaskOriginSchema),
|
|
23
|
-
depends_on: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
|
24
|
-
notes: Schema.optional(Schema.String),
|
|
25
|
-
created_at: Schema.String,
|
|
26
|
-
updated_at: Schema.String,
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
export const TaskMetaSchema = Schema.Struct({
|
|
30
|
-
_type: Schema.Literal('meta'),
|
|
31
|
-
title: Schema.String,
|
|
32
|
-
plan_name: Schema.String,
|
|
33
|
-
created_at: Schema.String,
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
/** A single tasks.jsonl line is either the meta record or a task record. */
|
|
37
|
-
export const TasksLineSchema = Schema.Union(TaskMetaSchema, TaskRecordSchema);
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Plan lifecycle statuses.
|
|
41
|
-
* - in-progress: active, tracked, eligible for auto-resolution
|
|
42
|
-
* - done: completed (all tasks resolved)
|
|
43
|
-
* - superseded: closed because another plan absorbed the work
|
|
44
|
-
* - abandoned: closed without shipping (rejected / won't do)
|
|
45
|
-
* Only `in-progress` is treated as active; the rest are terminal.
|
|
46
|
-
*/
|
|
47
|
-
export const PlanStatusSchema = Schema.Literal('in-progress', 'done', 'superseded', 'abandoned');
|
|
48
|
-
|
|
49
|
-
export const PlanManifestEntrySchema = Schema.Struct({
|
|
50
|
-
_type: Schema.Literal('plan'),
|
|
51
|
-
name: Schema.String,
|
|
52
|
-
status: PlanStatusSchema,
|
|
53
|
-
title: Schema.String,
|
|
54
|
-
created_at: Schema.String,
|
|
55
|
-
completed_at: Schema.NullOr(Schema.String),
|
|
56
|
-
/** Optional human-readable reason, used for terminal statuses. */
|
|
57
|
-
reason: Schema.optional(Schema.String),
|
|
58
|
-
/** Parent initiative name (kebab). Absent = standalone flat plan. */
|
|
59
|
-
initiative: Schema.optional(Schema.String),
|
|
60
|
-
/**
|
|
61
|
-
* Plan-level dependencies: names of plans this plan depends on. Distinct from
|
|
62
|
-
* the task-level `depends_on` above. Cross-initiative references are allowed.
|
|
63
|
-
*/
|
|
64
|
-
depends_on: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Initiative lifecycle statuses reuse the plan lifecycle literals. An
|
|
69
|
-
* initiative's status is a projection of its member plans' statuses, with the
|
|
70
|
-
* same terminal-guard semantics as plans.
|
|
71
|
-
*/
|
|
72
|
-
export const InitiativeStatusSchema = PlanStatusSchema;
|
|
73
|
-
|
|
74
|
-
export const InitiativeManifestEntrySchema = Schema.Struct({
|
|
75
|
-
_type: Schema.Literal('initiative'),
|
|
76
|
-
name: Schema.String,
|
|
77
|
-
status: InitiativeStatusSchema,
|
|
78
|
-
title: Schema.String,
|
|
79
|
-
created_at: Schema.String,
|
|
80
|
-
completed_at: Schema.NullOr(Schema.String),
|
|
81
|
-
/** Optional human-readable reason, used for terminal statuses. */
|
|
82
|
-
reason: Schema.optional(Schema.String),
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
export const ExecPendingConfigSchema = Schema.Struct({
|
|
86
|
-
model: Schema.Struct({ provider: Schema.String, id: Schema.String }),
|
|
87
|
-
thinking: Schema.String,
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
export const decodeTaskRecord = Schema.decodeUnknownEither(TaskRecordSchema);
|
|
91
|
-
export const decodeTaskMeta = Schema.decodeUnknownEither(TaskMetaSchema);
|
|
92
|
-
export const decodeTasksLine = Schema.decodeUnknownEither(TasksLineSchema);
|
|
93
|
-
export const decodePlanManifestEntry = Schema.decodeUnknownEither(PlanManifestEntrySchema);
|
|
94
|
-
export const decodeInitiativeManifestEntry = Schema.decodeUnknownEither(
|
|
95
|
-
InitiativeManifestEntrySchema,
|
|
96
|
-
);
|
|
97
|
-
export const decodeExecPendingConfig = Schema.decodeUnknownEither(ExecPendingConfigSchema);
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { Effect } from 'effect';
|
|
2
|
-
import { createWriteStream } from 'node:fs';
|
|
3
|
-
import { open, rename, rm } from 'node:fs/promises';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
5
|
-
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { PlanWriteError } from '../errors.js';
|
|
7
|
-
|
|
8
|
-
export interface AtomicWriteOptions {
|
|
9
|
-
/** Test seam: file mode for the temporary file. */
|
|
10
|
-
mode?: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Atomically write `data` to `path`: write to a temp file, fsync, rename into
|
|
15
|
-
* place, then best-effort fsync the directory. Failures surface as
|
|
16
|
-
* `PlanWriteError`.
|
|
17
|
-
*/
|
|
18
|
-
export function writeFileAtomic(
|
|
19
|
-
path: string,
|
|
20
|
-
data: string | Buffer,
|
|
21
|
-
options: AtomicWriteOptions = {},
|
|
22
|
-
): Effect.Effect<void, PlanWriteError> {
|
|
23
|
-
return Effect.tryPromise({
|
|
24
|
-
try: () => writeFileAtomicPromise(path, data, options),
|
|
25
|
-
catch: (cause) => new PlanWriteError({ path, cause }),
|
|
26
|
-
});
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
async function writeFileAtomicPromise(
|
|
30
|
-
path: string,
|
|
31
|
-
data: string | Buffer,
|
|
32
|
-
options: AtomicWriteOptions,
|
|
33
|
-
): Promise<void> {
|
|
34
|
-
const dir = dirname(path);
|
|
35
|
-
const tempPath = join(dir, `.${process.pid}.${randomUUID()}.tmp`);
|
|
36
|
-
let completed = false;
|
|
37
|
-
|
|
38
|
-
try {
|
|
39
|
-
await writeAndSync(tempPath, data, options.mode);
|
|
40
|
-
await rename(tempPath, path);
|
|
41
|
-
completed = true;
|
|
42
|
-
await syncDirectory(dir);
|
|
43
|
-
} finally {
|
|
44
|
-
if (!completed) {
|
|
45
|
-
await rm(tempPath, { force: true }).catch(() => undefined);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
async function writeAndSync(path: string, data: string | Buffer, mode?: number): Promise<void> {
|
|
51
|
-
await new Promise<void>((resolve, reject) => {
|
|
52
|
-
const stream = createWriteStream(path, { flags: 'wx', mode });
|
|
53
|
-
stream.once('error', reject);
|
|
54
|
-
stream.once('finish', resolve);
|
|
55
|
-
stream.end(data);
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
const handle = await open(path, 'r+');
|
|
59
|
-
try {
|
|
60
|
-
await handle.sync();
|
|
61
|
-
} finally {
|
|
62
|
-
await handle.close();
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async function syncDirectory(dir: string): Promise<void> {
|
|
67
|
-
// Directory fsync is best-effort: supported on Unix, not always elsewhere.
|
|
68
|
-
const handle = await open(dir, 'r').catch(() => undefined);
|
|
69
|
-
if (!handle) return;
|
|
70
|
-
try {
|
|
71
|
-
await handle.sync().catch(() => undefined);
|
|
72
|
-
} finally {
|
|
73
|
-
await handle.close().catch(() => undefined);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,154 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `.plans/initiatives.jsonl` registry — the initiative-level sibling of
|
|
3
|
-
* `plans-manifest.ts`.
|
|
4
|
-
*
|
|
5
|
-
* An initiative groups multiple plans. Its `status` is a PROJECTION of its
|
|
6
|
-
* member plans' statuses (see `reconcileInitiativeStatus` in `../initiative.ts`
|
|
7
|
-
* for the projection wiring): `done` when every member plan is terminal,
|
|
8
|
-
* `in-progress` otherwise. Manually-set terminal statuses (`superseded` /
|
|
9
|
-
* `abandoned` via `update_initiative`) are never auto-overridden.
|
|
10
|
-
*
|
|
11
|
-
* This module is intentionally dependency-light: it knows how to read/write the
|
|
12
|
-
* registry. The projection (which must read the PLANS manifest) lives in
|
|
13
|
-
* `../initiative.ts` to keep the dependency direction one-way and cycle-free.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { Effect, Either, Option } from 'effect';
|
|
17
|
-
import { FileSystem } from '../effects/filesystem.js';
|
|
18
|
-
import { JsonlParseError, JsonlValidationError, PlanWriteError } from '../errors.js';
|
|
19
|
-
import { decodeInitiativeManifestEntry } from '../schema.js';
|
|
20
|
-
import type { InitiativeStatus } from '../types.js';
|
|
21
|
-
import { withFileLock } from './file-lock.js';
|
|
22
|
-
|
|
23
|
-
const MANIFEST_DIR = '.plans';
|
|
24
|
-
const MANIFEST_PATH = '.plans/initiatives.jsonl';
|
|
25
|
-
|
|
26
|
-
export interface InitiativeManifestEntry {
|
|
27
|
-
_type: 'initiative';
|
|
28
|
-
name: string;
|
|
29
|
-
status: InitiativeStatus;
|
|
30
|
-
title: string;
|
|
31
|
-
created_at: string;
|
|
32
|
-
completed_at: string | null;
|
|
33
|
-
reason?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** A status is terminal (closed) when it is anything other than in-progress. */
|
|
37
|
-
export function isTerminalStatus(status: InitiativeStatus): boolean {
|
|
38
|
-
return status !== 'in-progress';
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
type ReadError = JsonlParseError | JsonlValidationError;
|
|
42
|
-
|
|
43
|
-
export function readInitiativesManifest(): Effect.Effect<
|
|
44
|
-
InitiativeManifestEntry[],
|
|
45
|
-
ReadError,
|
|
46
|
-
FileSystem
|
|
47
|
-
> {
|
|
48
|
-
return Effect.gen(function* () {
|
|
49
|
-
const fs = yield* FileSystem;
|
|
50
|
-
// A missing or unreadable manifest is treated as "no initiatives".
|
|
51
|
-
const maybeText = yield* Effect.option(fs.readFileString(MANIFEST_PATH));
|
|
52
|
-
if (Option.isNone(maybeText)) return [];
|
|
53
|
-
|
|
54
|
-
const entries: InitiativeManifestEntry[] = [];
|
|
55
|
-
for (const [index, raw] of maybeText.value.split(/\r?\n/).entries()) {
|
|
56
|
-
if (!raw.trim()) continue;
|
|
57
|
-
const line = index + 1;
|
|
58
|
-
|
|
59
|
-
let parsed: unknown;
|
|
60
|
-
try {
|
|
61
|
-
parsed = JSON.parse(raw);
|
|
62
|
-
} catch (cause) {
|
|
63
|
-
return yield* Effect.fail(new JsonlParseError({ path: MANIFEST_PATH, line, cause }));
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
const decoded = decodeInitiativeManifestEntry(parsed);
|
|
67
|
-
if (Either.isLeft(decoded)) {
|
|
68
|
-
return yield* Effect.fail(
|
|
69
|
-
new JsonlValidationError({ path: MANIFEST_PATH, line, reason: decoded.left.message }),
|
|
70
|
-
);
|
|
71
|
-
}
|
|
72
|
-
entries.push(decoded.right);
|
|
73
|
-
}
|
|
74
|
-
return entries;
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function writeInitiativesManifest(
|
|
79
|
-
entries: InitiativeManifestEntry[],
|
|
80
|
-
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
81
|
-
return Effect.gen(function* () {
|
|
82
|
-
const fs = yield* FileSystem;
|
|
83
|
-
yield* fs.makeDir(MANIFEST_DIR);
|
|
84
|
-
const content =
|
|
85
|
-
entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length ? '\n' : '');
|
|
86
|
-
yield* fs.writeFileAtomic(MANIFEST_PATH, content);
|
|
87
|
-
});
|
|
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
|
-
|
|
144
|
-
export function upsertInitiativeEntry(
|
|
145
|
-
name: string,
|
|
146
|
-
updates: InitiativeUpsert,
|
|
147
|
-
): Effect.Effect<void, ReadError | PlanWriteError, FileSystem> {
|
|
148
|
-
return mutateInitiativesManifest((entries) =>
|
|
149
|
-
Effect.sync(() => {
|
|
150
|
-
applyInitiativeUpsert(entries, name, updates);
|
|
151
|
-
return true;
|
|
152
|
-
}),
|
|
153
|
-
);
|
|
154
|
-
}
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plan disk I/O — exec-pending markers and handoff documents.
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { Effect, Either, Option } from 'effect';
|
|
6
|
-
import { FileSystem } from '../effects/filesystem.js';
|
|
7
|
-
import type { PlanWriteError } from '../errors.js';
|
|
8
|
-
import { decodeExecPendingConfig } from '../schema.js';
|
|
9
|
-
import type { ExecPendingConfig } from '../types.js';
|
|
10
|
-
import { EXEC_PENDING_FILE } from '../constants.js';
|
|
11
|
-
|
|
12
|
-
const PLANS_DIR = '.plans';
|
|
13
|
-
|
|
14
|
-
export function writeExecPending(
|
|
15
|
-
dir: string,
|
|
16
|
-
config: ExecPendingConfig,
|
|
17
|
-
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
18
|
-
return Effect.gen(function* () {
|
|
19
|
-
const fs = yield* FileSystem;
|
|
20
|
-
yield* fs.makeDir(dir);
|
|
21
|
-
yield* fs.writeFileString(
|
|
22
|
-
`${dir}/${EXEC_PENDING_FILE}`,
|
|
23
|
-
JSON.stringify(config, null, 2) + '\n',
|
|
24
|
-
);
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function readAndClearExecPending(): Effect.Effect<
|
|
29
|
-
{ planDir: string; config: ExecPendingConfig } | undefined,
|
|
30
|
-
never,
|
|
31
|
-
FileSystem
|
|
32
|
-
> {
|
|
33
|
-
return Effect.gen(function* () {
|
|
34
|
-
const fs = yield* FileSystem;
|
|
35
|
-
const maybeDirs = yield* Effect.option(fs.listDirectories(PLANS_DIR));
|
|
36
|
-
if (Option.isNone(maybeDirs)) return undefined;
|
|
37
|
-
|
|
38
|
-
for (const name of maybeDirs.value) {
|
|
39
|
-
const dir = `${PLANS_DIR}/${name}`;
|
|
40
|
-
const markerPath = `${dir}/${EXEC_PENDING_FILE}`;
|
|
41
|
-
const maybeText = yield* Effect.option(fs.readFileString(markerPath));
|
|
42
|
-
if (Option.isNone(maybeText)) continue;
|
|
43
|
-
|
|
44
|
-
let parsed: unknown;
|
|
45
|
-
try {
|
|
46
|
-
parsed = JSON.parse(maybeText.value);
|
|
47
|
-
} catch {
|
|
48
|
-
continue;
|
|
49
|
-
}
|
|
50
|
-
const decoded = decodeExecPendingConfig(parsed);
|
|
51
|
-
if (Either.isLeft(decoded)) continue;
|
|
52
|
-
|
|
53
|
-
yield* Effect.ignore(fs.removeFile(markerPath));
|
|
54
|
-
return { planDir: dir, config: decoded.right };
|
|
55
|
-
}
|
|
56
|
-
return undefined;
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function saveHandoff(
|
|
61
|
-
planDir: string,
|
|
62
|
-
content: string,
|
|
63
|
-
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
64
|
-
return Effect.gen(function* () {
|
|
65
|
-
const fs = yield* FileSystem;
|
|
66
|
-
yield* fs.makeDir(planDir);
|
|
67
|
-
yield* fs.writeFileString(`${planDir}/HANDOFF.md`, content);
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function loadHandoff(planDir: string): Effect.Effect<string | undefined, never, FileSystem> {
|
|
72
|
-
return Effect.gen(function* () {
|
|
73
|
-
const fs = yield* FileSystem;
|
|
74
|
-
const maybeText = yield* Effect.option(fs.readFileString(`${planDir}/HANDOFF.md`));
|
|
75
|
-
return Option.getOrUndefined(maybeText);
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function saveInitiative(
|
|
80
|
-
initiativeDir: string,
|
|
81
|
-
content: string,
|
|
82
|
-
): Effect.Effect<void, PlanWriteError, FileSystem> {
|
|
83
|
-
return Effect.gen(function* () {
|
|
84
|
-
const fs = yield* FileSystem;
|
|
85
|
-
yield* fs.makeDir(initiativeDir);
|
|
86
|
-
yield* fs.writeFileString(`${initiativeDir}/INITIATIVE.md`, content);
|
|
87
|
-
});
|
|
88
|
-
}
|