opencode-ship 1.1.5 → 1.1.8

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/dist/plugin.js CHANGED
@@ -1,4 +1,4 @@
1
- // opencode-ship v1.1.5
1
+ // opencode-ship v1.1.8
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
4
  var __esm = (fn, res) => function __init() {
@@ -12706,7 +12706,7 @@ tool.schema = external_exports;
12706
12706
 
12707
12707
  // src/plugin.js
12708
12708
  import { resolve as resolve22 } from "node:path";
12709
- import { readFile as readFile24 } from "node:fs/promises";
12709
+ import { readFile as readFile26 } from "node:fs/promises";
12710
12710
 
12711
12711
  // src/adapter.js
12712
12712
  import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
@@ -13740,6 +13740,9 @@ function createWorktreeTool(deps) {
13740
13740
  expectedRoot: resolve7(deps.repoRoot, worktreeRoot)
13741
13741
  };
13742
13742
  }
13743
+ if (m.schemaVersion >= 2 && !m.workflowId) {
13744
+ return { kind: "missing-workflow-link", taskId: m.taskId };
13745
+ }
13743
13746
  const remote = deps.remote ?? "origin";
13744
13747
  const hasRemote = remoteExists(remote, deps.repoRoot);
13745
13748
  if (hasRemote) {
@@ -14925,6 +14928,42 @@ function createMergeTool(deps) {
14925
14928
  } else if (m.schemaVersion >= 2) {
14926
14929
  return { kind: "missing-workflow-link", taskId: m.taskId };
14927
14930
  }
14931
+ if (pr.headSha !== (m.lastPrHeadSha ?? pr.headSha)) {
14932
+ return {
14933
+ kind: "head-changed",
14934
+ headSha: pr.headSha,
14935
+ manifestSha: m.lastPrHeadSha ?? ""
14936
+ };
14937
+ }
14938
+ async function recordMerged(merged2, reason) {
14939
+ const t = transition(
14940
+ { ...m, lastPrHeadSha: merged2.headSha },
14941
+ "merged",
14942
+ { reason }
14943
+ );
14944
+ if (!t.ok) return { kind: "lifecycle", reason: t.reason };
14945
+ const next = {
14946
+ ...m,
14947
+ lastPrHeadSha: merged2.headSha,
14948
+ state: t.to,
14949
+ transitionLog: [
14950
+ ...m.transitionLog,
14951
+ { from: t.from, to: t.to, at: t.at, reason: t.reason }
14952
+ ],
14953
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
14954
+ };
14955
+ const path = await writeManifest(deps.repoRoot, next);
14956
+ if (finalEvidence && m.workflowId) {
14957
+ await appendRunEvent(deps.repoRoot, m.workflowId, finalEvidence.runState, {
14958
+ kind: RUN_EVENT_KINDS.MERGE,
14959
+ data: { mergeSha: merged2.mergeSha ?? merged2.mergeCommitSha ?? merged2.headSha }
14960
+ });
14961
+ }
14962
+ return { kind: "merge", contractVersion: 1, manifestPath: path, pr: m.prNumber, taskId: m.taskId };
14963
+ }
14964
+ if (pr.merged) {
14965
+ return recordMerged(pr, `reconciled external merge as ${input.subject}`);
14966
+ }
14928
14967
  if (freshGates) {
14929
14968
  const required2 = deps.adapter?.ready?.requires ?? [
14930
14969
  "review",
@@ -14951,13 +14990,6 @@ function createMergeTool(deps) {
14951
14990
  });
14952
14991
  if (!result.ok) return gateFailureEnvelope(result);
14953
14992
  }
14954
- if (pr.headSha !== (m.lastPrHeadSha ?? pr.headSha)) {
14955
- return {
14956
- kind: "head-changed",
14957
- headSha: pr.headSha,
14958
- manifestSha: m.lastPrHeadSha ?? ""
14959
- };
14960
- }
14961
14993
  if (pr.draft) return { kind: "not-mergeable", reason: "PR is still draft" };
14962
14994
  if (pr.mergeable !== "MERGEABLE") {
14963
14995
  return { kind: "not-mergeable", reason: `mergeable=${pr.mergeable}` };
@@ -14967,30 +14999,7 @@ function createMergeTool(deps) {
14967
14999
  number: m.prNumber,
14968
15000
  subject: input.subject
14969
15001
  });
14970
- const t = transition(
14971
- { ...m, lastPrHeadSha: merged.headSha },
14972
- "merged",
14973
- { reason: `squash merged as ${input.subject}` }
14974
- );
14975
- if (!t.ok) return { kind: "lifecycle", reason: t.reason };
14976
- const next = {
14977
- ...m,
14978
- lastPrHeadSha: merged.headSha,
14979
- state: t.to,
14980
- transitionLog: [
14981
- ...m.transitionLog,
14982
- { from: t.from, to: t.to, at: t.at, reason: t.reason }
14983
- ],
14984
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
14985
- };
14986
- const path = await writeManifest(deps.repoRoot, next);
14987
- if (finalEvidence && m.workflowId) {
14988
- await appendRunEvent(deps.repoRoot, m.workflowId, finalEvidence.runState, {
14989
- kind: RUN_EVENT_KINDS.MERGE,
14990
- data: { mergeSha: merged.mergeSha ?? merged.mergeCommitSha ?? merged.headSha }
14991
- });
14992
- }
14993
- return { kind: "merge", contractVersion: 1, manifestPath: path, pr: m.prNumber, taskId: m.taskId };
15002
+ return recordMerged(merged, `squash merged as ${input.subject}`);
14994
15003
  };
14995
15004
  }
14996
15005
 
@@ -15167,6 +15176,231 @@ function createCleanupTool(deps) {
15167
15176
  };
15168
15177
  }
15169
15178
 
15179
+ // src/tools/delivery-abandon.js
15180
+ import { join as join10 } from "node:path";
15181
+ import { spawnSync as spawnSync4 } from "node:child_process";
15182
+ import { existsSync as existsSync8 } from "node:fs";
15183
+
15184
+ // src/state/abandon-store.js
15185
+ import { readFile as readFile7 } from "node:fs/promises";
15186
+ import { join as join8 } from "node:path";
15187
+ import { createHash as createHash7 } from "node:crypto";
15188
+ init_durable_store();
15189
+ var SAFE_ID_RE2 = /^[A-Za-z0-9._-]{1,128}$/;
15190
+ var HASH_RE2 = /^[0-9a-f]{64}$/;
15191
+ function abandonDir(commonDir, taskId) {
15192
+ return join8(commonDir, "opencode-ship", "delivery", "abandoned", taskId);
15193
+ }
15194
+ function intentPathFor(commonDir, taskId) {
15195
+ return join8(abandonDir(commonDir, taskId), "intent.json");
15196
+ }
15197
+ function completionPathFor(commonDir, taskId) {
15198
+ return join8(abandonDir(commonDir, taskId), "completion.json");
15199
+ }
15200
+ async function readJsonOrNull2(path) {
15201
+ try {
15202
+ return JSON.parse(await readFile7(path, "utf8"));
15203
+ } catch (err) {
15204
+ if (err?.code === "ENOENT") return null;
15205
+ throw err;
15206
+ }
15207
+ }
15208
+ function withoutIntentHash(record2) {
15209
+ const copy = { ...record2 ?? {} };
15210
+ delete copy.intentHash;
15211
+ return copy;
15212
+ }
15213
+ function hashAbandonIntent(record2) {
15214
+ return createHash7("sha256").update(canonicalJson(withoutIntentHash(record2)), "utf8").digest("hex");
15215
+ }
15216
+ async function readAbandon(repoRoot, taskId) {
15217
+ const commonDir = await resolveGitCommonDir(repoRoot);
15218
+ if (!SAFE_ID_RE2.test(String(taskId ?? ""))) {
15219
+ return { intent: null, completion: null };
15220
+ }
15221
+ return {
15222
+ intent: await readJsonOrNull2(intentPathFor(commonDir, taskId)),
15223
+ completion: await readJsonOrNull2(completionPathFor(commonDir, taskId))
15224
+ };
15225
+ }
15226
+ async function publishOrReuse(path, record2) {
15227
+ try {
15228
+ await publishImmutableJson(path, record2);
15229
+ return { ok: true, record: record2, idempotent: false };
15230
+ } catch (err) {
15231
+ const message = String(err?.message ?? err);
15232
+ if (!message.includes("already exists")) throw err;
15233
+ const existing = await readJsonOrNull2(path);
15234
+ if (existing && canonicalJson(existing) === canonicalJson(record2)) {
15235
+ return { ok: true, record: existing, idempotent: true };
15236
+ }
15237
+ return { ok: false, kind: "abandon-conflict" };
15238
+ }
15239
+ }
15240
+ async function publishAbandonIntent(repoRoot, record2) {
15241
+ const taskId = String(record2?.taskId ?? "");
15242
+ if (!SAFE_ID_RE2.test(taskId)) {
15243
+ return { ok: false, kind: "invalid-task-id" };
15244
+ }
15245
+ const sealed = { ...withoutIntentHash(record2), intentHash: hashAbandonIntent(record2) };
15246
+ const commonDir = await resolveGitCommonDir(repoRoot);
15247
+ return publishOrReuse(intentPathFor(commonDir, taskId), sealed);
15248
+ }
15249
+ async function publishAbandonCompletion(repoRoot, record2) {
15250
+ const taskId = String(record2?.taskId ?? "");
15251
+ const intentHash = String(record2?.intentHash ?? "");
15252
+ if (!SAFE_ID_RE2.test(taskId)) {
15253
+ return { ok: false, kind: "invalid-task-id" };
15254
+ }
15255
+ if (!HASH_RE2.test(intentHash)) {
15256
+ return { ok: false, kind: "invalid-intent-hash" };
15257
+ }
15258
+ const commonDir = await resolveGitCommonDir(repoRoot);
15259
+ return publishOrReuse(completionPathFor(commonDir, taskId), record2);
15260
+ }
15261
+
15262
+ // src/skills/worktree.js
15263
+ import { execFile } from "node:child_process";
15264
+ import { promises as fs, existsSync as existsSync7 } from "node:fs";
15265
+ import { resolve as resolve9, dirname as dirname3, sep, isAbsolute, join as join9 } from "node:path";
15266
+ function listRegisteredWorktrees(mainRepo) {
15267
+ return new Promise((resolveP, rejectP) => {
15268
+ execFile(
15269
+ "git",
15270
+ ["-C", mainRepo, "worktree", "list", "--porcelain", "-z"],
15271
+ { shell: false, maxBuffer: 1024 * 1024 },
15272
+ (err, stdout) => {
15273
+ if (err) return rejectP(err);
15274
+ const records = parsePorcelain(stdout);
15275
+ const mainRecord = records.shift();
15276
+ const mainPath = mainRecord?.worktree ? resolve9(mainRecord.worktree) : null;
15277
+ const linked = [];
15278
+ for (const r of records) {
15279
+ if (!r.worktree) continue;
15280
+ const p = resolve9(r.worktree);
15281
+ if (mainPath && p === mainPath) continue;
15282
+ linked.push({ path: p, branch: r.HEAD ?? null });
15283
+ }
15284
+ resolveP(linked);
15285
+ }
15286
+ );
15287
+ });
15288
+ }
15289
+ function parsePorcelain(text) {
15290
+ const tokens = text.split("\0");
15291
+ const out = [];
15292
+ let current = {};
15293
+ for (const tok of tokens) {
15294
+ if (tok.length === 0) {
15295
+ if (Object.keys(current).length > 0) {
15296
+ out.push(current);
15297
+ current = {};
15298
+ }
15299
+ continue;
15300
+ }
15301
+ const idx = tok.indexOf(" ");
15302
+ const key = idx === -1 ? tok : tok.slice(0, idx);
15303
+ const value = idx === -1 ? "" : tok.slice(idx + 1);
15304
+ if (key === "branch") {
15305
+ current.HEAD = value.startsWith("refs/heads/") ? value : `refs/heads/${value}`;
15306
+ } else {
15307
+ current[key] = value;
15308
+ }
15309
+ }
15310
+ if (Object.keys(current).length > 0) out.push(current);
15311
+ return out;
15312
+ }
15313
+ async function validateLinkedWorktree(mainRepo, worktreePath, options = {}) {
15314
+ const main = resolve9(mainRepo);
15315
+ if (!existsSync7(main)) {
15316
+ return { ok: false, kind: "missing", message: `main repository ${main} does not exist` };
15317
+ }
15318
+ if (!worktreePath) {
15319
+ return { ok: false, kind: "unlinked", message: "worktreePath is required" };
15320
+ }
15321
+ const wt = resolve9(worktreePath);
15322
+ if (!existsSync7(wt)) {
15323
+ return { ok: false, kind: "missing", message: `worktree ${wt} does not exist` };
15324
+ }
15325
+ const isCurrent = wt === main;
15326
+ if (isCurrent) {
15327
+ const gitEntry = options.allowCurrentLinked ? await fs.lstat(join9(main, ".git")).catch(() => null) : null;
15328
+ if (!gitEntry?.isFile()) {
15329
+ return { ok: false, kind: "main", message: "installs into the main worktree are forbidden" };
15330
+ }
15331
+ }
15332
+ let cursor = wt;
15333
+ while (cursor !== dirname3(cursor)) {
15334
+ const stat3 = await fs.lstat(cursor).catch(() => null);
15335
+ if (stat3?.isSymbolicLink()) {
15336
+ return {
15337
+ ok: false,
15338
+ kind: "ancestor-symlink",
15339
+ message: `worktree path contains a symlink at ${cursor}`
15340
+ };
15341
+ }
15342
+ cursor = dirname3(cursor);
15343
+ }
15344
+ const real = await fs.realpath(wt).catch(() => null);
15345
+ if (real && real !== wt) {
15346
+ return {
15347
+ ok: false,
15348
+ kind: "symlink",
15349
+ message: `worktree ${wt} resolves through a symlink to ${real}`
15350
+ };
15351
+ }
15352
+ const linked = await listRegisteredWorktrees(main);
15353
+ const matched = linked.find((entry) => entry.path === wt);
15354
+ if (!matched) {
15355
+ return {
15356
+ ok: false,
15357
+ kind: "unlinked",
15358
+ message: `worktree ${wt} is not registered (git worktree list)`
15359
+ };
15360
+ }
15361
+ return { ok: true, path: wt, registered: !!matched };
15362
+ }
15363
+ function validateRelativeInstallPath(destRel) {
15364
+ if (typeof destRel !== "string" || destRel.length === 0) {
15365
+ return { ok: false, kind: "absolute", message: "destination path required" };
15366
+ }
15367
+ if (isAbsolute(destRel)) {
15368
+ return { ok: false, kind: "absolute", message: `destination must be relative: ${destRel}` };
15369
+ }
15370
+ if (destRel.includes("\\")) {
15371
+ return { ok: false, kind: "parent-relative", message: `destination must use POSIX separators: ${destRel}` };
15372
+ }
15373
+ const parts = destRel.split("/");
15374
+ for (const p of parts) {
15375
+ if (p === "" || p === "." || p === "..") {
15376
+ return { ok: false, kind: "parent-relative", message: `destination escapes worktree: ${destRel}` };
15377
+ }
15378
+ }
15379
+ return { ok: true };
15380
+ }
15381
+ async function validateInstallDestination(worktreeRoot, destRel) {
15382
+ const relativeCheck = validateRelativeInstallPath(destRel);
15383
+ if (!relativeCheck.ok) return relativeCheck;
15384
+ const root = resolve9(worktreeRoot);
15385
+ const destination = resolve9(root, ...destRel.split("/"));
15386
+ if (destination !== root && !destination.startsWith(root + sep)) {
15387
+ return { ok: false, kind: "escape", message: `destination escapes worktree: ${destRel}` };
15388
+ }
15389
+ let cursor = root;
15390
+ for (const part of destRel.split("/")) {
15391
+ cursor = join9(cursor, part);
15392
+ const entry = await fs.lstat(cursor).catch(() => null);
15393
+ if (!entry) continue;
15394
+ if (entry.isSymbolicLink()) {
15395
+ return { ok: false, kind: "symlink", message: `destination path contains a symlink at ${cursor}` };
15396
+ }
15397
+ if (cursor !== destination && !entry.isDirectory()) {
15398
+ return { ok: false, kind: "not-directory", message: `destination ancestor is not a directory: ${cursor}` };
15399
+ }
15400
+ }
15401
+ return { ok: true, path: destination };
15402
+ }
15403
+
15170
15404
  // src/tools/envelope.js
15171
15405
  import { randomBytes as randomBytes2 } from "node:crypto";
15172
15406
  var CONTRACT_VERSION = 2;
@@ -15205,25 +15439,224 @@ function failure(kind, message, options = {}) {
15205
15439
  };
15206
15440
  }
15207
15441
 
