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/CHANGELOG.md +53 -0
- package/README.md +12 -12
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/agents/ship-controller.md +9 -6
- package/assets/commands/ship-deliver.md +13 -28
- package/assets/skills/delivery-workflow/SKILL.md +19 -26
- package/dist/cli.js +20 -15
- package/dist/core.d.ts +601 -1
- package/dist/core.js +471 -36
- package/dist/plugin.js +1145 -549
- package/package.json +2 -1
- package/tests/plugin/expected-tools.mjs +6 -4
- package/tests/plugin/plugin-load.test.mjs +4 -4
package/dist/plugin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// opencode-ship v1.1.
|
|
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
|
|
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
|
-
|
|
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
|
|
15210
|
-
import { existsSync as
|
|
15211
|
-
import { join as
|
|
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
|
|
15648
|
+
return join11(opencodeShipStateDir(common), "github", "operations");
|
|
15216
15649
|
}
|
|
15217
|
-
var
|
|
15650
|
+
var SAFE_ID_RE3 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15218
15651
|
function operationPath(dir, operationId2) {
|
|
15219
|
-
if (!
|
|
15652
|
+
if (!SAFE_ID_RE3.test(operationId2)) {
|
|
15220
15653
|
throw new Error(`invalid operationId: ${JSON.stringify(operationId2)}`);
|
|
15221
15654
|
}
|
|
15222
|
-
return
|
|
15655
|
+
return join11(dir, `${operationId2}.json`);
|
|
15223
15656
|
}
|
|
15224
15657
|
async function hasOperation(repoRoot, operationId2) {
|
|
15225
15658
|
const dir = await operationsDir(repoRoot);
|
|
15226
|
-
return
|
|
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 (
|
|
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
|
|
15557
|
-
import { existsSync as
|
|
15558
|
-
import { dirname as
|
|
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
|
|
15947
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
15562
15948
|
function bytesHash(buffer) {
|
|
15563
|
-
return
|
|
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
|
|
15961
|
+
return resolve10(repoRoot, ".opencode", "ship.lock.json");
|
|
15572
15962
|
}
|
|
15573
15963
|
async function readLock2(repoRoot) {
|
|
15574
15964
|
const path = lockPath(repoRoot);
|
|
15575
|
-
if (!
|
|
15965
|
+
if (!existsSync10(path)) return null;
|
|
15576
15966
|
try {
|
|
15577
|
-
const raw = await
|
|
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
|
|
15982
|
+
import { join as join12 } from "node:path";
|
|
15597
15983
|
init_durable_store();
|
|
15598
|
-
import { createHash as
|
|
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
|
|
16009
|
+
return join12(opencodeShipStateDir(commonDir), "runs", workflowId, "dispatch", dispatchKey);
|
|
15624
16010
|
}
|
|
15625
16011
|
async function dispatchPath(commonDir, workflowId, dispatchKey) {
|
|
15626
|
-
return
|
|
16012
|
+
return join12(dispatchDir(commonDir, workflowId, dispatchKey), "dispatch.json");
|
|
15627
16013
|
}
|
|
15628
16014
|
function hashPayload(value) {
|
|
15629
|
-
return
|
|
16015
|
+
return createHash9("sha256").update(JSON.stringify(value), "utf8").digest("hex");
|
|
15630
16016
|
}
|
|
15631
|
-
async function
|
|
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 =
|
|
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 =
|
|
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:
|
|
16052
|
+
const { readdir: readdir10, readFile: readFile27 } = await import("node:fs/promises");
|
|
15671
16053
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15672
|
-
const baseDir =
|
|
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 =
|
|
16059
|
+
const initial = join12(baseDir, "dispatch.json");
|
|
15678
16060
|
if (!statSync2(initial, { throwIfNoEntry: false })) return null;
|
|
15679
|
-
return JSON.parse(await
|
|
16061
|
+
return JSON.parse(await readFile27(initial, "utf8"));
|
|
15680
16062
|
}
|
|
15681
16063
|
const last = files[files.length - 1];
|
|
15682
|
-
const raw = await
|
|
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:
|
|
16068
|
+
const { readFile: readFile27 } = await import("node:fs/promises");
|
|
15687
16069
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15688
|
-
const path =
|
|
16070
|
+
const path = join12(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey, "dispatch.json");
|
|
15689
16071
|
try {
|
|
15690
|
-
return JSON.parse(await
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
16194
|
+
...parentAudit
|
|
15755
16195
|
});
|
|
15756
16196
|
}
|
|
15757
16197
|
try {
|
|
15758
16198
|
const body = {
|
|
15759
|
-
parts: [{ type: "text", text: String(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
15819
|
-
const { existsSync:
|
|
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 =
|
|
15822
|
-
if (!
|
|
16261
|
+
const path = join12(opencodeShipStateDir(common), "runs", workflowId, "controller.json");
|
|
16262
|
+
if (!existsSync29(path)) return null;
|
|
15823
16263
|
try {
|
|
15824
|
-
return JSON.parse(await
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
16793
|
+
return createHash10("sha256").update(text, "utf8").digest("hex");
|
|
16243
16794
|
}
|
|
16244
16795
|
|
|
16245
16796
|
// src/workflow/plan-store.js
|
|
16246
|
-
import { readFile as
|
|
16247
|
-
import { existsSync as
|
|
16248
|
-
import { join as
|
|
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
|
|
16801
|
+
import { createHash as createHash11 } from "node:crypto";
|
|
16251
16802
|
function revisionsDir(commonDir, workflowId) {
|
|
16252
|
-
return
|
|
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
|
|
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 =
|
|
16271
|
-
if (
|
|
16272
|
-
const existing = JSON.parse(await
|
|
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 =
|
|
16303
|
-
const planPath =
|
|
16304
|
-
if (
|
|
16305
|
-
const planRecord = JSON.parse(await
|
|
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 (
|
|
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 =
|
|
16319
|
-
if (!
|
|
16869
|
+
const path = join14(revisionDir(common, workflowId, revision), "plan.json");
|
|
16870
|
+
if (!existsSync11(path)) return null;
|
|
16320
16871
|
try {
|
|
16321
|
-
const raw = await
|
|
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
|
|
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 || !
|
|
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
|
|
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 || !
|
|
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
|
|
16444
|
-
import { join as
|
|
16445
|
-
import { execFile } from "node:child_process";
|
|
16446
|
-
var
|
|
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 =
|
|
16451
|
-
const planRaw = await
|
|
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 =
|
|
17004
|
+
const approvalPath = join15(dir, "approval.json");
|
|
16454
17005
|
let approval = null;
|
|
16455
17006
|
try {
|
|
16456
|
-
approval = JSON.parse(await
|
|
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 || !
|
|
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
|
-
|
|
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
|
|
17084
|
+
import { join as join16 } from "node:path";
|
|
16534
17085
|
init_durable_store();
|
|
16535
|
-
import { createHash as
|
|
16536
|
-
|
|
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 || !
|
|
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 || !
|
|
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 =
|
|
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:
|
|
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 =
|
|
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(
|
|
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
|
|
16661
|
-
import { mkdir as mkdir11, readFile as
|
|
16662
|
-
import { existsSync as
|
|
16663
|
-
import { join as
|
|
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
|
|
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
|
-
|
|
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 || !
|
|
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 || !
|
|
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
|
|
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",
|
|
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 =
|
|
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 =
|
|
16778
|
-
if (
|
|
16779
|
-
const existing = JSON.parse(await
|
|
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
|
|
16801
|
-
import { existsSync as
|
|
16802
|
-
import { join as
|
|
16803
|
-
import { execFile as
|
|
16804
|
-
import { createHash as
|
|
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
|
|
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 || !
|
|
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 || !
|
|
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 || !
|
|
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:
|
|
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 =
|
|
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 =
|
|
16899
|
-
if (
|
|
16900
|
-
const existing = JSON.parse(await
|
|
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 =
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
17023
|
-
const packagePath =
|
|
17024
|
-
if (
|
|
17025
|
-
const existing = JSON.parse(await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
17084
|
-
import { existsSync as
|
|
17085
|
-
import { createHash as
|
|
17086
|
-
import { join as
|
|
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
|
|
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 || !
|
|
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 || !
|
|
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 =
|
|
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 =
|
|
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 (
|
|
17152
|
-
persistedRecord = JSON.parse(await
|
|
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:
|
|
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
|
|
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
|
|
17238
|
-
import { mkdir as mkdir14, readFile as
|
|
17239
|
-
import { existsSync as
|
|
17240
|
-
import { join as
|
|
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
|
|
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 || !
|
|
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 || !
|
|
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 =
|
|
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 =
|
|
17324
|
-
if (
|
|
17325
|
-
const existing = JSON.parse(await
|
|
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
|
|
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
|
|
17365
|
-
import { existsSync as
|
|
17366
|
-
import { join as
|
|
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
|
|
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 || !
|
|
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 =
|
|
17433
|
-
if (!
|
|
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
|
|
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 =
|
|
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 =
|
|
17458
|
-
if (
|
|
17459
|
-
record2 = JSON.parse(await
|
|
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
|
|
17521
|
-
import { join as
|
|
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
|
|
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 || !
|
|
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
|
|
17629
|
-
import { existsSync as
|
|
17630
|
-
import { join as
|
|
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 =
|
|
17639
|
-
const runRoot =
|
|
17640
|
-
const indexPath =
|
|
17641
|
-
if (!
|
|
17642
|
-
const index = JSON.parse(await
|
|
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 =
|
|
17645
|
-
if (
|
|
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 =
|
|
17648
|
-
if (
|
|
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
|
|
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
|
|
17665
|
-
import { resolve as
|
|
17666
|
-
import { createHash as
|
|
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
|
|
17671
|
-
import { dirname as
|
|
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
|
|
17740
|
-
import { existsSync as
|
|
17741
|
-
import { resolve as
|
|
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
|
|
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 (!
|
|
18422
|
+
if (!existsSync20(path)) return defaultPolicy();
|
|
17766
18423
|
try {
|
|
17767
|
-
const raw = await
|
|
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
|
|
17855
|
-
import { existsSync as
|
|
17856
|
-
import { resolve as resolve15, join as
|
|
17857
|
-
import { createHash as
|
|
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
|
|
17865
|
-
import { existsSync as
|
|
17866
|
-
import { resolve as
|
|
17867
|
-
import { createHash as
|
|
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
|
|
18528
|
+
return resolve14(repoRoot, INVENTORY_PATH);
|
|
17872
18529
|
}
|
|
17873
18530
|
async function readInventory(repoRoot) {
|
|
17874
18531
|
const path = inventoryPath(repoRoot);
|
|
17875
|
-
if (!
|
|
18532
|
+
if (!existsSync21(path)) {
|
|
17876
18533
|
return { schemaVersion: INVENTORY_SCHEMA, events: [] };
|
|
17877
18534
|
}
|
|
17878
18535
|
let raw;
|
|
17879
18536
|
try {
|
|
17880
|
-
raw = await
|
|
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(
|
|
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
|
|
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 &&
|
|
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
|
|
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 || !
|
|
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 (
|
|
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(
|
|
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 (
|
|
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 =
|
|
18344
|
-
if (!
|
|
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 =
|
|
18352
|
-
if (!
|
|
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 =
|
|
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
|
|
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:
|
|
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
|
|
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 =
|
|
18418
|
-
const dest =
|
|
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
|
|
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
|
|
18431
|
-
import { existsSync as
|
|
18432
|
-
import { resolve as resolve16, join as
|
|
18433
|
-
import { createHash as
|
|
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 =
|
|
18475
|
-
if (!
|
|
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
|
|
18480
|
-
const sha =
|
|
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 =
|
|
18488
|
-
if (
|
|
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
|
|
18511
|
-
import { existsSync as
|
|
18512
|
-
import { resolve as resolve17, join as
|
|
18513
|
-
import { createHash as
|
|
18514
|
-
var
|
|
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 || !
|
|
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 (!
|
|
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
|
|
18564
|
-
const sha =
|
|
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 =
|
|
19089
|
+
const filePath = join27(skillDir, ...f.path.split("/"));
|
|
18571
19090
|
await unlink5(filePath).catch(() => null);
|
|
18572
19091
|
}
|
|
18573
|
-
if (
|
|
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 (!
|
|
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 =
|
|
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
|
|
19140
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
18622
19141
|
async function reconcileOwner(repoRoot, adapter) {
|
|
18623
|
-
const r =
|
|
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
|
|
18632
|
-
import { existsSync as
|
|
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 (!
|
|
18945
|
-
const raw = await
|
|
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
|
|
19011
|
-
import { existsSync as
|
|
19012
|
-
import { resolve as resolve19, join as
|
|
19013
|
-
function
|
|
19014
|
-
const r =
|
|
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 (
|
|
19022
|
-
if (
|
|
19023
|
-
if (
|
|
19024
|
-
if (
|
|
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 =
|
|
19029
|
-
if (!
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
19672
|
+
import { spawnSync as spawnSync7 } from "node:child_process";
|
|
19154
19673
|
import { resolve as pathResolve } from "node:path";
|
|
19155
|
-
import { existsSync as
|
|
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 =
|
|
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
|
-
|
|
19162
|
-
|
|
19163
|
-
|
|
19164
|
-
|
|
19165
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
|
19199
|
-
const out = [];
|
|
19715
|
+
const byTask = /* @__PURE__ */ new Map();
|
|
19200
19716
|
for (const e of entries) {
|
|
19201
19717
|
if (!e || !e.taskId) continue;
|
|
19202
|
-
|
|
19203
|
-
seen.add(e.taskId);
|
|
19204
|
-
out.push(e);
|
|
19718
|
+
byTask.set(e.taskId, e);
|
|
19205
19719
|
}
|
|
19206
|
-
return
|
|
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)
|
|
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
|
-
|
|
19233
|
-
if (
|
|
19234
|
-
|
|
19235
|
-
|
|
19236
|
-
|
|
19237
|
-
|
|
19238
|
-
|
|
19239
|
-
|
|
19240
|
-
|
|
19241
|
-
|
|
19242
|
-
|
|
19243
|
-
|
|
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: "
|
|
19248
|
-
|
|
19798
|
+
stage: "branch-delete",
|
|
19799
|
+
expectedHeadSha: headSha,
|
|
19800
|
+
reason: "resume cleanup"
|
|
19249
19801
|
});
|
|
19250
|
-
return reject("remove-failed", { detail: removed.stderr });
|
|
19251
19802
|
}
|
|
19252
|
-
|
|
19253
|
-
|
|
19254
|
-
|
|
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: "
|
|
19259
|
-
reason: "
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
|
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(),
|