opencode-ship 1.1.7 → 1.1.9-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -1
- package/README.md +13 -13
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/agents/ship-controller.md +47 -22
- package/assets/agents/ship-final-spec-reviewer.md +11 -11
- package/assets/agents/ship-final-standards-reviewer.md +11 -11
- package/assets/agents/ship-plan.md +24 -5
- package/assets/agents/ship-planner.md +46 -20
- package/assets/agents/{delivery-reviewer.md → ship-reviewer.md} +16 -16
- package/assets/agents/ship-task-builder.md +11 -11
- package/assets/agents/ship-task-reviewer.md +12 -12
- package/assets/agents/{delivery-verifier.md → ship-verifier.md} +13 -13
- package/assets/commands/setup-ship-workflow.md +6 -5
- package/assets/commands/ship-deliver.md +27 -30
- package/assets/defaults/workflow-models.history.json +7 -0
- package/assets/defaults/workflow-models.json +5 -0
- package/assets/skills/brainstorming/SKILL.md +10 -7
- package/assets/skills/dispatching-parallel-agents/SKILL.md +1 -1
- package/assets/skills/engineering-workflow/SKILL.md +10 -4
- package/assets/skills/executing-plans/SKILL.md +18 -63
- package/assets/skills/planning-research-checkpoint/SKILL.md +16 -13
- package/assets/skills/receiving-code-review/SKILL.md +1 -1
- package/assets/skills/requesting-code-review/SKILL.md +1 -1
- package/assets/skills/setup-ship-workflow/SKILL.md +27 -33
- package/assets/skills/ship-workflow/SKILL.md +83 -0
- package/assets/skills/skill-discovery/SKILL.md +17 -89
- package/assets/skills/subagent-driven-development/SKILL.md +6 -2
- package/assets/skills/systematic-debugging/SKILL.md +1 -1
- package/assets/skills/test-driven-development/SKILL.md +1 -1
- package/assets/skills/verification-before-completion/SKILL.md +1 -1
- package/assets/skills/wayfinder/SKILL.md +7 -1
- package/assets/skills/writing-plans/SKILL.md +31 -20
- package/dist/cli.js +1482 -175
- package/dist/core.d.ts +18 -0
- package/dist/core.js +528 -11
- package/dist/plugin.js +18613 -17483
- package/package.json +2 -1
- package/schema/project-adapter.example.json +2 -2
- package/schema/project-opencode-shim.json +2 -0
- package/schema/ship-lock.schema.json +23 -2
- package/tests/plugin/expected-tools.mjs +48 -5
- package/tests/plugin/plugin-load.test.mjs +27 -5
- package/vendor/sources.json +21 -21
- package/assets/skills/delivery-workflow/SKILL.md +0 -64
package/dist/core.d.ts
CHANGED
|
@@ -100,6 +100,7 @@ export interface PullRequestSummary {
|
|
|
100
100
|
draft: boolean;
|
|
101
101
|
mergeable: "MERGEABLE" | "CONFLICTING" | "UNKNOWN";
|
|
102
102
|
mergeStateStatus: string;
|
|
103
|
+
state: "OPEN" | "CLOSED" | "MERGED" | "UNKNOWN";
|
|
103
104
|
merged: boolean;
|
|
104
105
|
mergedAt: string | null;
|
|
105
106
|
}
|
|
@@ -330,6 +331,12 @@ export interface CleanupToolInput {
|
|
|
330
331
|
taskId: string;
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
export interface AbandonToolInput {
|
|
335
|
+
taskId: string;
|
|
336
|
+
subject: string;
|
|
337
|
+
operationId?: string;
|
|
338
|
+
}
|
|
339
|
+
|
|
333
340
|
export type Envelope<TKind extends string, TData> = { kind: TKind } & TData;
|
|
334
341
|
|
|
335
342
|
// ---------------------------------------------------------------------------
|
|
@@ -581,3 +588,14 @@ export declare function createCleanupTool(deps: BaseToolDeps): (input: CleanupTo
|
|
|
581
588
|
| Envelope<"branch-delete-failed", { stderr: string }>
|
|
582
589
|
| Envelope<"unsafe-cleanup", { signals: readonly string[] }>
|
|
583
590
|
>;
|
|
591
|
+
|
|
592
|
+
export declare function createAbandonTool(deps: BaseToolDeps): (input: AbandonToolInput) => Promise<
|
|
593
|
+
| { contractVersion: 2; ok: true; kind: "abandon"; operationId: string; idempotent: boolean; data: {
|
|
594
|
+
taskId: string;
|
|
595
|
+
intentHash: string;
|
|
596
|
+
removedWorktree: boolean;
|
|
597
|
+
deletedBranch: boolean;
|
|
598
|
+
deletedManifest: boolean;
|
|
599
|
+
} }
|
|
600
|
+
| { contractVersion: 2; ok: false; kind: "abandon"; operationId: string; retryable: boolean; message: string; details: { kind: string } }
|
|
601
|
+
>;
|
package/dist/core.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// opencode-ship/core v1.1.
|
|
1
|
+
// opencode-ship/core v1.1.9-rc.1
|
|
2
2
|
|
|
3
3
|
// src/adapter.js
|
|
4
4
|
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
@@ -851,7 +851,7 @@ function validateGhArgv(argv) {
|
|
|
851
851
|
|
|
852
852
|
// src/drivers/gh-cli.js
|
|
853
853
|
function defaultRunner(cwd, env) {
|
|
854
|
-
return (args) => new Promise((
|
|
854
|
+
return (args) => new Promise((resolve11, reject) => {
|
|
855
855
|
const proc = spawn2("gh", args, {
|
|
856
856
|
cwd,
|
|
857
857
|
env,
|
|
@@ -863,7 +863,7 @@ function defaultRunner(cwd, env) {
|
|
|
863
863
|
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
864
864
|
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
865
865
|
proc.on("error", reject);
|
|
866
|
-
proc.on("close", (status) =>
|
|
866
|
+
proc.on("close", (status) => resolve11({ status: status ?? -1, stdout, stderr }));
|
|
867
867
|
});
|
|
868
868
|
}
|
|
869
869
|
function viewFields() {
|
|
@@ -1100,6 +1100,19 @@ Closes #${issueNumber}`;
|
|
|
1100
1100
|
]);
|
|
1101
1101
|
return fields.headRefOid;
|
|
1102
1102
|
},
|
|
1103
|
+
async readIssue({ repo, number }) {
|
|
1104
|
+
if (typeof number !== "number") throw new Error("readIssue: number is required");
|
|
1105
|
+
if (!parseRepoSlug(repo)) throw new Error(`readIssue: invalid repo slug ${repo}`);
|
|
1106
|
+
return ghJson(run, [
|
|
1107
|
+
"issue",
|
|
1108
|
+
"view",
|
|
1109
|
+
String(number),
|
|
1110
|
+
"--repo",
|
|
1111
|
+
repo,
|
|
1112
|
+
"--json",
|
|
1113
|
+
"title,body"
|
|
1114
|
+
]);
|
|
1115
|
+
},
|
|
1103
1116
|
async runCommand(argv) {
|
|
1104
1117
|
if (!Array.isArray(argv) || argv.length === 0) {
|
|
1105
1118
|
throw new Error("runCommand: argv must be a non-empty array");
|
|
@@ -1400,6 +1413,60 @@ function createInspectTool(deps) {
|
|
|
1400
1413
|
};
|
|
1401
1414
|
}
|
|
1402
1415
|
|
|
1416
|
+
// src/runtime/stages.js
|
|
1417
|
+
function progressLine(stage, extra = {}) {
|
|
1418
|
+
switch (stage) {
|
|
1419
|
+
case "setup":
|
|
1420
|
+
return "Setup: ready.";
|
|
1421
|
+
case "discover":
|
|
1422
|
+
return extra.count > 0 ? `Discover: ${extra.count} skills installed.` : "Discover: none (catalog only).";
|
|
1423
|
+
case "plan":
|
|
1424
|
+
return `Plan: ${extra.path}`;
|
|
1425
|
+
case "track":
|
|
1426
|
+
return `Track: issue #${extra.number}.`;
|
|
1427
|
+
case "build":
|
|
1428
|
+
return `Build: task ${extra.k}/${extra.n} ${extra.title}.`;
|
|
1429
|
+
case "review":
|
|
1430
|
+
return extra.ok ? "Review: pass." : "Review: fail (see notes).";
|
|
1431
|
+
case "verify":
|
|
1432
|
+
return extra.ok ? "Verify: pass." : "Verify: fail.";
|
|
1433
|
+
case "ready":
|
|
1434
|
+
return `Ready: PR #${extra.number}.`;
|
|
1435
|
+
case "merge":
|
|
1436
|
+
return `Merge: ${extra.sha}.`;
|
|
1437
|
+
case "cleanup":
|
|
1438
|
+
return "Cleanup: done.";
|
|
1439
|
+
default:
|
|
1440
|
+
return null;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
function nextLine(stage) {
|
|
1444
|
+
switch (stage) {
|
|
1445
|
+
case "setup":
|
|
1446
|
+
return "Next: tell me what you want to build.";
|
|
1447
|
+
case "discover":
|
|
1448
|
+
return "Next: I will use these skills while building.";
|
|
1449
|
+
case "plan":
|
|
1450
|
+
return "Next: say if this is the product you want.";
|
|
1451
|
+
case "approve":
|
|
1452
|
+
return "Next: start building now, or continue later.";
|
|
1453
|
+
case "track":
|
|
1454
|
+
return "Next: building starts.";
|
|
1455
|
+
case "build":
|
|
1456
|
+
case "review":
|
|
1457
|
+
case "verify":
|
|
1458
|
+
return "Next: I keep going.";
|
|
1459
|
+
case "ready":
|
|
1460
|
+
return "Next: say if I should merge it.";
|
|
1461
|
+
case "merge":
|
|
1462
|
+
return "Next: cleanup.";
|
|
1463
|
+
case "cleanup":
|
|
1464
|
+
return "Next: tell me what to build next.";
|
|
1465
|
+
default:
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1403
1470
|
// src/tools/delivery-issue.js
|
|
1404
1471
|
function createIssueTool(deps) {
|
|
1405
1472
|
return async function issue(input) {
|
|
@@ -1409,13 +1476,16 @@ function createIssueTool(deps) {
|
|
|
1409
1476
|
if (!input.branch) return { kind: "missing-input", field: "branch" };
|
|
1410
1477
|
const existing = await readManifest(deps.repoRoot, input.taskId);
|
|
1411
1478
|
if (existing) {
|
|
1479
|
+
const stage2 = "track";
|
|
1412
1480
|
return {
|
|
1413
1481
|
contractVersion: 1,
|
|
1414
1482
|
created: false,
|
|
1415
1483
|
issueNumber: existing.issueNumber,
|
|
1416
1484
|
issueUrl: `https://github.com/${deps.repoSlug}/issues/${existing.issueNumber}`,
|
|
1417
1485
|
manifestPath: "preserved",
|
|
1418
|
-
preserved: true
|
|
1486
|
+
preserved: true,
|
|
1487
|
+
progress: progressLine(stage2, { number: existing.issueNumber }),
|
|
1488
|
+
next: nextLine(stage2)
|
|
1419
1489
|
};
|
|
1420
1490
|
}
|
|
1421
1491
|
const ensured = await deps.driver.ensureIssue({
|
|
@@ -1453,12 +1523,15 @@ function createIssueTool(deps) {
|
|
|
1453
1523
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1454
1524
|
};
|
|
1455
1525
|
const path = await writeManifest(deps.repoRoot, next);
|
|
1526
|
+
const stage = "track";
|
|
1456
1527
|
return {
|
|
1457
1528
|
contractVersion: 1,
|
|
1458
1529
|
created: ensured.created,
|
|
1459
1530
|
issueNumber: ensured.summary.number,
|
|
1460
1531
|
issueUrl: ensured.summary.url,
|
|
1461
|
-
manifestPath: path
|
|
1532
|
+
manifestPath: path,
|
|
1533
|
+
progress: progressLine(stage, { number: ensured.summary.number }),
|
|
1534
|
+
next: nextLine(stage)
|
|
1462
1535
|
};
|
|
1463
1536
|
};
|
|
1464
1537
|
}
|
|
@@ -1532,6 +1605,9 @@ function createWorktreeTool(deps) {
|
|
|
1532
1605
|
expectedRoot: resolve7(deps.repoRoot, worktreeRoot)
|
|
1533
1606
|
};
|
|
1534
1607
|
}
|
|
1608
|
+
if (m.schemaVersion >= 2 && !m.workflowId) {
|
|
1609
|
+
return { kind: "missing-workflow-link", taskId: m.taskId };
|
|
1610
|
+
}
|
|
1535
1611
|
const remote = deps.remote ?? "origin";
|
|
1536
1612
|
const hasRemote = remoteExists(remote, deps.repoRoot);
|
|
1537
1613
|
if (hasRemote) {
|
|
@@ -2496,7 +2572,15 @@ function createReadyTool(deps) {
|
|
|
2496
2572
|
data: { headSha: prHead, taskId: input.taskId }
|
|
2497
2573
|
});
|
|
2498
2574
|
}
|
|
2499
|
-
|
|
2575
|
+
const stage = "ready";
|
|
2576
|
+
return {
|
|
2577
|
+
contractVersion: 1,
|
|
2578
|
+
manifestPath: path,
|
|
2579
|
+
pr: m.prNumber,
|
|
2580
|
+
workflowId,
|
|
2581
|
+
progress: progressLine(stage, { number: m.prNumber }),
|
|
2582
|
+
next: nextLine(stage)
|
|
2583
|
+
};
|
|
2500
2584
|
};
|
|
2501
2585
|
}
|
|
2502
2586
|
|
|
@@ -2558,7 +2642,16 @@ function createMergeTool(deps) {
|
|
|
2558
2642
|
data: { mergeSha: merged2.mergeSha ?? merged2.mergeCommitSha ?? merged2.headSha }
|
|
2559
2643
|
});
|
|
2560
2644
|
}
|
|
2561
|
-
|
|
2645
|
+
const stage = "merge";
|
|
2646
|
+
return {
|
|
2647
|
+
kind: "merge",
|
|
2648
|
+
contractVersion: 1,
|
|
2649
|
+
manifestPath: path,
|
|
2650
|
+
pr: m.prNumber,
|
|
2651
|
+
taskId: m.taskId,
|
|
2652
|
+
progress: progressLine(stage, { sha: merged2.headSha.slice(0, 7) }),
|
|
2653
|
+
next: nextLine(stage)
|
|
2654
|
+
};
|
|
2562
2655
|
}
|
|
2563
2656
|
if (pr.merged) {
|
|
2564
2657
|
return recordMerged(pr, `reconciled external merge as ${input.subject}`);
|
|
@@ -2770,16 +2863,439 @@ function createCleanupTool(deps) {
|
|
|
2770
2863
|
contractVersion: 1,
|
|
2771
2864
|
manifestPath: null,
|
|
2772
2865
|
removedPath: wtPath,
|
|
2773
|
-
bootstrapRecovery: isBootstrapRecovery
|
|
2866
|
+
bootstrapRecovery: isBootstrapRecovery,
|
|
2867
|
+
progress: progressLine("cleanup"),
|
|
2868
|
+
next: nextLine("cleanup")
|
|
2869
|
+
};
|
|
2870
|
+
};
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
// src/tools/delivery-abandon.js
|
|
2874
|
+
import { join as join10 } from "node:path";
|
|
2875
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
2876
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
2877
|
+
|
|
2878
|
+
// src/state/abandon-store.js
|
|
2879
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
2880
|
+
import { join as join8 } from "node:path";
|
|
2881
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
2882
|
+
var SAFE_ID_RE2 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
2883
|
+
var HASH_RE2 = /^[0-9a-f]{64}$/;
|
|
2884
|
+
function abandonDir(commonDir, taskId) {
|
|
2885
|
+
return join8(commonDir, "opencode-ship", "delivery", "abandoned", taskId);
|
|
2886
|
+
}
|
|
2887
|
+
function intentPathFor(commonDir, taskId) {
|
|
2888
|
+
return join8(abandonDir(commonDir, taskId), "intent.json");
|
|
2889
|
+
}
|
|
2890
|
+
function completionPathFor(commonDir, taskId) {
|
|
2891
|
+
return join8(abandonDir(commonDir, taskId), "completion.json");
|
|
2892
|
+
}
|
|
2893
|
+
async function readJsonOrNull2(path) {
|
|
2894
|
+
try {
|
|
2895
|
+
return JSON.parse(await readFile7(path, "utf8"));
|
|
2896
|
+
} catch (err) {
|
|
2897
|
+
if (err?.code === "ENOENT") return null;
|
|
2898
|
+
throw err;
|
|
2899
|
+
}
|
|
2900
|
+
}
|
|
2901
|
+
function withoutIntentHash(record) {
|
|
2902
|
+
const copy = { ...record ?? {} };
|
|
2903
|
+
delete copy.intentHash;
|
|
2904
|
+
return copy;
|
|
2905
|
+
}
|
|
2906
|
+
function hashAbandonIntent(record) {
|
|
2907
|
+
return createHash7("sha256").update(canonicalJson(withoutIntentHash(record)), "utf8").digest("hex");
|
|
2908
|
+
}
|
|
2909
|
+
async function readAbandon(repoRoot, taskId) {
|
|
2910
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
2911
|
+
if (!SAFE_ID_RE2.test(String(taskId ?? ""))) {
|
|
2912
|
+
return { intent: null, completion: null };
|
|
2913
|
+
}
|
|
2914
|
+
return {
|
|
2915
|
+
intent: await readJsonOrNull2(intentPathFor(commonDir, taskId)),
|
|
2916
|
+
completion: await readJsonOrNull2(completionPathFor(commonDir, taskId))
|
|
2917
|
+
};
|
|
2918
|
+
}
|
|
2919
|
+
async function publishOrReuse(path, record) {
|
|
2920
|
+
try {
|
|
2921
|
+
await publishImmutableJson(path, record);
|
|
2922
|
+
return { ok: true, record, idempotent: false };
|
|
2923
|
+
} catch (err) {
|
|
2924
|
+
const message = String(err?.message ?? err);
|
|
2925
|
+
if (!message.includes("already exists")) throw err;
|
|
2926
|
+
const existing = await readJsonOrNull2(path);
|
|
2927
|
+
if (existing && canonicalJson(existing) === canonicalJson(record)) {
|
|
2928
|
+
return { ok: true, record: existing, idempotent: true };
|
|
2929
|
+
}
|
|
2930
|
+
return { ok: false, kind: "abandon-conflict" };
|
|
2931
|
+
}
|
|
2932
|
+
}
|
|
2933
|
+
async function publishAbandonIntent(repoRoot, record) {
|
|
2934
|
+
const taskId = String(record?.taskId ?? "");
|
|
2935
|
+
if (!SAFE_ID_RE2.test(taskId)) {
|
|
2936
|
+
return { ok: false, kind: "invalid-task-id" };
|
|
2937
|
+
}
|
|
2938
|
+
const sealed = { ...withoutIntentHash(record), intentHash: hashAbandonIntent(record) };
|
|
2939
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
2940
|
+
return publishOrReuse(intentPathFor(commonDir, taskId), sealed);
|
|
2941
|
+
}
|
|
2942
|
+
async function publishAbandonCompletion(repoRoot, record) {
|
|
2943
|
+
const taskId = String(record?.taskId ?? "");
|
|
2944
|
+
const intentHash = String(record?.intentHash ?? "");
|
|
2945
|
+
if (!SAFE_ID_RE2.test(taskId)) {
|
|
2946
|
+
return { ok: false, kind: "invalid-task-id" };
|
|
2947
|
+
}
|
|
2948
|
+
if (!HASH_RE2.test(intentHash)) {
|
|
2949
|
+
return { ok: false, kind: "invalid-intent-hash" };
|
|
2950
|
+
}
|
|
2951
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
2952
|
+
return publishOrReuse(completionPathFor(commonDir, taskId), record);
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
// src/skills/worktree.js
|
|
2956
|
+
import { execFile } from "node:child_process";
|
|
2957
|
+
import { promises as fs, existsSync as existsSync7 } from "node:fs";
|
|
2958
|
+
import { resolve as resolve9, dirname as dirname3, sep, isAbsolute, join as join9 } from "node:path";
|
|
2959
|
+
function listRegisteredWorktrees(mainRepo) {
|
|
2960
|
+
return new Promise((resolveP, rejectP) => {
|
|
2961
|
+
execFile(
|
|
2962
|
+
"git",
|
|
2963
|
+
["-C", mainRepo, "worktree", "list", "--porcelain", "-z"],
|
|
2964
|
+
{ shell: false, maxBuffer: 1024 * 1024 },
|
|
2965
|
+
(err, stdout) => {
|
|
2966
|
+
if (err) return rejectP(err);
|
|
2967
|
+
const records = parsePorcelain(stdout);
|
|
2968
|
+
const mainRecord = records.shift();
|
|
2969
|
+
const mainPath = mainRecord?.worktree ? resolve9(mainRecord.worktree) : null;
|
|
2970
|
+
const linked = [];
|
|
2971
|
+
for (const r of records) {
|
|
2972
|
+
if (!r.worktree) continue;
|
|
2973
|
+
const p = resolve9(r.worktree);
|
|
2974
|
+
if (mainPath && p === mainPath) continue;
|
|
2975
|
+
linked.push({ path: p, branch: r.HEAD ?? null });
|
|
2976
|
+
}
|
|
2977
|
+
resolveP(linked);
|
|
2978
|
+
}
|
|
2979
|
+
);
|
|
2980
|
+
});
|
|
2981
|
+
}
|
|
2982
|
+
function parsePorcelain(text) {
|
|
2983
|
+
const tokens = text.split("\0");
|
|
2984
|
+
const out = [];
|
|
2985
|
+
let current = {};
|
|
2986
|
+
for (const tok of tokens) {
|
|
2987
|
+
if (tok.length === 0) {
|
|
2988
|
+
if (Object.keys(current).length > 0) {
|
|
2989
|
+
out.push(current);
|
|
2990
|
+
current = {};
|
|
2991
|
+
}
|
|
2992
|
+
continue;
|
|
2993
|
+
}
|
|
2994
|
+
const idx = tok.indexOf(" ");
|
|
2995
|
+
const key = idx === -1 ? tok : tok.slice(0, idx);
|
|
2996
|
+
const value = idx === -1 ? "" : tok.slice(idx + 1);
|
|
2997
|
+
if (key === "branch") {
|
|
2998
|
+
current.HEAD = value.startsWith("refs/heads/") ? value : `refs/heads/${value}`;
|
|
2999
|
+
} else {
|
|
3000
|
+
current[key] = value;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
if (Object.keys(current).length > 0) out.push(current);
|
|
3004
|
+
return out;
|
|
3005
|
+
}
|
|
3006
|
+
async function validateLinkedWorktree(mainRepo, worktreePath, options = {}) {
|
|
3007
|
+
const main = resolve9(mainRepo);
|
|
3008
|
+
if (!existsSync7(main)) {
|
|
3009
|
+
return { ok: false, kind: "missing", message: `main repository ${main} does not exist` };
|
|
3010
|
+
}
|
|
3011
|
+
if (!worktreePath) {
|
|
3012
|
+
return { ok: false, kind: "unlinked", message: "worktreePath is required" };
|
|
3013
|
+
}
|
|
3014
|
+
const wt = resolve9(worktreePath);
|
|
3015
|
+
if (!existsSync7(wt)) {
|
|
3016
|
+
return { ok: false, kind: "missing", message: `worktree ${wt} does not exist` };
|
|
3017
|
+
}
|
|
3018
|
+
const isCurrent = wt === main;
|
|
3019
|
+
if (isCurrent) {
|
|
3020
|
+
const gitEntry = options.allowCurrentLinked ? await fs.lstat(join9(main, ".git")).catch(() => null) : null;
|
|
3021
|
+
if (!gitEntry?.isFile()) {
|
|
3022
|
+
return { ok: false, kind: "main", message: "installs into the main worktree are forbidden" };
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
let cursor = wt;
|
|
3026
|
+
while (cursor !== dirname3(cursor)) {
|
|
3027
|
+
const stat2 = await fs.lstat(cursor).catch(() => null);
|
|
3028
|
+
if (stat2?.isSymbolicLink()) {
|
|
3029
|
+
return {
|
|
3030
|
+
ok: false,
|
|
3031
|
+
kind: "ancestor-symlink",
|
|
3032
|
+
message: `worktree path contains a symlink at ${cursor}`
|
|
3033
|
+
};
|
|
3034
|
+
}
|
|
3035
|
+
cursor = dirname3(cursor);
|
|
3036
|
+
}
|
|
3037
|
+
const real = await fs.realpath(wt).catch(() => null);
|
|
3038
|
+
if (real && real !== wt) {
|
|
3039
|
+
return {
|
|
3040
|
+
ok: false,
|
|
3041
|
+
kind: "symlink",
|
|
3042
|
+
message: `worktree ${wt} resolves through a symlink to ${real}`
|
|
3043
|
+
};
|
|
3044
|
+
}
|
|
3045
|
+
const linked = await listRegisteredWorktrees(main);
|
|
3046
|
+
const matched = linked.find((entry) => entry.path === wt);
|
|
3047
|
+
if (!matched) {
|
|
3048
|
+
return {
|
|
3049
|
+
ok: false,
|
|
3050
|
+
kind: "unlinked",
|
|
3051
|
+
message: `worktree ${wt} is not registered (git worktree list)`
|
|
2774
3052
|
};
|
|
3053
|
+
}
|
|
3054
|
+
return { ok: true, path: wt, registered: !!matched };
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// src/tools/envelope.js
|
|
3058
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
3059
|
+
var CONTRACT_VERSION = 2;
|
|
3060
|
+
function operationId(prefix = "op") {
|
|
3061
|
+
return `${prefix}-${Date.now().toString(36)}-${randomBytes2(4).toString("hex")}`;
|
|
3062
|
+
}
|
|
3063
|
+
function success(kind, data, options = {}) {
|
|
3064
|
+
if (typeof kind !== "string" || kind.length === 0) {
|
|
3065
|
+
throw new Error("envelope.success: kind must be a non-empty string");
|
|
3066
|
+
}
|
|
3067
|
+
return {
|
|
3068
|
+
contractVersion: CONTRACT_VERSION,
|
|
3069
|
+
ok: true,
|
|
3070
|
+
kind,
|
|
3071
|
+
operationId: options.operationId ?? operationId(kind),
|
|
3072
|
+
idempotent: options.idempotent !== false,
|
|
3073
|
+
data
|
|
3074
|
+
};
|
|
3075
|
+
}
|
|
3076
|
+
function failure(kind, message, options = {}) {
|
|
3077
|
+
if (typeof kind !== "string" || kind.length === 0) {
|
|
3078
|
+
throw new Error("envelope.failure: kind must be a non-empty string");
|
|
3079
|
+
}
|
|
3080
|
+
if (typeof message !== "string" || message.length === 0) {
|
|
3081
|
+
throw new Error("envelope.failure: message must be a non-empty string");
|
|
3082
|
+
}
|
|
3083
|
+
const details = options.details ?? {};
|
|
3084
|
+
return {
|
|
3085
|
+
contractVersion: CONTRACT_VERSION,
|
|
3086
|
+
ok: false,
|
|
3087
|
+
kind,
|
|
3088
|
+
operationId: options.operationId ?? operationId(`${kind}-err`),
|
|
3089
|
+
retryable: options.retryable === true,
|
|
3090
|
+
message,
|
|
3091
|
+
details
|
|
3092
|
+
};
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
// src/tools/delivery-abandon.js
|
|
3096
|
+
import { readFile as readFile8 } from "node:fs/promises";
|
|
3097
|
+
function runGit2(args, cwd) {
|
|
3098
|
+
return spawnSync4("git", args, {
|
|
3099
|
+
cwd,
|
|
3100
|
+
encoding: "utf8",
|
|
3101
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3102
|
+
env: process.env
|
|
3103
|
+
});
|
|
3104
|
+
}
|
|
3105
|
+
function defaultRemoveWorktree(repoRoot, path) {
|
|
3106
|
+
return runGit2(["worktree", "remove", path], repoRoot);
|
|
3107
|
+
}
|
|
3108
|
+
function defaultDeleteBranch(repoRoot, branch, expectedSha) {
|
|
3109
|
+
const args = ["update-ref", "-d", `refs/heads/${branch}`];
|
|
3110
|
+
if (expectedSha && /^[0-9a-f]{7,}$/i.test(expectedSha)) args.push(expectedSha);
|
|
3111
|
+
return runGit2(args, repoRoot);
|
|
3112
|
+
}
|
|
3113
|
+
function branchExists(repoRoot, branch) {
|
|
3114
|
+
return runGit2(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot).status === 0;
|
|
3115
|
+
}
|
|
3116
|
+
function remoteBranchHead(repoRoot, remote, branch) {
|
|
3117
|
+
const r = runGit2(["ls-remote", "--heads", remote, branch], repoRoot);
|
|
3118
|
+
if (r.status !== 0) return { ok: false, sha: null, present: false };
|
|
3119
|
+
const line = r.stdout.split("\n").find((row) => row.includes(`refs/heads/${branch}`));
|
|
3120
|
+
if (!line) return { ok: true, sha: null, present: false };
|
|
3121
|
+
return { ok: true, sha: line.split(/\s+/)[0], present: true };
|
|
3122
|
+
}
|
|
3123
|
+
function unpublishedAhead(repoRoot, remote, branch) {
|
|
3124
|
+
const r = runGit2(["rev-list", "--count", `${remote}/${branch}..${branch}`], repoRoot);
|
|
3125
|
+
if (r.status !== 0) return null;
|
|
3126
|
+
const n = parseInt(r.stdout.trim(), 10);
|
|
3127
|
+
return Number.isFinite(n) ? n : null;
|
|
3128
|
+
}
|
|
3129
|
+
function refuse(opId, kind, extras = {}) {
|
|
3130
|
+
return failure("abandon", kind, {
|
|
3131
|
+
operationId: opId,
|
|
3132
|
+
retryable: false,
|
|
3133
|
+
details: { kind, ...extras }
|
|
3134
|
+
});
|
|
3135
|
+
}
|
|
3136
|
+
async function readRunSnapshot(repoRoot, workflowId) {
|
|
3137
|
+
try {
|
|
3138
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
3139
|
+
const path = join10(opencodeShipStateDir(common), "runs", workflowId, "run.json");
|
|
3140
|
+
if (!existsSync8(path)) return null;
|
|
3141
|
+
return JSON.parse(await readFile8(path, "utf8"));
|
|
3142
|
+
} catch {
|
|
3143
|
+
return null;
|
|
3144
|
+
}
|
|
3145
|
+
}
|
|
3146
|
+
async function validateLiveAttempt({ deps, manifest, subject, opId }) {
|
|
3147
|
+
if (!manifest.prNumber) return refuse(opId, "missing-pr");
|
|
3148
|
+
if (!manifest.worktreePath) return refuse(opId, "missing-worktree-path");
|
|
3149
|
+
const pr = await deps.driver.readPullRequest({
|
|
3150
|
+
repo: deps.repoSlug,
|
|
3151
|
+
number: manifest.prNumber
|
|
3152
|
+
});
|
|
3153
|
+
if (pr.merged || pr.state === "MERGED") return refuse(opId, "pr-merged");
|
|
3154
|
+
if (pr.state !== "CLOSED") return refuse(opId, "pr-open");
|
|
3155
|
+
if (pr.headRefName && pr.headRefName !== manifest.branch) {
|
|
3156
|
+
return refuse(opId, "branch-mismatch", { expected: manifest.branch, received: pr.headRefName });
|
|
3157
|
+
}
|
|
3158
|
+
if (pr.baseRefName && pr.baseRefName !== manifest.baseBranch) {
|
|
3159
|
+
return refuse(opId, "base-mismatch", { expected: manifest.baseBranch, received: pr.baseRefName });
|
|
3160
|
+
}
|
|
3161
|
+
const linked = await validateLinkedWorktree(deps.repoRoot, manifest.worktreePath);
|
|
3162
|
+
if (!linked.ok) {
|
|
3163
|
+
return refuse(opId, "invalid-worktree", { reason: linked.kind, message: linked.message });
|
|
3164
|
+
}
|
|
3165
|
+
if (!isWorktreeClean(linked.path)) return refuse(opId, "dirty-worktree");
|
|
3166
|
+
if (isRebaseInProgress(linked.path)) return refuse(opId, "rebase-in-progress");
|
|
3167
|
+
const head = currentHead(linked.path);
|
|
3168
|
+
if (!head || head !== manifest.lastPrHeadSha || head !== pr.headSha) {
|
|
3169
|
+
return refuse(opId, "head-mismatch", {
|
|
3170
|
+
local: head ?? "",
|
|
3171
|
+
manifest: manifest.lastPrHeadSha ?? "",
|
|
3172
|
+
pr: pr.headSha ?? ""
|
|
3173
|
+
});
|
|
3174
|
+
}
|
|
3175
|
+
const remote = deps.remote ?? "origin";
|
|
3176
|
+
const remoteHead = remoteBranchHead(linked.path, remote, manifest.branch);
|
|
3177
|
+
if (remoteHead.ok && remoteHead.present && remoteHead.sha !== head) {
|
|
3178
|
+
return refuse(opId, "remote-diverged", { local: head, remote: remoteHead.sha });
|
|
3179
|
+
}
|
|
3180
|
+
const ahead = unpublishedAhead(linked.path, remote, manifest.branch);
|
|
3181
|
+
if (ahead !== null && ahead > 0) {
|
|
3182
|
+
return refuse(opId, "has-unpublished-commits", { ahead, branch: manifest.branch, remote });
|
|
3183
|
+
}
|
|
3184
|
+
if (manifest.workflowId) {
|
|
3185
|
+
const snapshot = await readRunSnapshot(deps.repoRoot, manifest.workflowId);
|
|
3186
|
+
if (snapshot?.state === "ready") return refuse(opId, "workflow-ready");
|
|
3187
|
+
if (snapshot?.state === "merged") return refuse(opId, "workflow-merged");
|
|
3188
|
+
}
|
|
3189
|
+
return {
|
|
3190
|
+
ok: true,
|
|
3191
|
+
intent: {
|
|
3192
|
+
schemaVersion: 1,
|
|
3193
|
+
taskId: manifest.taskId,
|
|
3194
|
+
issueNumber: manifest.issueNumber,
|
|
3195
|
+
prNumber: manifest.prNumber,
|
|
3196
|
+
branch: manifest.branch,
|
|
3197
|
+
worktreePath: linked.path,
|
|
3198
|
+
headSha: head,
|
|
3199
|
+
workflowId: manifest.workflowId ?? null,
|
|
3200
|
+
subject,
|
|
3201
|
+
requestedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3202
|
+
}
|
|
3203
|
+
};
|
|
3204
|
+
}
|
|
3205
|
+
async function resumeCleanup({ deps, intent, opId }) {
|
|
3206
|
+
const removeWorktree = deps.removeWorktree ?? defaultRemoveWorktree;
|
|
3207
|
+
const deleteBranch = deps.deleteBranch ?? defaultDeleteBranch;
|
|
3208
|
+
const removeManifest = deps.deleteManifest ?? deleteManifest;
|
|
3209
|
+
let removedWorktree = !existsSync8(intent.worktreePath);
|
|
3210
|
+
if (!removedWorktree) {
|
|
3211
|
+
let removed;
|
|
3212
|
+
try {
|
|
3213
|
+
removed = await Promise.resolve(removeWorktree(deps.repoRoot, intent.worktreePath));
|
|
3214
|
+
} catch (err) {
|
|
3215
|
+
return refuse(opId, "remove-failed", { stderr: String(err?.message ?? err) });
|
|
3216
|
+
}
|
|
3217
|
+
if (removed?.status !== 0 && existsSync8(intent.worktreePath)) {
|
|
3218
|
+
return refuse(opId, "remove-failed", { stderr: removed?.stderr ?? "" });
|
|
3219
|
+
}
|
|
3220
|
+
removedWorktree = !existsSync8(intent.worktreePath);
|
|
3221
|
+
}
|
|
3222
|
+
let deletedBranch = !branchExists(deps.repoRoot, intent.branch);
|
|
3223
|
+
if (!deletedBranch) {
|
|
3224
|
+
let deleted;
|
|
3225
|
+
try {
|
|
3226
|
+
deleted = await Promise.resolve(deleteBranch(deps.repoRoot, intent.branch, intent.headSha));
|
|
3227
|
+
} catch (err) {
|
|
3228
|
+
return refuse(opId, "branch-delete-failed", { stderr: String(err?.message ?? err) });
|
|
3229
|
+
}
|
|
3230
|
+
if (deleted?.status !== 0 && branchExists(deps.repoRoot, intent.branch)) {
|
|
3231
|
+
return refuse(opId, "branch-delete-failed", { stderr: deleted?.stderr ?? "" });
|
|
3232
|
+
}
|
|
3233
|
+
deletedBranch = !branchExists(deps.repoRoot, intent.branch);
|
|
3234
|
+
}
|
|
3235
|
+
try {
|
|
3236
|
+
await Promise.resolve(removeManifest(deps.repoRoot, intent.taskId));
|
|
3237
|
+
} catch (err) {
|
|
3238
|
+
return refuse(opId, "manifest-delete-failed", { stderr: String(err?.message ?? err) });
|
|
3239
|
+
}
|
|
3240
|
+
const remaining = await readManifest(deps.repoRoot, intent.taskId);
|
|
3241
|
+
const deletedManifest = remaining === null;
|
|
3242
|
+
const completion = {
|
|
3243
|
+
schemaVersion: 1,
|
|
3244
|
+
taskId: intent.taskId,
|
|
3245
|
+
intentHash: intent.intentHash,
|
|
3246
|
+
removedWorktree,
|
|
3247
|
+
deletedBranch,
|
|
3248
|
+
deletedManifest,
|
|
3249
|
+
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3250
|
+
};
|
|
3251
|
+
const published = await publishAbandonCompletion(deps.repoRoot, completion);
|
|
3252
|
+
if (!published.ok) return refuse(opId, published.kind);
|
|
3253
|
+
return success("abandon", {
|
|
3254
|
+
taskId: intent.taskId,
|
|
3255
|
+
intentHash: intent.intentHash,
|
|
3256
|
+
removedWorktree,
|
|
3257
|
+
deletedBranch,
|
|
3258
|
+
deletedManifest
|
|
3259
|
+
}, { operationId: opId, idempotent: published.idempotent === true });
|
|
3260
|
+
}
|
|
3261
|
+
function createAbandonTool(deps) {
|
|
3262
|
+
return async function abandon(input) {
|
|
3263
|
+
const opId = input.operationId ?? `abandon-${Date.now().toString(36)}`;
|
|
3264
|
+
const taskId = String(input.taskId ?? "");
|
|
3265
|
+
const subject = String(input.subject ?? "").trim();
|
|
3266
|
+
if (!taskId) return refuse(opId, "missing-input", { field: "taskId" });
|
|
3267
|
+
if (!subject) return refuse(opId, "missing-input", { field: "subject" });
|
|
3268
|
+
const existing = await readAbandon(deps.repoRoot, taskId);
|
|
3269
|
+
if (existing.completion) {
|
|
3270
|
+
return success("abandon", {
|
|
3271
|
+
taskId,
|
|
3272
|
+
intentHash: existing.completion.intentHash,
|
|
3273
|
+
removedWorktree: existing.completion.removedWorktree,
|
|
3274
|
+
deletedBranch: existing.completion.deletedBranch,
|
|
3275
|
+
deletedManifest: existing.completion.deletedManifest
|
|
3276
|
+
}, { operationId: opId, idempotent: true });
|
|
3277
|
+
}
|
|
3278
|
+
if (existing.intent) {
|
|
3279
|
+
if (existing.intent.subject !== subject || existing.intent.taskId !== taskId) {
|
|
3280
|
+
return refuse(opId, "abandon-conflict");
|
|
3281
|
+
}
|
|
3282
|
+
return resumeCleanup({ deps, intent: existing.intent, opId });
|
|
3283
|
+
}
|
|
3284
|
+
const manifest = await readManifest(deps.repoRoot, taskId);
|
|
3285
|
+
if (!manifest) return refuse(opId, "missing-manifest", { taskId });
|
|
3286
|
+
const validated = await validateLiveAttempt({ deps, manifest, subject, opId });
|
|
3287
|
+
if (!validated.ok) return validated;
|
|
3288
|
+
const publishedIntent = await publishAbandonIntent(deps.repoRoot, validated.intent);
|
|
3289
|
+
if (!publishedIntent.ok) return refuse(opId, publishedIntent.kind);
|
|
3290
|
+
return resumeCleanup({ deps, intent: publishedIntent.record, opId });
|
|
2775
3291
|
};
|
|
2776
3292
|
}
|
|
2777
3293
|
|
|
2778
3294
|
// src/version.js
|
|
2779
|
-
import { readFileSync, existsSync as
|
|
2780
|
-
import { dirname as
|
|
3295
|
+
import { readFileSync, existsSync as existsSync9 } from "node:fs";
|
|
3296
|
+
import { dirname as dirname4, resolve as resolve10 } from "node:path";
|
|
2781
3297
|
import { fileURLToPath } from "node:url";
|
|
2782
|
-
var PACKAGE_VERSION = "1.1.
|
|
3298
|
+
var PACKAGE_VERSION = "1.1.9-rc.1";
|
|
2783
3299
|
var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
|
|
2784
3300
|
export {
|
|
2785
3301
|
ADAPTER_CONTRACT_VERSION,
|
|
@@ -2793,6 +3309,7 @@ export {
|
|
|
2793
3309
|
bucketFor,
|
|
2794
3310
|
canTransition,
|
|
2795
3311
|
checkGates,
|
|
3312
|
+
createAbandonTool,
|
|
2796
3313
|
createCleanupTool,
|
|
2797
3314
|
createGhDriver,
|
|
2798
3315
|
createGhStub,
|