taskplane 0.15.0 → 0.16.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.
@@ -46,6 +46,7 @@ import {
46
46
  import { buildExecutionContext } from "./workspace.ts";
47
47
  import { openSettingsTui } from "./settings-tui.ts";
48
48
  import { loadProjectConfig } from "./config-loader.ts";
49
+ import { runMigrations } from "./migrations.ts";
49
50
  import {
50
51
  activateSupervisor,
51
52
  deactivateSupervisor,
@@ -1493,6 +1494,23 @@ export default function (pi: ExtensionAPI) {
1493
1494
  };
1494
1495
  }
1495
1496
 
1497
+ // TP-063: Run additive migrations before batch start (primary trigger).
1498
+ // Non-fatal — failures warn but never block batch execution.
1499
+ try {
1500
+ const migrationResult = runMigrations(execCtx.repoRoot);
1501
+ if (migrationResult.messages.length > 0) {
1502
+ ctx.ui.notify(migrationResult.messages.join("\n"), "info");
1503
+ }
1504
+ if (migrationResult.errors.length > 0) {
1505
+ ctx.ui.notify(
1506
+ `⚠️ Migration warnings:\n${migrationResult.errors.map(e => ` ⚠ ${e.id}: ${e.error}`).join("\n")}`,
1507
+ "warning",
1508
+ );
1509
+ }
1510
+ } catch {
1511
+ // Swallow — migrations must never block /orch
1512
+ }
1513
+
1496
1514
  // TP-128: Transition from routing-mode supervisor to batch execution
1497
1515
  if (supervisorState.active && supervisorState.routingContext) {
1498
1516
  await deactivateSupervisor(pi, supervisorState);
@@ -2853,6 +2871,19 @@ export default function (pi: ExtensionAPI) {
2853
2871
  supervisorConfig = { ...DEFAULT_SUPERVISOR_CONFIG };
2854
2872
  }
2855
2873
 
2874
+ // TP-063: Run additive migrations on session start (safety net trigger).
2875
+ // This ensures migrations run even if the user doesn't invoke /orch.
2876
+ // Non-fatal — failures are silently swallowed so startup is never blocked.
2877
+ try {
2878
+ const migrationResult = runMigrations(execCtx.repoRoot);
2879
+ if (migrationResult.messages.length > 0) {
2880
+ ctx.ui.notify(migrationResult.messages.join("\n"), "info");
2881
+ }
2882
+ // Errors on session_start are silent — avoid noisy warnings at startup
2883
+ } catch {
2884
+ // Swallow — migrations must never block session startup
2885
+ }
2886
+
2856
2887
  // Set status line
2857
2888
  const areaCount = Object.keys(runnerConfig.task_areas).length;
2858
2889
  const modeLabel = execCtx.mode === "workspace" ? "workspace" : "repo";
@@ -24,3 +24,4 @@ export * from "./abort.ts";
24
24
  export * from "./workspace.ts";
25
25
  export * from "./diagnostics.ts";
26
26
  export * from "./supervisor.ts";
27
+ export * from "./migrations.ts";
@@ -0,0 +1,275 @@
1
+ /**
2
+ * Additive Upgrade Migrations for Taskplane
3
+ *
4
+ * Provides a lightweight migration runner that applies additive-only
5
+ * changes (e.g., creating missing scaffold files) when extensions load
6
+ * or `/orch` starts. Migrations never overwrite existing files.
7
+ *
8
+ * Migration state is tracked in `.pi/taskplane.json` under the
9
+ * `migrations` key, preserving all existing version-tracker fields.
10
+ *
11
+ * @module migrations
12
+ * @since TP-063
13
+ */
14
+
15
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from "fs";
16
+ import { join, dirname } from "path";
17
+ import { fileURLToPath } from "url";
18
+
19
+ // ── Types ────────────────────────────────────────────────────────────
20
+
21
+ /**
22
+ * Metadata for a single additive migration.
23
+ */
24
+ export interface Migration {
25
+ /** Unique, stable identifier (e.g., "add-supervisor-local-template-v1") */
26
+ id: string;
27
+ /** Human-readable description for logs */
28
+ description: string;
29
+ /**
30
+ * Execute the migration. Should only create files that don't exist.
31
+ *
32
+ * @param projectRoot - Project root directory
33
+ * @param packageRoot - Taskplane package root (for template resolution)
34
+ * @returns A short message describing what was created, or null if skipped (already exists)
35
+ * @throws If the migration cannot complete (e.g., missing template source)
36
+ */
37
+ run(projectRoot: string, packageRoot: string): string | null;
38
+ }
39
+
40
+ /**
41
+ * Record of a single applied migration in `.pi/taskplane.json`.
42
+ */
43
+ export interface AppliedMigration {
44
+ /** ISO timestamp when the migration was applied */
45
+ appliedAt: string;
46
+ }
47
+
48
+ /**
49
+ * The `migrations` section within `.pi/taskplane.json`.
50
+ */
51
+ export interface MigrationState {
52
+ applied: Record<string, AppliedMigration>;
53
+ }
54
+
55
+ /**
56
+ * Shape of `.pi/taskplane.json` (partial — only fields we read/write).
57
+ * Other fields (version, installedAt, lastUpgraded, components) are
58
+ * preserved as-is during read-modify-write.
59
+ */
60
+ export interface TaskplaneMeta {
61
+ [key: string]: unknown;
62
+ migrations?: MigrationState;
63
+ }
64
+
65
+ /**
66
+ * Result of running migrations.
67
+ */
68
+ export interface MigrationRunResult {
69
+ /** Migration IDs that were applied in this run */
70
+ applied: string[];
71
+ /** Migration IDs that were skipped (already applied or target exists) */
72
+ skipped: string[];
73
+ /** Migrations that failed with errors (non-fatal — logged and skipped) */
74
+ errors: Array<{ id: string; error: string }>;
75
+ /** Human-readable messages for each applied migration */
76
+ messages: string[];
77
+ }
78
+
79
+ // ── Meta File Helpers ────────────────────────────────────────────────
80
+
81
+ const TASKPLANE_META_FILENAME = "taskplane.json";
82
+
83
+ /**
84
+ * Load `.pi/taskplane.json`, returning its content or an empty object
85
+ * if the file doesn't exist or is malformed.
86
+ *
87
+ * Never throws — returns `{}` for any read/parse error.
88
+ */
89
+ export function loadTaskplaneMeta(projectRoot: string): TaskplaneMeta {
90
+ const metaPath = join(projectRoot, ".pi", TASKPLANE_META_FILENAME);
91
+ try {
92
+ if (!existsSync(metaPath)) return {};
93
+ const raw = readFileSync(metaPath, "utf-8");
94
+ const parsed = JSON.parse(raw);
95
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
96
+ return parsed as TaskplaneMeta;
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Save `.pi/taskplane.json`, merging the provided meta with any
104
+ * existing content. Creates the `.pi/` directory if needed.
105
+ *
106
+ * Performs a shallow merge at the top level — existing keys not in
107
+ * `meta` are preserved. The `migrations` key is always taken from
108
+ * the provided `meta` object (deep replacement).
109
+ */
110
+ export function saveTaskplaneMeta(projectRoot: string, meta: TaskplaneMeta): void {
111
+ const piDir = join(projectRoot, ".pi");
112
+ mkdirSync(piDir, { recursive: true });
113
+
114
+ const metaPath = join(piDir, TASKPLANE_META_FILENAME);
115
+
116
+ // Read existing content to preserve version-tracker fields
117
+ let existing: TaskplaneMeta = {};
118
+ try {
119
+ if (existsSync(metaPath)) {
120
+ const raw = readFileSync(metaPath, "utf-8");
121
+ const parsed = JSON.parse(raw);
122
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
123
+ existing = parsed as TaskplaneMeta;
124
+ }
125
+ }
126
+ } catch {
127
+ // Existing file unreadable — start fresh but we'll overwrite only our keys
128
+ }
129
+
130
+ // Merge: existing fields preserved, our fields override
131
+ const merged = { ...existing, ...meta };
132
+ writeFileSync(metaPath, JSON.stringify(merged, null, 2) + "\n", "utf-8");
133
+ }
134
+
135
+ // ── Package Root Resolution ──────────────────────────────────────────
136
+
137
+ /**
138
+ * Resolve the taskplane package root directory.
139
+ *
140
+ * Uses ESM `import.meta.url` to compute the path deterministically.
141
+ * The package root is two levels up from this file:
142
+ * `<package-root>/extensions/taskplane/migrations.ts`
143
+ *
144
+ * @param importMetaUrl - Pass `import.meta.url` from the calling module
145
+ * @returns Absolute path to the package root
146
+ */
147
+ export function resolvePackageRoot(importMetaUrl?: string): string {
148
+ const url = importMetaUrl ?? import.meta.url;
149
+ const thisDir = dirname(fileURLToPath(url));
150
+ // extensions/taskplane/ → extensions/ → package root
151
+ return join(thisDir, "..", "..");
152
+ }
153
+
154
+ // ── Migration Registry ──────────────────────────────────────────────
155
+
156
+ /**
157
+ * Registry of all additive migrations, ordered by creation date.
158
+ *
159
+ * New migrations are appended to this array. Each migration must:
160
+ * - Have a unique, stable `id` (never renamed after release)
161
+ * - Only create files that don't exist (additive-only)
162
+ * - Throw on unrecoverable errors (e.g., missing template source)
163
+ * - Return null if the target already exists (skip)
164
+ */
165
+ export const MIGRATION_REGISTRY: Migration[] = [
166
+ {
167
+ id: "add-supervisor-local-template-v1",
168
+ description: "Create .pi/agents/supervisor.md from template if missing",
169
+ run(projectRoot: string, packageRoot: string): string | null {
170
+ const targetPath = join(projectRoot, ".pi", "agents", "supervisor.md");
171
+
172
+ // Skip if file already exists — never overwrite
173
+ if (existsSync(targetPath)) {
174
+ return null;
175
+ }
176
+
177
+ // Resolve template source
178
+ const templatePath = join(packageRoot, "templates", "agents", "local", "supervisor.md");
179
+ if (!existsSync(templatePath)) {
180
+ throw new Error(
181
+ `Migration template not found: ${templatePath}. ` +
182
+ `This may indicate a packaging issue with the taskplane package.`,
183
+ );
184
+ }
185
+
186
+ // Create target directory and copy template
187
+ mkdirSync(dirname(targetPath), { recursive: true });
188
+ copyFileSync(templatePath, targetPath);
189
+
190
+ return "Created .pi/agents/supervisor.md from template";
191
+ },
192
+ },
193
+ ];
194
+
195
+ // ── Migration Runner ─────────────────────────────────────────────────
196
+
197
+ /**
198
+ * Run all pending additive migrations.
199
+ *
200
+ * Loads migration state from `.pi/taskplane.json`, runs only unapplied
201
+ * migrations from the registry, and persists applied IDs + timestamps.
202
+ *
203
+ * Each migration is individually try/caught:
204
+ * - Success → recorded as applied, message logged
205
+ * - Skip (returns null) → recorded as applied (target already exists)
206
+ * - Error → logged and skipped (NOT recorded — will be retried next time)
207
+ *
208
+ * @param projectRoot - Project root directory
209
+ * @param packageRoot - Taskplane package root (for template resolution).
210
+ * If omitted, resolved from import.meta.url.
211
+ * @returns Migration run result with applied/skipped/error details
212
+ */
213
+ export function runMigrations(
214
+ projectRoot: string,
215
+ packageRoot?: string,
216
+ ): MigrationRunResult {
217
+ const pkgRoot = packageRoot ?? resolvePackageRoot();
218
+ const result: MigrationRunResult = {
219
+ applied: [],
220
+ skipped: [],
221
+ errors: [],
222
+ messages: [],
223
+ };
224
+
225
+ // Load current state
226
+ const meta = loadTaskplaneMeta(projectRoot);
227
+ const migrationState: MigrationState = meta.migrations ?? { applied: {} };
228
+
229
+ let stateChanged = false;
230
+
231
+ for (const migration of MIGRATION_REGISTRY) {
232
+ // Skip already-applied migrations
233
+ if (migrationState.applied[migration.id]) {
234
+ result.skipped.push(migration.id);
235
+ continue;
236
+ }
237
+
238
+ try {
239
+ const message = migration.run(projectRoot, pkgRoot);
240
+
241
+ // Record as applied (whether it created something or skipped)
242
+ migrationState.applied[migration.id] = {
243
+ appliedAt: new Date().toISOString(),
244
+ };
245
+ stateChanged = true;
246
+
247
+ if (message) {
248
+ result.applied.push(migration.id);
249
+ result.messages.push(`📦 Migration: ${message}`);
250
+ } else {
251
+ // Target already existed — still mark as applied so we don't recheck
252
+ result.skipped.push(migration.id);
253
+ }
254
+ } catch (err: unknown) {
255
+ const errMsg = err instanceof Error ? err.message : String(err);
256
+ result.errors.push({ id: migration.id, error: errMsg });
257
+ // NOT recorded as applied — will be retried next time
258
+ }
259
+ }
260
+
261
+ // Persist state if anything changed
262
+ if (stateChanged) {
263
+ try {
264
+ saveTaskplaneMeta(projectRoot, { ...meta, migrations: migrationState });
265
+ } catch (err: unknown) {
266
+ const errMsg = err instanceof Error ? err.message : String(err);
267
+ result.errors.push({
268
+ id: "__state_save",
269
+ error: `Failed to persist migration state: ${errMsg}`,
270
+ });
271
+ }
272
+ }
273
+
274
+ return result;
275
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",