@difflab/pi 0.4.0 → 0.5.0-rc.202609231313.eaf7fa0
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 +9 -9
- package/agents/diffpi-copilot.md +6 -3
- package/agents/diffpi-orchestrator.md +1 -1
- package/agents/diffpi-planner.md +3 -3
- package/agents/diffpi-reviewer.md +3 -3
- package/agents/diffpi-worker.md +2 -2
- package/dist/assets.d.ts +1 -0
- package/dist/assets.d.ts.map +1 -1
- package/dist/cli/plan.d.ts +7 -3
- package/dist/cli/plan.d.ts.map +1 -1
- package/dist/cli.js +306 -811
- package/dist/commands/plan.d.ts +0 -1
- package/dist/commands/plan.d.ts.map +1 -1
- package/dist/commands/review.d.ts +0 -1
- package/dist/commands/review.d.ts.map +1 -1
- package/dist/commands.d.ts +22 -0
- package/dist/commands.d.ts.map +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/environment.d.ts +2 -1
- package/dist/environment.d.ts.map +1 -1
- package/dist/extensions/cmdtsx.d.ts +2 -0
- package/dist/extensions/cmdtsx.d.ts.map +1 -0
- package/dist/extensions/fsx.d.ts +8 -0
- package/dist/extensions/fsx.d.ts.map +1 -0
- package/dist/extensions/index.js +5057 -3069
- package/dist/extensions/processx.d.ts +1 -0
- 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/extensions/tuicrx.d.ts +1 -8
- package/dist/extensions/tuicrx.d.ts.map +1 -1
- package/dist/extensions/zodx.d.ts +8 -0
- package/dist/extensions/zodx.d.ts.map +1 -0
- package/dist/index.d.ts +10 -11
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +352 -1149
- package/dist/plan/index.d.ts +2 -5
- package/dist/plan/index.d.ts.map +1 -1
- package/dist/plan/markdown.d.ts.map +1 -1
- package/dist/plan/operations.d.ts +43 -0
- package/dist/plan/operations.d.ts.map +1 -0
- package/dist/plan/reviews.d.ts +28 -0
- package/dist/plan/reviews.d.ts.map +1 -0
- package/dist/plan/store.d.ts +6 -3
- package/dist/plan/store.d.ts.map +1 -1
- package/dist/plan/types.d.ts +3 -22
- package/dist/plan/types.d.ts.map +1 -1
- package/dist/review/github-review-backend.d.ts.map +1 -1
- package/dist/review/gitlab-review-backend.d.ts.map +1 -1
- package/dist/review/index.d.ts +6 -6
- package/dist/review/index.d.ts.map +1 -1
- package/dist/review/local-reviews.d.ts +31 -0
- package/dist/review/local-reviews.d.ts.map +1 -0
- package/dist/review/review-backend.d.ts +2 -3
- package/dist/review/review-backend.d.ts.map +1 -1
- package/dist/review/review-markdown.d.ts +1 -4
- package/dist/review/review-markdown.d.ts.map +1 -1
- package/dist/review/types.d.ts +0 -28
- package/dist/review/types.d.ts.map +1 -1
- package/dist/store.d.ts +0 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/tools/ci.d.ts +6 -0
- package/dist/tools/ci.d.ts.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +1887 -2805
- package/dist/tools/log.d.ts.map +1 -1
- package/dist/tools/modes.d.ts.map +1 -1
- package/dist/tools/plan-schema.d.ts +211 -0
- package/dist/tools/plan-schema.d.ts.map +1 -0
- package/dist/tools/plan.d.ts +2 -2
- package/dist/tools/plan.d.ts.map +1 -1
- package/dist/tools/review.d.ts +0 -30
- package/dist/tools/review.d.ts.map +1 -1
- package/dist/workflow.d.ts +80 -0
- package/dist/workflow.d.ts.map +1 -0
- package/package.json +4 -2
- package/skills/plan/SKILL.md +15 -7
- package/skills/review/SKILL.md +6 -8
- package/templates/plan/PLAN.md +2 -0
- package/workflows/plan/annotate.md +13 -0
- package/workflows/plan/finalize.md +15 -0
- package/workflows/plan/go.md +20 -0
- package/{skills/plan/references/workflows → workflows/plan}/help.md +11 -4
- package/workflows/plan/init.md +13 -0
- package/workflows/plan/new.md +20 -0
- package/workflows/plan/update.md +18 -0
- package/workflows/review/address.md +8 -0
- package/workflows/review/auto.md +8 -0
- package/workflows/review/complete.md +7 -0
- package/workflows/review/edit.md +7 -0
- package/workflows/review/help.md +14 -0
- package/workflows/review/merge.md +6 -0
- package/workflows/review/new.md +7 -0
- package/workflows/review/publish.md +6 -0
- package/dist/plan/annotations.d.ts +0 -25
- package/dist/plan/annotations.d.ts.map +0 -1
- package/dist/plan/controller.d.ts +0 -65
- package/dist/plan/controller.d.ts.map +0 -1
- package/dist/plan/execution.d.ts +0 -13
- package/dist/plan/execution.d.ts.map +0 -1
- package/dist/plan/ids.d.ts +0 -5
- package/dist/plan/ids.d.ts.map +0 -1
- package/dist/plan/lock.d.ts +0 -11
- package/dist/plan/lock.d.ts.map +0 -1
- package/dist/plan/log.d.ts +0 -6
- package/dist/plan/log.d.ts.map +0 -1
- package/dist/plan/parser.d.ts +0 -18
- package/dist/plan/parser.d.ts.map +0 -1
- package/dist/review/local-review-backend.d.ts +0 -3
- package/dist/review/local-review-backend.d.ts.map +0 -1
- package/dist/review/review-state.d.ts +0 -20
- package/dist/review/review-state.d.ts.map +0 -1
- package/skills/plan/references/workflows/annotate.md +0 -6
- package/skills/plan/references/workflows/finalize.md +0 -7
- package/skills/plan/references/workflows/go.md +0 -8
- package/skills/plan/references/workflows/init.md +0 -6
- package/skills/plan/references/workflows/new.md +0 -8
- package/skills/plan/references/workflows/update.md +0 -7
- package/skills/review/references/workflows/address.md +0 -11
- package/skills/review/references/workflows/auto.md +0 -18
- package/skills/review/references/workflows/complete.md +0 -7
- package/skills/review/references/workflows/edit.md +0 -7
- package/skills/review/references/workflows/help.md +0 -30
- package/skills/review/references/workflows/merge.md +0 -6
- package/skills/review/references/workflows/new.md +0 -19
- package/skills/review/references/workflows/open.md +0 -3
- package/skills/review/references/workflows/publish.md +0 -8
package/dist/cli.js
CHANGED
|
@@ -2331,7 +2331,11 @@ 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: [
|
|
2334
|
+
stdio: [
|
|
2335
|
+
options.interactive ? "inherit" : options.input === undefined ? "ignore" : "pipe",
|
|
2336
|
+
options.interactive ? "inherit" : "pipe",
|
|
2337
|
+
"pipe"
|
|
2338
|
+
],
|
|
2335
2339
|
signal: options.signal
|
|
2336
2340
|
});
|
|
2337
2341
|
let stdout = "";
|
|
@@ -2348,6 +2352,8 @@ function run(command, args, options = {}) {
|
|
|
2348
2352
|
});
|
|
2349
2353
|
child.stderr?.on("data", (chunk) => {
|
|
2350
2354
|
const text = chunk.toString();
|
|
2355
|
+
if (options.interactive)
|
|
2356
|
+
process.stderr.write(text);
|
|
2351
2357
|
if (unbounded)
|
|
2352
2358
|
stderrChunks.push(text);
|
|
2353
2359
|
else
|
|
@@ -2376,32 +2382,52 @@ function appendBounded(current, next) {
|
|
|
2376
2382
|
return combined.length <= MAX_CAPTURED_OUTPUT_LENGTH ? combined : combined.slice(-MAX_CAPTURED_OUTPUT_LENGTH);
|
|
2377
2383
|
}
|
|
2378
2384
|
|
|
2379
|
-
// src/
|
|
2380
|
-
import {
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
})
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2385
|
+
// src/plan/store.ts
|
|
2386
|
+
import { mkdir as mkdir3, readdir, readFile as readFile3, realpath as realpath3, rm as rm2 } from "node:fs/promises";
|
|
2387
|
+
import { basename as basename3, dirname as dirname4, join as join7, resolve as resolve3, sep } from "node:path";
|
|
2388
|
+
|
|
2389
|
+
// src/extensions/fsx.ts
|
|
2390
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
2391
|
+
import { basename, dirname, join } from "node:path";
|
|
2392
|
+
async function atomicWrite(path, content) {
|
|
2393
|
+
const temp = join(dirname(path), `.${basename(path)}.${crypto.randomUUID()}.tmp`);
|
|
2394
|
+
await writeFile(temp, content, { encoding: "utf8", mode: 384, flag: "wx" });
|
|
2395
|
+
await rename(temp, path);
|
|
2396
|
+
}
|
|
2397
|
+
async function withDirectoryLock(lockDir, operation, options = {}) {
|
|
2398
|
+
const waitMs = options.waitMs ?? 5000;
|
|
2399
|
+
const pollMs = options.pollMs ?? 25;
|
|
2400
|
+
const started = Date.now();
|
|
2401
|
+
while (true) {
|
|
2402
|
+
try {
|
|
2403
|
+
await mkdir(lockDir);
|
|
2404
|
+
break;
|
|
2405
|
+
} catch (error) {
|
|
2406
|
+
if (error.code !== "EEXIST")
|
|
2407
|
+
throw error;
|
|
2408
|
+
if (Date.now() - started >= waitMs)
|
|
2409
|
+
throw new Error(`Timed out waiting to ${options.operation ?? "acquire lock"}: ${lockDir}`);
|
|
2410
|
+
await sleep(pollMs);
|
|
2411
|
+
}
|
|
2412
|
+
}
|
|
2413
|
+
try {
|
|
2414
|
+
return await operation();
|
|
2415
|
+
} finally {
|
|
2416
|
+
await rm(lockDir, { recursive: true, force: true });
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
function sleep(ms) {
|
|
2420
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2398
2421
|
}
|
|
2399
2422
|
|
|
2400
|
-
// src/
|
|
2401
|
-
import {
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2423
|
+
// src/extensions/zodx.ts
|
|
2424
|
+
import { z } from "zod";
|
|
2425
|
+
var zx = {
|
|
2426
|
+
cwd: z.string().optional(),
|
|
2427
|
+
id: z.string().min(1).max(80).regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
|
|
2428
|
+
revision: z.number().int().nonnegative(),
|
|
2429
|
+
text: z.string().trim().min(1)
|
|
2430
|
+
};
|
|
2405
2431
|
|
|
2406
2432
|
// src/log.ts
|
|
2407
2433
|
import { randomUUID } from "node:crypto";
|
|
@@ -2418,229 +2444,167 @@ async function appendLogEntry(logPath, event) {
|
|
|
2418
2444
|
return entry;
|
|
2419
2445
|
}
|
|
2420
2446
|
|
|
2421
|
-
// src/
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
}
|
|
2447
|
+
// src/store.ts
|
|
2448
|
+
import { createHash } from "node:crypto";
|
|
2449
|
+
import { lstat, mkdir as mkdir2, readlink, realpath as realpath2, symlink, unlink } from "node:fs/promises";
|
|
2450
|
+
import { homedir } from "node:os";
|
|
2451
|
+
import { dirname as dirname2, isAbsolute as isAbsolute2, join as join3, resolve as resolve2 } from "node:path";
|
|
2425
2452
|
|
|
2426
|
-
// src/
|
|
2427
|
-
import {
|
|
2428
|
-
import {
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
const
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2453
|
+
// src/extensions/gitx.ts
|
|
2454
|
+
import { realpath } from "node:fs/promises";
|
|
2455
|
+
import { basename as basename2, isAbsolute, join as join2, resolve } from "node:path";
|
|
2456
|
+
async function gitToplevel(cwd) {
|
|
2457
|
+
const result = await run("git", ["-C", cwd, "rev-parse", "--show-toplevel"]);
|
|
2458
|
+
const top = result.stdout.trim();
|
|
2459
|
+
return result.code === 0 && top ? top : resolve(cwd);
|
|
2460
|
+
}
|
|
2461
|
+
async function inspectGitRepository(cwd) {
|
|
2462
|
+
const root = await gitToplevel(cwd);
|
|
2463
|
+
const remoteResult = await run("git", ["-C", root, "remote", "get-url", "origin"]);
|
|
2464
|
+
const remote = remoteResult.code === 0 ? remoteResult.stdout.trim() : "";
|
|
2465
|
+
const commonResult = await run("git", ["-C", root, "rev-parse", "--git-common-dir"]);
|
|
2466
|
+
const common = commonResult.stdout.trim();
|
|
2467
|
+
const commonPath = commonResult.code === 0 && common ? resolve(isAbsolute(common) ? common : join2(root, common)) : root;
|
|
2468
|
+
const commonDir = await canonicalPath(commonPath);
|
|
2469
|
+
return {
|
|
2470
|
+
root,
|
|
2471
|
+
commonDir,
|
|
2472
|
+
...remote ? { remote } : {},
|
|
2473
|
+
name: remote ? repositoryName(remote) : basename2(resolve(commonDir, "..")) || basename2(root),
|
|
2474
|
+
identity: remote ? `remote:${normalizeRemote(remote)}` : `git-common-dir:${commonDir}`
|
|
2441
2475
|
};
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
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
|
-
}
|
|
2476
|
+
}
|
|
2477
|
+
function normalizeRemote(remote) {
|
|
2478
|
+
return remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "").toLowerCase();
|
|
2479
|
+
}
|
|
2480
|
+
function repositoryName(remote) {
|
|
2481
|
+
const normalized = remote.trim().replace(/\.git\/?$/i, "").replace(/\/+$/, "");
|
|
2482
|
+
return normalized.split(/[/\\:]/).filter(Boolean).at(-1) ?? "";
|
|
2483
|
+
}
|
|
2484
|
+
async function canonicalPath(path) {
|
|
2462
2485
|
try {
|
|
2463
|
-
return await
|
|
2464
|
-
}
|
|
2465
|
-
|
|
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 });
|
|
2486
|
+
return await realpath(path);
|
|
2487
|
+
} catch {
|
|
2488
|
+
return resolve(path);
|
|
2471
2489
|
}
|
|
2472
2490
|
}
|
|
2473
2491
|
|
|
2474
|
-
// src/
|
|
2475
|
-
var
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
const
|
|
2485
|
-
const
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
const state = await withPlanLock(record.dir, async () => {
|
|
2492
|
-
const previous = await readAnnotationState(record).catch((error) => {
|
|
2493
|
-
if (error.code === "ENOENT")
|
|
2494
|
-
return;
|
|
2492
|
+
// src/store.ts
|
|
2493
|
+
var STORE_LINK = ".diffpi";
|
|
2494
|
+
var LEGACY_STORE_LINK = join3(".pi", "diffpi");
|
|
2495
|
+
function storeGlobalRoot(homeDir = homedir()) {
|
|
2496
|
+
return join3(homeDir, ".difflab", "diffpi", "projects");
|
|
2497
|
+
}
|
|
2498
|
+
async function ensureStore(cwd, homeDir = homedir()) {
|
|
2499
|
+
const repository = await inspectGitRepository(cwd);
|
|
2500
|
+
const root = repository.root;
|
|
2501
|
+
const slug = projectSlug(repository);
|
|
2502
|
+
const dest = join3(storeGlobalRoot(homeDir), slug);
|
|
2503
|
+
const link = join3(root, STORE_LINK);
|
|
2504
|
+
await mkdir2(dest, { recursive: true });
|
|
2505
|
+
try {
|
|
2506
|
+
await assertStoreLink(link, dest);
|
|
2507
|
+
} catch (error) {
|
|
2508
|
+
if (error.code !== "ENOENT")
|
|
2495
2509
|
throw error;
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
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 };
|
|
2510
|
+
await symlink(dest, link);
|
|
2511
|
+
}
|
|
2512
|
+
await removeLegacyStoreLink(join3(root, LEGACY_STORE_LINK), dest);
|
|
2513
|
+
return { slug, root, dest, link, linked: true };
|
|
2507
2514
|
}
|
|
2508
|
-
async function
|
|
2509
|
-
const
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
let raw;
|
|
2515
|
+
async function plansDir(cwd, homeDir = homedir()) {
|
|
2516
|
+
const store = await ensureStore(cwd, homeDir);
|
|
2517
|
+
const dir = join3(store.link, "plan");
|
|
2518
|
+
await mkdir2(dir, { recursive: true });
|
|
2519
|
+
return dir;
|
|
2520
|
+
}
|
|
2521
|
+
async function assertStoreLink(path, dest) {
|
|
2522
|
+
const entry = await lstat(path);
|
|
2523
|
+
if (!entry.isSymbolicLink())
|
|
2524
|
+
throw new Error(`${path} exists and is not a symlink.`);
|
|
2525
|
+
const target = await symlinkTarget(path);
|
|
2526
|
+
if (target !== await canonicalPath2(dest))
|
|
2527
|
+
throw new Error(`${path} points to ${target}, not ${dest}.`);
|
|
2528
|
+
}
|
|
2529
|
+
async function removeLegacyStoreLink(path, dest) {
|
|
2524
2530
|
try {
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2531
|
+
const entry = await lstat(path);
|
|
2532
|
+
if (!entry.isSymbolicLink())
|
|
2533
|
+
return;
|
|
2534
|
+
const target = await symlinkTarget(path);
|
|
2535
|
+
if (target === await canonicalPath2(dest))
|
|
2536
|
+
await unlink(path);
|
|
2537
|
+
} catch (error) {
|
|
2538
|
+
if (error.code !== "ENOENT")
|
|
2539
|
+
throw error;
|
|
2528
2540
|
}
|
|
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" });
|
|
2558
2541
|
}
|
|
2559
|
-
function
|
|
2560
|
-
|
|
2542
|
+
async function symlinkTarget(path) {
|
|
2543
|
+
const target = await readlink(path);
|
|
2544
|
+
return canonicalPath2(isAbsolute2(target) ? target : resolve2(dirname2(path), target));
|
|
2561
2545
|
}
|
|
2562
|
-
async function
|
|
2563
|
-
const source = await readFile3(annotationStatePath(record), "utf8");
|
|
2564
|
-
let value;
|
|
2546
|
+
async function canonicalPath2(path) {
|
|
2565
2547
|
try {
|
|
2566
|
-
|
|
2548
|
+
return await realpath2(path);
|
|
2567
2549
|
} catch {
|
|
2568
|
-
|
|
2569
|
-
}
|
|
2570
|
-
if (value.schemaVersion !== 1 || typeof value.sessionSlug !== "string" || !Array.isArray(value.appliedCommentIds)) {
|
|
2571
|
-
throw new Error(`Malformed annotation state: ${annotationStatePath(record)}.`);
|
|
2550
|
+
return resolve2(path);
|
|
2572
2551
|
}
|
|
2573
|
-
return value;
|
|
2574
2552
|
}
|
|
2575
|
-
function
|
|
2576
|
-
const
|
|
2577
|
-
|
|
2578
|
-
|
|
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
|
-
};
|
|
2553
|
+
function projectSlug(repository) {
|
|
2554
|
+
const readable = repository.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
|
|
2555
|
+
const digest = createHash("sha256").update(repository.identity).digest("hex").slice(0, 12);
|
|
2556
|
+
return `${readable}-${digest}`;
|
|
2596
2557
|
}
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
child.on("close", (code) => resolve({ code: code ?? 1, stdout: "", stderr }));
|
|
2612
|
-
});
|
|
2558
|
+
|
|
2559
|
+
// src/templates.ts
|
|
2560
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
2561
|
+
import { homedir as homedir2 } from "node:os";
|
|
2562
|
+
import { join as join5, normalize } from "node:path";
|
|
2563
|
+
|
|
2564
|
+
// src/assets.ts
|
|
2565
|
+
import { existsSync } from "node:fs";
|
|
2566
|
+
import { dirname as dirname3, join as join4 } from "node:path";
|
|
2567
|
+
import { fileURLToPath } from "node:url";
|
|
2568
|
+
function resolveBundledAssetDir(name, moduleUrl = import.meta.url) {
|
|
2569
|
+
const moduleDir = dirname3(fileURLToPath(moduleUrl));
|
|
2570
|
+
const candidates = [join4(moduleDir, name), join4(moduleDir, "..", name), join4(moduleDir, "..", "..", name)];
|
|
2571
|
+
return candidates.find((path) => existsSync(path)) ?? candidates[1];
|
|
2613
2572
|
}
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
await writeFile2(temp, `${JSON.stringify(value, null, 2)}
|
|
2617
|
-
`, { mode: 384, flag: "wx" });
|
|
2618
|
-
await rename(temp, path);
|
|
2573
|
+
function resolveBundledTemplatesDir(moduleUrl = import.meta.url) {
|
|
2574
|
+
return resolveBundledAssetDir("templates", moduleUrl);
|
|
2619
2575
|
}
|
|
2620
2576
|
|
|
2621
|
-
// src/
|
|
2622
|
-
function
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2577
|
+
// src/templates.ts
|
|
2578
|
+
async function loadTemplate(name, options = {}) {
|
|
2579
|
+
const relative = templateRelativePath(name);
|
|
2580
|
+
const userPath = join5(options.homeDir ?? homedir2(), ".difflab", "diffpi", "templates", relative);
|
|
2581
|
+
const bundledPath = join5(options.bundledDir ?? resolveBundledTemplatesDir(), relative);
|
|
2582
|
+
const user = await readOptionalFile(userPath);
|
|
2583
|
+
if (user !== undefined)
|
|
2584
|
+
return { name, path: userPath, source: "user", content: user };
|
|
2585
|
+
const bundled = await readOptionalFile(bundledPath);
|
|
2586
|
+
if (bundled !== undefined)
|
|
2587
|
+
return { name, path: bundledPath, source: "bundled", content: bundled };
|
|
2588
|
+
throw new Error(`Template "${name}" was not found at ${userPath} or ${bundledPath}.`);
|
|
2589
|
+
}
|
|
2590
|
+
function renderTemplate(content, variables) {
|
|
2591
|
+
return content.replace(/\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/g, (match, key) => variables[key] ?? match);
|
|
2592
|
+
}
|
|
2593
|
+
function templateRelativePath(name) {
|
|
2594
|
+
const normalized = normalize(name.replaceAll("\\", "/")).replace(/^\.\//, "");
|
|
2595
|
+
if (!normalized || normalized.startsWith("../") || normalized.includes("/../") || normalized.startsWith("/")) {
|
|
2596
|
+
throw new Error(`Invalid template name: ${name}`);
|
|
2597
|
+
}
|
|
2598
|
+
return normalized.endsWith(".md") ? normalized : `${normalized}.md`;
|
|
2634
2599
|
}
|
|
2635
|
-
function
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
].join(" ");
|
|
2600
|
+
async function readOptionalFile(path) {
|
|
2601
|
+
try {
|
|
2602
|
+
return await readFile2(path, "utf8");
|
|
2603
|
+
} catch (error) {
|
|
2604
|
+
if (error.code === "ENOENT")
|
|
2605
|
+
return;
|
|
2606
|
+
throw error;
|
|
2607
|
+
}
|
|
2644
2608
|
}
|
|
2645
2609
|
|
|
2646
2610
|
// src/plan/markdown.ts
|
|
@@ -3989,7 +3953,7 @@ var handle = {
|
|
|
3989
3953
|
};
|
|
3990
3954
|
|
|
3991
3955
|
// ../../node_modules/mdast-util-to-markdown/lib/join.js
|
|
3992
|
-
var
|
|
3956
|
+
var join6 = [joinDefaults];
|
|
3993
3957
|
function joinDefaults(left, right, parent, state) {
|
|
3994
3958
|
if (right.type === "code" && formatCodeAsIndented(right, state) && (left.type === "list" || left.type === right.type && formatCodeAsIndented(left, state))) {
|
|
3995
3959
|
return false;
|
|
@@ -6535,7 +6499,7 @@ function toMarkdown(tree, options) {
|
|
|
6535
6499
|
handle: undefined,
|
|
6536
6500
|
indentLines,
|
|
6537
6501
|
indexStack: [],
|
|
6538
|
-
join: [...
|
|
6502
|
+
join: [...join6],
|
|
6539
6503
|
options: {},
|
|
6540
6504
|
safe: safeBound,
|
|
6541
6505
|
stack: [],
|
|
@@ -6839,7 +6803,7 @@ import { default as default2 } from "node:path";
|
|
|
6839
6803
|
import { default as default3 } from "node:process";
|
|
6840
6804
|
|
|
6841
6805
|
// ../../node_modules/vfile/lib/minurl.js
|
|
6842
|
-
import { fileURLToPath } from "node:url";
|
|
6806
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
6843
6807
|
|
|
6844
6808
|
// ../../node_modules/vfile/lib/minurl.shared.js
|
|
6845
6809
|
function isUrl(fileUrlOrPath) {
|
|
@@ -6925,7 +6889,7 @@ class VFile {
|
|
|
6925
6889
|
}
|
|
6926
6890
|
set path(path) {
|
|
6927
6891
|
if (isUrl(path)) {
|
|
6928
|
-
path =
|
|
6892
|
+
path = fileURLToPath2(path);
|
|
6929
6893
|
}
|
|
6930
6894
|
assertNonEmpty(path, "path");
|
|
6931
6895
|
if (this.path !== path) {
|
|
@@ -7280,25 +7244,6 @@ function looksLikeAValue(value) {
|
|
|
7280
7244
|
function isUint8Array2(value) {
|
|
7281
7245
|
return Boolean(value && typeof value === "object" && "byteLength" in value && "byteOffset" in value);
|
|
7282
7246
|
}
|
|
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
|
-
|
|
7302
7247
|
// src/plan/markdown.ts
|
|
7303
7248
|
var PLAN_MARKER = /<!-- diffpi-plan: (\{[^\n]+\}) -->/;
|
|
7304
7249
|
var PHASE_MARKER = /<!-- diffpi-phase: (\{[^\n]+\}) -->/g;
|
|
@@ -7346,8 +7291,6 @@ function validatePlanDocument(plan, options = {}) {
|
|
|
7346
7291
|
add("design-too-long", "error", `Design is ${designWords} words; finalization allows at most 800.`);
|
|
7347
7292
|
if (options.strict && plan.phases.length === 0)
|
|
7348
7293
|
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.`);
|
|
7351
7294
|
return issues;
|
|
7352
7295
|
}
|
|
7353
7296
|
function renderPlanDocument(plan, previousSource) {
|
|
@@ -7689,6 +7632,19 @@ function parseCsv(value) {
|
|
|
7689
7632
|
return [];
|
|
7690
7633
|
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
7691
7634
|
}
|
|
7635
|
+
function assertStableId(value, label) {
|
|
7636
|
+
if (!zx.id.safeParse(value).success)
|
|
7637
|
+
throw new Error(`${label} must be a lowercase stable slug, not a path: ${value}`);
|
|
7638
|
+
}
|
|
7639
|
+
function assertUniqueIds(ids, label) {
|
|
7640
|
+
const seen = new Set;
|
|
7641
|
+
for (const id of ids) {
|
|
7642
|
+
assertStableId(id, label);
|
|
7643
|
+
if (seen.has(id))
|
|
7644
|
+
throw new Error(`Duplicate ${label}: ${id}.`);
|
|
7645
|
+
seen.add(id);
|
|
7646
|
+
}
|
|
7647
|
+
}
|
|
7692
7648
|
function cleanPlaceholder(value) {
|
|
7693
7649
|
return value.replace(/<!--[^]*?-->/g, "").trim();
|
|
7694
7650
|
}
|
|
@@ -7720,173 +7676,6 @@ function escapeRegExp(value) {
|
|
|
7720
7676
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
7721
7677
|
}
|
|
7722
7678
|
|
|
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);
|
|
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 });
|
|
7785
|
-
try {
|
|
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;
|
|
7887
|
-
}
|
|
7888
|
-
}
|
|
7889
|
-
|
|
7890
7679
|
// src/plan/store.ts
|
|
7891
7680
|
function planRecordName(shortSlug, date = new Date) {
|
|
7892
7681
|
const slug = normalizeSlug(shortSlug);
|
|
@@ -7902,7 +7691,7 @@ async function resolvePlan(cwd, query, filters = {}, options = {}) {
|
|
|
7902
7691
|
continue;
|
|
7903
7692
|
if (normalizedQuery && entry.name !== normalizedQuery && stripDate(entry.name) !== normalizedQuery)
|
|
7904
7693
|
continue;
|
|
7905
|
-
const dir =
|
|
7694
|
+
const dir = join7(root, entry.name);
|
|
7906
7695
|
await assertContained(root, dir);
|
|
7907
7696
|
try {
|
|
7908
7697
|
const record = await readRecord(dir);
|
|
@@ -7928,7 +7717,7 @@ function createPlanStore(options = {}) {
|
|
|
7928
7717
|
async init(input) {
|
|
7929
7718
|
const root = await plansDir(input.cwd, options.homeDir);
|
|
7930
7719
|
const id = planRecordName(input.shortSlug, now());
|
|
7931
|
-
const dir =
|
|
7720
|
+
const dir = join7(root, id);
|
|
7932
7721
|
await assertContained(root, dirname4(dir));
|
|
7933
7722
|
try {
|
|
7934
7723
|
await mkdir3(dir);
|
|
@@ -7948,25 +7737,19 @@ function createPlanStore(options = {}) {
|
|
|
7948
7737
|
branch: input.branch,
|
|
7949
7738
|
title: input.title?.trim() || titleFromSlug(input.shortSlug),
|
|
7950
7739
|
intent: input.intent?.trim() || "<!-- Describe the intended outcome. -->",
|
|
7740
|
+
issue_id: input.issueId?.trim() || "<!-- Add issue tracker ID. -->",
|
|
7741
|
+
issue_url: input.issueUrl?.trim() || "<!-- Add issue tracker URL. -->",
|
|
7951
7742
|
created_at: timestamp,
|
|
7952
7743
|
updated_at: timestamp
|
|
7953
7744
|
});
|
|
7954
|
-
const document = parsePlanDocument(source,
|
|
7955
|
-
await atomicWrite(
|
|
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
|
+
const document = parsePlanDocument(source, join7(dir, "PLAN.md"));
|
|
7746
|
+
await atomicWrite(join7(dir, "PLAN.md"), source);
|
|
7964
7747
|
return {
|
|
7965
7748
|
id,
|
|
7966
7749
|
dir,
|
|
7967
|
-
planPath:
|
|
7968
|
-
logPath:
|
|
7969
|
-
implementationDir:
|
|
7750
|
+
planPath: join7(dir, "PLAN.md"),
|
|
7751
|
+
logPath: join7(dir, "logs.txt"),
|
|
7752
|
+
implementationDir: join7(dir, "implementation"),
|
|
7970
7753
|
document,
|
|
7971
7754
|
source
|
|
7972
7755
|
};
|
|
@@ -7986,7 +7769,7 @@ function createPlanStore(options = {}) {
|
|
|
7986
7769
|
},
|
|
7987
7770
|
async mutate(cwd, query, operation, update) {
|
|
7988
7771
|
const initial = await this.read(cwd, query);
|
|
7989
|
-
return
|
|
7772
|
+
return withDirectoryLock(join7(initial.dir, ".lock"), async () => {
|
|
7990
7773
|
const current = await readRecord(initial.dir);
|
|
7991
7774
|
const next = await update(structuredClone(current.document));
|
|
7992
7775
|
if (next.id !== current.id)
|
|
@@ -8000,41 +7783,41 @@ function createPlanStore(options = {}) {
|
|
|
8000
7783
|
parsePlanDocument(source, current.planPath);
|
|
8001
7784
|
await atomicWrite(current.planPath, source);
|
|
8002
7785
|
await ensureImplementationFiles(current.dir, document, options);
|
|
8003
|
-
return { ...current, document, source, implementationDir:
|
|
7786
|
+
return { ...current, document, source, implementationDir: join7(current.dir, "implementation") };
|
|
8004
7787
|
}, { operation });
|
|
8005
7788
|
},
|
|
8006
7789
|
async log(cwd, query, event) {
|
|
8007
7790
|
const record = await this.read(cwd, query);
|
|
8008
|
-
return
|
|
7791
|
+
return appendLogEntry(record.logPath, event);
|
|
8009
7792
|
}
|
|
8010
7793
|
};
|
|
8011
7794
|
}
|
|
8012
7795
|
async function readRecord(dir) {
|
|
8013
|
-
const planPath =
|
|
8014
|
-
const logPath =
|
|
8015
|
-
const source = await
|
|
7796
|
+
const planPath = join7(dir, "PLAN.md");
|
|
7797
|
+
const logPath = join7(dir, "logs.txt");
|
|
7798
|
+
const source = await readFile3(planPath, "utf8");
|
|
8016
7799
|
const document = parsePlanDocument(source, planPath);
|
|
8017
7800
|
return {
|
|
8018
7801
|
id: basename3(dir),
|
|
8019
7802
|
dir,
|
|
8020
7803
|
planPath,
|
|
8021
7804
|
logPath,
|
|
8022
|
-
implementationDir:
|
|
7805
|
+
implementationDir: join7(dir, "implementation"),
|
|
8023
7806
|
document,
|
|
8024
7807
|
source
|
|
8025
7808
|
};
|
|
8026
7809
|
}
|
|
8027
7810
|
async function ensureImplementationFiles(dir, document, options) {
|
|
8028
|
-
const implementationDir =
|
|
7811
|
+
const implementationDir = join7(dir, "implementation");
|
|
8029
7812
|
await mkdir3(implementationDir, { recursive: true });
|
|
8030
7813
|
const template = await loadTemplate("plan/implementation", {
|
|
8031
7814
|
homeDir: options.homeDir,
|
|
8032
7815
|
bundledDir: options.bundledTemplatesDir
|
|
8033
7816
|
});
|
|
8034
7817
|
for (const phase of document.phases) {
|
|
8035
|
-
const path =
|
|
7818
|
+
const path = join7(implementationDir, `phase-${phase.id}.md`);
|
|
8036
7819
|
try {
|
|
8037
|
-
await
|
|
7820
|
+
await readFile3(path, "utf8");
|
|
8038
7821
|
} catch (error) {
|
|
8039
7822
|
if (error.code !== "ENOENT")
|
|
8040
7823
|
throw error;
|
|
@@ -8048,25 +7831,6 @@ async function ensureImplementationFiles(dir, document, options) {
|
|
|
8048
7831
|
}
|
|
8049
7832
|
}
|
|
8050
7833
|
}
|
|
8051
|
-
async function atomicWrite(path, content) {
|
|
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 {}
|
|
8069
|
-
}
|
|
8070
7834
|
async function assertContained(root, candidate) {
|
|
8071
7835
|
const canonicalRoot = await realpath3(root);
|
|
8072
7836
|
let canonicalCandidate;
|
|
@@ -8084,7 +7848,8 @@ function normalizeSlug(value) {
|
|
|
8084
7848
|
throw new Error(`Invalid plan slug: ${value}.`);
|
|
8085
7849
|
}
|
|
8086
7850
|
const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
8087
|
-
|
|
7851
|
+
if (!zx.id.safeParse(slug).success)
|
|
7852
|
+
throw new Error(`Invalid plan slug: ${value}.`);
|
|
8088
7853
|
return slug;
|
|
8089
7854
|
}
|
|
8090
7855
|
function normalizeQuery(value) {
|
|
@@ -8099,369 +7864,99 @@ function stripDate(value) {
|
|
|
8099
7864
|
function titleFromSlug(value) {
|
|
8100
7865
|
return normalizeSlug(value).replace(/^(?:[a-z]+-\d+-)/, "").split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
8101
7866
|
}
|
|
8102
|
-
|
|
8103
|
-
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8124
|
-
|
|
8125
|
-
|
|
8126
|
-
|
|
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
|
-
};
|
|
7867
|
+
// src/plan/reviews.ts
|
|
7868
|
+
import { mkdir as mkdir4, readFile as readFile4, writeFile as writeFile2 } from "node:fs/promises";
|
|
7869
|
+
import { dirname as dirname5, join as join8 } from "node:path";
|
|
7870
|
+
async function createPlanReview(record, runtime = {}) {
|
|
7871
|
+
if (await readPlanReview(record))
|
|
7872
|
+
throw new Error(`Plan revision ${record.document.revision} already has a review.`);
|
|
7873
|
+
const execute = runtime.execute ?? ((command, args, cwd, interactive) => run(command, args, { cwd, capture: "unbounded", interactive }));
|
|
7874
|
+
const result = await execute("tuicr", ["--file", record.dir], record.dir, true);
|
|
7875
|
+
const sourceId = [...result.stderr.matchAll(/^tuicr-session:\s*(\S+)\s*$/gm)].at(-1)?.[1];
|
|
7876
|
+
if (result.code !== 0)
|
|
7877
|
+
throw new Error(result.stderr.trim() || `tuicr exited with status ${result.code}.`);
|
|
7878
|
+
if (!sourceId)
|
|
7879
|
+
throw new Error("tuicr did not report a tuicr-session marker.");
|
|
7880
|
+
const response = await execute("tuicr", ["review", "comments", "--session", sourceId], record.dir, false);
|
|
7881
|
+
if (response.code !== 0)
|
|
7882
|
+
throw new Error(response.stderr.trim() || "Cannot read the tuicr review.");
|
|
7883
|
+
const review = {
|
|
7884
|
+
path: reviewPath(record),
|
|
7885
|
+
dump: {
|
|
7886
|
+
schemaVersion: 1,
|
|
7887
|
+
planId: record.id,
|
|
7888
|
+
planRevision: record.document.revision,
|
|
7889
|
+
source: "tuicr",
|
|
7890
|
+
sourceId,
|
|
7891
|
+
planSource: record.source,
|
|
7892
|
+
content: response.stdout
|
|
8154
7893
|
}
|
|
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;
|
|
8323
|
-
}
|
|
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");
|
|
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
7894
|
};
|
|
7895
|
+
await writeReview(review);
|
|
7896
|
+
return { code: result.code, review };
|
|
8417
7897
|
}
|
|
8418
|
-
function
|
|
8419
|
-
|
|
8420
|
-
|
|
8421
|
-
|
|
8422
|
-
|
|
8423
|
-
|
|
8424
|
-
|
|
8425
|
-
|
|
8426
|
-
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
|
|
8430
|
-
|
|
8431
|
-
}
|
|
7898
|
+
async function readPlanReview(record) {
|
|
7899
|
+
const path = reviewPath(record);
|
|
7900
|
+
let source;
|
|
7901
|
+
try {
|
|
7902
|
+
source = await readFile4(path, "utf8");
|
|
7903
|
+
} catch (error) {
|
|
7904
|
+
if (error.code === "ENOENT")
|
|
7905
|
+
return;
|
|
7906
|
+
throw error;
|
|
7907
|
+
}
|
|
7908
|
+
let dump;
|
|
7909
|
+
try {
|
|
7910
|
+
dump = JSON.parse(source);
|
|
7911
|
+
} catch {
|
|
7912
|
+
throw new Error(`Malformed plan review: ${path}.`);
|
|
7913
|
+
}
|
|
7914
|
+
if (!isPlanReviewDump(dump))
|
|
7915
|
+
throw new Error(`Malformed plan review: ${path}.`);
|
|
7916
|
+
return { path, dump };
|
|
7917
|
+
}
|
|
7918
|
+
function reviewPath(record) {
|
|
7919
|
+
return join8(record.dir, "reviews", `${record.document.revision}.json`);
|
|
7920
|
+
}
|
|
7921
|
+
async function writeReview(review) {
|
|
7922
|
+
await mkdir4(dirname5(review.path), { recursive: true });
|
|
7923
|
+
await writeFile2(review.path, `${JSON.stringify(review.dump, null, 2)}
|
|
7924
|
+
`, {
|
|
7925
|
+
encoding: "utf8",
|
|
7926
|
+
mode: 384,
|
|
7927
|
+
flag: "wx"
|
|
7928
|
+
});
|
|
8432
7929
|
}
|
|
8433
|
-
function
|
|
8434
|
-
|
|
7930
|
+
function isPlanReviewDump(value) {
|
|
7931
|
+
if (!value || typeof value !== "object")
|
|
7932
|
+
return false;
|
|
7933
|
+
const review = value;
|
|
7934
|
+
return review.schemaVersion === 1 && typeof review.planId === "string" && Number.isInteger(review.planRevision) && review.source === "tuicr" && typeof review.sourceId === "string" && typeof review.planSource === "string" && typeof review.content === "string";
|
|
8435
7935
|
}
|
|
7936
|
+
|
|
8436
7937
|
// src/cli/plan.ts
|
|
8437
|
-
var plans =
|
|
8438
|
-
|
|
8439
|
-
|
|
7938
|
+
var plans = createPlanStore();
|
|
7939
|
+
var runtime = { context: plans.context, createPlanReview };
|
|
7940
|
+
function createPlanCliCommand(io = process, planRuntime = runtime) {
|
|
7941
|
+
const program = new Command2().name("plan").description("Plan review commands").showHelpAfterError().exitOverride().configureOutput({
|
|
8440
7942
|
writeOut: (message) => io.stdout.write(message),
|
|
8441
7943
|
writeErr: (message) => io.stderr.write(message)
|
|
8442
7944
|
});
|
|
8443
|
-
|
|
8444
|
-
addAnnotationCommand(program, "annotations", io, controller);
|
|
7945
|
+
addReviewCommand(program, io, planRuntime);
|
|
8445
7946
|
return program;
|
|
8446
7947
|
}
|
|
8447
|
-
function
|
|
8448
|
-
program.command(
|
|
7948
|
+
function addReviewCommand(program, io, planRuntime) {
|
|
7949
|
+
program.command("annotate [plan]").description("Review a plan in tuicr and save the immutable plan review").option("--cwd <path>", "Repository working directory", process.cwd()).action(async (query, options) => {
|
|
8449
7950
|
const cwd = resolve4(options.cwd);
|
|
8450
|
-
const record = await resolveCliPlan(cwd, query,
|
|
8451
|
-
|
|
8452
|
-
|
|
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)}
|
|
7951
|
+
const record = await resolveCliPlan(cwd, query, planRuntime);
|
|
7952
|
+
const result = await planRuntime.createPlanReview(record);
|
|
7953
|
+
io.stdout.write(`Saved review for ${record.id} to ${result.review.path}.
|
|
8459
7954
|
`);
|
|
8460
7955
|
});
|
|
8461
7956
|
}
|
|
8462
|
-
async function resolveCliPlan(cwd, query,
|
|
7957
|
+
async function resolveCliPlan(cwd, query, planRuntime) {
|
|
8463
7958
|
const branch = query ? undefined : (await runChecked("git", ["-C", cwd, "branch", "--show-current"])).stdout.trim();
|
|
8464
|
-
const resolution = await
|
|
7959
|
+
const resolution = await planRuntime.context(cwd, query, query ? {} : { branch, statuses: ["draft", "ready", "in_progress", "blocked"] });
|
|
8465
7960
|
if (resolution.record)
|
|
8466
7961
|
return resolution.record;
|
|
8467
7962
|
if (resolution.ambiguous)
|