15442
+ // src/tools/delivery-abandon.js
15443
+ import { readFile as readFile8 } from "node:fs/promises";
15444
+ function runGit2(args, cwd) {
15445
+ return spawnSync4("git", args, {
15446
+ cwd,
15447
+ encoding: "utf8",
15448
+ stdio: ["ignore", "pipe", "pipe"],
15449
+ env: process.env
15450
+ });
15451
+ }
15452
+ function defaultRemoveWorktree(repoRoot, path) {
15453
+ return runGit2(["worktree", "remove", path], repoRoot);
15454
+ }
15455
+ function defaultDeleteBranch(repoRoot, branch, expectedSha) {
15456
+ const args = ["update-ref", "-d", `refs/heads/${branch}`];
15457
+ if (expectedSha && /^[0-9a-f]{7,}$/i.test(expectedSha)) args.push(expectedSha);
15458
+ return runGit2(args, repoRoot);
15459
+ }
15460
+ function branchExists(repoRoot, branch) {
15461
+ return runGit2(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoRoot).status === 0;
15462
+ }
15463
+ function remoteBranchHead(repoRoot, remote, branch) {
15464
+ const r = runGit2(["ls-remote", "--heads", remote, branch], repoRoot);
15465
+ if (r.status !== 0) return { ok: false, sha: null, present: false };
15466
+ const line = r.stdout.split("\n").find((row) => row.includes(`refs/heads/${branch}`));
15467
+ if (!line) return { ok: true, sha: null, present: false };
15468
+ return { ok: true, sha: line.split(/\s+/)[0], present: true };
15469
+ }
15470
+ function unpublishedAhead(repoRoot, remote, branch) {
15471
+ const r = runGit2(["rev-list", "--count", `${remote}/${branch}..${branch}`], repoRoot);
15472
+ if (r.status !== 0) return null;
15473
+ const n = parseInt(r.stdout.trim(), 10);
15474
+ return Number.isFinite(n) ? n : null;
15475
+ }
15476
+ function refuse(opId, kind, extras = {}) {
15477
+ return failure("abandon", kind, {
15478
+ operationId: opId,
15479
+ retryable: false,
15480
+ details: { kind, ...extras }
15481
+ });
15482
+ }
15483
+ async function readRunSnapshot(repoRoot, workflowId) {
15484
+ try {
15485
+ const common = await resolveGitCommonDir(repoRoot);
15486
+ const path = join10(opencodeShipStateDir(common), "runs", workflowId, "run.json");
15487
+ if (!existsSync8(path)) return null;
15488
+ return JSON.parse(await readFile8(path, "utf8"));
15489
+ } catch {
15490
+ return null;
15491
+ }
15492
+ }
15493
+ async function validateLiveAttempt({ deps, manifest, subject, opId }) {
15494
+ if (!manifest.prNumber) return refuse(opId, "missing-pr");
15495
+ if (!manifest.worktreePath) return refuse(opId, "missing-worktree-path");
15496
+ const pr = await deps.driver.readPullRequest({
15497
+ repo: deps.repoSlug,
15498
+ number: manifest.prNumber
15499
+ });
15500
+ if (pr.merged || pr.state === "MERGED") return refuse(opId, "pr-merged");
15501
+ if (pr.state !== "CLOSED") return refuse(opId, "pr-open");
15502
+ if (pr.headRefName && pr.headRefName !== manifest.branch) {
15503
+ return refuse(opId, "branch-mismatch", { expected: manifest.branch, received: pr.headRefName });
15504
+ }
15505
+ if (pr.baseRefName && pr.baseRefName !== manifest.baseBranch) {
15506
+ return refuse(opId, "base-mismatch", { expected: manifest.baseBranch, received: pr.baseRefName });
15507
+ }
15508
+ const linked = await validateLinkedWorktree(deps.repoRoot, manifest.worktreePath);
15509
+ if (!linked.ok) {
15510
+ return refuse(opId, "invalid-worktree", { reason: linked.kind, message: linked.message });
15511
+ }
15512
+ if (!isWorktreeClean(linked.path)) return refuse(opId, "dirty-worktree");
15513
+ if (isRebaseInProgress(linked.path)) return refuse(opId, "rebase-in-progress");
15514
+ const head = currentHead(linked.path);
15515
+ if (!head || head !== manifest.lastPrHeadSha || head !== pr.headSha) {
15516
+ return refuse(opId, "head-mismatch", {
15517
+ local: head ?? "",
15518
+ manifest: manifest.lastPrHeadSha ?? "",
15519
+ pr: pr.headSha ?? ""
15520
+ });
15521
+ }
15522
+ const remote = deps.remote ?? "origin";
15523
+ const remoteHead = remoteBranchHead(linked.path, remote, manifest.branch);
15524
+ if (remoteHead.ok && remoteHead.present && remoteHead.sha !== head) {
15525
+ return refuse(opId, "remote-diverged", { local: head, remote: remoteHead.sha });
15526
+ }
15527
+ const ahead = unpublishedAhead(linked.path, remote, manifest.branch);
15528
+ if (ahead !== null && ahead > 0) {
15529
+ return refuse(opId, "has-unpublished-commits", { ahead, branch: manifest.branch, remote });
15530
+ }
15531
+ if (manifest.workflowId) {
15532
+ const snapshot = await readRunSnapshot(deps.repoRoot, manifest.workflowId);
15533
+ if (snapshot?.state === "ready") return refuse(opId, "workflow-ready");
15534
+ if (snapshot?.state === "merged") return refuse(opId, "workflow-merged");
15535
+ }
15536
+ return {
15537
+ ok: true,
15538
+ intent: {
15539
+ schemaVersion: 1,
15540
+ taskId: manifest.taskId,
15541
+ issueNumber: manifest.issueNumber,
15542
+ prNumber: manifest.prNumber,
15543
+ branch: manifest.branch,
15544
+ worktreePath: linked.path,
15545
+ headSha: head,
15546
+ workflowId: manifest.workflowId ?? null,
15547
+ subject,
15548
+ requestedAt: (/* @__PURE__ */ new Date()).toISOString()
15549
+ }
15550
+ };
15551
+ }
15552
+ async function resumeCleanup({ deps, intent, opId }) {
15553
+ const removeWorktree = deps.removeWorktree ?? defaultRemoveWorktree;
15554
+ const deleteBranch = deps.deleteBranch ?? defaultDeleteBranch;
15555
+ const removeManifest = deps.deleteManifest ?? deleteManifest;
15556
+ let removedWorktree = !existsSync8(intent.worktreePath);
15557
+ if (!removedWorktree) {
15558
+ let removed;
15559
+ try {
15560
+ removed = await Promise.resolve(removeWorktree(deps.repoRoot, intent.worktreePath));
15561
+ } catch (err) {
15562
+ return refuse(opId, "remove-failed", { stderr: String(err?.message ?? err) });
15563
+ }
15564
+ if (removed?.status !== 0 && existsSync8(intent.worktreePath)) {
15565
+ return refuse(opId, "remove-failed", { stderr: removed?.stderr ?? "" });
15566
+ }
15567
+ removedWorktree = !existsSync8(intent.worktreePath);
15568
+ }
15569
+ let deletedBranch = !branchExists(deps.repoRoot, intent.branch);
15570
+ if (!deletedBranch) {
15571
+ let deleted;
15572
+ try {
15573
+ deleted = await Promise.resolve(deleteBranch(deps.repoRoot, intent.branch, intent.headSha));
15574
+ } catch (err) {
15575
+ return refuse(opId, "branch-delete-failed", { stderr: String(err?.message ?? err) });
15576
+ }
15577
+ if (deleted?.status !== 0 && branchExists(deps.repoRoot, intent.branch)) {
15578
+ return refuse(opId, "branch-delete-failed", { stderr: deleted?.stderr ?? "" });
15579
+ }
15580
+ deletedBranch = !branchExists(deps.repoRoot, intent.branch);
15581
+ }
15582
+ try {
15583
+ await Promise.resolve(removeManifest(deps.repoRoot, intent.taskId));
15584
+ } catch (err) {
15585
+ return refuse(opId, "manifest-delete-failed", { stderr: String(err?.message ?? err) });
15586
+ }
15587
+ const remaining = await readManifest(deps.repoRoot, intent.taskId);
15588
+ const deletedManifest = remaining === null;
15589
+ const completion = {
15590
+ schemaVersion: 1,
15591
+ taskId: intent.taskId,
15592
+ intentHash: intent.intentHash,
15593
+ removedWorktree,
15594
+ deletedBranch,
15595
+ deletedManifest,
15596
+ completedAt: (/* @__PURE__ */ new Date()).toISOString()
15597
+ };
15598
+ const published = await publishAbandonCompletion(deps.repoRoot, completion);
15599
+ if (!published.ok) return refuse(opId, published.kind);
15600
+ return success2("abandon", {
15601
+ taskId: intent.taskId,
15602
+ intentHash: intent.intentHash,
15603
+ removedWorktree,
15604
+ deletedBranch,
15605
+ deletedManifest
15606
+ }, { operationId: opId, idempotent: published.idempotent === true });
15607
+ }
15608
+ function createAbandonTool(deps) {
15609
+ return async function abandon(input) {
15610
+ const opId = input.operationId ?? `abandon-${Date.now().toString(36)}`;
15611
+ const taskId = String(input.taskId ?? "");
15612
+ const subject = String(input.subject ?? "").trim();
15613
+ if (!taskId) return refuse(opId, "missing-input", { field: "taskId" });
15614
+ if (!subject) return refuse(opId, "missing-input", { field: "subject" });
15615
+ const existing = await readAbandon(deps.repoRoot, taskId);
15616
+ if (existing.completion) {
15617
+ return success2("abandon", {
15618
+ taskId,
15619
+ intentHash: existing.completion.intentHash,
15620
+ removedWorktree: existing.completion.removedWorktree,
15621
+ deletedBranch: existing.completion.deletedBranch,
15622
+ deletedManifest: existing.completion.deletedManifest
15623
+ }, { operationId: opId, idempotent: true });
15624
+ }
15625
+ if (existing.intent) {
15626
+ if (existing.intent.subject !== subject || existing.intent.taskId !== taskId) {
15627
+ return refuse(opId, "abandon-conflict");
15628
+ }
15629
+ return resumeCleanup({ deps, intent: existing.intent, opId });
15630
+ }
15631
+ const manifest = await readManifest(deps.repoRoot, taskId);
15632
+ if (!manifest) return refuse(opId, "missing-manifest", { taskId });
15633
+ const validated = await validateLiveAttempt({ deps, manifest, subject, opId });
15634
+ if (!validated.ok) return validated;
15635
+ const publishedIntent = await publishAbandonIntent(deps.repoRoot, validated.intent);
15636
+ if (!publishedIntent.ok) return refuse(opId, publishedIntent.kind);
15637
+ return resumeCleanup({ deps, intent: publishedIntent.record, opId });
15638
+ };
15639
+ }
15640
+
15208
15641
  // src/state/github-operation-store.js
15209
- import { readFile as readFile7, writeFile as writeFile4, mkdir as mkdir5, readdir as readdir4, unlink as unlink3 } from "node:fs/promises";
15210
- import { existsSync as existsSync7 } from "node:fs";
15211
- import { join as join8, dirname as dirname3 } from "node:path";
15642
+ import { readFile as readFile9, writeFile as writeFile4, mkdir as mkdir5, readdir as readdir4, unlink as unlink3 } from "node:fs/promises";
15643
+ import { existsSync as existsSync9 } from "node:fs";
15644
+ import { join as join11, dirname as dirname4 } from "node:path";
15212
15645
  init_durable_store();
15213
15646
  async function operationsDir(repoRoot) {
15214
15647
  const common = await resolveGitCommonDir(repoRoot);
15215
- return join8(opencodeShipStateDir(common), "github", "operations");
15648
+ return join11(opencodeShipStateDir(common), "github", "operations");
15216
15649
  }
