@difflab/pi 0.4.0-rc.202609231106.845dbe5 → 0.4.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/README.md +4 -4
- package/agents/diffpi-orchestrator.md +1 -1
- package/agents/diffpi-planner.md +3 -3
- package/agents/diffpi-worker.md +2 -2
- package/dist/assets.d.ts +0 -1
- package/dist/assets.d.ts.map +1 -1
- package/dist/cli/plan.d.ts +3 -7
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli.js +816 -314
- package/dist/commands/plan.d.ts +1 -0
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/environment.d.ts +1 -2
- package/dist/environment.d.ts.map +1 -1
- package/dist/extensions/index.js +2509 -6152
- package/dist/extensions/processx.d.ts +0 -1
- package/dist/extensions/processx.d.ts.map +1 -1
- package/dist/extensions/subagentx.d.ts +1 -1
- package/dist/extensions/subagentx.d.ts.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +728 -195
- package/dist/plan/annotations.d.ts +25 -0
- package/dist/plan/annotations.d.ts.map +1 -0
- package/dist/plan/controller.d.ts +65 -0
- package/dist/plan/controller.d.ts.map +1 -0
- package/dist/plan/execution.d.ts +13 -0
- package/dist/plan/execution.d.ts.map +1 -0
- package/dist/plan/ids.d.ts +5 -0
- package/dist/plan/ids.d.ts.map +1 -0
- package/dist/plan/index.d.ts +4 -2
- package/dist/plan/index.d.ts.map +1 -1
- package/dist/plan/lock.d.ts +7 -2
- package/dist/plan/lock.d.ts.map +1 -1
- package/dist/plan/markdown.d.ts.map +1 -1
- package/dist/plan/parser.d.ts +18 -0
- package/dist/plan/parser.d.ts.map +1 -0
- package/dist/plan/store.d.ts +0 -2
- package/dist/plan/store.d.ts.map +1 -1
- package/dist/plan/types.d.ts +22 -3
- package/dist/plan/types.d.ts.map +1 -1
- package/dist/tools/index.d.ts +0 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2086 -1769
- package/dist/tools/log.d.ts.map +1 -1
- package/dist/tools/modes.d.ts.map +1 -1
- package/dist/tools/plan.d.ts +2 -2
- package/dist/tools/plan.d.ts.map +1 -1
- package/package.json +2 -4
- package/skills/plan/SKILL.md +7 -15
- package/skills/plan/references/workflows/annotate.md +6 -0
- package/skills/plan/references/workflows/finalize.md +7 -0
- package/skills/plan/references/workflows/go.md +8 -0
- package/{workflows/plan → skills/plan/references/workflows}/help.md +4 -11
- package/skills/plan/references/workflows/init.md +6 -0
- package/skills/plan/references/workflows/new.md +8 -0
- package/skills/plan/references/workflows/update.md +7 -0
- package/templates/plan/PLAN.md +0 -2
- package/dist/commands.d.ts +0 -22
- package/dist/commands.d.ts.map +0 -1
- package/dist/extensions/zodx.d.ts +0 -8
- package/dist/extensions/zodx.d.ts.map +0 -1
- package/dist/plan/operations.d.ts +0 -43
- package/dist/plan/operations.d.ts.map +0 -1
- package/dist/plan/reviews.d.ts +0 -28
- package/dist/plan/reviews.d.ts.map +0 -1
- package/dist/tools/ci.d.ts +0 -6
- package/dist/tools/ci.d.ts.map +0 -1
- package/dist/tools/plan-schema.d.ts +0 -211
- package/dist/tools/plan-schema.d.ts.map +0 -1
- package/dist/workflow.d.ts +0 -33
- package/dist/workflow.d.ts.map +0 -1
- package/workflows/plan/annotate.md +0 -13
- package/workflows/plan/finalize.md +0 -15
- package/workflows/plan/go.md +0 -20
- package/workflows/plan/init.md +0 -13
- package/workflows/plan/new.md +0 -20
- package/workflows/plan/update.md +0 -18
package/dist/cli.js
CHANGED
|
@@ -2331,11 +2331,7 @@ function run(command, args, options = {}) {
|
|
|
2331
2331
|
const child = spawn(command, args, {
|
|
2332
2332
|
cwd: options.cwd,
|
|
2333
2333
|
env: options.env ?? process.env,
|
|
2334
|
-
stdio: [
|
|
2335
|
-
options.interactive ? "inherit" : options.input === undefined ? "ignore" : "pipe",
|
|
2336
|
-
options.interactive ? "inherit" : "pipe",
|
|
2337
|
-
"pipe"
|
|
2338
|
-
],
|
|
2334
|
+
stdio: [options.input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
2339
2335
|
signal: options.signal
|
|
2340
2336
|
});
|
|
2341
2337
|
let stdout = "";
|
|
@@ -2352,8 +2348,6 @@ function run(command, args, options = {}) {
|
|
|
2352
2348
|
});
|
|
2353
2349
|
child.stderr?.on("data", (chunk) => {
|
|
2354
2350
|
const text = chunk.toString();
|
|
2355
|
-
if (options.interactive)
|
|
2356
|
-
process.stderr.write(text);
|
|
2357
2351
|
if (unbounded)
|
|
2358
2352
|
stderrChunks.push(text);
|
|
2359
2353
|
else
|
|
@@ -2382,200 +2376,271 @@ function appendBounded(current, next) {
|
|
|
2382
2376
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
2383
2377
|
}
|
|
2384
2378
|
|
|
2385
|
-
// src/
|
|
2386
|
-
import { mkdir as mkdir3, readdir, readFile as readFile3, realpath as realpath3, rename, rm as rm2, writeFile } from "node:fs/promises";
|
|
2387
|
-
import { basename as basename2, dirname as dirname3, join as join7, resolve as resolve3, sep } from "node:path";
|
|
2388
|
-
|
|
2389
|
-
// src/extensions/zodx.ts
|
|
2379
|
+
// src/extensions/subagentx.ts
|
|
2390
2380
|
import { z } from "zod";
|
|
2391
|
-
var
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2381
|
+
var escalationSchema = z.object({
|
|
2382
|
+
correlation: z.object({
|
|
2383
|
+
workflow: z.string().min(1),
|
|
2384
|
+
executionId: z.string().min(1).optional(),
|
|
2385
|
+
unitId: z.string().min(1).optional(),
|
|
2386
|
+
parentUnitId: z.string().min(1).optional(),
|
|
2387
|
+
metadata: z.record(z.string(), z.string()).optional()
|
|
2388
|
+
}).strict(),
|
|
2389
|
+
blocker: z.string().min(1),
|
|
2390
|
+
attempts: z.array(z.string()),
|
|
2391
|
+
evidence: z.array(z.string()),
|
|
2392
|
+
needsUserDecision: z.boolean()
|
|
2393
|
+
}).strict();
|
|
2394
|
+
var ESCALATION_OPEN = "<diffpi-subagent-escalation>";
|
|
2395
|
+
var ESCALATION_CLOSE = "</diffpi-subagent-escalation>";
|
|
2396
|
+
function renderSubagentEscalation(value) {
|
|
2397
|
+
return `${ESCALATION_OPEN}${JSON.stringify(escalationSchema.parse(value))}${ESCALATION_CLOSE}`;
|
|
2398
|
+
}
|
|
2397
2399
|
|
|
2398
|
-
// src/
|
|
2399
|
-
import {
|
|
2400
|
-
import {
|
|
2401
|
-
import {
|
|
2402
|
-
import {
|
|
2400
|
+
// src/plan/annotations.ts
|
|
2401
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
2402
|
+
import { readFile as readFile3, rename, writeFile as writeFile2 } from "node:fs/promises";
|
|
2403
|
+
import { basename, dirname, join as join2 } from "node:path";
|
|
2404
|
+
import { z as z2 } from "zod";
|
|
2403
2405
|
|
|
2404
|
-
// src/
|
|
2405
|
-
import {
|
|
2406
|
-
import {
|
|
2407
|
-
async function
|
|
2408
|
-
const
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
const root = await gitToplevel(cwd);
|
|
2414
|
-
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
2415
|
-
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
2416
|
-
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
2417
|
-
const common = commonResult.stdout.trim();
|
|
2418
|
-
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join(root, common)) : root;
|
|
2419
|
-
const commonDir = await canonicalPath(commonPath);
|
|
2420
|
-
return {
|
|
2421
|
-
root,
|
|
2422
|
-
commonDir,
|
|
2423
|
-
...remote ? { remote } : {},
|
|
2424
|
-
name: remote ? repositoryName(remote) : basename(resolve(commonDir, "..")) || basename(root),
|
|
2425
|
-
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
2406
|
+
// src/log.ts
|
|
2407
|
+
import { randomUUID } from "node:crypto";
|
|
2408
|
+
import { appendFile, readFile } from "node:fs/promises";
|
|
2409
|
+
async function appendLogEntry(logPath, event) {
|
|
2410
|
+
const entry = {
|
|
2411
|
+
...event,
|
|
2412
|
+
version: 1,
|
|
2413
|
+
eventId: event.eventId ?? randomUUID(),
|
|
2414
|
+
timestamp: event.timestamp ?? new Date().toISOString()
|
|
2426
2415
|
};
|
|
2416
|
+
await appendFile(logPath, `${JSON.stringify(entry)}
|
|
2417
|
+
`, { encoding: "utf8", mode: 384 });
|
|
2418
|
+
return entry;
|
|
2427
2419
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
2433
|
-
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
2420
|
+
|
|
2421
|
+
// src/plan/log.ts
|
|
2422
|
+
async function appendPlanLog(logPath, event) {
|
|
2423
|
+
return appendLogEntry(logPath, event);
|
|
2434
2424
|
}
|
|
2435
|
-
|
|
2425
|
+
|
|
2426
|
+
// src/plan/lock.ts
|
|
2427
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
2428
|
+
import { mkdir, readFile as readFile2, rm, writeFile } from "node:fs/promises";
|
|
2429
|
+
import { hostname } from "node:os";
|
|
2430
|
+
import { join } from "node:path";
|
|
2431
|
+
async function withPlanLock(planDir, operation, options = {}) {
|
|
2432
|
+
const lockDir = join(planDir, ".lock");
|
|
2433
|
+
const waitMs = options.waitMs ?? 5000;
|
|
2434
|
+
const pollMs = options.pollMs ?? 25;
|
|
2435
|
+
const owner = {
|
|
2436
|
+
token: randomUUID2(),
|
|
2437
|
+
pid: process.pid,
|
|
2438
|
+
hostname: hostname(),
|
|
2439
|
+
operation: options.operation ?? "plan mutation",
|
|
2440
|
+
acquiredAt: new Date().toISOString()
|
|
2441
|
+
};
|
|
2442
|
+
const started = Date.now();
|
|
2443
|
+
while (true) {
|
|
2444
|
+
try {
|
|
2445
|
+
await mkdir(lockDir);
|
|
2446
|
+
await writeFile(join(lockDir, "owner.json"), `${JSON.stringify(owner)}
|
|
2447
|
+
`, { mode: 384 });
|
|
2448
|
+
break;
|
|
2449
|
+
} catch (error) {
|
|
2450
|
+
if (error.code !== "EEXIST")
|
|
2451
|
+
throw error;
|
|
2452
|
+
if (Date.now() - started >= waitMs) {
|
|
2453
|
+
let existing = "unknown owner";
|
|
2454
|
+
try {
|
|
2455
|
+
existing = (await readFile2(join(lockDir, "owner.json"), "utf8")).trim();
|
|
2456
|
+
} catch {}
|
|
2457
|
+
throw new Error(`Timed out waiting for plan lock ${lockDir}; owner: ${existing}. Remove it only after confirming the owner is stale.`);
|
|
2458
|
+
}
|
|
2459
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2436
2462
|
try {
|
|
2437
|
-
return await
|
|
2438
|
-
}
|
|
2439
|
-
|
|
2463
|
+
return await operation();
|
|
2464
|
+
} finally {
|
|
2465
|
+
let current;
|
|
2466
|
+
try {
|
|
2467
|
+
current = JSON.parse(await readFile2(join(lockDir, "owner.json"), "utf8"));
|
|
2468
|
+
} catch {}
|
|
2469
|
+
if (current?.token === owner.token)
|
|
2470
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
2440
2471
|
}
|
|
2441
2472
|
}
|
|
2442
2473
|
|
|
2443
|
-
// src/
|
|
2444
|
-
var
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
const
|
|
2454
|
-
const
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2474
|
+
// src/plan/annotations.ts
|
|
2475
|
+
var commentSchema = z2.object({
|
|
2476
|
+
id: z2.string(),
|
|
2477
|
+
content: z2.string(),
|
|
2478
|
+
path: z2.string().optional(),
|
|
2479
|
+
start_line: z2.number().int().optional(),
|
|
2480
|
+
line: z2.number().int().optional(),
|
|
2481
|
+
end_line: z2.number().int().optional()
|
|
2482
|
+
}).loose();
|
|
2483
|
+
async function annotatePlan(record, runtime = {}) {
|
|
2484
|
+
const execute = runtime.execute ?? executeProcess;
|
|
2485
|
+
const result = await execute("tuicr", ["--file", record.planPath], dirname(record.planPath), true);
|
|
2486
|
+
const sessionSlug = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
|
|
2487
|
+
if (result.code !== 0)
|
|
2488
|
+
throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
|
|
2489
|
+
if (!sessionSlug)
|
|
2490
|
+
throw new Error("tuicr did not report a tuicr-session marker.");
|
|
2491
|
+
const state = await withPlanLock(record.dir, async () => {
|
|
2492
|
+
const previous = await readAnnotationState(record).catch((error) => {
|
|
2493
|
+
if (error.code === "ENOENT")
|
|
2494
|
+
return;
|
|
2460
2495
|
throw error;
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
}
|
|
2472
|
-
async function assertStoreLink(path, dest) {
|
|
2473
|
-
const entry = await lstat(path);
|
|
2474
|
-
if (!entry.isSymbolicLink())
|
|
2475
|
-
throw new Error(`${path} exists and is not a symlink.`);
|
|
2476
|
-
const target = await symlinkTarget(path);
|
|
2477
|
-
if (target !== await canonicalPath2(dest))
|
|
2478
|
-
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
2496
|
+
});
|
|
2497
|
+
const next = {
|
|
2498
|
+
schemaVersion: 1,
|
|
2499
|
+
sessionSlug,
|
|
2500
|
+
updatedAt: new Date().toISOString(),
|
|
2501
|
+
appliedCommentIds: previous?.appliedCommentIds ?? []
|
|
2502
|
+
};
|
|
2503
|
+
await atomicJson(annotationStatePath(record), next);
|
|
2504
|
+
return next;
|
|
2505
|
+
}, { operation: "save annotation session" });
|
|
2506
|
+
return { sessionSlug, code: result.code, state };
|
|
2479
2507
|
}
|
|
2480
|
-
async function
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
if (!entry.isSymbolicLink())
|
|
2508
|
+
async function readPlanAnnotations(record, options = {}) {
|
|
2509
|
+
const state = await readAnnotationState(record).catch((error) => {
|
|
2510
|
+
if (error.code === "ENOENT")
|
|
2484
2511
|
return;
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2512
|
+
throw error;
|
|
2513
|
+
});
|
|
2514
|
+
if (!state)
|
|
2515
|
+
return { comments: [], pending: [] };
|
|
2516
|
+
const execute = options.runtime?.execute ?? executeProcess;
|
|
2517
|
+
const response = await execute("tuicr", ["review", "comments", "--session", state.sessionSlug], record.dir, false);
|
|
2518
|
+
if (response.code !== 0) {
|
|
2519
|
+
if (/not found|no session|deleted/i.test(response.stderr))
|
|
2520
|
+
return { state, comments: [], pending: [] };
|
|
2521
|
+
throw new Error(response.stderr.trim() || "Cannot read tuicr comments.");
|
|
2522
|
+
}
|
|
2523
|
+
let raw;
|
|
2524
|
+
try {
|
|
2525
|
+
raw = JSON.parse(response.stdout);
|
|
2526
|
+
} catch {
|
|
2527
|
+
throw new Error("tuicr returned malformed comment JSON.");
|
|
2491
2528
|
}
|
|
2529
|
+
if (!Array.isArray(raw))
|
|
2530
|
+
throw new Error("tuicr comment output must be a JSON array.");
|
|
2531
|
+
const lines = record.source.split(`
|
|
2532
|
+
`);
|
|
2533
|
+
const applied = new Set(state.appliedCommentIds);
|
|
2534
|
+
const comments = raw.map((value, index) => normalizeComment(value, index, lines, applied));
|
|
2535
|
+
const pending = comments.filter((comment) => !comment.applied);
|
|
2536
|
+
return { state, comments: options.includeApplied ? comments : pending, pending };
|
|
2537
|
+
}
|
|
2538
|
+
async function acknowledgePlanAnnotations(record, commentIds, summary) {
|
|
2539
|
+
if (!summary.trim())
|
|
2540
|
+
throw new Error("Annotation acknowledgement summary is required.");
|
|
2541
|
+
return withPlanLock(record.dir, async () => {
|
|
2542
|
+
const state = await readAnnotationState(record);
|
|
2543
|
+
const next = {
|
|
2544
|
+
...state,
|
|
2545
|
+
updatedAt: new Date().toISOString(),
|
|
2546
|
+
appliedCommentIds: [...new Set([...state.appliedCommentIds, ...commentIds])]
|
|
2547
|
+
};
|
|
2548
|
+
await atomicJson(annotationStatePath(record), next);
|
|
2549
|
+
await appendPlanLog(record.logPath, {
|
|
2550
|
+
planRevision: record.document.revision,
|
|
2551
|
+
kind: "annotation",
|
|
2552
|
+
actor: "planner",
|
|
2553
|
+
message: summary,
|
|
2554
|
+
data: { commentIds: [...commentIds], sessionSlug: state.sessionSlug }
|
|
2555
|
+
});
|
|
2556
|
+
return next;
|
|
2557
|
+
}, { operation: "acknowledge annotations" });
|
|
2492
2558
|
}
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname(path), target));
|
|
2559
|
+
function annotationStatePath(record) {
|
|
2560
|
+
return join2(record.dir, "annotations.json");
|
|
2496
2561
|
}
|
|
2497
|
-
async function
|
|
2562
|
+
async function readAnnotationState(record) {
|
|
2563
|
+
const source = await readFile3(annotationStatePath(record), "utf8");
|
|
2564
|
+
let value;
|
|
2498
2565
|
try {
|
|
2499
|
-
|
|
2566
|
+
value = JSON.parse(source);
|
|
2500
2567
|
} catch {
|
|
2501
|
-
|
|
2568
|
+
throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
|
|
2502
2569
|
}
|
|
2570
|
+
if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
|
|
2571
|
+
throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
|
|
2572
|
+
}
|
|
2573
|
+
return value;
|
|
2503
2574
|
}
|
|
2504
|
-
function
|
|
2505
|
-
const
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
return resolveBundledAssetDir("templates", moduleUrl);
|
|
2526
|
-
}
|
|
2527
|
-
|
|
2528
|
-
// src/templates.ts
|
|
2529
|
-
async function loadTemplate(name, options = {}) {
|
|
2530
|
-
const relative = templateRelativePath(name);
|
|
2531
|
-
const userPath = join4(options.homeDir ?? homedir2(), ".difflab", "diffpi", "templates", relative);
|
|
2532
|
-
const bundledPath = join4(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
2533
|
-
const user = await readOptionalFile(userPath);
|
|
2534
|
-
if (user !== undefined)
|
|
2535
|
-
return { name, path: userPath, source: "user", content: user };
|
|
2536
|
-
const bundled = await readOptionalFile(bundledPath);
|
|
2537
|
-
if (bundled !== undefined)
|
|
2538
|
-
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
2539
|
-
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
2540
|
-
}
|
|
2541
|
-
function renderTemplate(content, variables) {
|
|
2542
|
-
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
2575
|
+
function normalizeComment(value, index, lines, applied) {
|
|
2576
|
+
const parsed = commentSchema.safeParse(value);
|
|
2577
|
+
if (!parsed.success)
|
|
2578
|
+
throw new Error(`Malformed tuicr comment at index ${index}.`);
|
|
2579
|
+
const raw = parsed.data;
|
|
2580
|
+
const line = raw.start_line ?? raw.line;
|
|
2581
|
+
const endLine = raw.end_line ?? line;
|
|
2582
|
+
const targetPath = raw.path;
|
|
2583
|
+
const appliesToPlan = !targetPath || basename(targetPath) === basename("PLAN.md");
|
|
2584
|
+
const validAnchor = appliesToPlan && line !== undefined && line > 0 && line <= lines.length;
|
|
2585
|
+
return {
|
|
2586
|
+
id: raw.id,
|
|
2587
|
+
body: raw.content,
|
|
2588
|
+
file: targetPath,
|
|
2589
|
+
line,
|
|
2590
|
+
endLine,
|
|
2591
|
+
context: validAnchor ? lines.slice(line - 1, Math.min(endLine ?? line, lines.length)).join(`
|
|
2592
|
+
`) : undefined,
|
|
2593
|
+
stale: line !== undefined && !validAnchor,
|
|
2594
|
+
applied: applied.has(raw.id)
|
|
2595
|
+
};
|
|
2543
2596
|
}
|
|
2544
|
-
function
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2597
|
+
async function executeProcess(command, args, cwd, interactive) {
|
|
2598
|
+
if (!interactive) {
|
|
2599
|
+
const result = await run(command, args, { cwd, capture: "unbounded" });
|
|
2600
|
+
return { code: result.code, stdout: result.stdout, stderr: result.stderr };
|
|
2548
2601
|
}
|
|
2549
|
-
return
|
|
2602
|
+
return new Promise((resolve, reject) => {
|
|
2603
|
+
const child = spawn2(command, args, { cwd, stdio: ["inherit", "inherit", "pipe"] });
|
|
2604
|
+
let stderr = "";
|
|
2605
|
+
child.stderr.on("data", (chunk) => {
|
|
2606
|
+
const text = chunk.toString();
|
|
2607
|
+
stderr += text;
|
|
2608
|
+
process.stderr.write(text);
|
|
2609
|
+
});
|
|
2610
|
+
child.on("error", reject);
|
|
2611
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
|
|
2612
|
+
});
|
|
2550
2613
|
}
|
|
2551
|
-
async function
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
return;
|
|
2557
|
-
throw error;
|
|
2558
|
-
}
|
|
2614
|
+
async function atomicJson(path, value) {
|
|
2615
|
+
const temp = join2(dirname(path), `.${basename(path)}.${crypto.randomUUID()}.tmp`);
|
|
2616
|
+
await writeFile2(temp, `${JSON.stringify(value, null, 2)}
|
|
2617
|
+
`, { mode: 384, flag: "wx" });
|
|
2618
|
+
await rename(temp, path);
|
|
2559
2619
|
}
|
|
2560
2620
|
|
|
2561
|
-
// src/
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
...event,
|
|
2621
|
+
// src/plan/execution.ts
|
|
2622
|
+
function createExecutionPacket(plan, coordinator) {
|
|
2623
|
+
if (!plan.execution?.active)
|
|
2624
|
+
throw new Error(`Plan ${plan.id} has no active execution.`);
|
|
2625
|
+
return {
|
|
2567
2626
|
version: 1,
|
|
2568
|
-
|
|
2569
|
-
|
|
2627
|
+
planId: plan.id,
|
|
2628
|
+
executionId: plan.execution.id,
|
|
2629
|
+
policy: plan.execution.policy,
|
|
2630
|
+
cwd: plan.execution.cwd,
|
|
2631
|
+
branch: plan.execution.branch,
|
|
2632
|
+
coordinator
|
|
2570
2633
|
};
|
|
2571
|
-
await appendFile(logPath, `${JSON.stringify(entry)}
|
|
2572
|
-
`, { encoding: "utf8", mode: 384 });
|
|
2573
|
-
return entry;
|
|
2574
2634
|
}
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2635
|
+
function renderExecutionPrompt(packet) {
|
|
2636
|
+
return [
|
|
2637
|
+
`Execute the durable Diffpi plan using this packet: ${JSON.stringify(packet)}.`,
|
|
2638
|
+
"Call plan_context first and keep plan tools as the source of truth for eligibility, ownership, status, gates, and commits.",
|
|
2639
|
+
"Use SubagentWorkflow for deterministic multi-task coordination: pipeline dependent work, parallelize only tasks with non-overlapping declared file scopes, and use structured schemas for worker outcomes.",
|
|
2640
|
+
"The extension cannot launch workflow children over RPC, so the active orchestrator must invoke SubagentWorkflow itself.",
|
|
2641
|
+
"Persist every transition, progress event, issue, and deviation. Do not edit PLAN.md directly. Delegated implementation workers never commit or restructure the plan.",
|
|
2642
|
+
"For commit-per-phase execution, commit after local gates, push immediately, record pending CI on the completed phase, and launch a bounded background Worker to call plan_watch_ci for the exact SHA while the next phase executes. Collect that monitor before the next push and collect all monitors before plan completion; failed or timed-out CI blocks execution."
|
|
2643
|
+
].join(" ");
|
|
2579
2644
|
}
|
|
2580
2645
|
|
|
2581
2646
|
// src/plan/markdown.ts
|
|
@@ -3924,7 +3989,7 @@ var handle = {
|
|
|
3924
3989
|
};
|
|
3925
3990
|
|
|
3926
3991
|
// ../../node_modules/mdast-util-to-markdown/lib/join.js
|
|
3927
|
-
var
|
|
3992
|
+
var join3 = [joinDefaults];
|
|
3928
3993
|
function joinDefaults(left, right, parent, state) {
|
|
3929
3994
|
if (right.type === "code" && formatCodeAsIndented(right, state) && (left.type === "list" || left.type === right.type && formatCodeAsIndented(left, state))) {
|
|
3930
3995
|
return false;
|
|
@@ -6470,7 +6535,7 @@ function toMarkdown(tree, options) {
|
|
|
6470
6535
|
handle: undefined,
|
|
6471
6536
|
indentLines,
|
|
6472
6537
|
indexStack: [],
|
|
6473
|
-
join: [...
|
|
6538
|
+
join: [...join3],
|
|
6474
6539
|
options: {},
|
|
6475
6540
|
safe: safeBound,
|
|
6476
6541
|
stack: [],
|
|
@@ -6774,7 +6839,7 @@ import { default as default2 } from "node:path";
|
|
|
6774
6839
|
import { default as default3 } from "node:process";
|
|
6775
6840
|
|
|
6776
6841
|
// ../../node_modules/vfile/lib/minurl.js
|
|
6777
|
-
import { fileURLToPath
|
|
6842
|
+
import { fileURLToPath } from "node:url";
|
|
6778
6843
|
|
|
6779
6844
|
// ../../node_modules/vfile/lib/minurl.shared.js
|
|
6780
6845
|
function isUrl(fileUrlOrPath) {
|
|
@@ -6860,7 +6925,7 @@ class VFile {
|
|
|
6860
6925
|
}
|
|
6861
6926
|
set path(path) {
|
|
6862
6927
|
if (isUrl(path)) {
|
|
6863
|
-
path =
|
|
6928
|
+
path = fileURLToPath(path);
|
|
6864
6929
|
}
|
|
6865
6930
|
assertNonEmpty(path, "path");
|
|
6866
6931
|
if (this.path !== path) {
|
|
@@ -7215,6 +7280,25 @@ function looksLikeAValue(value) {
|
|
|
7215
7280
|
function isUint8Array2(value) {
|
|
7216
7281
|
return Boolean(value && typeof value === "object" && "byteLength" in value && "byteOffset" in value);
|
|
7217
7282
|
}
|
|
7283
|
+
// src/plan/ids.ts
|
|
7284
|
+
var STABLE_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
7285
|
+
function isStableId(value) {
|
|
7286
|
+
return STABLE_ID.test(value) && value.length <= 80;
|
|
7287
|
+
}
|
|
7288
|
+
function assertStableId(value, label = "identifier") {
|
|
7289
|
+
if (!isStableId(value))
|
|
7290
|
+
throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
|
|
7291
|
+
}
|
|
7292
|
+
function assertUniqueIds(ids, label) {
|
|
7293
|
+
const seen = new Set;
|
|
7294
|
+
for (const id of ids) {
|
|
7295
|
+
assertStableId(id, label);
|
|
7296
|
+
if (seen.has(id))
|
|
7297
|
+
throw new Error(`Duplicate ${label}: ${id}.`);
|
|
7298
|
+
seen.add(id);
|
|
7299
|
+
}
|
|
7300
|
+
}
|
|
7301
|
+
|
|
7218
7302
|
// src/plan/markdown.ts
|
|
7219
7303
|
var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
|
|
7220
7304
|
var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
|
|
@@ -7262,6 +7346,8 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7262
7346
|
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
7263
7347
|
if (options.strict && plan.phases.length === 0)
|
|
7264
7348
|
add("no-phases", "error", "Finalization requires at least one phase.");
|
|
7349
|
+
if (options.strict && (options.pendingAnnotations ?? 0) > 0)
|
|
7350
|
+
add("pending-annotations", "error", `${options.pendingAnnotations} annotations remain pending.`);
|
|
7265
7351
|
return issues;
|
|
7266
7352
|
}
|
|
7267
7353
|
function renderPlanDocument(plan, previousSource) {
|
|
@@ -7603,19 +7689,6 @@ function parseCsv(value) {
|
|
|
7603
7689
|
return [];
|
|
7604
7690
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
7605
7691
|
}
|
|
7606
|
-
function assertStableId(value, label) {
|
|
7607
|
-
if (!zx.id.safeParse(value).success)
|
|
7608
|
-
throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
|
|
7609
|
-
}
|
|
7610
|
-
function assertUniqueIds(ids, label) {
|
|
7611
|
-
const seen = new Set;
|
|
7612
|
-
for (const id of ids) {
|
|
7613
|
-
assertStableId(id, label);
|
|
7614
|
-
if (seen.has(id))
|
|
7615
|
-
throw new Error(`Duplicate ${label}: ${id}.`);
|
|
7616
|
-
seen.add(id);
|
|
7617
|
-
}
|
|
7618
|
-
}
|
|
7619
7692
|
function cleanPlaceholder(value) {
|
|
7620
7693
|
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
7621
7694
|
}
|
|
@@ -7647,30 +7720,170 @@ function escapeRegExp(value) {
|
|
|
7647
7720
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7648
7721
|
}
|
|
7649
7722
|
|
|
7650
|
-
// src/plan/
|
|
7651
|
-
import { mkdir as
|
|
7652
|
-
import { join as
|
|
7653
|
-
|
|
7654
|
-
|
|
7655
|
-
|
|
7656
|
-
|
|
7657
|
-
|
|
7658
|
-
|
|
7659
|
-
|
|
7660
|
-
|
|
7661
|
-
|
|
7662
|
-
|
|
7663
|
-
|
|
7664
|
-
|
|
7665
|
-
|
|
7666
|
-
|
|
7667
|
-
|
|
7668
|
-
|
|
7723
|
+
// src/plan/store.ts
|
|
7724
|
+
import { open, mkdir as mkdir3, readdir, readFile as readFile5, realpath as realpath3, rename as rename2, rm as rm2 } from "node:fs/promises";
|
|
7725
|
+
import { basename as basename3, dirname as dirname4, join as join8, resolve as resolve3, sep } from "node:path";
|
|
7726
|
+
|
|
7727
|
+
// src/store.ts
|
|
7728
|
+
import { createHash } from "node:crypto";
|
|
7729
|
+
import { lstat, mkdir as mkdir2, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
|
|
7730
|
+
import { homedir } from "node:os";
|
|
7731
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join5, resolve as resolve2 } from "node:path";
|
|
7732
|
+
|
|
7733
|
+
// src/extensions/gitx.ts
|
|
7734
|
+
import { realpath } from "node:fs/promises";
|
|
7735
|
+
import { basename as basename2, isAbsolute, join as join4, resolve } from "node:path";
|
|
7736
|
+
async function gitToplevel(cwd) {
|
|
7737
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
7738
|
+
const top = result.stdout.trim();
|
|
7739
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
7740
|
+
}
|
|
7741
|
+
async function inspectGitRepository(cwd) {
|
|
7742
|
+
const root = await gitToplevel(cwd);
|
|
7743
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
7744
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
7745
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
7746
|
+
const common = commonResult.stdout.trim();
|
|
7747
|
+
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join4(root, common)) : root;
|
|
7748
|
+
const commonDir = await canonicalPath(commonPath);
|
|
7749
|
+
return {
|
|
7750
|
+
root,
|
|
7751
|
+
commonDir,
|
|
7752
|
+
...remote ? { remote } : {},
|
|
7753
|
+
name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
|
|
7754
|
+
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
7755
|
+
};
|
|
7756
|
+
}
|
|
7757
|
+
function normalizeRemote(remote) {
|
|
7758
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
7759
|
+
}
|
|
7760
|
+
function repositoryName(remote) {
|
|
7761
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
7762
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
7763
|
+
}
|
|
7764
|
+
async function canonicalPath(path) {
|
|
7765
|
+
try {
|
|
7766
|
+
return await realpath(path);
|
|
7767
|
+
} catch {
|
|
7768
|
+
return resolve(path);
|
|
7669
7769
|
}
|
|
7770
|
+
}
|
|
7771
|
+
|
|
7772
|
+
// src/store.ts
|
|
7773
|
+
var STORE_LINK = ".diffpi";
|
|
7774
|
+
var LEGACY_STORE_LINK = join5(".pi", "diffpi");
|
|
7775
|
+
function storeGlobalRoot(homeDir = homedir()) {
|
|
7776
|
+
return join5(homeDir, ".difflab", "diffpi", "projects");
|
|
7777
|
+
}
|
|
7778
|
+
async function ensureStore(cwd, homeDir = homedir()) {
|
|
7779
|
+
const repository = await inspectGitRepository(cwd);
|
|
7780
|
+
const root = repository.root;
|
|
7781
|
+
const slug = projectSlug(repository);
|
|
7782
|
+
const dest = join5(storeGlobalRoot(homeDir), slug);
|
|
7783
|
+
const link = join5(root, STORE_LINK);
|
|
7784
|
+
await mkdir2(dest, { recursive: true });
|
|
7670
7785
|
try {
|
|
7671
|
-
|
|
7672
|
-
}
|
|
7673
|
-
|
|
7786
|
+
await assertStoreLink(link, dest);
|
|
7787
|
+
} catch (error) {
|
|
7788
|
+
if (error.code !== "ENOENT")
|
|
7789
|
+
throw error;
|
|
7790
|
+
await symlink(dest, link);
|
|
7791
|
+
}
|
|
7792
|
+
await removeLegacyStoreLink(join5(root, LEGACY_STORE_LINK), dest);
|
|
7793
|
+
return { slug, root, dest, link, linked: true };
|
|
7794
|
+
}
|
|
7795
|
+
async function plansDir(cwd, homeDir = homedir()) {
|
|
7796
|
+
const store = await ensureStore(cwd, homeDir);
|
|
7797
|
+
const dir = join5(store.link, "plan");
|
|
7798
|
+
await mkdir2(dir, { recursive: true });
|
|
7799
|
+
return dir;
|
|
7800
|
+
}
|
|
7801
|
+
async function assertStoreLink(path, dest) {
|
|
7802
|
+
const entry = await lstat(path);
|
|
7803
|
+
if (!entry.isSymbolicLink())
|
|
7804
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
7805
|
+
const target = await symlinkTarget(path);
|
|
7806
|
+
if (target !== await canonicalPath2(dest))
|
|
7807
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
7808
|
+
}
|
|
7809
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
7810
|
+
try {
|
|
7811
|
+
const entry = await lstat(path);
|
|
7812
|
+
if (!entry.isSymbolicLink())
|
|
7813
|
+
return;
|
|
7814
|
+
const target = await symlinkTarget(path);
|
|
7815
|
+
if (target === await canonicalPath2(dest))
|
|
7816
|
+
await unlink(path);
|
|
7817
|
+
} catch (error) {
|
|
7818
|
+
if (error.code !== "ENOENT")
|
|
7819
|
+
throw error;
|
|
7820
|
+
}
|
|
7821
|
+
}
|
|
7822
|
+
async function symlinkTarget(path) {
|
|
7823
|
+
const target = await readlink(path);
|
|
7824
|
+
return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname2(path), target));
|
|
7825
|
+
}
|
|
7826
|
+
async function canonicalPath2(path) {
|
|
7827
|
+
try {
|
|
7828
|
+
return await realpath2(path);
|
|
7829
|
+
} catch {
|
|
7830
|
+
return resolve2(path);
|
|
7831
|
+
}
|
|
7832
|
+
}
|
|
7833
|
+
function projectSlug(repository) {
|
|
7834
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
7835
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
7836
|
+
return `${readable}-${digest}`;
|
|
7837
|
+
}
|
|
7838
|
+
|
|
7839
|
+
// src/templates.ts
|
|
7840
|
+
import { readFile as readFile4 } from "node:fs/promises";
|
|
7841
|
+
import { homedir as homedir2 } from "node:os";
|
|
7842
|
+
import { join as join7, normalize } from "node:path";
|
|
7843
|
+
|
|
7844
|
+
// src/assets.ts
|
|
7845
|
+
import { existsSync } from "node:fs";
|
|
7846
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
7847
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
7848
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
7849
|
+
const moduleDir = dirname3(fileURLToPath2(moduleUrl));
|
|
7850
|
+
const candidates = [join6(moduleDir, name), join6(moduleDir, "..", name), join6(moduleDir, "..", "..", name)];
|
|
7851
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
7852
|
+
}
|
|
7853
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
7854
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
7855
|
+
}
|
|
7856
|
+
|
|
7857
|
+
// src/templates.ts
|
|
7858
|
+
async function loadTemplate(name, options = {}) {
|
|
7859
|
+
const relative = templateRelativePath(name);
|
|
7860
|
+
const userPath = join7(options.homeDir ?? homedir2(), ".difflab", "diffpi", "templates", relative);
|
|
7861
|
+
const bundledPath = join7(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
7862
|
+
const user = await readOptionalFile(userPath);
|
|
7863
|
+
if (user !== undefined)
|
|
7864
|
+
return { name, path: userPath, source: "user", content: user };
|
|
7865
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
7866
|
+
if (bundled !== undefined)
|
|
7867
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
7868
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
7869
|
+
}
|
|
7870
|
+
function renderTemplate(content, variables) {
|
|
7871
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
7872
|
+
}
|
|
7873
|
+
function templateRelativePath(name) {
|
|
7874
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
7875
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
7876
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
7877
|
+
}
|
|
7878
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
7879
|
+
}
|
|
7880
|
+
async function readOptionalFile(path) {
|
|
7881
|
+
try {
|
|
7882
|
+
return await readFile4(path, "utf8");
|
|
7883
|
+
} catch (error) {
|
|
7884
|
+
if (error.code === "ENOENT")
|
|
7885
|
+
return;
|
|
7886
|
+
throw error;
|
|
7674
7887
|
}
|
|
7675
7888
|
}
|
|
7676
7889
|
|
|
@@ -7689,7 +7902,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7689
7902
|
continue;
|
|
7690
7903
|
if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
|
|
7691
7904
|
continue;
|
|
7692
|
-
const dir =
|
|
7905
|
+
const dir = join8(root, entry.name);
|
|
7693
7906
|
await assertContained(root, dir);
|
|
7694
7907
|
try {
|
|
7695
7908
|
const record = await readRecord(dir);
|
|
@@ -7715,8 +7928,8 @@ function createPlanStore(options = {}) {
|
|
|
7715
7928
|
async init(input) {
|
|
7716
7929
|
const root = await plansDir(input.cwd, options.homeDir);
|
|
7717
7930
|
const id = planRecordName(input.shortSlug, now());
|
|
7718
|
-
const dir =
|
|
7719
|
-
await assertContained(root,
|
|
7931
|
+
const dir = join8(root, id);
|
|
7932
|
+
await assertContained(root, dirname4(dir));
|
|
7720
7933
|
try {
|
|
7721
7934
|
await mkdir3(dir);
|
|
7722
7935
|
} catch (error) {
|
|
@@ -7735,19 +7948,25 @@ function createPlanStore(options = {}) {
|
|
|
7735
7948
|
branch: input.branch,
|
|
7736
7949
|
title: input.title?.trim() || titleFromSlug(input.shortSlug),
|
|
7737
7950
|
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
7738
|
-
issue_id: input.issueId?.trim() || "<!-- Add issue tracker ID. -->",
|
|
7739
|
-
issue_url: input.issueUrl?.trim() || "<!-- Add issue tracker URL. -->",
|
|
7740
7951
|
created_at: timestamp,
|
|
7741
7952
|
updated_at: timestamp
|
|
7742
7953
|
});
|
|
7743
|
-
const document = parsePlanDocument(source,
|
|
7744
|
-
await atomicWrite(
|
|
7954
|
+
const document = parsePlanDocument(source, join8(dir, "PLAN.md"));
|
|
7955
|
+
await atomicWrite(join8(dir, "PLAN.md"), source);
|
|
7956
|
+
await ensureImplementationFiles(dir, document, options);
|
|
7957
|
+
await atomicWrite(join8(dir, "logs.txt"), "");
|
|
7958
|
+
await appendPlanLog(join8(dir, "logs.txt"), {
|
|
7959
|
+
planRevision: document.revision,
|
|
7960
|
+
kind: "created",
|
|
7961
|
+
actor: "diffpi",
|
|
7962
|
+
message: `Created plan ${id}.`
|
|
7963
|
+
});
|
|
7745
7964
|
return {
|
|
7746
7965
|
id,
|
|
7747
7966
|
dir,
|
|
7748
|
-
planPath:
|
|
7749
|
-
logPath:
|
|
7750
|
-
implementationDir:
|
|
7967
|
+
planPath: join8(dir, "PLAN.md"),
|
|
7968
|
+
logPath: join8(dir, "logs.txt"),
|
|
7969
|
+
implementationDir: join8(dir, "implementation"),
|
|
7751
7970
|
document,
|
|
7752
7971
|
source
|
|
7753
7972
|
};
|
|
@@ -7781,41 +8000,41 @@ function createPlanStore(options = {}) {
|
|
|
7781
8000
|
parsePlanDocument(source, current.planPath);
|
|
7782
8001
|
await atomicWrite(current.planPath, source);
|
|
7783
8002
|
await ensureImplementationFiles(current.dir, document, options);
|
|
7784
|
-
return { ...current, document, source, implementationDir:
|
|
8003
|
+
return { ...current, document, source, implementationDir: join8(current.dir, "implementation") };
|
|
7785
8004
|
}, { operation });
|
|
7786
8005
|
},
|
|
7787
8006
|
async log(cwd, query, event) {
|
|
7788
8007
|
const record = await this.read(cwd, query);
|
|
7789
|
-
return appendPlanLog(record.logPath, event);
|
|
8008
|
+
return withPlanLock(record.dir, () => appendPlanLog(record.logPath, event), { operation: "append log" });
|
|
7790
8009
|
}
|
|
7791
8010
|
};
|
|
7792
8011
|
}
|
|
7793
8012
|
async function readRecord(dir) {
|
|
7794
|
-
const planPath =
|
|
7795
|
-
const logPath =
|
|
7796
|
-
const source = await
|
|
8013
|
+
const planPath = join8(dir, "PLAN.md");
|
|
8014
|
+
const logPath = join8(dir, "logs.txt");
|
|
8015
|
+
const source = await readFile5(planPath, "utf8");
|
|
7797
8016
|
const document = parsePlanDocument(source, planPath);
|
|
7798
8017
|
return {
|
|
7799
|
-
id:
|
|
8018
|
+
id: basename3(dir),
|
|
7800
8019
|
dir,
|
|
7801
8020
|
planPath,
|
|
7802
8021
|
logPath,
|
|
7803
|
-
implementationDir:
|
|
8022
|
+
implementationDir: join8(dir, "implementation"),
|
|
7804
8023
|
document,
|
|
7805
8024
|
source
|
|
7806
8025
|
};
|
|
7807
8026
|
}
|
|
7808
8027
|
async function ensureImplementationFiles(dir, document, options) {
|
|
7809
|
-
const implementationDir =
|
|
8028
|
+
const implementationDir = join8(dir, "implementation");
|
|
7810
8029
|
await mkdir3(implementationDir, { recursive: true });
|
|
7811
8030
|
const template = await loadTemplate("plan/implementation", {
|
|
7812
8031
|
homeDir: options.homeDir,
|
|
7813
8032
|
bundledDir: options.bundledTemplatesDir
|
|
7814
8033
|
});
|
|
7815
8034
|
for (const phase of document.phases) {
|
|
7816
|
-
const path =
|
|
8035
|
+
const path = join8(implementationDir, `phase-${phase.id}.md`);
|
|
7817
8036
|
try {
|
|
7818
|
-
await
|
|
8037
|
+
await readFile5(path, "utf8");
|
|
7819
8038
|
} catch (error) {
|
|
7820
8039
|
if (error.code !== "ENOENT")
|
|
7821
8040
|
throw error;
|
|
@@ -7830,9 +8049,23 @@ async function ensureImplementationFiles(dir, document, options) {
|
|
|
7830
8049
|
}
|
|
7831
8050
|
}
|
|
7832
8051
|
async function atomicWrite(path, content) {
|
|
7833
|
-
const temp =
|
|
7834
|
-
await
|
|
7835
|
-
|
|
8052
|
+
const temp = join8(dirname4(path), `.${basename3(path)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
8053
|
+
const file = await open(temp, "wx", 384);
|
|
8054
|
+
try {
|
|
8055
|
+
await file.writeFile(content, "utf8");
|
|
8056
|
+
await file.sync();
|
|
8057
|
+
} finally {
|
|
8058
|
+
await file.close();
|
|
8059
|
+
}
|
|
8060
|
+
await rename2(temp, path);
|
|
8061
|
+
try {
|
|
8062
|
+
const directory = await open(dirname4(path), "r");
|
|
8063
|
+
try {
|
|
8064
|
+
await directory.sync();
|
|
8065
|
+
} finally {
|
|
8066
|
+
await directory.close();
|
|
8067
|
+
}
|
|
8068
|
+
} catch {}
|
|
7836
8069
|
}
|
|
7837
8070
|
async function assertContained(root, candidate) {
|
|
7838
8071
|
const canonicalRoot = await realpath3(root);
|
|
@@ -7851,8 +8084,7 @@ function normalizeSlug(value) {
|
|
|
7851
8084
|
throw new Error(`Invalid plan slug: ${value}.`);
|
|
7852
8085
|
}
|
|
7853
8086
|
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
7854
|
-
|
|
7855
|
-
throw new Error(`Invalid plan slug: ${value}.`);
|
|
8087
|
+
assertStableId(slug, "plan slug");
|
|
7856
8088
|
return slug;
|
|
7857
8089
|
}
|
|
7858
8090
|
function normalizeQuery(value) {
|
|
@@ -7867,99 +8099,369 @@ function stripDate(value) {
|
|
|
7867
8099
|
function titleFromSlug(value) {
|
|
7868
8100
|
return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
7869
8101
|
}
|
|
7870
|
-
|
|
7871
|
-
|
|
7872
|
-
|
|
7873
|
-
|
|
7874
|
-
|
|
7875
|
-
|
|
7876
|
-
|
|
7877
|
-
|
|
7878
|
-
|
|
7879
|
-
|
|
7880
|
-
|
|
7881
|
-
|
|
7882
|
-
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
sourceId,
|
|
7894
|
-
planSource: record.source,
|
|
7895
|
-
content: response.stdout
|
|
7896
|
-
}
|
|
8102
|
+
|
|
8103
|
+
// src/plan/controller.ts
|
|
8104
|
+
function createPlanController(options = {}) {
|
|
8105
|
+
const store = createPlanStore(options);
|
|
8106
|
+
const controller = {
|
|
8107
|
+
context: store.context,
|
|
8108
|
+
create: store.init,
|
|
8109
|
+
read: store.read,
|
|
8110
|
+
update: store.mutate,
|
|
8111
|
+
appendLog: store.log,
|
|
8112
|
+
updateStatus: (cwd, query, input) => updateStatus(store, cwd, query, input),
|
|
8113
|
+
updateCi: (cwd, query, input) => updateCi(store, cwd, query, input),
|
|
8114
|
+
retryCi: (cwd, query, input) => retryCi(store, cwd, query, input),
|
|
8115
|
+
assertPhasePushEligible,
|
|
8116
|
+
validate: validatePlanDocument,
|
|
8117
|
+
countDesignWords,
|
|
8118
|
+
annotate: annotatePlan,
|
|
8119
|
+
annotations: readPlanAnnotations,
|
|
8120
|
+
acknowledgeAnnotations: acknowledgePlanAnnotations,
|
|
8121
|
+
executionPacket: createExecutionPacket,
|
|
8122
|
+
executionPrompt: renderExecutionPrompt,
|
|
8123
|
+
renderEscalation: renderSubagentEscalation,
|
|
8124
|
+
assertStableId
|
|
7897
8125
|
};
|
|
7898
|
-
|
|
7899
|
-
|
|
8126
|
+
return controller;
|
|
8127
|
+
}
|
|
8128
|
+
async function updateStatus(store, cwd, query, input) {
|
|
8129
|
+
let escalation;
|
|
8130
|
+
const record = await store.mutate(cwd, query, "update status", (plan) => {
|
|
8131
|
+
if (input.target.type === "plan") {
|
|
8132
|
+
if (plan.status !== input.expectedStatus)
|
|
8133
|
+
throw new Error(`Expected plan status ${input.expectedStatus}, found ${plan.status}.`);
|
|
8134
|
+
assertPlanTransition(plan.status, input.status);
|
|
8135
|
+
if (input.status === "ready") {
|
|
8136
|
+
const errors = validatePlanDocument(plan, { strict: true }).filter((issue) => issue.severity === "error");
|
|
8137
|
+
if (errors.length)
|
|
8138
|
+
throw new Error(`Plan cannot become ready: ${errors.map((issue) => issue.message).join(" ")}`);
|
|
8139
|
+
}
|
|
8140
|
+
if ((input.status === "blocked" || input.status === "completed") && plan.status === "in_progress")
|
|
8141
|
+
assertExecutionOwner(plan, input.executionId);
|
|
8142
|
+
if (input.status === "completed" && plan.phases.some((phase) => phase.status !== "completed" && phase.status !== "skipped"))
|
|
8143
|
+
throw new Error("Every phase must be complete or skipped before the plan completes.");
|
|
8144
|
+
if (input.status === "completed" && plan.execution?.policy === "commit-per-phase") {
|
|
8145
|
+
const unsettled = plan.phases.filter((phase) => phase.status === "completed" && (!phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending")));
|
|
8146
|
+
if (unsettled.length)
|
|
8147
|
+
throw new Error(`CI must settle successfully for phases: ${unsettled.map((phase) => phase.id).join(", ")}.`);
|
|
8148
|
+
}
|
|
8149
|
+
return {
|
|
8150
|
+
...heartbeat(plan),
|
|
8151
|
+
status: input.status,
|
|
8152
|
+
execution: input.status === "completed" || input.status === "blocked" ? plan.execution && { ...plan.execution, active: false, heartbeatAt: new Date().toISOString() } : plan.execution
|
|
8153
|
+
};
|
|
8154
|
+
}
|
|
8155
|
+
if (!input.target.id)
|
|
8156
|
+
throw new Error("Target ID is required.");
|
|
8157
|
+
const phase = input.target.type === "phase" ? getPhase(plan, input.target.id) : findTaskPhase(plan, input.target.id);
|
|
8158
|
+
if (input.target.type === "phase") {
|
|
8159
|
+
if (phase.status !== input.expectedStatus)
|
|
8160
|
+
throw new Error(`Expected phase status ${input.expectedStatus}, found ${phase.status}.`);
|
|
8161
|
+
if (phase.status === "completed")
|
|
8162
|
+
throw new Error(`Completed phase ${phase.id} cannot transition again.`);
|
|
8163
|
+
assertExecutionOwner(plan, input.executionId);
|
|
8164
|
+
assertPhaseTransition(phase.status, input.status);
|
|
8165
|
+
if (input.status === "in_progress" || input.status === "completed")
|
|
8166
|
+
assertPhaseDependenciesSatisfied(plan, phase);
|
|
8167
|
+
if (input.status === "completed") {
|
|
8168
|
+
if (phase.tasks.some((task) => task.status !== "completed" && task.status !== "skipped"))
|
|
8169
|
+
throw new Error("All phase tasks must be complete or skipped.");
|
|
8170
|
+
if (phase.gate.status !== "passed")
|
|
8171
|
+
throw new Error("Phase gates must pass before completion.");
|
|
8172
|
+
if (plan.execution?.policy === "commit-per-phase")
|
|
8173
|
+
assertPhasePushEligible(plan, phase.id, input.executionId);
|
|
8174
|
+
if (plan.execution?.policy === "commit-per-phase" && !input.commit)
|
|
8175
|
+
throw new Error("Commit-per-phase execution requires commit metadata before phase completion.");
|
|
8176
|
+
if (plan.execution?.policy === "commit-per-phase" && (!input.commit?.pushedAt || input.commit.ci?.status !== "pending"))
|
|
8177
|
+
throw new Error("Commit-per-phase execution requires a pushed commit with pending CI monitoring.");
|
|
8178
|
+
if (plan.execution?.policy === "no-commit" && input.commit)
|
|
8179
|
+
throw new Error("No-commit execution cannot record a phase commit.");
|
|
8180
|
+
}
|
|
8181
|
+
const updated = {
|
|
8182
|
+
...phase,
|
|
8183
|
+
status: input.status,
|
|
8184
|
+
revision: phase.revision + 1,
|
|
8185
|
+
commit: input.commit ?? phase.commit,
|
|
8186
|
+
blocker: input.blockedReason ? blocker(input) : phase.blocker
|
|
8187
|
+
};
|
|
8188
|
+
if (input.status === "blocked" && input.blockedReason)
|
|
8189
|
+
escalation = escalationFor(plan, updated.id, undefined, input);
|
|
8190
|
+
const phases = plan.phases.map((item) => {
|
|
8191
|
+
if (item.id === updated.id)
|
|
8192
|
+
return updated;
|
|
8193
|
+
if (input.status === "completed" && plan.execution?.policy === "commit-per-phase" && input.commit && item.status !== "completed" && item.status !== "skipped" && item.gate.status === "passed")
|
|
8194
|
+
return { ...item, gate: { ...item.gate, status: "stale" } };
|
|
8195
|
+
return item;
|
|
8196
|
+
});
|
|
8197
|
+
return { ...heartbeat(plan), phases };
|
|
8198
|
+
}
|
|
8199
|
+
const task = getTask(phase, input.target.id);
|
|
8200
|
+
if (task.status !== input.expectedStatus)
|
|
8201
|
+
throw new Error(`Expected task status ${input.expectedStatus}, found ${task.status}.`);
|
|
8202
|
+
assertExecutionOwner(plan, input.executionId);
|
|
8203
|
+
assertTaskTransition(task, input.status, input.executionId, input.actor);
|
|
8204
|
+
if (input.status === "in_progress" || input.status === "completed") {
|
|
8205
|
+
if (phase.status !== "in_progress")
|
|
8206
|
+
throw new Error(`Phase ${phase.id} is not in progress.`);
|
|
8207
|
+
assertTaskDependenciesSatisfied(plan, task);
|
|
8208
|
+
}
|
|
8209
|
+
const updatedTask = {
|
|
8210
|
+
...task,
|
|
8211
|
+
status: input.status,
|
|
8212
|
+
revision: task.revision + 1,
|
|
8213
|
+
owner: input.status === "in_progress" ? input.actor : task.owner,
|
|
8214
|
+
executionId: input.status === "in_progress" ? input.executionId : task.executionId,
|
|
8215
|
+
blocker: input.blockedReason ? blocker(input) : task.blocker
|
|
8216
|
+
};
|
|
8217
|
+
if (input.status === "blocked" && input.blockedReason)
|
|
8218
|
+
escalation = escalationFor(plan, phase.id, task.id, input);
|
|
8219
|
+
const updatedPhase = {
|
|
8220
|
+
...phase,
|
|
8221
|
+
revision: phase.revision + 1,
|
|
8222
|
+
tasks: phase.tasks.map((item) => item.id === updatedTask.id ? updatedTask : item)
|
|
8223
|
+
};
|
|
8224
|
+
return {
|
|
8225
|
+
...heartbeat(plan),
|
|
8226
|
+
phases: plan.phases.map((item) => item.id === updatedPhase.id ? updatedPhase : item)
|
|
8227
|
+
};
|
|
8228
|
+
});
|
|
8229
|
+
await store.log(cwd, record.id, {
|
|
8230
|
+
planRevision: record.document.revision,
|
|
8231
|
+
kind: input.status === "blocked" ? "blocker" : "status",
|
|
8232
|
+
actor: input.actor,
|
|
8233
|
+
message: input.message,
|
|
8234
|
+
executionId: input.executionId,
|
|
8235
|
+
phaseId: input.target.type === "phase" ? input.target.id : escalation?.correlation.parentUnitId ?? escalation?.correlation.unitId,
|
|
8236
|
+
taskId: input.target.type === "task" ? input.target.id : undefined,
|
|
8237
|
+
evidence: input.evidence
|
|
8238
|
+
});
|
|
8239
|
+
return { record, escalation };
|
|
8240
|
+
}
|
|
8241
|
+
async function updateCi(store, cwd, query, input) {
|
|
8242
|
+
const record = await store.mutate(cwd, query, "update CI status", (plan) => {
|
|
8243
|
+
assertExecutionOwner(plan, input.executionId);
|
|
8244
|
+
if (plan.execution?.policy !== "commit-per-phase")
|
|
8245
|
+
throw new Error("CI status applies only to commit-per-phase execution.");
|
|
8246
|
+
const phase = getPhase(plan, input.phaseId);
|
|
8247
|
+
if (phase.status !== "completed")
|
|
8248
|
+
throw new Error(`Phase ${phase.id} must be completed before CI can settle.`);
|
|
8249
|
+
if (!phase.commit || phase.commit.sha !== input.sha)
|
|
8250
|
+
throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
|
|
8251
|
+
const current = phase.commit.ci?.status;
|
|
8252
|
+
if (current !== "pending")
|
|
8253
|
+
throw new Error(`CI for phase ${phase.id} is already ${current ?? "untracked"}.`);
|
|
8254
|
+
const timestamp = new Date().toISOString();
|
|
8255
|
+
const updated = {
|
|
8256
|
+
...phase,
|
|
8257
|
+
revision: phase.revision + 1,
|
|
8258
|
+
commit: {
|
|
8259
|
+
...phase.commit,
|
|
8260
|
+
ci: {
|
|
8261
|
+
...phase.commit.ci,
|
|
8262
|
+
status: input.status,
|
|
8263
|
+
startedAt: phase.commit.ci?.startedAt ?? timestamp,
|
|
8264
|
+
completedAt: timestamp,
|
|
8265
|
+
detail: input.detail
|
|
8266
|
+
}
|
|
8267
|
+
}
|
|
8268
|
+
};
|
|
8269
|
+
return {
|
|
8270
|
+
...heartbeat(plan),
|
|
8271
|
+
phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
|
|
8272
|
+
};
|
|
8273
|
+
});
|
|
8274
|
+
await store.log(cwd, record.id, {
|
|
8275
|
+
planRevision: record.document.revision,
|
|
8276
|
+
kind: "gate",
|
|
8277
|
+
actor: input.actor,
|
|
8278
|
+
message: `CI ${input.status} for ${input.phaseId}: ${input.detail}`,
|
|
8279
|
+
executionId: input.executionId,
|
|
8280
|
+
phaseId: input.phaseId,
|
|
8281
|
+
evidence: [input.sha]
|
|
8282
|
+
});
|
|
8283
|
+
return record;
|
|
8284
|
+
}
|
|
8285
|
+
async function retryCi(store, cwd, query, input) {
|
|
8286
|
+
let previousDetail;
|
|
8287
|
+
const record = await store.mutate(cwd, query, "retry CI", (plan) => {
|
|
8288
|
+
assertExecutionOwner(plan, input.executionId);
|
|
8289
|
+
if (plan.execution?.policy !== "commit-per-phase")
|
|
8290
|
+
throw new Error("CI retry applies only to commit-per-phase execution.");
|
|
8291
|
+
const phase = getPhase(plan, input.phaseId);
|
|
8292
|
+
if (phase.status !== "completed")
|
|
8293
|
+
throw new Error(`Phase ${phase.id} must be completed before CI can retry.`);
|
|
8294
|
+
if (!phase.commit || phase.commit.sha !== input.sha)
|
|
8295
|
+
throw new Error(`Phase ${phase.id} is not committed at ${input.sha}.`);
|
|
8296
|
+
if (phase.commit.ci?.status !== "failed")
|
|
8297
|
+
throw new Error(`CI for phase ${phase.id} cannot retry from ${phase.commit.ci?.status ?? "untracked"}.`);
|
|
8298
|
+
previousDetail = phase.commit.ci.detail;
|
|
8299
|
+
const updated = {
|
|
8300
|
+
...phase,
|
|
8301
|
+
revision: phase.revision + 1,
|
|
8302
|
+
commit: {
|
|
8303
|
+
...phase.commit,
|
|
8304
|
+
ci: { status: "pending", startedAt: new Date().toISOString() }
|
|
8305
|
+
}
|
|
8306
|
+
};
|
|
8307
|
+
return {
|
|
8308
|
+
...heartbeat(plan),
|
|
8309
|
+
phases: plan.phases.map((item) => item.id === updated.id ? updated : item)
|
|
8310
|
+
};
|
|
8311
|
+
});
|
|
8312
|
+
await store.log(cwd, record.id, {
|
|
8313
|
+
planRevision: record.document.revision,
|
|
8314
|
+
kind: "gate",
|
|
8315
|
+
actor: input.actor,
|
|
8316
|
+
message: `Retrying CI for ${input.phaseId}: ${input.reason}`,
|
|
8317
|
+
executionId: input.executionId,
|
|
8318
|
+
phaseId: input.phaseId,
|
|
8319
|
+
evidence: [input.sha],
|
|
8320
|
+
data: { previousStatus: "failed", previousDetail }
|
|
8321
|
+
});
|
|
8322
|
+
return record;
|
|
7900
8323
|
}
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
|
|
7905
|
-
|
|
7906
|
-
|
|
7907
|
-
|
|
7908
|
-
|
|
7909
|
-
|
|
7910
|
-
|
|
7911
|
-
|
|
7912
|
-
try {
|
|
7913
|
-
dump = JSON.parse(source);
|
|
7914
|
-
} catch {
|
|
7915
|
-
throw new Error(`Malformed plan review: ${path}.`);
|
|
7916
|
-
}
|
|
7917
|
-
if (!isPlanReviewDump(dump))
|
|
7918
|
-
throw new Error(`Malformed plan review: ${path}.`);
|
|
7919
|
-
return { path, dump };
|
|
7920
|
-
}
|
|
7921
|
-
function reviewPath(record) {
|
|
7922
|
-
return join8(record.dir, "reviews", `${record.document.revision}.json`);
|
|
7923
|
-
}
|
|
7924
|
-
async function writeReview(review) {
|
|
7925
|
-
await mkdir4(dirname4(review.path), { recursive: true });
|
|
7926
|
-
await writeFile2(review.path, `${JSON.stringify(review.dump, null, 2)}
|
|
7927
|
-
`, {
|
|
7928
|
-
encoding: "utf8",
|
|
7929
|
-
mode: 384,
|
|
7930
|
-
flag: "wx"
|
|
8324
|
+
function assertPhasePushEligible(plan, phaseId, executionId) {
|
|
8325
|
+
assertExecutionOwner(plan, executionId);
|
|
8326
|
+
if (plan.execution?.policy !== "commit-per-phase")
|
|
8327
|
+
return;
|
|
8328
|
+
const phaseIndex = plan.phases.findIndex((phase) => phase.id === phaseId);
|
|
8329
|
+
if (phaseIndex < 0)
|
|
8330
|
+
throw new Error(`Unknown phase: ${phaseId}.`);
|
|
8331
|
+
const unsettled = plan.phases.slice(0, phaseIndex).filter((phase) => {
|
|
8332
|
+
if (phase.status === "skipped")
|
|
8333
|
+
return false;
|
|
8334
|
+
return phase.status !== "completed" || !phase.commit?.pushedAt || !["passed", "skipped"].includes(phase.commit.ci?.status ?? "pending");
|
|
7931
8335
|
});
|
|
8336
|
+
if (unsettled.length)
|
|
8337
|
+
throw new Error(`Prior phase CI must settle successfully before another push: ${unsettled.map((phase) => phase.id).join(", ")}.`);
|
|
8338
|
+
}
|
|
8339
|
+
function assertPhaseDependenciesSatisfied(plan, phase) {
|
|
8340
|
+
const unsettled = phase.dependencies.map((id) => getPhase(plan, id)).filter((dependency) => !dependencySatisfied(dependency.status));
|
|
8341
|
+
if (unsettled.length)
|
|
8342
|
+
throw new Error(`Phase ${phase.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
|
|
8343
|
+
}
|
|
8344
|
+
function assertTaskDependenciesSatisfied(plan, task) {
|
|
8345
|
+
const tasks = plan.phases.flatMap((phase) => phase.tasks);
|
|
8346
|
+
const unsettled = task.dependencies.map((id) => {
|
|
8347
|
+
const dependency = tasks.find((candidate) => candidate.id === id);
|
|
8348
|
+
if (!dependency)
|
|
8349
|
+
throw new Error(`Unknown task dependency: ${id}.`);
|
|
8350
|
+
return dependency;
|
|
8351
|
+
}).filter((dependency) => !dependencySatisfied(dependency.status));
|
|
8352
|
+
if (unsettled.length)
|
|
8353
|
+
throw new Error(`Task ${task.id} has incomplete dependencies: ${unsettled.map((dependency) => `${dependency.id} (${dependency.status})`).join(", ")}.`);
|
|
8354
|
+
}
|
|
8355
|
+
function dependencySatisfied(status) {
|
|
8356
|
+
return status === "completed" || status === "skipped";
|
|
8357
|
+
}
|
|
8358
|
+
var PLAN_TRANSITIONS = {
|
|
8359
|
+
draft: ["ready"],
|
|
8360
|
+
ready: ["draft", "in_progress"],
|
|
8361
|
+
in_progress: ["blocked", "completed"],
|
|
8362
|
+
blocked: ["draft", "ready", "in_progress"],
|
|
8363
|
+
completed: []
|
|
8364
|
+
};
|
|
8365
|
+
var WORK_TRANSITIONS = {
|
|
8366
|
+
pending: ["in_progress", "skipped"],
|
|
8367
|
+
in_progress: ["blocked", "completed"],
|
|
8368
|
+
blocked: ["pending", "in_progress", "skipped"],
|
|
8369
|
+
completed: [],
|
|
8370
|
+
skipped: []
|
|
8371
|
+
};
|
|
8372
|
+
function assertPlanTransition(from, to) {
|
|
8373
|
+
if (from !== to && !PLAN_TRANSITIONS[from].includes(to))
|
|
8374
|
+
throw new Error(`Invalid plan status transition: ${from} -> ${to}.`);
|
|
8375
|
+
}
|
|
8376
|
+
function assertPhaseTransition(from, to) {
|
|
8377
|
+
if (from !== to && !WORK_TRANSITIONS[from].includes(to))
|
|
8378
|
+
throw new Error(`Invalid phase status transition: ${from} -> ${to}.`);
|
|
8379
|
+
}
|
|
8380
|
+
function assertTaskTransition(task, to, executionId, actor) {
|
|
8381
|
+
if (task.status !== to && !WORK_TRANSITIONS[task.status].includes(to))
|
|
8382
|
+
throw new Error(`Invalid task status transition: ${task.status} -> ${to}.`);
|
|
8383
|
+
if (task.status === "in_progress" && task.executionId && executionId !== task.executionId)
|
|
8384
|
+
throw new Error(`Task ${task.id} is owned by execution ${task.executionId}.`);
|
|
8385
|
+
if (task.status === "in_progress" && task.owner && actor && actor !== task.owner)
|
|
8386
|
+
throw new Error(`Task ${task.id} is owned by ${task.owner}.`);
|
|
8387
|
+
}
|
|
8388
|
+
function getPhase(plan, phaseId) {
|
|
8389
|
+
const phase = plan.phases.find((item) => item.id === phaseId);
|
|
8390
|
+
if (!phase)
|
|
8391
|
+
throw new Error(`Unknown phase: ${phaseId}.`);
|
|
8392
|
+
return phase;
|
|
8393
|
+
}
|
|
8394
|
+
function findTaskPhase(plan, taskId) {
|
|
8395
|
+
const phase = plan.phases.find((item) => item.tasks.some((task) => task.id === taskId));
|
|
8396
|
+
if (!phase)
|
|
8397
|
+
throw new Error(`Unknown task: ${taskId}.`);
|
|
8398
|
+
return phase;
|
|
8399
|
+
}
|
|
8400
|
+
function getTask(phase, taskId) {
|
|
8401
|
+
const task = phase.tasks.find((item) => item.id === taskId);
|
|
8402
|
+
if (!task)
|
|
8403
|
+
throw new Error(`Unknown task: ${taskId}.`);
|
|
8404
|
+
return task;
|
|
8405
|
+
}
|
|
8406
|
+
function assertExecutionOwner(plan, executionId) {
|
|
8407
|
+
if (!plan.execution?.active || !executionId || plan.execution.id !== executionId)
|
|
8408
|
+
throw new Error(`Execution ${executionId ?? "(missing)"} does not own this plan.`);
|
|
8409
|
+
}
|
|
8410
|
+
function blocker(input) {
|
|
8411
|
+
return {
|
|
8412
|
+
reason: input.blockedReason,
|
|
8413
|
+
attempts: input.attempts,
|
|
8414
|
+
evidence: input.evidence,
|
|
8415
|
+
needsUserDecision: input.needsUserDecision
|
|
8416
|
+
};
|
|
7932
8417
|
}
|
|
7933
|
-
function
|
|
7934
|
-
|
|
7935
|
-
|
|
7936
|
-
|
|
7937
|
-
|
|
8418
|
+
function escalationFor(plan, phaseId, taskId, input) {
|
|
8419
|
+
return {
|
|
8420
|
+
correlation: {
|
|
8421
|
+
workflow: "plan",
|
|
8422
|
+
executionId: input.executionId,
|
|
8423
|
+
unitId: taskId ?? phaseId,
|
|
8424
|
+
parentUnitId: taskId ? phaseId : undefined,
|
|
8425
|
+
metadata: { planId: plan.id }
|
|
8426
|
+
},
|
|
8427
|
+
blocker: input.blockedReason,
|
|
8428
|
+
attempts: input.attempts ?? [],
|
|
8429
|
+
evidence: input.evidence ?? [],
|
|
8430
|
+
needsUserDecision: input.needsUserDecision ?? false
|
|
8431
|
+
};
|
|
8432
|
+
}
|
|
8433
|
+
function heartbeat(plan) {
|
|
8434
|
+
return plan.execution?.active ? { ...plan, execution: { ...plan.execution, heartbeatAt: new Date().toISOString() } } : plan;
|
|
7938
8435
|
}
|
|
7939
|
-
|
|
7940
8436
|
// src/cli/plan.ts
|
|
7941
|
-
var plans =
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
const program = new Command2().name("plan").description("Plan review commands").showHelpAfterError().exitOverride().configureOutput({
|
|
8437
|
+
var plans = createPlanController();
|
|
8438
|
+
function createPlanCliCommand(io = process, controller = plans) {
|
|
8439
|
+
const program = new Command2().name("plan").description("Plan annotation commands").showHelpAfterError().exitOverride().configureOutput({
|
|
7945
8440
|
writeOut: (message) => io.stdout.write(message),
|
|
7946
8441
|
writeErr: (message) => io.stderr.write(message)
|
|
7947
8442
|
});
|
|
7948
|
-
|
|
8443
|
+
addAnnotationCommand(program, "annotate", io, controller);
|
|
8444
|
+
addAnnotationCommand(program, "annotations", io, controller);
|
|
7949
8445
|
return program;
|
|
7950
8446
|
}
|
|
7951
|
-
function
|
|
7952
|
-
program.command(
|
|
8447
|
+
function addAnnotationCommand(program, verb, io, controller) {
|
|
8448
|
+
program.command(`${verb} [plan]`).description(verb === "annotate" ? "Open a plan in tuicr for annotation" : "Print plan annotations as JSON").option("--cwd <path>", "Repository working directory", process.cwd()).action(async (query, options) => {
|
|
7953
8449
|
const cwd = resolve4(options.cwd);
|
|
7954
|
-
const record = await resolveCliPlan(cwd, query,
|
|
7955
|
-
|
|
7956
|
-
|
|
8450
|
+
const record = await resolveCliPlan(cwd, query, controller);
|
|
8451
|
+
if (verb === "annotate") {
|
|
8452
|
+
const result = await controller.annotate(record);
|
|
8453
|
+
io.stdout.write(`Annotated ${record.id} in tuicr session ${result.sessionSlug}.
|
|
8454
|
+
`);
|
|
8455
|
+
return;
|
|
8456
|
+
}
|
|
8457
|
+
const annotations = await controller.annotations(record, { includeApplied: true });
|
|
8458
|
+
io.stdout.write(`${JSON.stringify(annotations, null, 2)}
|
|
7957
8459
|
`);
|
|
7958
8460
|
});
|
|
7959
8461
|
}
|
|
7960
|
-
async function resolveCliPlan(cwd, query,
|
|
8462
|
+
async function resolveCliPlan(cwd, query, controller) {
|
|
7961
8463
|
const branch = query ? undefined : (await runChecked("git", ["-C", cwd, "branch", "--show-current"])).stdout.trim();
|
|
7962
|
-
const resolution = await
|
|
8464
|
+
const resolution = await controller.context(cwd, query, query ? {} : { branch, statuses: ["draft", "ready", "in_progress", "blocked"] });
|
|
7963
8465
|
if (resolution.record)
|
|
7964
8466
|
return resolution.record;
|
|
7965
8467
|
if (resolution.ambiguous)
|