15217
- var SAFE_ID_RE2 = /^[A-Za-z0-9._-]{1,128}$/;
15650
+ var SAFE_ID_RE3 = /^[A-Za-z0-9._-]{1,128}$/;
15218
15651
  function operationPath(dir, operationId2) {
15219
- if (!SAFE_ID_RE2.test(operationId2)) {
15652
+ if (!SAFE_ID_RE3.test(operationId2)) {
15220
15653
  throw new Error(`invalid operationId: ${JSON.stringify(operationId2)}`);
15221
15654
  }
15222
- return join8(dir, `${operationId2}.json`);
15655
+ return join11(dir, `${operationId2}.json`);
15223
15656
  }
15224
15657
  async function hasOperation(repoRoot, operationId2) {
15225
15658
  const dir = await operationsDir(repoRoot);
15226
- return existsSync7(operationPath(dir, operationId2));
15659
+ return existsSync9(operationPath(dir, operationId2));
15227
15660
  }
15228
15661
  async function recordOperation(repoRoot, operationId2, record2) {
15229
15662
  if (typeof operationId2 !== "string" || operationId2.length === 0) {
@@ -15235,7 +15668,7 @@ async function recordOperation(repoRoot, operationId2, record2) {
15235
15668
  const dir = await operationsDir(repoRoot);
15236
15669
  await mkdir5(dir, { recursive: true });
15237
15670
  const path = operationPath(dir, operationId2);
15238
- if (existsSync7(path)) {
15671
+ if (existsSync9(path)) {
15239
15672
  return { recorded: false, path };
15240
15673
  }
15241
15674
  const fullRecord = {
@@ -15505,76 +15938,33 @@ function createPublishTool(deps) {
15505
15938
  };
15506
15939
  }
15507
15940
 
15508
- // src/profile.js
15509
- var PROFILES = Object.freeze(["engineering"]);
15510
- var LEGACY_PROFILES = Object.freeze(["core"]);
15511
-
15512
- // src/installer/engineering-config.js
15513
- var MODEL_ID_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
15514
- var DEFAULTS = Object.freeze({
15515
- planner: "openai/gpt-5.6-sol",
15516
- builder: "minimax/MiniMax-M3",
15517
- finalReviewer: "openai/gpt-5.6-sol"
15518
- });
15519
- function resolveModelRoles(cfg, { strict = false, allowDeferred = false } = {}) {
15520
- const REQUIRED = ["planner", "builder", "finalReviewer"];
15521
- if (strict) {
15522
- const issues = [];
15523
- for (const role of REQUIRED) {
15524
- const id = cfg?.models?.[role];
15525
- if (typeof id !== "string" || id.length === 0 || !MODEL_ID_RE.test(id)) {
15526
- issues.push(role);
15527
- }
15528
- }
15529
- if (issues.length > 0) {
15530
- throw new Error(`resolveModelRoles: required role(s) missing or invalid: ${issues.join(", ")}`);
15531
- }
15532
- return { planner: cfg.models.planner, builder: cfg.models.builder, finalReviewer: cfg.models.finalReviewer };
15533
- }
15534
- const out = { ...DEFAULTS };
15535
- if (cfg && cfg.models) {
15536
- for (const [role, id] of Object.entries(cfg.models)) {
15537
- if (id && typeof id === "string" && id.length > 0) {
15538
- out[role] = id;
15539
- } else if (strict && Object.prototype.hasOwnProperty.call(cfg.models, role)) {
15540
- throw new Error(`resolveModelRoles: user provided empty model id for '${role}'`);
15541
- }
15542
- }
15543
- }
15544
- if (allowDeferred) {
15545
- return { planner: out.planner ?? null, builder: out.builder ?? null, finalReviewer: out.finalReviewer ?? null };
15546
- }
15547
- for (const role of REQUIRED) {
15548
- if (!out[role]) {
15549
- throw new Error(`resolveModelRoles: required role '${role}' missing and no default available`);
15550
- }
15551
- }
15552
- return out;
15553
- }
15554
-
15555
15941
  // src/installer/lock.js
15556
- import { readFile as readFile8, writeFile as writeFile5, rename as rename3, mkdir as mkdir6 } from "node:fs/promises";
15557
- import { existsSync as existsSync8 } from "node:fs";
15558
- import { dirname as dirname4, resolve as resolve9, posix } from "node:path";
15942
+ import { readFile as readFile10, writeFile as writeFile5, rename as rename3, mkdir as mkdir6 } from "node:fs/promises";
15943
+ import { existsSync as existsSync10 } from "node:fs";
15944
+ import { dirname as dirname5, resolve as resolve10, posix } from "node:path";
15559
15945
 
15560
15946
  // src/installer/hash.js
15561
- import { createHash as createHash7 } from "node:crypto";
15947
+ import { createHash as createHash8 } from "node:crypto";
15562
15948
  function bytesHash(buffer) {
15563
- return createHash7("sha256").update(buffer).digest("hex");
15949
+ return createHash8("sha256").update(buffer).digest("hex");
15564
15950
  }
15565
15951
  function bytesHashString(text) {
15566
15952
  return bytesHash(Buffer.from(text, "utf8"));
15567
15953
  }
15568
15954
 
15955
+ // src/profile.js
15956
+ var PROFILES = Object.freeze(["engineering"]);
15957
+ var LEGACY_PROFILES = Object.freeze(["core"]);
15958
+
15569
15959
  // src/installer/lock.js
15570
15960
  function lockPath(repoRoot) {
15571
- return resolve9(repoRoot, ".opencode", "ship.lock.json");
15961
+ return resolve10(repoRoot, ".opencode", "ship.lock.json");
15572
15962
  }
15573
15963
  async function readLock2(repoRoot) {
15574
15964
  const path = lockPath(repoRoot);
15575
- if (!existsSync8(path)) return null;
15965
+ if (!existsSync10(path)) return null;
15576
15966
  try {
15577
- const raw = await readFile8(path, "utf8");
15967
+ const raw = await readFile10(path, "utf8");
15578
15968
  return JSON.parse(raw);
15579
15969
  } catch {
15580
15970
  return null;
@@ -15587,15 +15977,11 @@ function isSetupComplete(lock) {
15587
15977
  return manager.setupComplete === true;
15588
15978
  }
15589
15979
 
15590
- // src/tools/ship-plan-start.js
15591
- import { join as join10 } from "node:path";
15592
- import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
15593
-
15594
15980
  // src/runtime/opencode-dispatcher.js
15595
15981
  import { mkdir as mkdir7 } from "node:fs/promises";
15596
- import { join as join9 } from "node:path";
15982
+ import { join as join12 } from "node:path";
15597
15983
  init_durable_store();
15598
- import { createHash as createHash8 } from "node:crypto";
15984
+ import { createHash as createHash9 } from "node:crypto";
15599
15985
  import { dirname as dirnameOf } from "node:path";
15600
15986
  var ROLE_PLANNER = "planner";
15601
15987
  var ROLE_BUILDER = "builder";
@@ -15620,20 +16006,16 @@ function dispatchKeyFor(role, input) {
15620
16006
  }
15621
16007
  }
15622
16008
  function dispatchDir(commonDir, workflowId, dispatchKey) {
15623
- return join9(opencodeShipStateDir(commonDir), "runs", workflowId, "dispatch", dispatchKey);
16009
+ return join12(opencodeShipStateDir(commonDir), "runs", workflowId, "dispatch", dispatchKey);
15624
16010
  }
15625
16011
  async function dispatchPath(commonDir, workflowId, dispatchKey) {
15626
- return join9(dispatchDir(commonDir, workflowId, dispatchKey), "dispatch.json");
16012
+ return join12(dispatchDir(commonDir, workflowId, dispatchKey), "dispatch.json");
15627
16013
  }
15628
16014
  function hashPayload(value) {
15629
- return createHash8("sha256").update(JSON.stringify(value), "utf8").digest("hex");
16015
+ return createHash9("sha256").update(JSON.stringify(value), "utf8").digest("hex");
15630
16016
  }
15631
- async function prepareDispatch(repoRoot, workflowId, role, keyInput, payload) {
15632
- if (!ROLE_KEYS.has(role)) {
15633
- throw new Error(`prepareDispatch: unknown role ${role}`);
15634
- }
16017
+ async function prepareDispatchRecord(repoRoot, workflowId, dispatchKey, role, keyInput, payload) {
15635
16018
  const common = await resolveGitCommonDir(repoRoot);
15636
- const dispatchKey = dispatchKeyFor(role, keyInput);
15637
16019
  const commonDir = opencodeShipStateDir(common);
15638
16020
  const dir = dispatchDir(common, workflowId, dispatchKey);
15639
16021
  await mkdir7(dir, { recursive: true });
@@ -15652,10 +16034,10 @@ async function prepareDispatch(repoRoot, workflowId, role, keyInput, payload) {
15652
16034
  }
15653
16035
  async function transitionDispatch(repoRoot, workflowId, dispatchKey, nextState, fields = {}) {
15654
16036
  const common = await resolveGitCommonDir(repoRoot);
15655
- const baseDir = join9(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey);
16037
+ const baseDir = join12(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey);
15656
16038
  await mkdir7(baseDir, { recursive: true });
15657
16039
  const next = Number(fields.sequence ?? 0);
15658
- const path = join9(baseDir, `seq-${String(next).padStart(6, "0")}.json`);
16040
+ const path = join12(baseDir, `seq-${String(next).padStart(6, "0")}.json`);
15659
16041
  const record2 = {
15660
16042
  workflowId,
15661
16043
  dispatchKey,
@@ -15667,27 +16049,27 @@ async function transitionDispatch(repoRoot, workflowId, dispatchKey, nextState,
15667
16049
  return record2;
15668
16050
  }
15669
16051
  async function readLatestDispatch(repoRoot, workflowId, dispatchKey) {
15670
- const { readdir: readdir10, readFile: readFile25 } = await import("node:fs/promises");
16052
+ const { readdir: readdir10, readFile: readFile27 } = await import("node:fs/promises");
15671
16053
  const common = await resolveGitCommonDir(repoRoot);
15672
- const baseDir = join9(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey);
16054
+ const baseDir = join12(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey);
15673
16055
  const { readdirSync: readdirSync2, statSync: statSync2 } = await import("node:fs");
15674
16056
  if (!statSync2(baseDir, { throwIfNoEntry: false })) return null;
15675
16057
  const files = readdirSync2(baseDir).filter((f) => f.startsWith("seq-")).sort();
15676
16058
  if (files.length === 0) {
15677
- const initial = join9(baseDir, "dispatch.json");
16059
+ const initial = join12(baseDir, "dispatch.json");
15678
16060
  if (!statSync2(initial, { throwIfNoEntry: false })) return null;
15679
- return JSON.parse(await readFile25(initial, "utf8"));
16061
+ return JSON.parse(await readFile27(initial, "utf8"));
15680
16062
  }
15681
16063
  const last = files[files.length - 1];
15682
- const raw = await readFile25(join9(baseDir, last), "utf8");
16064
+ const raw = await readFile27(join12(baseDir, last), "utf8");
15683
16065
  return JSON.parse(raw);
15684
16066
  }
15685
16067
  async function readPreparedDispatch(repoRoot, workflowId, dispatchKey) {
15686
- const { readFile: readFile25 } = await import("node:fs/promises");
16068
+ const { readFile: readFile27 } = await import("node:fs/promises");
15687
16069
  const common = await resolveGitCommonDir(repoRoot);
15688
- const path = join9(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey, "dispatch.json");
16070
+ const path = join12(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey, "dispatch.json");
15689
16071
  try {
15690
- return JSON.parse(await readFile25(path, "utf8"));
16072
+ return JSON.parse(await readFile27(path, "utf8"));
15691
16073
  } catch (err) {
15692
16074
  if (err?.code === "ENOENT") return null;
15693
16075
  throw err;
@@ -15698,33 +16080,91 @@ async function dispatchWorker(input) {
15698
16080
  if (!ROLE_KEYS.has(role)) {
15699
16081
  throw new Error(`dispatchWorker: unknown role ${role}`);
15700
16082
  }
16083
+ const dispatchKey = dispatchKeyFor(role, keyInput);
16084
+ return dispatchSession({
16085
+ repoRoot,
16086
+ workflowId,
16087
+ dispatchKey,
16088
+ role,
16089
+ keyInput,
16090
+ payload,
16091
+ client,
16092
+ parentSessionID,
16093
+ titleMarker,
16094
+ agent,
16095
+ model,
16096
+ promptText: payload?.promptText,
16097
+ requireControllerLease: true,
16098
+ caller: "dispatchWorker"
16099
+ });
16100
+ }
16101
+ async function dispatchController({ repoRoot, issueNumber, client, parentSessionID }) {
16102
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
16103
+ throw new Error("dispatchController: positive issueNumber required");
16104
+ }
16105
+ return dispatchSession({
16106
+ repoRoot,
16107
+ workflowId: `wf-${issueNumber}`,
16108
+ dispatchKey: `controller:issue-${issueNumber}`,
16109
+ role: "controller",
16110
+ keyInput: { issueNumber },
16111
+ payload: { issueNumber, agent: "ship-controller" },
16112
+ client,
16113
+ parentSessionID,
16114
+ titleMarker: `ship-controller-wf-${issueNumber}`,
16115
+ agent: "ship-controller",
16116
+ promptText: `Start or resume durable delivery for issue #${issueNumber}. Call ship_plan_start before implementation mutation.`,
16117
+ requireControllerLease: false,
16118
+ caller: "dispatchController"
16119
+ });
16120
+ }
16121
+ async function dispatchSession(input) {
16122
+ const {
16123
+ repoRoot,
16124
+ workflowId,
16125
+ dispatchKey,
16126
+ role,
16127
+ keyInput,
16128
+ payload,
16129
+ client,
16130
+ parentSessionID,
16131
+ titleMarker,
16132
+ agent,
16133
+ model,
16134
+ promptText,
16135
+ requireControllerLease,
16136
+ caller
16137
+ } = input;
15701
16138
  if (!client || typeof client.session?.create !== "function" || typeof client.session?.promptAsync !== "function") {
15702
- throw new Error("dispatchWorker: client.session.create and client.session.promptAsync are required");
16139
+ throw new Error(`${caller}: client.session.create and client.session.promptAsync are required`);
15703
16140
  }
15704
16141
  if (!parentSessionID || typeof parentSessionID !== "string") {
15705
- throw new Error("dispatchWorker: parentSessionID required");
16142
+ throw new Error(`${caller}: parentSessionID required`);
16143
+ }
16144
+ if (requireControllerLease) {
16145
+ await assertControllerLease(repoRoot, workflowId, parentSessionID);
15706
16146
  }
15707
- await assertControllerLease(repoRoot, workflowId, parentSessionID);
15708
- const dispatchKey = dispatchKeyFor(role, keyInput);
15709
16147
  const common = await resolveGitCommonDir(repoRoot);
15710
16148
  const stateDir = opencodeShipStateDir(common);
15711
16149
  const preparedPayload = { ...payload, agent: agent ?? null, model: model ?? null };
16150
+ const parentAudit = requireControllerLease ? { controllerSessionID: parentSessionID } : { parentSessionID };
15712
16151
  return withResourceLock(stateDir, `dispatch:${workflowId}:${dispatchKey}`, async () => {
15713
16152
  let latest = await readLatestDispatch(repoRoot, workflowId, dispatchKey);
15714
16153
  const preparedRecord = await readPreparedDispatch(repoRoot, workflowId, dispatchKey);
15715
16154
  if (preparedRecord && preparedRecord.payloadHash !== hashPayload(preparedPayload)) {
15716
- throw new Error(`dispatchWorker: payload changed for existing dispatch ${dispatchKey}`);
16155
+ throw new Error(`${caller}: payload changed for existing dispatch ${dispatchKey}`);
15717
16156
  }
15718
16157
  if (latest?.state === "prompted" || latest?.state === "completed") {
15719
16158
  return { sessionID: latest.sessionID, dispatchKey };
15720
16159
  }
15721
16160
  if (!preparedRecord) {
15722
- await prepareDispatch(repoRoot, workflowId, role, keyInput, preparedPayload);
16161
+ await prepareDispatchRecord(repoRoot, workflowId, dispatchKey, role, keyInput, preparedPayload);
15723
16162
  latest = { state: "prepared", sequence: 0 };
15724
16163
  }
15725
16164
  let sequence = Number(latest?.sequence ?? 0);
15726
16165
  const title = titleMarker ?? `ship-${role}-${dispatchKey}`;
15727
- let sessionID = latest?.state === "created" ? latest.sessionID : null;
16166
+ const canReuseSession = latest?.state === "created" || latest?.state === "failed" && latest?.lastError?.startsWith("promptAsync:");
16167
+ let sessionID = canReuseSession ? latest?.sessionID ?? null : null;
15728
16168
  if (!sessionID) {
15729
16169
  try {
15730
16170
  const created = await client.session.create({
@@ -15732,12 +16172,12 @@ async function dispatchWorker(input) {
15732
16172
  query: { directory: repoRoot }
15733
16173
  });
15734
16174
  if (created?.error) {
15735
- throw new Error(`dispatchWorker: session.create failed: ${formatSdkError(created.error)}`);
16175
+ throw new Error(`${caller}: session.create failed: ${formatSdkError(created.error)}`);
15736
16176
  }
15737
16177
  const createdData = created?.data ?? created;
15738
16178
  sessionID = createdData?.id ?? createdData?.sessionID;
15739
16179
  if (!sessionID) {
15740
- throw new Error(`dispatchWorker: client.session.create did not return a session id`);
16180
+ throw new Error(`${caller}: client.session.create did not return a session id`);
15741
16181
  }
15742
16182
  } catch (err) {
15743
16183
  sequence += 1;
@@ -15751,12 +16191,12 @@ async function dispatchWorker(input) {
15751
16191
  await transitionDispatch(repoRoot, workflowId, dispatchKey, "created", {
15752
16192
  sequence,
15753
16193
  sessionID,
15754
- controllerSessionID: parentSessionID
16194
+ ...parentAudit
15755
16195
  });
15756
16196
  }
15757
16197
  try {
15758
16198
  const body = {
15759
- parts: [{ type: "text", text: String(payload?.promptText ?? "") }]
16199
+ parts: [{ type: "text", text: String(promptText ?? "") }]
15760
16200
  };
15761
16201
  if (agent) body.agent = agent;
15762
16202
  if (model) body.model = parseModelId(model);
@@ -15766,14 +16206,14 @@ async function dispatchWorker(input) {
15766
16206
  query: { directory: repoRoot }
15767
16207
  });
15768
16208
  if (prompted?.error) {
15769
- throw new Error(`dispatchWorker: session.promptAsync failed: ${formatSdkError(prompted.error)}`);
16209
+ throw new Error(`${caller}: session.promptAsync failed: ${formatSdkError(prompted.error)}`);
15770
16210
  }
15771
16211
  } catch (err) {
15772
16212
  sequence += 1;
15773
16213
  await transitionDispatch(repoRoot, workflowId, dispatchKey, "failed", {
15774
16214
  sequence,
15775
16215
  sessionID,
15776
- controllerSessionID: parentSessionID,
16216
+ ...parentAudit,
15777
16217
  lastError: `promptAsync: ${err?.message ?? err}`
15778
16218
  });
15779
16219
  throw err;
@@ -15782,7 +16222,7 @@ async function dispatchWorker(input) {
15782
16222
  await transitionDispatch(repoRoot, workflowId, dispatchKey, "prompted", {
15783
16223
  sequence,
15784
16224
  sessionID,
15785
- controllerSessionID: parentSessionID
16225
+ ...parentAudit
15786
16226
  });
15787
16227
  return { sessionID, dispatchKey };
15788
16228
  });
@@ -15806,7 +16246,7 @@ function formatSdkError(error45) {
15806
16246
  async function issueControllerLease(repoRoot, workflowId, controllerSessionID) {
15807
16247
  const { atomicReplaceJson: atomicReplaceJson2 } = await Promise.resolve().then(() => (init_durable_store(), durable_store_exports));
15808
16248
  const common = await resolveGitCommonDir(repoRoot);
15809
- const path = join9(opencodeShipStateDir(common), "runs", workflowId, "controller.json");
16249
+ const path = join12(opencodeShipStateDir(common), "runs", workflowId, "controller.json");
15810
16250
  await mkdir7(dirnameOf(path), { recursive: true });
15811
16251
  await atomicReplaceJson2(path, {
15812
16252
  workflowId,
@@ -15815,13 +16255,13 @@ async function issueControllerLease(repoRoot, workflowId, controllerSessionID) {
15815
16255
  });
15816
16256
  }
15817
16257
  async function readControllerLease(repoRoot, workflowId) {
15818
- const { readFile: readFile25 } = await import("node:fs/promises");
15819
- const { existsSync: existsSync28 } = await import("node:fs");
16258
+ const { readFile: readFile27 } = await import("node:fs/promises");
16259
+ const { existsSync: existsSync29 } = await import("node:fs");
15820
16260
  const common = await resolveGitCommonDir(repoRoot);
15821
- const path = join9(opencodeShipStateDir(common), "runs", workflowId, "controller.json");
15822
- if (!existsSync28(path)) return null;
16261
+ const path = join12(opencodeShipStateDir(common), "runs", workflowId, "controller.json");
16262
+ if (!existsSync29(path)) return null;
15823
16263
  try {
15824
- return JSON.parse(await readFile25(path, "utf8"));
16264
+ return JSON.parse(await readFile27(path, "utf8"));
15825
16265
  } catch {
15826
16266
  return null;
15827
16267
  }
@@ -15890,7 +16330,118 @@ var ROLES = Object.freeze({
15890
16330
  FINAL_SPEC: ROLE_FINAL_SPEC
15891
16331
  });
15892
16332
 
16333
+ // src/tools/ship-deliver.js
16334
+ function createDeliverTool(deps) {
16335
+ return async function deliver(input) {
16336
+ const opId = input.operationId ?? `deliver-${Date.now().toString(36)}`;
16337
+ const issueNumber = input.issueNumber;
16338
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
16339
+ return failure("deliver", "positive issueNumber required", {
16340
+ operationId: opId,
16341
+ retryable: false,
16342
+ details: { workflowId: null, controllerSessionID: null }
16343
+ });
16344
+ }
16345
+ const workflowId = `wf-${issueNumber}`;
16346
+ const ctx = deps.ctx ?? null;
16347
+ if (!ctx || typeof ctx.sessionID !== "string" || ctx.sessionID.length === 0) {
16348
+ return failure("deliver", "ToolContext.sessionID required (Build session)", {
16349
+ operationId: opId,
16350
+ retryable: false,
16351
+ details: { workflowId, controllerSessionID: null }
16352
+ });
16353
+ }
16354
+ if (ctx.agent !== "build") {
16355
+ return failure("deliver", "ToolContext.agent must be Build", {
16356
+ operationId: opId,
16357
+ retryable: false,
16358
+ details: { workflowId, controllerSessionID: null }
16359
+ });
16360
+ }
16361
+ const client = deps.opencodeClient;
16362
+ if (!client || typeof client.session?.create !== "function" || typeof client.session?.promptAsync !== "function") {
16363
+ return failure("deliver", "OpenCode client is unavailable", {
16364
+ operationId: opId,
16365
+ retryable: false,
16366
+ details: { workflowId, controllerSessionID: null }
16367
+ });
16368
+ }
16369
+ const lock = await readLock2(deps.repoRoot);
16370
+ if (!isSetupComplete(lock)) {
16371
+ return failure("deliver", "setup is not complete; run /setup-ship-workflow first", {
16372
+ operationId: opId,
16373
+ retryable: false,
16374
+ details: { workflowId, controllerSessionID: null }
16375
+ });
16376
+ }
16377
+ try {
16378
+ const dispatched = await dispatchController({
16379
+ repoRoot: deps.repoRoot,
16380
+ issueNumber,
16381
+ client,
16382
+ parentSessionID: ctx.sessionID
16383
+ });
16384
+ return success2("deliver", {
16385
+ workflowId,
16386
+ controllerSessionID: dispatched.sessionID,
16387
+ dispatchKey: dispatched.dispatchKey
16388
+ }, { operationId: opId });
16389
+ } catch (err) {
16390
+ return failure("deliver", String(err?.message ?? err), {
16391
+ operationId: opId,
16392
+ retryable: true,
16393
+ details: { workflowId, controllerSessionID: null }
16394
+ });
16395
+ }
16396
+ };
16397
+ }
16398
+
16399
+ // src/installer/engineering-config.js
16400
+ var MODEL_ID_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
16401
+ var DEFAULTS = Object.freeze({
16402
+ planner: "openai/gpt-5.6-sol",
16403
+ builder: "minimax/MiniMax-M3",
16404
+ finalReviewer: "openai/gpt-5.6-sol"
16405
+ });
16406
+ function resolveModelRoles(cfg, { strict = false, allowDeferred = false } = {}) {
16407
+ const REQUIRED = ["planner", "builder", "finalReviewer"];
16408
+ if (strict) {
16409
+ const issues = [];
16410
+ for (const role of REQUIRED) {
16411
+ const id = cfg?.models?.[role];
16412
+ if (typeof id !== "string" || id.length === 0 || !MODEL_ID_RE.test(id)) {
16413
+ issues.push(role);
16414
+ }
16415
+ }
16416
+ if (issues.length > 0) {
16417
+ throw new Error(`resolveModelRoles: required role(s) missing or invalid: ${issues.join(", ")}`);
16418
+ }
16419
+ return { planner: cfg.models.planner, builder: cfg.models.builder, finalReviewer: cfg.models.finalReviewer };
16420
+ }
16421
+ const out = { ...DEFAULTS };
16422
+ if (cfg && cfg.models) {
16423
+ for (const [role, id] of Object.entries(cfg.models)) {
16424
+ if (id && typeof id === "string" && id.length > 0) {
16425
+ out[role] = id;
16426
+ } else if (strict && Object.prototype.hasOwnProperty.call(cfg.models, role)) {
16427
+ throw new Error(`resolveModelRoles: user provided empty model id for '${role}'`);
16428
+ }
16429
+ }
16430
+ }
16431
+ if (allowDeferred) {
16432
+ return { planner: out.planner ?? null, builder: out.builder ?? null, finalReviewer: out.finalReviewer ?? null };
16433
+ }
16434
+ for (const role of REQUIRED) {
16435
+ if (!out[role]) {
16436
+ throw new Error(`resolveModelRoles: required role '${role}' missing and no default available`);
16437
+ }
16438
+ }
16439
+ return out;
16440
+ }
16441
+
15893
16442
  // src/tools/ship-plan-start.js
16443
+ import { join as join13 } from "node:path";
16444
+ import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
15894
16445
  function normalizeWorkflowId(issueNumber) {
15895
16446
  return `wf-${issueNumber}`;
15896
16447
  }
@@ -15919,7 +16470,7 @@ function createPlanStartTool(deps) {
15919
16470
  const repoRoot = deps.repoRoot;
15920
16471
  try {
15921
16472
  const commonDir = await resolveGitCommonDir(repoRoot);
15922
- const wfDir = join10(opencodeShipStateDir(commonDir), "plans", workflowId);
16473
+ const wfDir = join13(opencodeShipStateDir(commonDir), "plans", workflowId);
15923
16474
  await mkdir8(wfDir, { recursive: true });
15924
16475
  await issueControllerLease(repoRoot, workflowId, ctx.sessionID);
15925
16476
  const matchingManifests = (await listManifests(repoRoot)).filter((manifest) => manifest.issueNumber === issueNumber);
@@ -15962,7 +16513,7 @@ function createPlanStartTool(deps) {
15962
16513
  createdAt: (/* @__PURE__ */ new Date()).toISOString(),
15963
16514
  state: "drafting"
15964
16515
  };
15965
- await writeFile6(join10(wfDir, "index.json"), JSON.stringify(indexRecord, null, 2), "utf8");
16516
+ await writeFile6(join13(wfDir, "index.json"), JSON.stringify(indexRecord, null, 2), "utf8");
15966
16517
  return success2("plan-start", {
15967
16518
  workflowId,
15968
16519
  issueNumber,
@@ -15982,7 +16533,7 @@ function createPlanStartTool(deps) {
15982
16533
  }
15983
16534
 
15984
16535
  // src/workflow/plan.js
15985
- import { createHash as createHash9 } from "node:crypto";
16536
+ import { createHash as createHash10 } from "node:crypto";
15986
16537
  function isPlainObject2(v) {
15987
16538
  return v !== null && typeof v === "object" && !Array.isArray(v);
15988
16539
  }
@@ -16239,21 +16790,21 @@ function computePlanHash(plan) {
16239
16790
  return sha2563(json2);
16240
16791
  }
16241
16792
  function sha2563(text) {
16242
- return createHash9("sha256").update(text, "utf8").digest("hex");
16793
+ return createHash10("sha256").update(text, "utf8").digest("hex");
16243
16794
  }
16244
16795
 
16245
16796
  // src/workflow/plan-store.js
16246
- import { readFile as readFile9, writeFile as writeFile7, mkdir as mkdir9, readdir as readdir5, unlink as unlink4 } from "node:fs/promises";
16247
- import { existsSync as existsSync9 } from "node:fs";
16248
- import { join as join11, dirname as dirname5 } from "node:path";
16797
+ import { readFile as readFile11, writeFile as writeFile7, mkdir as mkdir9, readdir as readdir5, unlink as unlink4 } from "node:fs/promises";
16798
+ import { existsSync as existsSync11 } from "node:fs";
16799
+ import { join as join14, dirname as dirname6 } from "node:path";
16249
16800
  init_durable_store();
16250
- import { createHash as createHash10 } from "node:crypto";
16801
+ import { createHash as createHash11 } from "node:crypto";
16251
16802
  function revisionsDir(commonDir, workflowId) {
16252
- return join11(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions");
16803
+ return join14(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions");
16253
16804
  }
16254
16805
  function revisionDir(commonDir, workflowId, revision) {
16255
16806
  const n = String(revision).padStart(6, "0");
16256
- return join11(revisionsDir(commonDir, workflowId), n);
16807
+ return join14(revisionsDir(commonDir, workflowId), n);
16257
16808
  }
16258
16809
  async function resolveCommon(repoRoot) {
16259
16810
  return resolveGitCommonDir(repoRoot);
@@ -16267,9 +16818,9 @@ async function publishPlanRevision(repoRoot, plan) {
16267
16818
  const common = await resolveCommon(repoRoot);
16268
16819
  const dir = revisionDir(common, plan.workflowId, plan.revision);
16269
16820
  await mkdir9(dir, { recursive: true });
16270
- const planPath = join11(dir, "plan.json");
16271
- if (existsSync9(planPath)) {
16272
- const existing = JSON.parse(await readFile9(planPath, "utf8"));
16821
+ const planPath = join14(dir, "plan.json");
16822
+ if (existsSync11(planPath)) {
16823
+ const existing = JSON.parse(await readFile11(planPath, "utf8"));
16273
16824
  if (existing?.plan?.workflowId !== plan.workflowId) {
16274
16825
  throw new Error(`publishPlanRevision: workflowId mismatch on existing record at ${planPath}`);
16275
16826
  }
@@ -16299,15 +16850,15 @@ async function publishApproval(repoRoot, approval) {
16299
16850
  const common = await resolveCommon(repoRoot);
16300
16851
  const dir = revisionDir(common, approval.workflowId, approval.revision);
16301
16852
  await mkdir9(dir, { recursive: true });
16302
- const path = join11(dir, "approval.json");
16303
- const planPath = join11(dir, "plan.json");
16304
- if (existsSync9(planPath)) {
16305
- const planRecord = JSON.parse(await readFile9(planPath, "utf8"));
16853
+ const path = join14(dir, "approval.json");
16854
+ const planPath = join14(dir, "plan.json");
16855
+ if (existsSync11(planPath)) {
16856
+ const planRecord = JSON.parse(await readFile11(planPath, "utf8"));
16306
16857
  if (planRecord.hash !== approval.sha256) {
16307
16858
  throw new Error(`publishApproval: sha256 mismatch with plan record (plan ${planRecord.hash?.slice(0, 8)}, approval ${approval.sha256.slice(0, 8)})`);
16308
16859
  }
16309
16860
  }
16310
- if (existsSync9(path)) {
16861
+ if (existsSync11(path)) {
16311
16862
  return { recorded: false, path };
16312
16863
  }
16313
16864
  await publishImmutableJson(path, { ...approval, publishedAt: (/* @__PURE__ */ new Date()).toISOString() });
@@ -16315,10 +16866,10 @@ async function publishApproval(repoRoot, approval) {
16315
16866
  }
16316
16867
  async function readPlanRevision(repoRoot, workflowId, revision) {
16317
16868
  const common = await resolveCommon(repoRoot);
16318
- const path = join11(revisionDir(common, workflowId, revision), "plan.json");
16319
- if (!existsSync9(path)) return null;
16869
+ const path = join14(revisionDir(common, workflowId, revision), "plan.json");
16870
+ if (!existsSync11(path)) return null;
16320
16871
  try {
16321
- const raw = await readFile9(path, "utf8");
16872
+ const raw = await readFile11(path, "utf8");
16322
16873
  return JSON.parse(raw);
16323
16874
  } catch {
16324
16875
  return null;
@@ -16326,14 +16877,14 @@ async function readPlanRevision(repoRoot, workflowId, revision) {
16326
16877
  }
16327
16878
 
16328
16879
  // src/tools/ship-plan-submit.js
16329
- var SAFE_ID_RE3 = /^[A-Za-z0-9._-]{1,128}$/;
16880
+ var SAFE_ID_RE4 = /^[A-Za-z0-9._-]{1,128}$/;
16330
16881
  function createPlanSubmitTool(deps) {
16331
16882
  return async function planSubmit(input) {
16332
16883
  const opId = input.operationId ?? `plan-submit-${Date.now().toString(36)}`;
16333
16884
  const workflowId = String(input.workflowId ?? "");
16334
16885
  const revision = Number(input.revision);
16335
16886
  const plan = input.plan;
16336
- if (!workflowId || !SAFE_ID_RE3.test(workflowId)) {
16887
+ if (!workflowId || !SAFE_ID_RE4.test(workflowId)) {
16337
16888
  return failure("plan-submit", "workflowId required (safe id)", { operationId: opId, retryable: false });
16338
16889
  }
16339
16890
  if (!Number.isInteger(revision) || revision <= 0) {
@@ -16384,7 +16935,7 @@ function createPlanSubmitTool(deps) {
16384
16935
  }
16385
16936
 
16386
16937
  // src/tools/ship-plan-approve.js
16387
- var SAFE_ID_RE4 = /^[A-Za-z0-9._-]{1,128}$/;
16938
+ var SAFE_ID_RE5 = /^[A-Za-z0-9._-]{1,128}$/;
16388
16939
  function createPlanApproveTool(deps) {
16389
16940
  return async function planApprove(input) {
16390
16941
  const opId = input.operationId ?? `plan-approve-${Date.now().toString(36)}`;
@@ -16392,7 +16943,7 @@ function createPlanApproveTool(deps) {
16392
16943
  const revision = Number(input.revision);
16393
16944
  const sha2565 = String(input.sha256 ?? "");
16394
16945
  const subject = String(input.subject ?? "");
16395
- if (!workflowId || !SAFE_ID_RE4.test(workflowId)) {
16946
+ if (!workflowId || !SAFE_ID_RE5.test(workflowId)) {
16396
16947
  return failure("plan-approve", "workflowId required (safe id)", { operationId: opId, retryable: false });
16397
16948
  }
16398
16949
  if (!Number.isInteger(revision) || revision <= 0) {
@@ -16440,20 +16991,20 @@ function createPlanApproveTool(deps) {
16440
16991
  }
16441
16992
 
16442
16993
  // src/tools/ship-run-start.js
16443
- import { readFile as readFile10 } from "node:fs/promises";
16444
- import { join as join12 } from "node:path";
16445
- import { execFile } from "node:child_process";
16446
- var SAFE_ID_RE5 = /^[A-Za-z0-9._-]{1,128}$/;
16994
+ import { readFile as readFile12 } from "node:fs/promises";
16995
+ import { join as join15 } from "node:path";
16996
+ import { execFile as execFile2 } from "node:child_process";
16997
+ var SAFE_ID_RE6 = /^[A-Za-z0-9._-]{1,128}$/;
16447
16998
  async function readRevisionRecord(repoRoot, workflowId, revision) {
16448
16999
  const commonDir = await resolveGitCommonDir(repoRoot);
16449
17000
  const rev = String(revision).padStart(6, "0");
16450
- const dir = join12(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions", rev);
16451
- const planRaw = await readFile10(join12(dir, "plan.json"), "utf8");
17001
+ const dir = join15(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions", rev);
17002
+ const planRaw = await readFile12(join15(dir, "plan.json"), "utf8");
16452
17003
  const plan = JSON.parse(planRaw);
16453
- const approvalPath = join12(dir, "approval.json");
17004
+ const approvalPath = join15(dir, "approval.json");
16454
17005
  let approval = null;
16455
17006
  try {
16456
- approval = JSON.parse(await readFile10(approvalPath, "utf8"));
17007
+ approval = JSON.parse(await readFile12(approvalPath, "utf8"));
16457
17008
  } catch {
16458
17009
  approval = null;
16459
17010
  }
@@ -16463,7 +17014,7 @@ function createRunStartTool(deps) {
16463
17014
  return async function runStart(input) {
16464
17015
  const opId = input.operationId ?? `run-start-${Date.now().toString(36)}`;
16465
17016
  const workflowId = String(input.workflowId ?? "");
16466
- if (!workflowId || !SAFE_ID_RE5.test(workflowId)) {
17017
+ if (!workflowId || !SAFE_ID_RE6.test(workflowId)) {
16467
17018
  return failure("run-start", "workflowId required (safe id)", { operationId: opId, retryable: false });
16468
17019
  }
16469
17020
  const ctx = input.ctx ?? deps.ctx ?? null;
@@ -16521,7 +17072,7 @@ function createRunStartTool(deps) {
16521
17072
  }
16522
17073
  function gitHead(cwd) {
16523
17074
  return new Promise((resolveP, rejectP) => {
16524
- execFile("git", ["-C", cwd, "rev-parse", "HEAD"], { cwd, shell: false }, (err, stdout, stderr) => {
17075
+ execFile2("git", ["-C", cwd, "rev-parse", "HEAD"], { cwd, shell: false }, (err, stdout, stderr) => {
16525
17076
  if (err) return rejectP(new Error(stderr || err.message));
16526
17077
  resolveP(String(stdout).trim());
16527
17078
  });
@@ -16530,19 +17081,93 @@ function gitHead(cwd) {
16530
17081
 
16531
17082
  // src/tools/ship-task-start.js
16532
17083
  import { mkdir as mkdir10 } from "node:fs/promises";
16533
- import { join as join13 } from "node:path";
17084
+ import { join as join16 } from "node:path";
16534
17085
  init_durable_store();
16535
- import { createHash as createHash11 } from "node:crypto";
16536
- var SAFE_ID_RE6 = /^[A-Za-z0-9._-]{1,128}$/;
17086
+ import { createHash as createHash12 } from "node:crypto";
17087
+
17088
+ // src/workflow/worktree-resolver.js
17089
+ function failureRecord(kind, details = {}) {
17090
+ return { ok: false, kind, ...details };
17091
+ }
17092
+ async function resolveWorkflowWorktree(repoRoot, workflowId) {
17093
+ const runState = await readRunState(repoRoot, workflowId);
17094
+ if (!runState) {
17095
+ return failureRecord("missing-workflow-run", { workflowId });
17096
+ }
17097
+ const planRecord = await readPlanRevision(repoRoot, workflowId, runState.revision);
17098
+ if (!planRecord) {
17099
+ return failureRecord("missing-workflow-plan", {
17100
+ workflowId,
17101
+ revision: runState.revision
17102
+ });
17103
+ }
17104
+ if (planRecord.hash !== runState.sha256) {
17105
+ return failureRecord("workflow-plan-mismatch", {
17106
+ workflowId,
17107
+ revision: runState.revision,
17108
+ expected: runState.sha256,
17109
+ received: planRecord.hash
17110
+ });
17111
+ }
17112
+ const issueNumber = planRecord.plan?.source?.issueNumber;
17113
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
17114
+ return failureRecord("missing-workflow-issue", {
17115
+ workflowId,
17116
+ revision: runState.revision
17117
+ });
17118
+ }
17119
+ const matches = (await listManifests(repoRoot)).filter((manifest2) => manifest2.issueNumber === issueNumber);
17120
+ if (matches.length !== 1) {
17121
+ return failureRecord("ambiguous-workflow-manifest", {
17122
+ issueNumber,
17123
+ count: matches.length
17124
+ });
17125
+ }
17126
+ const manifest = matches[0];
17127
+ if (manifest.schemaVersion !== 2) {
17128
+ return failureRecord("workflow-mismatch", {
17129
+ expectedSchema: 2,
17130
+ receivedSchema: manifest.schemaVersion
17131
+ });
17132
+ }
17133
+ if (manifest.workflowId !== workflowId) {
17134
+ return failureRecord("workflow-mismatch", {
17135
+ expected: workflowId,
17136
+ received: manifest.workflowId
17137
+ });
17138
+ }
17139
+ if (!manifest.worktreePath) {
17140
+ return failureRecord("missing-worktree-path", { taskId: manifest.taskId });
17141
+ }
17142
+ const linked = await validateLinkedWorktree(repoRoot, manifest.worktreePath, {
17143
+ allowCurrentLinked: true
17144
+ });
17145
+ if (!linked.ok) {
17146
+ return failureRecord("invalid-worktree", {
17147
+ reason: linked.kind,
17148
+ message: linked.message
17149
+ });
17150
+ }
17151
+ return {
17152
+ ok: true,
17153
+ workflowId,
17154
+ issueNumber,
17155
+ manifest,
17156
+ worktreePath: linked.path
17157
+ };
17158
+ }
17159
+
17160
+ // src/tools/ship-task-start.js
17161
+ var SAFE_ID_RE7 = /^[A-Za-z0-9._-]{1,128}$/;
16537
17162
  function createTaskStartTool(deps) {
16538
17163
  return async function taskStart(input) {
16539
17164
  const opId = input.operationId ?? `task-start-${Date.now().toString(36)}`;
16540
17165
  const workflowId = String(input.workflowId ?? "");
16541
17166
  const taskId = String(input.taskId ?? "");
16542
- if (!workflowId || !SAFE_ID_RE6.test(workflowId)) {
17167
+ if (!workflowId || !SAFE_ID_RE7.test(workflowId)) {
16543
17168
  return failure("task-start", "workflowId required (safe id)", { operationId: opId, retryable: false });
16544
17169
  }
16545
- if (!taskId || !SAFE_ID_RE6.test(taskId)) {
17170
+ if (!taskId || !SAFE_ID_RE7.test(taskId)) {
16546
17171
  return failure("task-start", "taskId required (safe id)", { operationId: opId, retryable: false });
16547
17172
  }
16548
17173
  const ctx = input.ctx ?? deps.ctx ?? null;
@@ -16582,13 +17207,21 @@ function createTaskStartTool(deps) {
16582
17207
  if (unsatisfied.length > 0) {
16583
17208
  return failure("task-start", `task dependencies are incomplete: ${unsatisfied.join(", ")}`, { operationId: opId, retryable: false });
16584
17209
  }
16585
- const briefHash = createHash11("sha256").update(canonicalJson(task), "utf8").digest("hex");
17210
+ const briefHash = createHash12("sha256").update(canonicalJson(task), "utf8").digest("hex");
16586
17211
  const round = runState.round > 0 ? runState.round : 1;
16587
17212
  try {
17213
+ const resolved = await resolveWorkflowWorktree(deps.repoRoot, workflowId);
17214
+ if (!resolved.ok) {
17215
+ return failure("task-start", `workflow worktree resolution failed: ${resolved.kind}`, {
17216
+ operationId: opId,
17217
+ retryable: false,
17218
+ details: resolved
17219
+ });
17220
+ }
16588
17221
  let dispatchResult = null;
16589
17222
  if (deps.opencodeClient) {
16590
17223
  dispatchResult = await dispatchWorker({
16591
- repoRoot: deps.repoRoot,
17224
+ repoRoot: resolved.worktreePath,
16592
17225
  workflowId,
16593
17226
  role: ROLES.BUILDER,
16594
17227
  keyInput: { taskId, round },
@@ -16608,7 +17241,7 @@ ${JSON.stringify(task, null, 2)}`
16608
17241
  });
16609
17242
  }
16610
17243
  const commonDir = await resolveGitCommonDir(deps.repoRoot);
16611
- const dispatchDir2 = join13(
17244
+ const dispatchDir2 = join16(
16612
17245
  opencodeShipStateDir(commonDir),
16613
17246
  "runs",
16614
17247
  workflowId,
@@ -16631,7 +17264,7 @@ ${JSON.stringify(task, null, 2)}`
16631
17264
  task,
16632
17265
  dispatchedAt: (/* @__PURE__ */ new Date()).toISOString()
16633
17266
  };
16634
- await publishImmutableJson(join13(dispatchDir2, "dispatch.json"), record2);
17267
+ await publishImmutableJson(join16(dispatchDir2, "dispatch.json"), record2);
16635
17268
  const { state, event } = await appendRunEvent(
16636
17269
  deps.repoRoot,
16637
17270
  workflowId,
@@ -16657,15 +17290,15 @@ ${JSON.stringify(task, null, 2)}`
16657
17290
  }
16658
17291
 
16659
17292
  // src/tools/ship-task-commit.js
16660
- import { execFile as execFile2 } from "node:child_process";
16661
- import { mkdir as mkdir11, readFile as readFile11 } from "node:fs/promises";
16662
- import { existsSync as existsSync10 } from "node:fs";
16663
- import { join as join14 } from "node:path";
17293
+ import { execFile as execFile3 } from "node:child_process";
17294
+ import { mkdir as mkdir11, readFile as readFile13 } from "node:fs/promises";
17295
+ import { existsSync as existsSync12 } from "node:fs";
17296
+ import { join as join17 } from "node:path";
16664
17297
  init_durable_store();
16665
- var SAFE_ID_RE7 = /^[A-Za-z0-9._-]{1,128}$/;
17298
+ var SAFE_ID_RE8 = /^[A-Za-z0-9._-]{1,128}$/;
16666
17299
  function spawn5(cmd, args, cwd) {
16667
17300
  return new Promise((resolveP, rejectP) => {
16668
- execFile2(cmd, args, { cwd, shell: false }, (err, stdout, stderr) => {
17301
+ execFile3(cmd, args, { cwd, shell: false }, (err, stdout, stderr) => {
16669
17302
  if (err) {
16670
17303
  const msg = typeof stderr === "string" ? stderr : stderr ? String(stderr) : err.message;
16671
17304
  return rejectP(new Error(`${cmd} failed: ${msg}`));
@@ -16684,10 +17317,10 @@ function createTaskCommitTool(deps) {
16684
17317
  const planHash = String(input.planHash ?? "");
16685
17318
  const reviewHash = String(input.reviewHash ?? "");
16686
17319
  const round = Number(input.round ?? 1);
16687
- if (!workflowId || !SAFE_ID_RE7.test(workflowId)) {
17320
+ if (!workflowId || !SAFE_ID_RE8.test(workflowId)) {
16688
17321
  return failure("task-commit", "workflowId required (safe id)", { operationId: opId, retryable: false });
16689
17322
  }
16690
- if (!taskId || !SAFE_ID_RE7.test(taskId)) {
17323
+ if (!taskId || !SAFE_ID_RE8.test(taskId)) {
16691
17324
  return failure("task-commit", "taskId required (safe id)", { operationId: opId, retryable: false });
16692
17325
  }
16693
17326
  if (!/^[0-9a-f]{40}$/.test(expectedHead)) {
@@ -16749,19 +17382,27 @@ function createTaskCommitTool(deps) {
16749
17382
  return failure("task-commit", `round does not match the active run (${runState.round})`, { operationId: opId, retryable: false });
16750
17383
  }
16751
17384
  try {
16752
- const actualHead = (await spawn5("git", ["-C", deps.repoRoot, "rev-parse", "HEAD"], deps.repoRoot)).trim();
17385
+ const resolved = await resolveWorkflowWorktree(deps.repoRoot, workflowId);
17386
+ if (!resolved.ok) {
17387
+ return failure("task-commit", `workflow worktree resolution failed: ${resolved.kind}`, {
17388
+ operationId: opId,
17389
+ retryable: false,
17390
+ details: resolved
17391
+ });
17392
+ }
17393
+ const actualHead = (await spawn5("git", ["-C", resolved.worktreePath, "rev-parse", "HEAD"], resolved.worktreePath)).trim();
16753
17394
  if (actualHead !== expectedHead) {
16754
17395
  return failure("task-commit", `HEAD drift (expected ${expectedHead.slice(0, 8)}, got ${actualHead.slice(0, 8)})`, { operationId: opId, retryable: false });
16755
17396
  }
16756
17397
  const trailers = buildCommitTrailers({ workflowId, planHash, taskId, round, reviewHash });
16757
- const message = await spawn5("git", ["-C", deps.repoRoot, "log", "-1", "--format=%B", expectedHead], deps.repoRoot);
17398
+ const message = await spawn5("git", ["-C", resolved.worktreePath, "log", "-1", "--format=%B", expectedHead], resolved.worktreePath);
16758
17399
  const trailerLines = trailers.map((t) => ` ${t}`).join("\n");
16759
17400
  const missingTrailer = trailers.find((trailer) => !message.includes(trailer));
16760
17401
  if (missingTrailer) {
16761
17402
  return failure("task-commit", `commit ${expectedHead.slice(0, 8)} missing trailer: ${missingTrailer}`, { operationId: opId, retryable: false });
16762
17403
  }
16763
17404
  const commonDir = await resolveGitCommonDir(deps.repoRoot);
16764
- const commitDir = join14(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "commit");
17405
+ const commitDir = join17(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "commit");
16765
17406
  await mkdir11(commitDir, { recursive: true });
16766
17407
  const record2 = {
16767
17408
  workflowId,
@@ -16774,9 +17415,9 @@ function createTaskCommitTool(deps) {
16774
17415
  trailerBlock: trailerLines,
16775
17416
  committedAt: (/* @__PURE__ */ new Date()).toISOString()
16776
17417
  };
16777
- const commitPath = join14(commitDir, "commit.json");
16778
- if (existsSync10(commitPath)) {
16779
- const existing = JSON.parse(await readFile11(commitPath, "utf8"));
17418
+ const commitPath = join17(commitDir, "commit.json");
17419
+ if (existsSync12(commitPath)) {
17420
+ const existing = JSON.parse(await readFile13(commitPath, "utf8"));
16780
17421
  if (existing.workflowId !== workflowId || existing.taskId !== taskId || existing.round !== round || existing.commitSha !== commitSha || existing.planHash !== planHash || existing.reviewHash !== reviewHash) {
16781
17422
  return failure("task-commit", "immutable task commit conflicts with retry", { operationId: opId, retryable: false });
16782
17423
  }
@@ -16797,13 +17438,13 @@ function createTaskCommitTool(deps) {
16797
17438
  }
16798
17439
 
16799
17440
  // src/tools/ship-task-complete.js
16800
- import { mkdir as mkdir12, readFile as readFile12 } from "node:fs/promises";
16801
- import { existsSync as existsSync11 } from "node:fs";
16802
- import { join as join15 } from "node:path";
16803
- import { execFile as execFile3 } from "node:child_process";
16804
- import { createHash as createHash12 } from "node:crypto";
17441
+ import { mkdir as mkdir12, readFile as readFile14 } from "node:fs/promises";
17442
+ import { existsSync as existsSync13 } from "node:fs";
17443
+ import { join as join18 } from "node:path";
17444
+ import { execFile as execFile4 } from "node:child_process";
17445
+ import { createHash as createHash13 } from "node:crypto";
16805
17446
  init_durable_store();
16806
- var SAFE_ID_RE8 = /^[A-Za-z0-9._-]{1,128}$/;
17447
+ var SAFE_ID_RE9 = /^[A-Za-z0-9._-]{1,128}$/;
16807
17448
  function createTaskCompleteTool(deps) {
16808
17449
  return async function taskComplete(input) {
16809
17450
  const opId = input.operationId ?? `task-complete-${Date.now().toString(36)}`;
@@ -16812,16 +17453,16 @@ function createTaskCompleteTool(deps) {
16812
17453
  const moreTasks = input.moreTasks === false ? false : input.moreTasks === true ? true : null;
16813
17454
  const nextTaskId = input.nextTaskId ? String(input.nextTaskId) : null;
16814
17455
  const expectedHead = String(input.expectedHead ?? "");
16815
- if (!workflowId || !SAFE_ID_RE8.test(workflowId)) {
17456
+ if (!workflowId || !SAFE_ID_RE9.test(workflowId)) {
16816
17457
  return failure("task-complete", "workflowId required (safe id)", { operationId: opId, retryable: false });
16817
17458
  }
16818
- if (!taskId || !SAFE_ID_RE8.test(taskId)) {
17459
+ if (!taskId || !SAFE_ID_RE9.test(taskId)) {
16819
17460
  return failure("task-complete", "taskId required (safe id)", { operationId: opId, retryable: false });
16820
17461
  }
16821
17462
  if (moreTasks === null) {
16822
17463
  return failure("task-complete", "moreTasks must be explicitly true or false", { operationId: opId, retryable: false });
16823
17464
  }
16824
- if (moreTasks && (!nextTaskId || !SAFE_ID_RE8.test(nextTaskId))) {
17465
+ if (moreTasks && (!nextTaskId || !SAFE_ID_RE9.test(nextTaskId))) {
16825
17466
  return failure("task-complete", "nextTaskId required when moreTasks=true", { operationId: opId, retryable: false });
16826
17467
  }
16827
17468
  if (!moreTasks && !/^[0-9a-f]{40}$/.test(expectedHead)) {
@@ -16864,6 +17505,14 @@ function createTaskCompleteTool(deps) {
16864
17505
  return failure("task-complete", `plan still has incomplete tasks: ${remainingTasks.map((task) => task.id).join(", ")}`, { operationId: opId, retryable: false });
16865
17506
  }
16866
17507
  try {
17508
+ const resolved = await resolveWorkflowWorktree(deps.repoRoot, workflowId);
17509
+ if (!resolved.ok) {
17510
+ return failure("task-complete", `workflow worktree resolution failed: ${resolved.kind}`, {
17511
+ operationId: opId,
17512
+ retryable: false,
17513
+ details: resolved
17514
+ });
17515
+ }
16867
17516
  const gateEvidence = moreTasks ? null : await loadTrustedGateEvidence({
16868
17517
  repoRoot: deps.repoRoot,
16869
17518
  repoSlug: deps.repoSlug,
@@ -16875,7 +17524,7 @@ function createTaskCompleteTool(deps) {
16875
17524
  });
16876
17525
  const commonDir = await resolveGitCommonDir(deps.repoRoot);
16877
17526
  const finalPackage = moreTasks ? null : await loadOrBuildFinalPackage({
16878
- repoRoot: deps.repoRoot,
17527
+ repoRoot: resolved.worktreePath,
16879
17528
  commonDir,
16880
17529
  workflowId,
16881
17530
  runState,
@@ -16885,7 +17534,7 @@ function createTaskCompleteTool(deps) {
16885
17534
  ciHash: gateEvidence?.ciHash ?? "",
16886
17535
  gateTaskId: gateEvidence?.taskId ?? ""
16887
17536
  });
16888
- const completeDir = join15(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "complete");
17537
+ const completeDir = join18(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "complete");
16889
17538
  await mkdir12(completeDir, { recursive: true });
16890
17539
  const record2 = {
16891
17540
  workflowId,
@@ -16895,9 +17544,9 @@ function createTaskCompleteTool(deps) {
16895
17544
  finalReview: finalPackage,
16896
17545
  completedAt: (/* @__PURE__ */ new Date()).toISOString()
16897
17546
  };
16898
- const completePath = join15(completeDir, "complete.json");
16899
- if (existsSync11(completePath)) {
16900
- const existing = JSON.parse(await readFile12(completePath, "utf8"));
17547
+ const completePath = join18(completeDir, "complete.json");
17548
+ if (existsSync13(completePath)) {
17549
+ const existing = JSON.parse(await readFile14(completePath, "utf8"));
16901
17550
  if (existing.workflowId !== workflowId || existing.taskId !== taskId || existing.moreTasks !== moreTasks || (existing.nextTaskId ?? null) !== (nextTaskId ?? null) || (existing.finalReview?.packageHash ?? null) !== (finalPackage?.packageHash ?? null)) {
16902
17551
  return failure("task-complete", "immutable task completion conflicts with retry", { operationId: opId, retryable: false });
16903
17552
  }
@@ -16924,7 +17573,7 @@ function createTaskCompleteTool(deps) {
16924
17573
  if (!moreTasks && deps.opencodeClient) {
16925
17574
  const models = resolveModelRoles(deps.config?.workflow, { strict: true });
16926
17575
  const packageHash = finalPackage.packageHash;
16927
- const packagePath = join15(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", "package.json");
17576
+ const packagePath = join18(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", "package.json");
16928
17577
  const promptText = [
16929
17578
  `Review workflow ${workflowId} package ${packageHash} at HEAD ${finalPackage.headSha} against merge base ${finalPackage.mergeBaseSha}.`,
16930
17579
  `Canonical package path: ${packagePath}`,
@@ -16933,7 +17582,7 @@ ${JSON.stringify(finalPackage, null, 2)}`
16933
17582
  ].join("\n\n");
16934
17583
  const [standards, spec] = await Promise.all([
16935
17584
  dispatchWorker({
16936
- repoRoot: deps.repoRoot,
17585
+ repoRoot: resolved.worktreePath,
16937
17586
  workflowId,
16938
17587
  role: ROLES.FINAL_STANDARDS,
16939
17588
  keyInput: { packageHash },
@@ -16945,7 +17594,7 @@ ${JSON.stringify(finalPackage, null, 2)}`
16945
17594
  model: models.finalReviewer
16946
17595
  }),
16947
17596
  dispatchWorker({
16948
- repoRoot: deps.repoRoot,
17597
+ repoRoot: resolved.worktreePath,
16949
17598
  workflowId,
16950
17599
  role: ROLES.FINAL_SPEC,
16951
17600
  keyInput: { packageHash },
@@ -16983,7 +17632,7 @@ async function loadTrustedGateEvidence({ repoRoot, repoSlug, driver, adapter, wo
16983
17632
  const manifests = (await listManifests(repoRoot)).filter((manifest2) => manifest2.issueNumber === issueNumber);
16984
17633
  if (manifests.length !== 1) throw new Error(`expected one delivery manifest for issue #${issueNumber}, found ${manifests.length}`);
16985
17634
  const manifest = manifests[0];
16986
- if (manifest.schemaVersion < 2 || manifest.workflowId !== workflowId) {
17635
+ if (manifest.schemaVersion !== 2 || manifest.workflowId !== workflowId) {
16987
17636
  throw new Error("delivery manifest is not linked to the current workflow");
16988
17637
  }
16989
17638
  if (manifest.lastVerifierSha !== expectedHead || !/^[0-9a-f]{64}$/.test(manifest.lastVerificationHash ?? "")) {
@@ -17019,10 +17668,10 @@ async function loadTrustedGateEvidence({ repoRoot, repoSlug, driver, adapter, wo
17019
17668
  return { verificationHash: verification.receiptHash, ciHash: ci.receiptHash, taskId: manifest.taskId };
17020
17669
  }
17021
17670
  async function loadOrBuildFinalPackage({ repoRoot, commonDir, workflowId, runState, planRecord, expectedHead, verificationHash, ciHash, gateTaskId }) {
17022
- const reviewDir = join15(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review");
17023
- const packagePath = join15(reviewDir, "package.json");
17024
- if (existsSync11(packagePath)) {
17025
- const existing = JSON.parse(await readFile12(packagePath, "utf8"));
17671
+ const reviewDir = join18(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review");
17672
+ const packagePath = join18(reviewDir, "package.json");
17673
+ if (existsSync13(packagePath)) {
17674
+ const existing = JSON.parse(await readFile14(packagePath, "utf8"));
17026
17675
  if (existing.headSha !== expectedHead || existing.verificationHash !== verificationHash || existing.ciHash !== ciHash || existing.gateTaskId !== gateTaskId) {
17027
17676
  throw new Error("final review package already exists with different gate evidence");
17028
17677
  }
@@ -17039,10 +17688,10 @@ async function loadOrBuildFinalPackage({ repoRoot, commonDir, workflowId, runSta
17039
17688
  throw new Error(`approved base ${mergeBaseSha.slice(0, 8)} is not the merge base of final HEAD`);
17040
17689
  }
17041
17690
  const revision = String(runState.revision).padStart(6, "0");
17042
- const approvalRaw = await readFile12(join15(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions", revision, "approval.json"), "utf8");
17691
+ const approvalRaw = await readFile14(join18(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions", revision, "approval.json"), "utf8");
17043
17692
  const tasks = [];
17044
17693
  for (const completedTaskId of runState.completedTasks ?? []) {
17045
- const commitRaw = await readFile12(join15(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", completedTaskId, "commit", "commit.json"), "utf8");
17694
+ const commitRaw = await readFile14(join18(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", completedTaskId, "commit", "commit.json"), "utf8");
17046
17695
  const commit = JSON.parse(commitRaw);
17047
17696
  tasks.push({
17048
17697
  taskId: completedTaskId,
@@ -17069,23 +17718,23 @@ async function loadOrBuildFinalPackage({ repoRoot, commonDir, workflowId, runSta
17069
17718
  }
17070
17719
  function git(cwd, args) {
17071
17720
  return new Promise((resolveP, rejectP) => {
17072
- execFile3("git", ["-C", cwd, ...args], { cwd, shell: false }, (err, stdout, stderr) => {
17721
+ execFile4("git", ["-C", cwd, ...args], { cwd, shell: false }, (err, stdout, stderr) => {
17073
17722
  if (err) return rejectP(new Error(`git ${args[0]} failed: ${stderr || err.message}`));
17074
17723
  resolveP(String(stdout));
17075
17724
  });
17076
17725
  });
17077
17726
  }
17078
17727
  function sha2564(value) {
17079
- return createHash12("sha256").update(value, "utf8").digest("hex");
17728
+ return createHash13("sha256").update(value, "utf8").digest("hex");
17080
17729
  }
17081
17730
 
17082
17731
  // src/tools/ship-task-report.js
17083
- import { mkdir as mkdir13, readFile as readFile13 } from "node:fs/promises";
17084
- import { existsSync as existsSync12 } from "node:fs";
17085
- import { createHash as createHash13 } from "node:crypto";
17086
- import { join as join16 } from "node:path";
17732
+ import { mkdir as mkdir13, readFile as readFile15 } from "node:fs/promises";
17733
+ import { existsSync as existsSync14 } from "node:fs";
17734
+ import { createHash as createHash14 } from "node:crypto";
17735
+ import { join as join19 } from "node:path";
17087
17736
  init_durable_store();
17088
- var SAFE_ID_RE9 = /^[A-Za-z0-9._-]{1,128}$/;
17737
+ var SAFE_ID_RE10 = /^[A-Za-z0-9._-]{1,128}$/;
17089
17738
  function createTaskReportTool(deps) {
17090
17739
  return async function taskReport(input) {
17091
17740
  const opId = input.operationId ?? `task-report-${Date.now().toString(36)}`;
@@ -17093,10 +17742,10 @@ function createTaskReportTool(deps) {
17093
17742
  const taskId = String(input.taskId ?? "");
17094
17743
  const round = Number(input.round ?? 1);
17095
17744
  const summary = String(input.summary ?? "");
17096
- if (!workflowId || !SAFE_ID_RE9.test(workflowId)) {
17745
+ if (!workflowId || !SAFE_ID_RE10.test(workflowId)) {
17097
17746
  return failure("task-report", "workflowId required (safe id)", { operationId: opId, retryable: false });
17098
17747
  }
17099
- if (!taskId || !SAFE_ID_RE9.test(taskId)) {
17748
+ if (!taskId || !SAFE_ID_RE10.test(taskId)) {
17100
17749
  return failure("task-report", "taskId required (safe id)", { operationId: opId, retryable: false });
17101
17750
  }
17102
17751
  if (!Number.isInteger(round) || round <= 0) {
@@ -17131,8 +17780,16 @@ function createTaskReportTool(deps) {
17131
17780
  return failure("task-report", `another task is already active (${runState.activeTask})`, { operationId: opId, retryable: false });
17132
17781
  }
17133
17782
  try {
17783
+ const resolved = await resolveWorkflowWorktree(deps.repoRoot, workflowId);
17784
+ if (!resolved.ok) {
17785
+ return failure("task-report", `workflow worktree resolution failed: ${resolved.kind}`, {
17786
+ operationId: opId,
17787
+ retryable: false,
17788
+ details: resolved
17789
+ });
17790
+ }
17134
17791
  const commonDir = await resolveGitCommonDir(deps.repoRoot);
17135
- const reportDir = join16(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
17792
+ const reportDir = join19(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
17136
17793
  await mkdir13(reportDir, { recursive: true });
17137
17794
  const record2 = {
17138
17795
  workflowId,
@@ -17144,12 +17801,12 @@ function createTaskReportTool(deps) {
17144
17801
  tests: Array.isArray(input.tests) ? input.tests : [],
17145
17802
  submittedAt: (/* @__PURE__ */ new Date()).toISOString()
17146
17803
  };
17147
- const reportPath = join16(reportDir, "implementer-report.json");
17804
+ const reportPath = join19(reportDir, "implementer-report.json");
17148
17805
  let state = runState;
17149
17806
  let event = runState.events.at(-1) ?? { sequence: 0 };
17150
17807
  let persistedRecord = record2;
17151
- if (existsSync12(reportPath)) {
17152
- persistedRecord = JSON.parse(await readFile13(reportPath, "utf8"));
17808
+ if (existsSync14(reportPath)) {
17809
+ persistedRecord = JSON.parse(await readFile15(reportPath, "utf8"));
17153
17810
  if (!sameReport(persistedRecord, record2)) {
17154
17811
  return failure("task-report", "immutable report already exists with different content", { operationId: opId, retryable: false });
17155
17812
  }
@@ -17180,7 +17837,7 @@ function createTaskReportTool(deps) {
17180
17837
  if (deps.opencodeClient) {
17181
17838
  try {
17182
17839
  reviewerDispatch = await dispatchWorker({
17183
- repoRoot: deps.repoRoot,
17840
+ repoRoot: resolved.worktreePath,
17184
17841
  workflowId,
17185
17842
  role: ROLES.TASK_REVIEWER,
17186
17843
  keyInput: { taskId, round },
@@ -17224,7 +17881,7 @@ function reportHash(record2) {
17224
17881
  const sorted = Object.keys(record2).sort();
17225
17882
  const ordered = {};
17226
17883
  for (const k of sorted) ordered[k] = record2[k];
17227
- return createHash13("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
17884
+ return createHash14("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
17228
17885
  }
17229
17886
  function sameReport(left, right) {
17230
17887
  for (const field of ["workflowId", "taskId", "round", "builderSessionID", "summary"]) {
@@ -17234,12 +17891,12 @@ function sameReport(left, right) {
17234
17891
  }
17235
17892
 
17236
17893
  // src/tools/ship-task-review.js
17237
- import { createHash as createHash14 } from "node:crypto";
17238
- import { mkdir as mkdir14, readFile as readFile14 } from "node:fs/promises";
17239
- import { existsSync as existsSync13 } from "node:fs";
17240
- import { join as join17 } from "node:path";
17894
+ import { createHash as createHash15 } from "node:crypto";
17895
+ import { mkdir as mkdir14, readFile as readFile16 } from "node:fs/promises";
17896
+ import { existsSync as existsSync15 } from "node:fs";
17897
+ import { join as join20 } from "node:path";
17241
17898
  init_durable_store();
17242
- var SAFE_ID_RE10 = /^[A-Za-z0-9._-]{1,128}$/;
17899
+ var SAFE_ID_RE11 = /^[A-Za-z0-9._-]{1,128}$/;
17243
17900
  var VERDICT_VALUES = /* @__PURE__ */ new Set(["pass", "fail", "none"]);
17244
17901
  function createTaskReviewTool(deps) {
17245
17902
  return async function taskReview(input) {
@@ -17249,10 +17906,10 @@ function createTaskReviewTool(deps) {
17249
17906
  const round = Number(input.round ?? 1);
17250
17907
  const spec = input.spec;
17251
17908
  const quality = input.quality;
17252
- if (!workflowId || !SAFE_ID_RE10.test(workflowId)) {
17909
+ if (!workflowId || !SAFE_ID_RE11.test(workflowId)) {
17253
17910
  return failure("task-review", "workflowId required (safe id)", { operationId: opId, retryable: false });
17254
17911
  }
17255
- if (!taskId || !SAFE_ID_RE10.test(taskId)) {
17912
+ if (!taskId || !SAFE_ID_RE11.test(taskId)) {
17256
17913
  return failure("task-review", "taskId required (safe id)", { operationId: opId, retryable: false });
17257
17914
  }
17258
17915
  if (!Number.isInteger(round) || round <= 0) {
@@ -17308,7 +17965,7 @@ function createTaskReviewTool(deps) {
17308
17965
  const specPass = String(spec.verdict) === "pass";
17309
17966
  const qualityPass = String(quality.verdict) === "pass";
17310
17967
  const commonDir = await resolveGitCommonDir(deps.repoRoot);
17311
- const reviewDir = join17(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
17968
+ const reviewDir = join20(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
17312
17969
  await mkdir14(reviewDir, { recursive: true });
17313
17970
  const record2 = {
17314
17971
  workflowId,
@@ -17320,9 +17977,9 @@ function createTaskReviewTool(deps) {
17320
17977
  state: specPass && qualityPass ? "commit-pending" : "fix-pending",
17321
17978
  reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
17322
17979
  };
17323
- const reviewPath = join17(reviewDir, "review.json");
17324
- if (existsSync13(reviewPath)) {
17325
- const existing = JSON.parse(await readFile14(reviewPath, "utf8"));
17980
+ const reviewPath = join20(reviewDir, "review.json");
17981
+ if (existsSync15(reviewPath)) {
17982
+ const existing = JSON.parse(await readFile16(reviewPath, "utf8"));
17326
17983
  if (existing.workflowId !== workflowId || existing.taskId !== taskId || existing.round !== round || existing.reviewerSessionID !== auth.sessionID || JSON.stringify(existing.spec) !== JSON.stringify(spec) || JSON.stringify(existing.quality) !== JSON.stringify(quality)) {
17327
17984
  return failure("task-review", "immutable task review conflicts with retry", { operationId: opId, retryable: false });
17328
17985
  }
@@ -17357,15 +18014,15 @@ function verdictHash(record2) {
17357
18014
  const sorted = Object.keys(record2).sort();
17358
18015
  const ordered = {};
17359
18016
  for (const k of sorted) ordered[k] = record2[k];
17360
- return createHash14("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
18017
+ return createHash15("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
17361
18018
  }
17362
18019
 
17363
18020
  // src/tools/ship-final-review.js
17364
- import { mkdir as mkdir15, readFile as readFile15 } from "node:fs/promises";
17365
- import { existsSync as existsSync14 } from "node:fs";
17366
- import { join as join18 } from "node:path";
18021
+ import { mkdir as mkdir15, readFile as readFile17 } from "node:fs/promises";
18022
+ import { existsSync as existsSync16 } from "node:fs";
18023
+ import { join as join21 } from "node:path";
17367
18024
  init_durable_store();
17368
- var SAFE_ID_RE11 = /^[A-Za-z0-9._-]{1,128}$/;
18025
+ var SAFE_ID_RE12 = /^[A-Za-z0-9._-]{1,128}$/;
17369
18026
  var AXES = /* @__PURE__ */ new Set(["standards", "spec"]);
17370
18027
  var VERDICTS = /* @__PURE__ */ new Set(["pass", "fail", "blocked"]);
17371
18028
  var ROLE_FOR_AXIS = {
@@ -17382,7 +18039,7 @@ function createFinalReviewTool(deps) {
17382
18039
  const mergeBaseSha = String(input.mergeBaseSha ?? "");
17383
18040
  const packageHash = String(input.packageHash ?? "");
17384
18041
  const findings = Array.isArray(input.findings) ? input.findings : [];
17385
- if (!workflowId || !SAFE_ID_RE11.test(workflowId)) {
18042
+ if (!workflowId || !SAFE_ID_RE12.test(workflowId)) {
17386
18043
  return failure("final-review", "workflowId required (safe id)", { operationId: opId, retryable: false });
17387
18044
  }
17388
18045
  if (!AXES.has(axis)) {
@@ -17429,18 +18086,18 @@ function createFinalReviewTool(deps) {
17429
18086
  }
17430
18087
  try {
17431
18088
  const commonDir = await resolveGitCommonDir(deps.repoRoot);
17432
- const packagePath = join18(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", "package.json");
17433
- if (!existsSync14(packagePath)) {
18089
+ const packagePath = join21(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", "package.json");
18090
+ if (!existsSync16(packagePath)) {
17434
18091
  return failure("final-review", "canonical final review package is missing", { operationId: opId, retryable: false });
17435
18092
  }
17436
- const finalPackage = JSON.parse(await readFile15(packagePath, "utf8"));
18093
+ const finalPackage = JSON.parse(await readFile17(packagePath, "utf8"));
17437
18094
  if (hashFinalReviewPackage(finalPackage) !== finalPackage.packageHash) {
17438
18095
  return failure("final-review", "canonical final review package hash is invalid", { operationId: opId, retryable: false });
17439
18096
  }
17440
18097
  if (finalPackage.packageHash !== packageHash || finalPackage.headSha !== headSha || finalPackage.mergeBaseSha !== mergeBaseSha) {
17441
18098
  return failure("final-review", "review input does not match the canonical final review package", { operationId: opId, retryable: false });
17442
18099
  }
17443
- const reviewDir = join18(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", axis);
18100
+ const reviewDir = join21(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", axis);
17444
18101
  await mkdir15(reviewDir, { recursive: true });
17445
18102
  let record2 = {
17446
18103
  workflowId,
@@ -17454,9 +18111,9 @@ function createFinalReviewTool(deps) {
17454
18111
  findings,
17455
18112
  reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
17456
18113
  };
17457
- const reviewPath = join18(reviewDir, "review.json");
17458
- if (existsSync14(reviewPath)) {
17459
- record2 = JSON.parse(await readFile15(reviewPath, "utf8"));
18114
+ const reviewPath = join21(reviewDir, "review.json");
18115
+ if (existsSync16(reviewPath)) {
18116
+ record2 = JSON.parse(await readFile17(reviewPath, "utf8"));
17460
18117
  if (record2.axis !== axis || record2.verdict !== verdict || record2.headSha !== headSha || record2.mergeBaseSha !== mergeBaseSha || record2.packageHash !== packageHash || record2.reviewerSessionID !== auth.sessionID || hashAxisRecord(
17461
18118
  /** @type {any} */
17462
18119
  record2
@@ -17517,8 +18174,8 @@ function createFinalReviewTool(deps) {
17517
18174
 
17518
18175
  // src/workflow/resume.js
17519
18176
  import { readdir as readdir6, mkdir as mkdir16 } from "node:fs/promises";
17520
- import { existsSync as existsSync15 } from "node:fs";
17521
- import { join as join19 } from "node:path";
18177
+ import { existsSync as existsSync17 } from "node:fs";
18178
+ import { join as join22 } from "node:path";
17522
18179
  init_durable_store();
17523
18180
  function parseTrailer(text, key) {
17524
18181
  if (typeof text !== "string") return null;
@@ -17593,12 +18250,12 @@ async function resumeRun(repoRoot, workflowId) {
17593
18250
  }
17594
18251
 
17595
18252
  // src/tools/ship-resume.js
17596
- var SAFE_ID_RE12 = /^[A-Za-z0-9._-]{1,128}$/;
18253
+ var SAFE_ID_RE13 = /^[A-Za-z0-9._-]{1,128}$/;
17597
18254
  function createResumeTool(deps) {
17598
18255
  return async function resume(input) {
17599
18256
  const opId = input.operationId ?? `resume-${Date.now().toString(36)}`;
17600
18257
  const workflowId = String(input.workflowId ?? "");
17601
- if (!workflowId || !SAFE_ID_RE12.test(workflowId)) {
18258
+ if (!workflowId || !SAFE_ID_RE13.test(workflowId)) {
17602
18259
  return failure("resume", "workflowId required (safe id)", { operationId: opId, retryable: false });
17603
18260
  }
17604
18261
  const ctx = input.ctx ?? deps.ctx ?? null;
@@ -17625,9 +18282,9 @@ function createResumeTool(deps) {
17625
18282
  }
17626
18283
 
17627
18284
  // src/tools/ship-status.js
17628
- import { readFile as readFile16, readdir as readdir7 } from "node:fs/promises";
17629
- import { existsSync as existsSync16 } from "node:fs";
17630
- import { join as join20 } from "node:path";
18285
+ import { readFile as readFile18, readdir as readdir7 } from "node:fs/promises";
18286
+ import { existsSync as existsSync18 } from "node:fs";
18287
+ import { join as join23 } from "node:path";
17631
18288
  function createStatusTool(deps) {
17632
18289
  return async function status(input) {
17633
18290
  const opId = input.operationId ?? `status-${Date.now().toString(36)}`;
@@ -17635,21 +18292,21 @@ function createStatusTool(deps) {
17635
18292
  if (!workflowId) return failure("status", "workflowId required", { operationId: opId, retryable: false });
17636
18293
  try {
17637
18294
  const commonDir = await resolveGitCommonDir(deps.repoRoot);
17638
- const planRoot = join20(opencodeShipStateDir(commonDir), "plans", workflowId);
17639
- const runRoot = join20(opencodeShipStateDir(commonDir), "runs", workflowId);
17640
- const indexPath = join20(planRoot, "index.json");
17641
- if (!existsSync16(indexPath)) return failure("status", "no workflow record", { operationId: opId, retryable: false });
17642
- const index = JSON.parse(await readFile16(indexPath, "utf8"));
18295
+ const planRoot = join23(opencodeShipStateDir(commonDir), "plans", workflowId);
18296
+ const runRoot = join23(opencodeShipStateDir(commonDir), "runs", workflowId);
18297
+ const indexPath = join23(planRoot, "index.json");
18298
+ if (!existsSync18(indexPath)) return failure("status", "no workflow record", { operationId: opId, retryable: false });
18299
+ const index = JSON.parse(await readFile18(indexPath, "utf8"));
17643
18300
  let run = null;
17644
- const runPath = join20(runRoot, "run.json");
17645
- if (existsSync16(runPath)) run = JSON.parse(await readFile16(runPath, "utf8"));
18301
+ const runPath = join23(runRoot, "run.json");
18302
+ if (existsSync18(runPath)) run = JSON.parse(await readFile18(runPath, "utf8"));
17646
18303
  let lastEvent = null;
17647
- const eventsDir = join20(runRoot, "events");
17648
- if (existsSync16(eventsDir)) {
18304
+ const eventsDir = join23(runRoot, "events");
18305
+ if (existsSync18(eventsDir)) {
17649
18306
  const events = await readdir7(eventsDir);
17650
18307
  const sorted = events.filter((n) => n.endsWith(".json")).sort();
17651
18308
  if (sorted.length > 0) {
17652
- lastEvent = JSON.parse(await readFile16(join20(eventsDir, sorted[sorted.length - 1]), "utf8"));
18309
+ lastEvent = JSON.parse(await readFile18(join23(eventsDir, sorted[sorted.length - 1]), "utf8"));
17653
18310
  }
17654
18311
  }
17655
18312
  return success2("status", { workflowId, index, run, lastEvent }, { operationId: opId });
@@ -17661,14 +18318,14 @@ function createStatusTool(deps) {
17661
18318
 
17662
18319
  // src/skills/registry.js
17663
18320
  import { spawn as spawn7 } from "node:child_process";
17664
- import { readFile as readFile17, writeFile as writeFile8, mkdir as mkdir17 } from "node:fs/promises";
17665
- import { resolve as resolve11, dirname as dirname7 } from "node:path";
17666
- import { createHash as createHash15 } from "node:crypto";
18321
+ import { readFile as readFile19, writeFile as writeFile8, mkdir as mkdir17 } from "node:fs/promises";
18322
+ import { resolve as resolve12, dirname as dirname8 } from "node:path";
18323
+ import { createHash as createHash16 } from "node:crypto";
17667
18324
 
17668
18325
  // src/tools/skill-discovery.js
17669
18326
  import { spawn as spawn6 } from "node:child_process";
17670
- import { existsSync as existsSync17, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
17671
- import { dirname as dirname6, join as join21, normalize, resolve as resolve10, sep } from "node:path";
18327
+ import { existsSync as existsSync19, readFileSync, writeFileSync, mkdirSync, readdirSync, statSync } from "node:fs";
18328
+ import { dirname as dirname7, join as join24, normalize, resolve as resolve11, sep as sep2 } from "node:path";
17672
18329
  var DEFAULT_TRUSTED_OWNERS = Object.freeze([
17673
18330
  "vercel-labs",
17674
18331
  "anthropics",
@@ -17736,9 +18393,9 @@ async function listSkills({ repoRoot, query, npmBin = "npx" }) {
17736
18393
  }
17737
18394
 
17738
18395
  // src/skills/policy.js
17739
- import { readFile as readFile18, writeFile as writeFile9 } from "node:fs/promises";
17740
- import { existsSync as existsSync18 } from "node:fs";
17741
- import { resolve as resolve12 } from "node:path";
18396
+ import { readFile as readFile20, writeFile as writeFile9 } from "node:fs/promises";
18397
+ import { existsSync as existsSync20 } from "node:fs";
18398
+ import { resolve as resolve13 } from "node:path";
17742
18399
  var DEFAULT_TRUSTED_OWNERS2 = Object.freeze([
17743
18400
  "vercel-labs",
17744
18401
  "anthropics",
@@ -17750,7 +18407,7 @@ var DEFAULT_MIN_INSTALLS = 1e3;
17750
18407
  var MAX_TRUSTED_PER_RUN = 5;
17751
18408
  var POLICY_PATH = ".opencode/ship.skills.policy.json";
17752
18409
  function policyPath(repoRoot) {
17753
- return resolve12(repoRoot, POLICY_PATH);
18410
+ return resolve13(repoRoot, POLICY_PATH);
17754
18411
  }
17755
18412
  function defaultPolicy() {
17756
18413
  return {
@@ -17762,9 +18419,9 @@ function defaultPolicy() {
17762
18419
  }
17763
18420
  async function readPolicy(repoRoot) {
17764
18421
  const path = policyPath(repoRoot);
17765
- if (!existsSync18(path)) return defaultPolicy();
18422
+ if (!existsSync20(path)) return defaultPolicy();
17766
18423
  try {
17767
- const raw = await readFile18(path, "utf8");
18424
+ const raw = await readFile20(path, "utf8");
17768
18425
  const parsed = JSON.parse(raw);
17769
18426
  return mergePolicy(defaultPolicy(), parsed);
17770
18427
  } catch {
@@ -17851,33 +18508,33 @@ function createSkillDiscoverTool(deps) {
17851
18508
  }
17852
18509
 
17853
18510
  // src/tools/ship-skill-install.js
17854
- import { readFile as readFile20, writeFile as writeFile11, mkdir as mkdir19, rm, rename as rename5, stat as stat2 } from "node:fs/promises";
17855
- import { existsSync as existsSync21 } from "node:fs";
17856
- import { resolve as resolve15, join as join23, dirname as dirname10, sep as sep3, isAbsolute as isAbsolute3 } from "node:path";
17857
- import { createHash as createHash17 } from "node:crypto";
18511
+ import { readFile as readFile22, writeFile as writeFile11, mkdir as mkdir19, rm, rename as rename5, stat as stat2 } from "node:fs/promises";
18512
+ import { existsSync as existsSync22 } from "node:fs";
18513
+ import { resolve as resolve15, join as join25, dirname as dirname10, sep as sep3, isAbsolute as isAbsolute3 } from "node:path";
18514
+ import { createHash as createHash18 } from "node:crypto";
17858
18515
  import { execFile as execFile5 } from "node:child_process";
17859
18516
  import { mkdtemp } from "node:fs/promises";
17860
18517
  import { tmpdir } from "node:os";
17861
18518
  import { randomBytes as randomBytes3 } from "node:crypto";
17862
18519
 
17863
18520
  // src/skills/inventory.js
17864
- import { readFile as readFile19, writeFile as writeFile10, mkdir as mkdir18, rename as rename4 } from "node:fs/promises";
17865
- import { existsSync as existsSync19 } from "node:fs";
17866
- import { resolve as resolve13, dirname as dirname8, isAbsolute, posix as posix2 } from "node:path";
17867
- import { createHash as createHash16 } from "node:crypto";
18521
+ import { readFile as readFile21, writeFile as writeFile10, mkdir as mkdir18, rename as rename4 } from "node:fs/promises";
18522
+ import { existsSync as existsSync21 } from "node:fs";
18523
+ import { resolve as resolve14, dirname as dirname9, isAbsolute as isAbsolute2, posix as posix2 } from "node:path";
18524
+ import { createHash as createHash17 } from "node:crypto";
17868
18525
  var INVENTORY_PATH = ".opencode/ship.skills.lock.json";
17869
18526
  var INVENTORY_SCHEMA = 2;
17870
18527
  function inventoryPath(repoRoot) {
17871
- return resolve13(repoRoot, INVENTORY_PATH);
18528
+ return resolve14(repoRoot, INVENTORY_PATH);
17872
18529
  }
17873
18530
  async function readInventory(repoRoot) {
17874
18531
  const path = inventoryPath(repoRoot);
17875
- if (!existsSync19(path)) {
18532
+ if (!existsSync21(path)) {
17876
18533
  return { schemaVersion: INVENTORY_SCHEMA, events: [] };
17877
18534
  }
17878
18535
  let raw;
17879
18536
  try {
17880
- raw = await readFile19(path, "utf8");
18537
+ raw = await readFile21(path, "utf8");
17881
18538
  } catch (err) {
17882
18539
  return { schemaVersion: INVENTORY_SCHEMA, events: [], parseError: `read failed: ${err?.message ?? err}` };
17883
18540
  }
@@ -17904,7 +18561,7 @@ async function readInventory(repoRoot) {
17904
18561
  }
17905
18562
  async function writeInventory(repoRoot, inventory) {
17906
18563
  const path = inventoryPath(repoRoot);
17907
- await mkdir18(dirname8(path), { recursive: true });
18564
+ await mkdir18(dirname9(path), { recursive: true });
17908
18565
  const tmp = `${path}.${Date.now().toString(36)}.tmp`;
17909
18566
  await writeFile10(tmp, JSON.stringify({ schemaVersion: INVENTORY_SCHEMA, events: inventory.events }, null, 2) + "\n", "utf8");
17910
18567
  await rename4(tmp, path);
@@ -17924,7 +18581,7 @@ function canonicalize3(value) {
17924
18581
  return JSON.stringify(sort(value));
17925
18582
  }
17926
18583
  function hashEvent(event) {
17927
- return createHash16("sha256").update(canonicalize3(event), "utf8").digest("hex");
18584
+ return createHash17("sha256").update(canonicalize3(event), "utf8").digest("hex");
17928
18585
  }
17929
18586
  async function appendEvent2(repoRoot, eventInput) {
17930
18587
  const inventory = await readInventory(repoRoot);
@@ -17937,7 +18594,7 @@ async function appendEvent2(repoRoot, eventInput) {
17937
18594
  }
17938
18595
  const previousHash = inventory.events.length > 0 ? inventory.events[inventory.events.length - 1].hash : "0".repeat(64);
17939
18596
  const sequence = inventory.events.length + 1;
17940
- if (eventInput.destination && isAbsolute(eventInput.destination)) {
18597
+ if (eventInput.destination && isAbsolute2(eventInput.destination)) {
17941
18598
  throw new Error(`inventory refuses absolute destination: ${eventInput.destination}`);
17942
18599
  }
17943
18600
  const base = {
@@ -18037,146 +18694,8 @@ async function findActiveInstall(repoRoot, skillName) {
18037
18694
  return { ok: true, install: null, uninstallHash: null };
18038
18695
  }
18039
18696
 
18040
- // src/skills/worktree.js
18041
- import { execFile as execFile4 } from "node:child_process";
18042
- import { promises as fs, existsSync as existsSync20 } from "node:fs";
18043
- import { resolve as resolve14, dirname as dirname9, sep as sep2, isAbsolute as isAbsolute2, join as join22 } from "node:path";
18044
- function listRegisteredWorktrees(mainRepo) {
18045
- return new Promise((resolveP, rejectP) => {
18046
- execFile4(
18047
- "git",
18048
- ["-C", mainRepo, "worktree", "list", "--porcelain", "-z"],
18049
- { shell: false, maxBuffer: 1024 * 1024 },
18050
- (err, stdout) => {
18051
- if (err) return rejectP(err);
18052
- const records = parsePorcelain(stdout);
18053
- const mainRecord = records.shift();
18054
- const mainPath = mainRecord?.worktree ? resolve14(mainRecord.worktree) : null;
18055
- const linked = [];
18056
- for (const r of records) {
18057
- if (!r.worktree) continue;
18058
- const p = resolve14(r.worktree);
18059
- if (mainPath && p === mainPath) continue;
18060
- linked.push({ path: p, branch: r.HEAD ?? null });
18061
- }
18062
- resolveP(linked);
18063
- }
18064
- );
18065
- });
18066
- }
18067
- function parsePorcelain(text) {
18068
- const tokens = text.split("\0");
18069
- const out = [];
18070
- let current = {};
18071
- for (const tok of tokens) {
18072
- if (tok.length === 0) {
18073
- if (Object.keys(current).length > 0) {
18074
- out.push(current);
18075
- current = {};
18076
- }
18077
- continue;
18078
- }
18079
- const idx = tok.indexOf(" ");
18080
- const key = idx === -1 ? tok : tok.slice(0, idx);
18081
- const value = idx === -1 ? "" : tok.slice(idx + 1);
18082
- if (key === "branch") {
18083
- current.HEAD = value.startsWith("refs/heads/") ? value : `refs/heads/${value}`;
18084
- } else {
18085
- current[key] = value;
18086
- }
18087
- }
18088
- if (Object.keys(current).length > 0) out.push(current);
18089
- return out;
18090
- }
18091
- async function validateLinkedWorktree(mainRepo, worktreePath) {
18092
- const main = resolve14(mainRepo);
18093
- if (!existsSync20(main)) {
18094
- return { ok: false, kind: "missing", message: `main repository ${main} does not exist` };
18095
- }
18096
- if (!worktreePath) {
18097
- return { ok: false, kind: "unlinked", message: "worktreePath is required" };
18098
- }
18099
- const wt = resolve14(worktreePath);
18100
- if (!existsSync20(wt)) {
18101
- return { ok: false, kind: "missing", message: `worktree ${wt} does not exist` };
18102
- }
18103
- if (wt === main) {
18104
- return { ok: false, kind: "main", message: "installs into the main worktree are forbidden" };
18105
- }
18106
- let cursor = wt;
18107
- while (cursor !== dirname9(cursor)) {
18108
- const stat3 = await fs.lstat(cursor).catch(() => null);
18109
- if (stat3?.isSymbolicLink()) {
18110
- return {
18111
- ok: false,
18112
- kind: "ancestor-symlink",
18113
- message: `worktree path contains a symlink at ${cursor}`
18114
- };
18115
- }
18116
- cursor = dirname9(cursor);
18117
- }
18118
- const real = await fs.realpath(wt).catch(() => null);
18119
- if (real && real !== wt) {
18120
- return {
18121
- ok: false,
18122
- kind: "symlink",
18123
- message: `worktree ${wt} resolves through a symlink to ${real}`
18124
- };
18125
- }
18126
- const linked = await listRegisteredWorktrees(main);
18127
- const matched = linked.find((entry) => entry.path === wt);
18128
- if (!matched) {
18129
- return {
18130
- ok: false,
18131
- kind: "unlinked",
18132
- message: `worktree ${wt} is not registered (git worktree list)`
18133
- };
18134
- }
18135
- return { ok: true, path: wt, registered: !!matched };
18136
- }
18137
- function validateRelativeInstallPath(destRel) {
18138
- if (typeof destRel !== "string" || destRel.length === 0) {
18139
- return { ok: false, kind: "absolute", message: "destination path required" };
18140
- }
18141
- if (isAbsolute2(destRel)) {
18142
- return { ok: false, kind: "absolute", message: `destination must be relative: ${destRel}` };
18143
- }
18144
- if (destRel.includes("\\")) {
18145
- return { ok: false, kind: "parent-relative", message: `destination must use POSIX separators: ${destRel}` };
18146
- }
18147
- const parts = destRel.split("/");
18148
- for (const p of parts) {
18149
- if (p === "" || p === "." || p === "..") {
18150
- return { ok: false, kind: "parent-relative", message: `destination escapes worktree: ${destRel}` };
18151
- }
18152
- }
18153
- return { ok: true };
18154
- }
18155
- async function validateInstallDestination(worktreeRoot, destRel) {
18156
- const relativeCheck = validateRelativeInstallPath(destRel);
18157
- if (!relativeCheck.ok) return relativeCheck;
18158
- const root = resolve14(worktreeRoot);
18159
- const destination = resolve14(root, ...destRel.split("/"));
18160
- if (destination !== root && !destination.startsWith(root + sep2)) {
18161
- return { ok: false, kind: "escape", message: `destination escapes worktree: ${destRel}` };
18162
- }
18163
- let cursor = root;
18164
- for (const part of destRel.split("/")) {
18165
- cursor = join22(cursor, part);
18166
- const entry = await fs.lstat(cursor).catch(() => null);
18167
- if (!entry) continue;
18168
- if (entry.isSymbolicLink()) {
18169
- return { ok: false, kind: "symlink", message: `destination path contains a symlink at ${cursor}` };
18170
- }
18171
- if (cursor !== destination && !entry.isDirectory()) {
18172
- return { ok: false, kind: "not-directory", message: `destination ancestor is not a directory: ${cursor}` };
18173
- }
18174
- }
18175
- return { ok: true, path: destination };
18176
- }
18177
-
18178
18697
  // src/tools/ship-skill-install.js
18179
- var SAFE_ID_RE13 = /^[A-Za-z0-9._-]{1,128}$/;
18698
+ var SAFE_ID_RE14 = /^[A-Za-z0-9._-]{1,128}$/;
18180
18699
  var SAFE_NAME_RE = /^[A-Za-z0-9._/-]{1,160}$/;
18181
18700
  var SAFE_VERSION_RE = /^[A-Za-z0-9._+-]{1,64}$/;
18182
18701
  var SKILLS_CLI_VERSION = "1.0.4";
@@ -18193,7 +18712,7 @@ function createSkillInstallTool(deps) {
18193
18712
  if (!worktreePath) {
18194
18713
  return failure("skill-install", "worktreePath required (must be the active issue worktree)", { operationId: opId, retryable: false });
18195
18714
  }
18196
- if (!skillName || !SAFE_ID_RE13.test(skillName)) {
18715
+ if (!skillName || !SAFE_ID_RE14.test(skillName)) {
18197
18716
  return failure("skill-install", "skillName required (safe id)", { operationId: opId, retryable: false });
18198
18717
  }
18199
18718
  if (version2 && !SAFE_VERSION_RE.test(version2)) {
@@ -18223,7 +18742,7 @@ function createSkillInstallTool(deps) {
18223
18742
  return failure("skill-install", `destination rejected: ${destinationCheck.message}`, { operationId: opId, retryable: false });
18224
18743
  }
18225
18744
  const destAbs = destinationCheck.path;
18226
- if (existsSync21(destAbs)) {
18745
+ if (existsSync22(destAbs)) {
18227
18746
  return failure("skill-install", "destination already exists; use ship_skill_audit to detect drift", { operationId: opId, retryable: false });
18228
18747
  }
18229
18748
  const managedCatalog = (deps.config?.value?.skills ?? []).map((s) => s?.name).filter(Boolean);
@@ -18243,7 +18762,7 @@ function createSkillInstallTool(deps) {
18243
18762
  if (!decision.ok) {
18244
18763
  return failure("skill-install", `policy forbids install: ${decision.reason}`, { operationId: opId, retryable: false });
18245
18764
  }
18246
- const stage = await mkdtemp(join23(tmpdir(), `ship-skill-stage-${randomBytes3(4).toString("hex")}-`));
18765
+ const stage = await mkdtemp(join25(tmpdir(), `ship-skill-stage-${randomBytes3(4).toString("hex")}-`));
18247
18766
  let installedFiles;
18248
18767
  try {
18249
18768
  const materialise = deps.materialiseFromSkillsCli ?? materialiseFromSkillsCli;
@@ -18294,7 +18813,7 @@ function createSkillInstallTool(deps) {
18294
18813
  sequence: recorded.sequence
18295
18814
  }, { operationId: opId });
18296
18815
  } catch (err) {
18297
- if (existsSync21(destAbs)) {
18816
+ if (existsSync22(destAbs)) {
18298
18817
  await rm(destAbs, { recursive: true, force: true }).catch(() => null);
18299
18818
  }
18300
18819
  return failure("skill-install", String(err?.message ?? err), { operationId: opId, retryable: true });
@@ -18340,16 +18859,16 @@ async function materialiseFromSkillsCli({ packageSpec, skillName, version: versi
18340
18859
  );
18341
18860
  });
18342
18861
  if (!result.ok) return result;
18343
- const stagedDir = join23(stageDir, ".opencode", "skills", skillName);
18344
- if (!existsSync21(stagedDir)) {
18862
+ const stagedDir = join25(stageDir, ".opencode", "skills", skillName);
18863
+ if (!existsSync22(stagedDir)) {
18345
18864
  return {
18346
18865
  ok: false,
18347
18866
  retryable: false,
18348
18867
  message: `skills CLI did not produce ${stagedDir}`
18349
18868
  };
18350
18869
  }
18351
- const skillMd = join23(stagedDir, "SKILL.md");
18352
- if (!existsSync21(skillMd)) {
18870
+ const skillMd = join25(stagedDir, "SKILL.md");
18871
+ if (!existsSync22(skillMd)) {
18353
18872
  return {
18354
18873
  ok: false,
18355
18874
  retryable: false,
@@ -18381,25 +18900,25 @@ async function walk(rootDir, currentDir, out) {
18381
18900
  const { readdir: readdir10 } = await import("node:fs/promises");
18382
18901
  const entries = await readdir10(currentDir, { withFileTypes: true });
18383
18902
  for (const e of entries) {
18384
- const abs = join23(currentDir, e.name);
18903
+ const abs = join25(currentDir, e.name);
18385
18904
  if (e.isDirectory()) {
18386
18905
  if (e.name === ".git") continue;
18387
18906
  await walk(rootDir, abs, out);
18388
18907
  continue;
18389
18908
  }
18390
18909
  if (!e.isFile()) continue;
18391
- const raw = await readFile20(abs);
18910
+ const raw = await readFile22(abs);
18392
18911
  const fileStat = await stat2(abs);
18393
18912
  out.push({
18394
18913
  path: abs.slice(rootDir.length + 1).split(sep3).join("/"),
18395
- sha256: createHash17("sha256").update(raw).digest("hex"),
18914
+ sha256: createHash18("sha256").update(raw).digest("hex"),
18396
18915
  mode: fileStat.mode & 511,
18397
18916
  size: fileStat.size
18398
18917
  });
18399
18918
  }
18400
18919
  }
18401
18920
  function hashBytes(bytes) {
18402
- return createHash17("sha256").update(bytes).digest("hex");
18921
+ return createHash18("sha256").update(bytes).digest("hex");
18403
18922
  }
18404
18923
  function hashesEqual(a, b) {
18405
18924
  if (a.length !== b.length) return false;
@@ -18414,23 +18933,23 @@ async function copyDir(srcDir, destDir) {
18414
18933
  const { readdir: readdir10 } = await import("node:fs/promises");
18415
18934
  const entries = await readdir10(srcDir, { withFileTypes: true });
18416
18935
  for (const e of entries) {
18417
- const src = join23(srcDir, e.name);
18418
- const dest = join23(destDir, e.name);
18936
+ const src = join25(srcDir, e.name);
18937
+ const dest = join25(destDir, e.name);
18419
18938
  if (e.isDirectory()) {
18420
18939
  if (e.name === ".git") continue;
18421
18940
  await copyDir(src, dest);
18422
18941
  } else if (e.isFile()) {
18423
- const raw = await readFile20(src);
18942
+ const raw = await readFile22(src);
18424
18943
  await writeFile11(dest, raw, { mode: 420 });
18425
18944
  }
18426
18945
  }
18427
18946
  }
18428
18947
 
18429
18948
  // src/tools/ship-skill-audit.js
18430
- import { readdir as readdir8, readFile as readFile21 } from "node:fs/promises";
18431
- import { existsSync as existsSync22 } from "node:fs";
18432
- import { resolve as resolve16, join as join24, isAbsolute as isAbsolute4 } from "node:path";
18433
- import { createHash as createHash18 } from "node:crypto";
18949
+ import { readdir as readdir8, readFile as readFile23 } from "node:fs/promises";
18950
+ import { existsSync as existsSync23 } from "node:fs";
18951
+ import { resolve as resolve16, join as join26, isAbsolute as isAbsolute4 } from "node:path";
18952
+ import { createHash as createHash19 } from "node:crypto";
18434
18953
  function createSkillAuditTool(deps) {
18435
18954
  return async function skillAudit(input) {
18436
18955
  const opId = input.operationId ?? `skill-audit-${Date.now().toString(36)}`;
@@ -18471,21 +18990,21 @@ function createSkillAuditTool(deps) {
18471
18990
  continue;
18472
18991
  }
18473
18992
  for (const f of ev.files ?? []) {
18474
- const filePath = join24(installRoot, ev.destination, f.path);
18475
- if (!existsSync22(filePath)) {
18993
+ const filePath = join26(installRoot, ev.destination, f.path);
18994
+ if (!existsSync23(filePath)) {
18476
18995
  missing.push({ skill: ev.skill, path: f.path, sequence: ev.sequence });
18477
18996
  continue;
18478
18997
  }
18479
- const raw = await readFile21(filePath);
18480
- const sha = createHash18("sha256").update(raw).digest("hex");
18998
+ const raw = await readFile23(filePath);
18999
+ const sha = createHash19("sha256").update(raw).digest("hex");
18481
19000
  if (sha !== f.sha256) {
18482
19001
  drifted.push({ skill: ev.skill, path: f.path, expected: f.sha256, actual: sha, sequence: ev.sequence });
18483
19002
  }
18484
19003
  }
18485
19004
  }
18486
19005
  const untracked = [];
18487
- const opencodeDir = join24(inventoryRoot, ".opencode", "skills");
18488
- if (existsSync22(opencodeDir)) {
19006
+ const opencodeDir = join26(inventoryRoot, ".opencode", "skills");
19007
+ if (existsSync23(opencodeDir)) {
18489
19008
  const entries = await readdir8(opencodeDir, { withFileTypes: true }).catch(() => []);
18490
19009
  for (const e of entries) {
18491
19010
  if (!e.isDirectory()) continue;
@@ -18507,16 +19026,16 @@ function createSkillAuditTool(deps) {
18507
19026
  }
18508
19027
 
18509
19028
  // src/tools/ship-skill-uninstall.js
18510
- import { readFile as readFile22, unlink as unlink5, rm as rm2, readdir as readdir9, lstat } from "node:fs/promises";
18511
- import { existsSync as existsSync23 } from "node:fs";
18512
- import { resolve as resolve17, join as join25 } from "node:path";
18513
- import { createHash as createHash19 } from "node:crypto";
18514
- var SAFE_ID_RE14 = /^[A-Za-z0-9._-]{1,128}$/;
19029
+ import { readFile as readFile24, unlink as unlink5, rm as rm2, readdir as readdir9, lstat } from "node:fs/promises";
19030
+ import { existsSync as existsSync24 } from "node:fs";
19031
+ import { resolve as resolve17, join as join27 } from "node:path";
19032
+ import { createHash as createHash20 } from "node:crypto";
19033
+ var SAFE_ID_RE15 = /^[A-Za-z0-9._-]{1,128}$/;
18515
19034
  function createSkillUninstallTool(deps) {
18516
19035
  return async function skillUninstall(input) {
18517
19036
  const opId = input.operationId ?? `skill-uninstall-${Date.now().toString(36)}`;
18518
19037
  const skillName = String(input.skill ?? "");
18519
- if (!skillName || !SAFE_ID_RE14.test(skillName)) {
19038
+ if (!skillName || !SAFE_ID_RE15.test(skillName)) {
18520
19039
  return failure("skill-uninstall", "skill required (safe id)", { operationId: opId, retryable: false });
18521
19040
  }
18522
19041
  const repoRoot = resolve17(deps.repoRoot);
@@ -18557,20 +19076,20 @@ function createSkillUninstallTool(deps) {
18557
19076
  return failure("skill-uninstall", `recorded file rejected: ${fileCheck.message}`, { operationId: opId, retryable: false });
18558
19077
  }
18559
19078
  const filePath = fileCheck.path;
18560
- if (!existsSync23(filePath)) {
19079
+ if (!existsSync24(filePath)) {
18561
19080
  return failure("skill-uninstall", `recorded file missing: ${f.path}`, { operationId: opId, retryable: false });
18562
19081
  }
18563
- const raw = await readFile22(filePath);
18564
- const sha = createHash19("sha256").update(raw).digest("hex");
19082
+ const raw = await readFile24(filePath);
19083
+ const sha = createHash20("sha256").update(raw).digest("hex");
18565
19084
  if (sha !== f.sha256) {
18566
19085
  return failure("skill-uninstall", `recorded file drifted: ${f.path}`, { operationId: opId, retryable: false });
18567
19086
  }
18568
19087
  }
18569
19088
  for (const f of found.install.files ?? []) {
18570
- const filePath = join25(skillDir, ...f.path.split("/"));
19089
+ const filePath = join27(skillDir, ...f.path.split("/"));
18571
19090
  await unlink5(filePath).catch(() => null);
18572
19091
  }
18573
- if (existsSync23(skillDir)) {
19092
+ if (existsSync24(skillDir)) {
18574
19093
  await rm2(skillDir, { recursive: true, force: true });
18575
19094
  }
18576
19095
  const recorded = await appendEvent2(inventoryRoot, {
@@ -18589,12 +19108,12 @@ function createSkillUninstallTool(deps) {
18589
19108
  };
18590
19109
  }
18591
19110
  async function listInstalledFiles(root) {
18592
- if (!existsSync23(root)) return { ok: true, paths: [] };
19111
+ if (!existsSync24(root)) return { ok: true, paths: [] };
18593
19112
  const paths = [];
18594
19113
  const walk2 = async (dir, prefix = "") => {
18595
19114
  for (const entry of await readdir9(dir, { withFileTypes: true })) {
18596
19115
  const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
18597
- const absolute = join25(dir, entry.name);
19116
+ const absolute = join27(dir, entry.name);
18598
19117
  const info = await lstat(absolute);
18599
19118
  if (info.isSymbolicLink()) return { ok: false, message: `symlinked content present: ${relative}` };
18600
19119
  if (info.isDirectory()) {
@@ -18618,9 +19137,9 @@ function recoverManifestAfterCrash(manifest) {
18618
19137
  }
18619
19138
 
18620
19139
  // src/installer/plugin-owner.js
18621
- import { spawnSync as spawnSync4 } from "node:child_process";
19140
+ import { spawnSync as spawnSync5 } from "node:child_process";
18622
19141
  async function reconcileOwner(repoRoot, adapter) {
18623
- const r = spawnSync4("git", ["-C", repoRoot, "config", "--get", "user.name"], {
19142
+ const r = spawnSync5("git", ["-C", repoRoot, "config", "--get", "user.name"], {
18624
19143
  encoding: "utf8"
18625
19144
  });
18626
19145
  if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
@@ -18628,8 +19147,8 @@ async function reconcileOwner(repoRoot, adapter) {
18628
19147
  }
18629
19148
 
18630
19149
  // src/installer/config.js
18631
- import { readFile as readFile23, writeFile as writeFile12, rename as rename6, mkdir as mkdir20 } from "node:fs/promises";
18632
- import { existsSync as existsSync24 } from "node:fs";
19150
+ import { readFile as readFile25, writeFile as writeFile12, rename as rename6, mkdir as mkdir20 } from "node:fs/promises";
19151
+ import { existsSync as existsSync25 } from "node:fs";
18633
19152
  import { dirname as dirname11, resolve as resolve18 } from "node:path";
18634
19153
 
18635
19154
  // schema/ship-config.schema.json
@@ -18941,8 +19460,8 @@ function configPath(repoRoot) {
18941
19460
  }
18942
19461
  async function loadConfig(repoRoot) {
18943
19462
  const path = configPath(repoRoot);
18944
- if (!existsSync24(path)) return null;
18945
- const raw = await readFile23(path, "utf8");
19463
+ if (!existsSync25(path)) return null;
19464
+ const raw = await readFile25(path, "utf8");
18946
19465
  let parsed;
18947
19466
  try {
18948
19467
  parsed = JSON.parse(raw);
@@ -19007,26 +19526,26 @@ function renderDefaultConfig(detection, overrides = {}) {
19007
19526
  }
19008
19527
 
19009
19528
  // src/installer/detection/project.js
19010
- import { spawnSync as spawnSync5 } from "node:child_process";
19011
- import { existsSync as existsSync25, readFileSync as readFileSync2 } from "node:fs";
19012
- import { resolve as resolve19, join as join26 } from "node:path";
19013
- function runGit2(cwd, args) {
19014
- const r = spawnSync5("git", ["-C", cwd, ...args], {
19529
+ import { spawnSync as spawnSync6 } from "node:child_process";
19530
+ import { existsSync as existsSync26, readFileSync as readFileSync2 } from "node:fs";
19531
+ import { resolve as resolve19, join as join28 } from "node:path";
19532
+ function runGit3(cwd, args) {
19533
+ const r = spawnSync6("git", ["-C", cwd, ...args], {
19015
19534
  stdio: ["ignore", "pipe", "pipe"],
19016
19535
  encoding: "utf8"
19017
19536
  });
19018
19537
  return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
19019
19538
  }
19020
19539
  function detectPackageManager(repoRoot) {
19021
- if (existsSync25(join26(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
19022
- if (existsSync25(join26(repoRoot, "yarn.lock"))) return "yarn";
19023
- if (existsSync25(join26(repoRoot, "bun.lockb"))) return "bun";
19024
- if (existsSync25(join26(repoRoot, "package-lock.json"))) return "npm";
19540
+ if (existsSync26(join28(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
19541
+ if (existsSync26(join28(repoRoot, "yarn.lock"))) return "yarn";
19542
+ if (existsSync26(join28(repoRoot, "bun.lockb"))) return "bun";
19543
+ if (existsSync26(join28(repoRoot, "package-lock.json"))) return "npm";
19025
19544
  return null;
19026
19545
  }
19027
19546
  function readPackageJson(repoRoot) {
19028
- const path = join26(repoRoot, "package.json");
19029
- if (!existsSync25(path)) return null;
19547
+ const path = join28(repoRoot, "package.json");
19548
+ if (!existsSync26(path)) return null;
19030
19549
  try {
19031
19550
  return JSON.parse(readFileSync2(path, "utf8"));
19032
19551
  } catch {
@@ -19064,7 +19583,7 @@ function parseRepoSlugFromRemote(url2) {
19064
19583
  return null;
19065
19584
  }
19066
19585
  function detectRemote(repoRoot) {
19067
- const remotes = runGit2(repoRoot, ["remote", "-v"]);
19586
+ const remotes = runGit3(repoRoot, ["remote", "-v"]);
19068
19587
  if (remotes.status !== 0) return { candidates: [], primary: null };
19069
19588
  const lines = remotes.stdout.split("\n").filter(Boolean);
19070
19589
  const map2 = /* @__PURE__ */ new Map();
@@ -19080,23 +19599,23 @@ function detectRemote(repoRoot) {
19080
19599
  return { candidates: list, primary: origin };
19081
19600
  }
19082
19601
  function detectDefaultBranch(repoRoot, remoteName) {
19083
- const head = runGit2(repoRoot, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`]);
19602
+ const head = runGit3(repoRoot, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`]);
19084
19603
  if (head.status === 0) {
19085
19604
  const ref = head.stdout.trim();
19086
19605
  const match = ref.match(/^refs\/remotes\/[^/]+\/(.+)$/);
19087
19606
  if (match) return match[1];
19088
19607
  }
19089
- const local = runGit2(repoRoot, ["remote", "show", remoteName]);
19608
+ const local = runGit3(repoRoot, ["remote", "show", remoteName]);
19090
19609
  if (local.status === 0) {
19091
19610
  const match = local.stdout.match(/HEAD branch:\s*(\S+)/);
19092
19611
  if (match) return match[1];
19093
19612
  }
19094
- const branch = runGit2(repoRoot, ["branch", "--list"]);
19613
+ const branch = runGit3(repoRoot, ["branch", "--list"]);
19095
19614
  if (branch.status === 0 && /\*\s*main\b/.test(branch.stdout)) return "main";
19096
19615
  return null;
19097
19616
  }
19098
19617
  function detectOwner(repoRoot) {
19099
- const user = runGit2(repoRoot, ["config", "--get", "user.name"]);
19618
+ const user = runGit3(repoRoot, ["config", "--get", "user.name"]);
19100
19619
  if (user.status === 0 && user.stdout.trim()) return user.stdout.trim();
19101
19620
  const fallback = process.env.USER ?? process.env.USERNAME ?? "opencode-ship";
19102
19621
  return fallback;
@@ -19104,13 +19623,13 @@ function detectOwner(repoRoot) {
19104
19623
  function detectProject(repoRoot = process.cwd()) {
19105
19624
  const errors = [];
19106
19625
  const cwd = resolve19(repoRoot);
19107
- const inside = runGit2(cwd, ["rev-parse", "--show-toplevel"]);
19626
+ const inside = runGit3(cwd, ["rev-parse", "--show-toplevel"]);
19108
19627
  if (inside.status !== 0) {
19109
19628
  errors.push({ kind: "not-a-git-repo", path: cwd, detail: inside.stderr.trim() });
19110
19629
  return { repoRoot: cwd, errors };
19111
19630
  }
19112
19631
  const repoRootActual = inside.stdout.trim();
19113
- const headBranch = runGit2(repoRootActual, ["symbolic-ref", "--short", "HEAD"]);
19632
+ const headBranch = runGit3(repoRootActual, ["symbolic-ref", "--short", "HEAD"]);
19114
19633
  if (headBranch.status !== 0 || headBranch.stdout.trim().length === 0) {
19115
19634
  errors.push({ kind: "detached-head", path: repoRootActual, detail: headBranch.stderr.trim() });
19116
19635
  }
@@ -19150,21 +19669,19 @@ function detectProject(repoRoot = process.cwd()) {
19150
19669
  }
19151
19670
 
19152
19671
  // src/installer/cleanup.js
19153
- import { spawnSync as spawnSync6 } from "node:child_process";
19672
+ import { spawnSync as spawnSync7 } from "node:child_process";
19154
19673
  import { resolve as pathResolve } from "node:path";
19155
- import { existsSync as existsSync26, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
19674
+ import { existsSync as existsSync27, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
19156
19675
  function spawn8(repoRoot, args) {
19157
- const r = spawnSync6("git", ["-C", repoRoot, ...args], { encoding: "utf8" });
19676
+ const r = spawnSync7("git", ["-C", repoRoot, ...args], { encoding: "utf8" });
19158
19677
  return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
19159
19678
  }
19160
19679
  function casDeleteBranch2(repoRoot, branch, expectedSha) {
19161
- const argv = ["update-ref", "-d"];
19162
- if (expectedSha && /^[0-9f]{7,}$/i.test(expectedSha)) {
19163
- argv.push(`refs/heads/${branch}`, expectedSha);
19164
- } else {
19165
- argv.push(`refs/heads/${branch}`);
19166
- }
19167
- return spawn8(repoRoot, argv).status ?? -1;
19680
+ if (!isValidExpectedSha(expectedSha)) return -1;
19681
+ return spawn8(repoRoot, ["update-ref", "-d", `refs/heads/${branch}`, expectedSha]).status ?? -1;
19682
+ }
19683
+ function isValidExpectedSha(value) {
19684
+ return typeof value === "string" && /^[0-9a-f]{7,}$/i.test(value);
19168
19685
  }
19169
19686
  function safeRemoveWorktree2(repoRoot, target) {
19170
19687
  const r = spawn8(repoRoot, ["worktree", "remove", target]);
@@ -19179,7 +19696,7 @@ async function cleanupPendingPath(repoRoot) {
19179
19696
  }
19180
19697
  async function loadCleanupPending(repoRoot) {
19181
19698
  const path = await cleanupPendingPath(repoRoot);
19182
- if (!existsSync26(path)) return [];
19699
+ if (!existsSync27(path)) return [];
19183
19700
  try {
19184
19701
  const raw = await readFileSync3(path, "utf8");
19185
19702
  const parsed = JSON.parse(raw);
@@ -19191,19 +19708,16 @@ async function loadCleanupPending(repoRoot) {
19191
19708
  async function saveCleanupPending(repoRoot, entries) {
19192
19709
  const path = await cleanupPendingPath(repoRoot);
19193
19710
  const dir = pathResolve(path, "..");
19194
- if (!existsSync26(dir)) mkdirSync2(dir, { recursive: true });
19711
+ if (!existsSync27(dir)) mkdirSync2(dir, { recursive: true });
19195
19712
  writeFileSync2(path, JSON.stringify(dedupePending(entries), null, 2) + "\n", "utf8");
19196
19713
  }
19197
19714
  function dedupePending(entries) {
19198
- const seen = /* @__PURE__ */ new Set();
19199
- const out = [];
19715
+ const byTask = /* @__PURE__ */ new Map();
19200
19716
  for (const e of entries) {
19201
19717
  if (!e || !e.taskId) continue;
19202
- if (seen.has(e.taskId)) continue;
19203
- seen.add(e.taskId);
19204
- out.push(e);
19718
+ byTask.set(e.taskId, e);
19205
19719
  }
19206
- return out;
19720
+ return [...byTask.values()];
19207
19721
  }
19208
19722
  async function appendCleanupPending(repoRoot, entry) {
19209
19723
  const current = await loadCleanupPending(repoRoot);
@@ -19211,13 +19725,28 @@ async function appendCleanupPending(repoRoot, entry) {
19211
19725
  await saveCleanupPending(repoRoot, next);
19212
19726
  return next;
19213
19727
  }
19728
+ async function clearCleanupPending(repoRoot, taskId) {
19729
+ const current = await loadCleanupPending(repoRoot);
19730
+ await saveCleanupPending(repoRoot, current.filter((entry) => entry.taskId !== taskId));
19731
+ }
19214
19732
  function reject(reason, extra = {}) {
19215
19733
  return { ok: false, reason, ...extra };
19216
19734
  }
19217
19735
  async function tryImmediateCleanup({ repoRoot, taskId, adapter }) {
19218
19736
  if (!repoRoot || !taskId) return reject("missing-args");
19737
+ const pending = (await loadCleanupPending(repoRoot)).find((entry) => entry.taskId === taskId);
19738
+ const stage = pending?.stage ?? "worktree-remove";
19739
+ if (!["worktree-remove", "branch-delete", "manifest-seal"].includes(stage)) {
19740
+ return reject("cleanup-stage", { stage });
19741
+ }
19219
19742
  const m = await readManifest(repoRoot, taskId);
19220
- if (!m) return reject("missing-manifest");
19743
+ if (!m) {
19744
+ if (pending && stage === "manifest-seal") {
19745
+ await clearCleanupPending(repoRoot, taskId);
19746
+ return { ok: true, removedPath: null, sealed: true };
19747
+ }
19748
+ return reject("missing-manifest");
19749
+ }
19221
19750
  if (m.state !== "merged" && m.state !== "cleanup-pending") {
19222
19751
  return reject("manifest-state", { state: m.state });
19223
19752
  }
@@ -19229,36 +19758,68 @@ async function tryImmediateCleanup({ repoRoot, taskId, adapter }) {
19229
19758
  if (!wtPath.startsWith(rootAbs + "/")) {
19230
19759
  return reject("worktree-out-of-root", { expected: rootAbs, got: wtPath });
19231
19760
  }
19232
- const status = spawn8(wtPath, ["status", "--porcelain"]);
19233
- if (status.status === 0 && status.stdout.trim().length > 0) return reject("dirty-worktree");
19234
- const rebase = spawn8(wtPath, ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"]);
19235
- if (rebase.status === 0) return reject("rebase-in-progress");
19236
- const head = spawn8(wtPath, ["rev-parse", "HEAD"]);
19237
- if (head.status !== 0) return reject("no-head");
19238
- const headSha = head.stdout.trim();
19239
- if (m.lastPrHeadSha && headSha !== m.lastPrHeadSha) {
19240
- return reject("head-mismatch", { expected: m.lastPrHeadSha, actual: headSha });
19241
- }
19242
- const removed = safeRemoveWorktree2(repoRoot, wtPath);
19243
- if (removed.status !== 0) {
19761
+ let headSha = isValidExpectedSha(pending?.expectedHeadSha) ? pending.expectedHeadSha : isValidExpectedSha(m.lastPrHeadSha) ? m.lastPrHeadSha : "";
19762
+ if (stage === "worktree-remove") {
19763
+ const status = spawn8(wtPath, ["status", "--porcelain"]);
19764
+ if (status.status === 0) {
19765
+ if (status.stdout.trim().length > 0) return reject("dirty-worktree");
19766
+ const rebase = spawn8(wtPath, ["rev-parse", "--verify", "--quiet", "REBASE_HEAD"]);
19767
+ if (rebase.status === 0) return reject("rebase-in-progress");
19768
+ const head = spawn8(wtPath, ["rev-parse", "HEAD"]);
19769
+ if (head.status !== 0) return reject("no-head");
19770
+ headSha = head.stdout.trim();
19771
+ if (m.lastPrHeadSha && headSha !== m.lastPrHeadSha) {
19772
+ return reject("head-mismatch", { expected: m.lastPrHeadSha, actual: headSha });
19773
+ }
19774
+ await appendCleanupPending(repoRoot, {
19775
+ taskId,
19776
+ failedAt: (/* @__PURE__ */ new Date()).toISOString(),
19777
+ stage: "worktree-remove",
19778
+ expectedHeadSha: headSha,
19779
+ reason: "worktree head validated"
19780
+ });
19781
+ const removed = safeRemoveWorktree2(repoRoot, wtPath);
19782
+ if (removed.status !== 0) {
19783
+ await appendCleanupPending(repoRoot, {
19784
+ taskId,
19785
+ failedAt: (/* @__PURE__ */ new Date()).toISOString(),
19786
+ stage: "worktree-remove",
19787
+ expectedHeadSha: headSha,
19788
+ reason: removed.stderr ?? "non-zero exit"
19789
+ });
19790
+ return reject("remove-failed", { detail: removed.stderr });
19791
+ }
19792
+ } else if (!headSha) {
19793
+ return reject("no-head");
19794
+ }
19244
19795
  await appendCleanupPending(repoRoot, {
19245
19796
  taskId,
19246
19797
  failedAt: (/* @__PURE__ */ new Date()).toISOString(),
19247
- stage: "worktree-remove",
19248
- reason: removed.stderr ?? "non-zero exit"
19798
+ stage: "branch-delete",
19799
+ expectedHeadSha: headSha,
19800
+ reason: "resume cleanup"
19249
19801
  });
19250
- return reject("remove-failed", { detail: removed.stderr });
19251
19802
  }
19252
- const branchDelete = casDeleteBranch2(repoRoot, m.branch, headSha);
19253
- const branchStillThere = spawn8(repoRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${m.branch}`]);
19254
- if (branchDelete !== 0 && branchStillThere.status === 0) {
19803
+ if (stage !== "manifest-seal") {
19804
+ if (!isValidExpectedSha(headSha)) return reject("no-head");
19805
+ const branchDelete = casDeleteBranch2(repoRoot, m.branch, headSha);
19806
+ const branchStillThere = spawn8(repoRoot, ["show-ref", "--verify", "--quiet", `refs/heads/${m.branch}`]);
19807
+ if (branchDelete !== 0 && branchStillThere.status === 0) {
19808
+ await appendCleanupPending(repoRoot, {
19809
+ taskId,
19810
+ failedAt: (/* @__PURE__ */ new Date()).toISOString(),
19811
+ stage: "branch-delete",
19812
+ expectedHeadSha: headSha,
19813
+ reason: "git update-ref failed"
19814
+ });
19815
+ return reject("branch-delete-failed");
19816
+ }
19255
19817
  await appendCleanupPending(repoRoot, {
19256
19818
  taskId,
19257
19819
  failedAt: (/* @__PURE__ */ new Date()).toISOString(),
19258
- stage: "branch-delete",
19259
- reason: "git update-ref failed"
19820
+ stage: "manifest-seal",
19821
+ reason: "resume cleanup"
19260
19822
  });
19261
- return reject("branch-delete-failed");
19262
19823
  }
19263
19824
  const next = {
19264
19825
  ...m,
@@ -19271,11 +19832,18 @@ async function tryImmediateCleanup({ repoRoot, taskId, adapter }) {
19271
19832
  };
19272
19833
  await writeManifest(repoRoot, next).catch(() => null);
19273
19834
  await deleteManifest(repoRoot, taskId);
19835
+ await clearCleanupPending(repoRoot, taskId);
19274
19836
  return { ok: true, removedPath: wtPath, sealed: true };
19275
19837
  }
19276
19838
  async function listPending(repoRoot) {
19277
19839
  const all = await listManifests(repoRoot).catch(() => []);
19278
- return all.filter((m) => m.state === "merged" || m.state === "cleanup-pending");
19840
+ const manifests = all.filter((m) => m.state === "merged" || m.state === "cleanup-pending");
19841
+ const queued = await loadCleanupPending(repoRoot);
19842
+ const byTask = new Map(manifests.map((manifest) => [manifest.taskId, manifest]));
19843
+ for (const entry of queued) {
19844
+ if (!byTask.has(entry.taskId)) byTask.set(entry.taskId, entry);
19845
+ }
19846
+ return [...byTask.values()];
19279
19847
  }
19280
19848
 
19281
19849
  // src/installer/ship-adapter.js
@@ -19325,10 +19893,10 @@ function selectRuntimeAdapter({ config: config2, shipAdapter, legacyAdapter }) {
19325
19893
  }
19326
19894
 
19327
19895
  // src/version.js
19328
- import { readFileSync as readFileSync4, existsSync as existsSync27 } from "node:fs";
19896
+ import { readFileSync as readFileSync4, existsSync as existsSync28 } from "node:fs";
19329
19897
  import { dirname as dirname12, resolve as resolve21 } from "node:path";
19330
19898
  import { fileURLToPath } from "node:url";
19331
- var PACKAGE_VERSION = "1.1.5";
19899
+ var PACKAGE_VERSION = "1.1.8";
19332
19900
  var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
19333
19901
 
19334
19902
  // src/plugin.js
@@ -19342,6 +19910,7 @@ var toolDefs = [
19342
19910
  ["delivery_ready", "Mark the PR ready after every required gate has passed.", "ready"],
19343
19911
  ["delivery_merge", "Squash merge the PR after an explicit user request.", "merge"],
19344
19912
  ["delivery_cleanup", "Remove the agent-owned worktree and branch after merge.", "cleanup"],
19913
+ ["delivery_abandon", "Abandon a closed unmerged delivery attempt after explicit approval.", "abandon"],
19345
19914
  ["delivery_github_read", "Typed read of issue, PR, or check data.", "githubRead"],
19346
19915
  ["delivery_issue_comment", "Idempotent typed comment on an issue.", "issueComment"],
19347
19916
  ["delivery_issue_labels", "Idempotent label add/remove on an issue.", "issueLabels"],
@@ -19349,6 +19918,7 @@ var toolDefs = [
19349
19918
  ["delivery_issue_close", "Close an issue with a recorded user permission subject.", "issueClose"],
19350
19919
  ["delivery_sync", "Fetch and merge base into the feature branch.", "sync"],
19351
19920
  ["delivery_publish", "Push the manifest branch to origin with HEAD verification.", "publish"],
19921
+ ["ship_deliver", "Dispatch durable delivery for an issue to the controller.", "deliver"],
19352
19922
  ["ship_plan_start", "Create a workflow and dispatch the configured planner.", "planStart"],
19353
19923
  ["ship_plan_submit", "Planner-only immutable PlanV2 submission.", "planSubmit"],
19354
19924
  ["ship_plan_approve", "Interactive approval + immutable local seal.", "planApprove"],
@@ -19417,7 +19987,7 @@ async function resolveRepoSlug(repoRoot, detection, config2) {
19417
19987
  const fromConfig = config2?.value?.project?.repository;
19418
19988
  if (typeof fromConfig === "string" && fromConfig.includes("/")) return fromConfig;
19419
19989
  if (detection?.repository) return detection.repository;
19420
- const gitConfig = await readFile24(resolve22(repoRoot, ".git/config"), "utf8").catch(() => null);
19990
+ const gitConfig = await readFile26(resolve22(repoRoot, ".git/config"), "utf8").catch(() => null);
19421
19991
  if (gitConfig) {
19422
19992
  const m = gitConfig.match(/url\s*=\s*.*?github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\b/);
19423
19993
  if (m) return `${m[1]}/${m[2]}`;
@@ -19602,6 +20172,21 @@ var factories = {
19602
20172
  remote: "origin"
19603
20173
  })
19604
20174
  },
20175
+ abandon: {
20176
+ args: {
20177
+ taskId: tool.schema.string(),
20178
+ subject: tool.schema.string(),
20179
+ operationId: tool.schema.string().optional()
20180
+ },
20181
+ build: (rt) => createAbandonTool({
20182
+ driver: rt.driver,
20183
+ repoRoot: rt.repoRoot,
20184
+ repoSlug: rt.repoSlug,
20185
+ owner: rt.owner,
20186
+ adapter: rt.adapter,
20187
+ remote: "origin"
20188
+ })
20189
+ },
19605
20190
  githubRead: {
19606
20191
  args: {
19607
20192
  resource: tool.schema.enum(["issue", "pr", "checks"]),
@@ -19697,6 +20282,17 @@ var factories = {
19697
20282
  owner: rt.owner
19698
20283
  })
19699
20284
  },
20285
+ deliver: {
20286
+ args: {
20287
+ issueNumber: tool.schema.number(),
20288
+ operationId: tool.schema.string().optional()
20289
+ },
20290
+ build: (rt, ctx) => createDeliverTool({
20291
+ repoRoot: rt.repoRoot,
20292
+ opencodeClient: rt.opencodeClient,
20293
+ ctx
20294
+ })
20295
+ },
19700
20296
  planStart: {
19701
20297
  args: {
19702
20298
  issueNumber: tool.schema.number(),