opencode-ship 1.1.7 → 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 +15 -1
- package/README.md +3 -3
- 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 +9 -4
- package/dist/core.d.ts +18 -0
- package/dist/core.js +431 -6
- package/dist/plugin.js +1025 -484
- package/package.json +1 -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) {
|
|
@@ -15173,6 +15176,231 @@ function createCleanupTool(deps) {
|
|
|
15173
15176
|
};
|
|
15174
15177
|
}
|
|
15175
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
|
+
|
|
15176
15404
|
// src/tools/envelope.js
|
|
15177
15405
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
15178
15406
|
var CONTRACT_VERSION = 2;
|
|
@@ -15211,25 +15439,224 @@ function failure(kind, message, options = {}) {
|
|
|
15211
15439
|
};
|
|
15212
15440
|
}
|
|
15213
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
|
+
|
|
15214
15641
|
// src/state/github-operation-store.js
|
|
15215
|
-
import { readFile as
|
|
15216
|
-
import { existsSync as
|
|
15217
|
-
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";
|
|
15218
15645
|
init_durable_store();
|
|
15219
15646
|
async function operationsDir(repoRoot) {
|
|
15220
15647
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15221
|
-
return
|
|
15648
|
+
return join11(opencodeShipStateDir(common), "github", "operations");
|
|
15222
15649
|
}
|
|
15223
|
-
var
|
|
15650
|
+
var SAFE_ID_RE3 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15224
15651
|
function operationPath(dir, operationId2) {
|
|
15225
|
-
if (!
|
|
15652
|
+
if (!SAFE_ID_RE3.test(operationId2)) {
|
|
15226
15653
|
throw new Error(`invalid operationId: ${JSON.stringify(operationId2)}`);
|
|
15227
15654
|
}
|
|
15228
|
-
return
|
|
15655
|
+
return join11(dir, `${operationId2}.json`);
|
|
15229
15656
|
}
|
|
15230
15657
|
async function hasOperation(repoRoot, operationId2) {
|
|
15231
15658
|
const dir = await operationsDir(repoRoot);
|
|
15232
|
-
return
|
|
15659
|
+
return existsSync9(operationPath(dir, operationId2));
|
|
15233
15660
|
}
|
|
15234
15661
|
async function recordOperation(repoRoot, operationId2, record2) {
|
|
15235
15662
|
if (typeof operationId2 !== "string" || operationId2.length === 0) {
|
|
@@ -15241,7 +15668,7 @@ async function recordOperation(repoRoot, operationId2, record2) {
|
|
|
15241
15668
|
const dir = await operationsDir(repoRoot);
|
|
15242
15669
|
await mkdir5(dir, { recursive: true });
|
|
15243
15670
|
const path = operationPath(dir, operationId2);
|
|
15244
|
-
if (
|
|
15671
|
+
if (existsSync9(path)) {
|
|
15245
15672
|
return { recorded: false, path };
|
|
15246
15673
|
}
|
|
15247
15674
|
const fullRecord = {
|
|
@@ -15511,76 +15938,33 @@ function createPublishTool(deps) {
|
|
|
15511
15938
|
};
|
|
15512
15939
|
}
|
|
15513
15940
|
|
|
15514
|
-
// src/profile.js
|
|
15515
|
-
var PROFILES = Object.freeze(["engineering"]);
|
|
15516
|
-
var LEGACY_PROFILES = Object.freeze(["core"]);
|
|
15517
|
-
|
|
15518
|
-
// src/installer/engineering-config.js
|
|
15519
|
-
var MODEL_ID_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
|
|
15520
|
-
var DEFAULTS = Object.freeze({
|
|
15521
|
-
planner: "openai/gpt-5.6-sol",
|
|
15522
|
-
builder: "minimax/MiniMax-M3",
|
|
15523
|
-
finalReviewer: "openai/gpt-5.6-sol"
|
|
15524
|
-
});
|
|
15525
|
-
function resolveModelRoles(cfg, { strict = false, allowDeferred = false } = {}) {
|
|
15526
|
-
const REQUIRED = ["planner", "builder", "finalReviewer"];
|
|
15527
|
-
if (strict) {
|
|
15528
|
-
const issues = [];
|
|
15529
|
-
for (const role of REQUIRED) {
|
|
15530
|
-
const id = cfg?.models?.[role];
|
|
15531
|
-
if (typeof id !== "string" || id.length === 0 || !MODEL_ID_RE.test(id)) {
|
|
15532
|
-
issues.push(role);
|
|
15533
|
-
}
|
|
15534
|
-
}
|
|
15535
|
-
if (issues.length > 0) {
|
|
15536
|
-
throw new Error(`resolveModelRoles: required role(s) missing or invalid: ${issues.join(", ")}`);
|
|
15537
|
-
}
|
|
15538
|
-
return { planner: cfg.models.planner, builder: cfg.models.builder, finalReviewer: cfg.models.finalReviewer };
|
|
15539
|
-
}
|
|
15540
|
-
const out = { ...DEFAULTS };
|
|
15541
|
-
if (cfg && cfg.models) {
|
|
15542
|
-
for (const [role, id] of Object.entries(cfg.models)) {
|
|
15543
|
-
if (id && typeof id === "string" && id.length > 0) {
|
|
15544
|
-
out[role] = id;
|
|
15545
|
-
} else if (strict && Object.prototype.hasOwnProperty.call(cfg.models, role)) {
|
|
15546
|
-
throw new Error(`resolveModelRoles: user provided empty model id for '${role}'`);
|
|
15547
|
-
}
|
|
15548
|
-
}
|
|
15549
|
-
}
|
|
15550
|
-
if (allowDeferred) {
|
|
15551
|
-
return { planner: out.planner ?? null, builder: out.builder ?? null, finalReviewer: out.finalReviewer ?? null };
|
|
15552
|
-
}
|
|
15553
|
-
for (const role of REQUIRED) {
|
|
15554
|
-
if (!out[role]) {
|
|
15555
|
-
throw new Error(`resolveModelRoles: required role '${role}' missing and no default available`);
|
|
15556
|
-
}
|
|
15557
|
-
}
|
|
15558
|
-
return out;
|
|
15559
|
-
}
|
|
15560
|
-
|
|
15561
15941
|
// src/installer/lock.js
|
|
15562
|
-
import { readFile as
|
|
15563
|
-
import { existsSync as
|
|
15564
|
-
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";
|
|
15565
15945
|
|
|
15566
15946
|
// src/installer/hash.js
|
|
15567
|
-
import { createHash as
|
|
15947
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
15568
15948
|
function bytesHash(buffer) {
|
|
15569
|
-
return
|
|
15949
|
+
return createHash8("sha256").update(buffer).digest("hex");
|
|
15570
15950
|
}
|
|
15571
15951
|
function bytesHashString(text) {
|
|
15572
15952
|
return bytesHash(Buffer.from(text, "utf8"));
|
|
15573
15953
|
}
|
|
15574
15954
|
|
|
15955
|
+
// src/profile.js
|
|
15956
|
+
var PROFILES = Object.freeze(["engineering"]);
|
|
15957
|
+
var LEGACY_PROFILES = Object.freeze(["core"]);
|
|
15958
|
+
|
|
15575
15959
|
// src/installer/lock.js
|
|
15576
15960
|
function lockPath(repoRoot) {
|
|
15577
|
-
return
|
|
15961
|
+
return resolve10(repoRoot, ".opencode", "ship.lock.json");
|
|
15578
15962
|
}
|
|
15579
15963
|
async function readLock2(repoRoot) {
|
|
15580
15964
|
const path = lockPath(repoRoot);
|
|
15581
|
-
if (!
|
|
15965
|
+
if (!existsSync10(path)) return null;
|
|
15582
15966
|
try {
|
|
15583
|
-
const raw = await
|
|
15967
|
+
const raw = await readFile10(path, "utf8");
|
|
15584
15968
|
return JSON.parse(raw);
|
|
15585
15969
|
} catch {
|
|
15586
15970
|
return null;
|
|
@@ -15593,15 +15977,11 @@ function isSetupComplete(lock) {
|
|
|
15593
15977
|
return manager.setupComplete === true;
|
|
15594
15978
|
}
|
|
15595
15979
|
|
|
15596
|
-
// src/tools/ship-plan-start.js
|
|
15597
|
-
import { join as join10 } from "node:path";
|
|
15598
|
-
import { mkdir as mkdir8, writeFile as writeFile6 } from "node:fs/promises";
|
|
15599
|
-
|
|
15600
15980
|
// src/runtime/opencode-dispatcher.js
|
|
15601
15981
|
import { mkdir as mkdir7 } from "node:fs/promises";
|
|
15602
|
-
import { join as
|
|
15982
|
+
import { join as join12 } from "node:path";
|
|
15603
15983
|
init_durable_store();
|
|
15604
|
-
import { createHash as
|
|
15984
|
+
import { createHash as createHash9 } from "node:crypto";
|
|
15605
15985
|
import { dirname as dirnameOf } from "node:path";
|
|
15606
15986
|
var ROLE_PLANNER = "planner";
|
|
15607
15987
|
var ROLE_BUILDER = "builder";
|
|
@@ -15626,20 +16006,16 @@ function dispatchKeyFor(role, input) {
|
|
|
15626
16006
|
}
|
|
15627
16007
|
}
|
|
15628
16008
|
function dispatchDir(commonDir, workflowId, dispatchKey) {
|
|
15629
|
-
return
|
|
16009
|
+
return join12(opencodeShipStateDir(commonDir), "runs", workflowId, "dispatch", dispatchKey);
|
|
15630
16010
|
}
|
|
15631
16011
|
async function dispatchPath(commonDir, workflowId, dispatchKey) {
|
|
15632
|
-
return
|
|
16012
|
+
return join12(dispatchDir(commonDir, workflowId, dispatchKey), "dispatch.json");
|
|
15633
16013
|
}
|
|
15634
16014
|
function hashPayload(value) {
|
|
15635
|
-
return
|
|
16015
|
+
return createHash9("sha256").update(JSON.stringify(value), "utf8").digest("hex");
|
|
15636
16016
|
}
|
|
15637
|
-
async function
|
|
15638
|
-
if (!ROLE_KEYS.has(role)) {
|
|
15639
|
-
throw new Error(`prepareDispatch: unknown role ${role}`);
|
|
15640
|
-
}
|
|
16017
|
+
async function prepareDispatchRecord(repoRoot, workflowId, dispatchKey, role, keyInput, payload) {
|
|
15641
16018
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15642
|
-
const dispatchKey = dispatchKeyFor(role, keyInput);
|
|
15643
16019
|
const commonDir = opencodeShipStateDir(common);
|
|
15644
16020
|
const dir = dispatchDir(common, workflowId, dispatchKey);
|
|
15645
16021
|
await mkdir7(dir, { recursive: true });
|
|
@@ -15658,10 +16034,10 @@ async function prepareDispatch(repoRoot, workflowId, role, keyInput, payload) {
|
|
|
15658
16034
|
}
|
|
15659
16035
|
async function transitionDispatch(repoRoot, workflowId, dispatchKey, nextState, fields = {}) {
|
|
15660
16036
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15661
|
-
const baseDir =
|
|
16037
|
+
const baseDir = join12(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey);
|
|
15662
16038
|
await mkdir7(baseDir, { recursive: true });
|
|
15663
16039
|
const next = Number(fields.sequence ?? 0);
|
|
15664
|
-
const path =
|
|
16040
|
+
const path = join12(baseDir, `seq-${String(next).padStart(6, "0")}.json`);
|
|
15665
16041
|
const record2 = {
|
|
15666
16042
|
workflowId,
|
|
15667
16043
|
dispatchKey,
|
|
@@ -15673,27 +16049,27 @@ async function transitionDispatch(repoRoot, workflowId, dispatchKey, nextState,
|
|
|
15673
16049
|
return record2;
|
|
15674
16050
|
}
|
|
15675
16051
|
async function readLatestDispatch(repoRoot, workflowId, dispatchKey) {
|
|
15676
|
-
const { readdir: readdir10, readFile:
|
|
16052
|
+
const { readdir: readdir10, readFile: readFile27 } = await import("node:fs/promises");
|
|
15677
16053
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15678
|
-
const baseDir =
|
|
16054
|
+
const baseDir = join12(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey);
|
|
15679
16055
|
const { readdirSync: readdirSync2, statSync: statSync2 } = await import("node:fs");
|
|
15680
16056
|
if (!statSync2(baseDir, { throwIfNoEntry: false })) return null;
|
|
15681
16057
|
const files = readdirSync2(baseDir).filter((f) => f.startsWith("seq-")).sort();
|
|
15682
16058
|
if (files.length === 0) {
|
|
15683
|
-
const initial =
|
|
16059
|
+
const initial = join12(baseDir, "dispatch.json");
|
|
15684
16060
|
if (!statSync2(initial, { throwIfNoEntry: false })) return null;
|
|
15685
|
-
return JSON.parse(await
|
|
16061
|
+
return JSON.parse(await readFile27(initial, "utf8"));
|
|
15686
16062
|
}
|
|
15687
16063
|
const last = files[files.length - 1];
|
|
15688
|
-
const raw = await
|
|
16064
|
+
const raw = await readFile27(join12(baseDir, last), "utf8");
|
|
15689
16065
|
return JSON.parse(raw);
|
|
15690
16066
|
}
|
|
15691
16067
|
async function readPreparedDispatch(repoRoot, workflowId, dispatchKey) {
|
|
15692
|
-
const { readFile:
|
|
16068
|
+
const { readFile: readFile27 } = await import("node:fs/promises");
|
|
15693
16069
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15694
|
-
const path =
|
|
16070
|
+
const path = join12(opencodeShipStateDir(common), "runs", workflowId, "dispatch", dispatchKey, "dispatch.json");
|
|
15695
16071
|
try {
|
|
15696
|
-
return JSON.parse(await
|
|
16072
|
+
return JSON.parse(await readFile27(path, "utf8"));
|
|
15697
16073
|
} catch (err) {
|
|
15698
16074
|
if (err?.code === "ENOENT") return null;
|
|
15699
16075
|
throw err;
|
|
@@ -15704,33 +16080,91 @@ async function dispatchWorker(input) {
|
|
|
15704
16080
|
if (!ROLE_KEYS.has(role)) {
|
|
15705
16081
|
throw new Error(`dispatchWorker: unknown role ${role}`);
|
|
15706
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;
|
|
15707
16138
|
if (!client || typeof client.session?.create !== "function" || typeof client.session?.promptAsync !== "function") {
|
|
15708
|
-
throw new Error(
|
|
16139
|
+
throw new Error(`${caller}: client.session.create and client.session.promptAsync are required`);
|
|
15709
16140
|
}
|
|
15710
16141
|
if (!parentSessionID || typeof parentSessionID !== "string") {
|
|
15711
|
-
throw new Error(
|
|
16142
|
+
throw new Error(`${caller}: parentSessionID required`);
|
|
16143
|
+
}
|
|
16144
|
+
if (requireControllerLease) {
|
|
16145
|
+
await assertControllerLease(repoRoot, workflowId, parentSessionID);
|
|
15712
16146
|
}
|
|
15713
|
-
await assertControllerLease(repoRoot, workflowId, parentSessionID);
|
|
15714
|
-
const dispatchKey = dispatchKeyFor(role, keyInput);
|
|
15715
16147
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15716
16148
|
const stateDir = opencodeShipStateDir(common);
|
|
15717
16149
|
const preparedPayload = { ...payload, agent: agent ?? null, model: model ?? null };
|
|
16150
|
+
const parentAudit = requireControllerLease ? { controllerSessionID: parentSessionID } : { parentSessionID };
|
|
15718
16151
|
return withResourceLock(stateDir, `dispatch:${workflowId}:${dispatchKey}`, async () => {
|
|
15719
16152
|
let latest = await readLatestDispatch(repoRoot, workflowId, dispatchKey);
|
|
15720
16153
|
const preparedRecord = await readPreparedDispatch(repoRoot, workflowId, dispatchKey);
|
|
15721
16154
|
if (preparedRecord && preparedRecord.payloadHash !== hashPayload(preparedPayload)) {
|
|
15722
|
-
throw new Error(
|
|
16155
|
+
throw new Error(`${caller}: payload changed for existing dispatch ${dispatchKey}`);
|
|
15723
16156
|
}
|
|
15724
16157
|
if (latest?.state === "prompted" || latest?.state === "completed") {
|
|
15725
16158
|
return { sessionID: latest.sessionID, dispatchKey };
|
|
15726
16159
|
}
|
|
15727
16160
|
if (!preparedRecord) {
|
|
15728
|
-
await
|
|
16161
|
+
await prepareDispatchRecord(repoRoot, workflowId, dispatchKey, role, keyInput, preparedPayload);
|
|
15729
16162
|
latest = { state: "prepared", sequence: 0 };
|
|
15730
16163
|
}
|
|
15731
16164
|
let sequence = Number(latest?.sequence ?? 0);
|
|
15732
16165
|
const title = titleMarker ?? `ship-${role}-${dispatchKey}`;
|
|
15733
|
-
|
|
16166
|
+
const canReuseSession = latest?.state === "created" || latest?.state === "failed" && latest?.lastError?.startsWith("promptAsync:");
|
|
16167
|
+
let sessionID = canReuseSession ? latest?.sessionID ?? null : null;
|
|
15734
16168
|
if (!sessionID) {
|
|
15735
16169
|
try {
|
|
15736
16170
|
const created = await client.session.create({
|
|
@@ -15738,12 +16172,12 @@ async function dispatchWorker(input) {
|
|
|
15738
16172
|
query: { directory: repoRoot }
|
|
15739
16173
|
});
|
|
15740
16174
|
if (created?.error) {
|
|
15741
|
-
throw new Error(
|
|
16175
|
+
throw new Error(`${caller}: session.create failed: ${formatSdkError(created.error)}`);
|
|
15742
16176
|
}
|
|
15743
16177
|
const createdData = created?.data ?? created;
|
|
15744
16178
|
sessionID = createdData?.id ?? createdData?.sessionID;
|
|
15745
16179
|
if (!sessionID) {
|
|
15746
|
-
throw new Error(
|
|
16180
|
+
throw new Error(`${caller}: client.session.create did not return a session id`);
|
|
15747
16181
|
}
|
|
15748
16182
|
} catch (err) {
|
|
15749
16183
|
sequence += 1;
|
|
@@ -15757,12 +16191,12 @@ async function dispatchWorker(input) {
|
|
|
15757
16191
|
await transitionDispatch(repoRoot, workflowId, dispatchKey, "created", {
|
|
15758
16192
|
sequence,
|
|
15759
16193
|
sessionID,
|
|
15760
|
-
|
|
16194
|
+
...parentAudit
|
|
15761
16195
|
});
|
|
15762
16196
|
}
|
|
15763
16197
|
try {
|
|
15764
16198
|
const body = {
|
|
15765
|
-
parts: [{ type: "text", text: String(
|
|
16199
|
+
parts: [{ type: "text", text: String(promptText ?? "") }]
|
|
15766
16200
|
};
|
|
15767
16201
|
if (agent) body.agent = agent;
|
|
15768
16202
|
if (model) body.model = parseModelId(model);
|
|
@@ -15772,14 +16206,14 @@ async function dispatchWorker(input) {
|
|
|
15772
16206
|
query: { directory: repoRoot }
|
|
15773
16207
|
});
|
|
15774
16208
|
if (prompted?.error) {
|
|
15775
|
-
throw new Error(
|
|
16209
|
+
throw new Error(`${caller}: session.promptAsync failed: ${formatSdkError(prompted.error)}`);
|
|
15776
16210
|
}
|
|
15777
16211
|
} catch (err) {
|
|
15778
16212
|
sequence += 1;
|
|
15779
16213
|
await transitionDispatch(repoRoot, workflowId, dispatchKey, "failed", {
|
|
15780
16214
|
sequence,
|
|
15781
16215
|
sessionID,
|
|
15782
|
-
|
|
16216
|
+
...parentAudit,
|
|
15783
16217
|
lastError: `promptAsync: ${err?.message ?? err}`
|
|
15784
16218
|
});
|
|
15785
16219
|
throw err;
|
|
@@ -15788,7 +16222,7 @@ async function dispatchWorker(input) {
|
|
|
15788
16222
|
await transitionDispatch(repoRoot, workflowId, dispatchKey, "prompted", {
|
|
15789
16223
|
sequence,
|
|
15790
16224
|
sessionID,
|
|
15791
|
-
|
|
16225
|
+
...parentAudit
|
|
15792
16226
|
});
|
|
15793
16227
|
return { sessionID, dispatchKey };
|
|
15794
16228
|
});
|
|
@@ -15812,7 +16246,7 @@ function formatSdkError(error45) {
|
|
|
15812
16246
|
async function issueControllerLease(repoRoot, workflowId, controllerSessionID) {
|
|
15813
16247
|
const { atomicReplaceJson: atomicReplaceJson2 } = await Promise.resolve().then(() => (init_durable_store(), durable_store_exports));
|
|
15814
16248
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15815
|
-
const path =
|
|
16249
|
+
const path = join12(opencodeShipStateDir(common), "runs", workflowId, "controller.json");
|
|
15816
16250
|
await mkdir7(dirnameOf(path), { recursive: true });
|
|
15817
16251
|
await atomicReplaceJson2(path, {
|
|
15818
16252
|
workflowId,
|
|
@@ -15821,13 +16255,13 @@ async function issueControllerLease(repoRoot, workflowId, controllerSessionID) {
|
|
|
15821
16255
|
});
|
|
15822
16256
|
}
|
|
15823
16257
|
async function readControllerLease(repoRoot, workflowId) {
|
|
15824
|
-
const { readFile:
|
|
15825
|
-
const { existsSync:
|
|
16258
|
+
const { readFile: readFile27 } = await import("node:fs/promises");
|
|
16259
|
+
const { existsSync: existsSync29 } = await import("node:fs");
|
|
15826
16260
|
const common = await resolveGitCommonDir(repoRoot);
|
|
15827
|
-
const path =
|
|
15828
|
-
if (!
|
|
16261
|
+
const path = join12(opencodeShipStateDir(common), "runs", workflowId, "controller.json");
|
|
16262
|
+
if (!existsSync29(path)) return null;
|
|
15829
16263
|
try {
|
|
15830
|
-
return JSON.parse(await
|
|
16264
|
+
return JSON.parse(await readFile27(path, "utf8"));
|
|
15831
16265
|
} catch {
|
|
15832
16266
|
return null;
|
|
15833
16267
|
}
|
|
@@ -15896,7 +16330,118 @@ var ROLES = Object.freeze({
|
|
|
15896
16330
|
FINAL_SPEC: ROLE_FINAL_SPEC
|
|
15897
16331
|
});
|
|
15898
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
|
+
|
|
15899
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";
|
|
15900
16445
|
function normalizeWorkflowId(issueNumber) {
|
|
15901
16446
|
return `wf-${issueNumber}`;
|
|
15902
16447
|
}
|
|
@@ -15925,7 +16470,7 @@ function createPlanStartTool(deps) {
|
|
|
15925
16470
|
const repoRoot = deps.repoRoot;
|
|
15926
16471
|
try {
|
|
15927
16472
|
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
15928
|
-
const wfDir =
|
|
16473
|
+
const wfDir = join13(opencodeShipStateDir(commonDir), "plans", workflowId);
|
|
15929
16474
|
await mkdir8(wfDir, { recursive: true });
|
|
15930
16475
|
await issueControllerLease(repoRoot, workflowId, ctx.sessionID);
|
|
15931
16476
|
const matchingManifests = (await listManifests(repoRoot)).filter((manifest) => manifest.issueNumber === issueNumber);
|
|
@@ -15968,7 +16513,7 @@ function createPlanStartTool(deps) {
|
|
|
15968
16513
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15969
16514
|
state: "drafting"
|
|
15970
16515
|
};
|
|
15971
|
-
await writeFile6(
|
|
16516
|
+
await writeFile6(join13(wfDir, "index.json"), JSON.stringify(indexRecord, null, 2), "utf8");
|
|
15972
16517
|
return success2("plan-start", {
|
|
15973
16518
|
workflowId,
|
|
15974
16519
|
issueNumber,
|
|
@@ -15988,7 +16533,7 @@ function createPlanStartTool(deps) {
|
|
|
15988
16533
|
}
|
|
15989
16534
|
|
|
15990
16535
|
// src/workflow/plan.js
|
|
15991
|
-
import { createHash as
|
|
16536
|
+
import { createHash as createHash10 } from "node:crypto";
|
|
15992
16537
|
function isPlainObject2(v) {
|
|
15993
16538
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
15994
16539
|
}
|
|
@@ -16245,21 +16790,21 @@ function computePlanHash(plan) {
|
|
|
16245
16790
|
return sha2563(json2);
|
|
16246
16791
|
}
|
|
16247
16792
|
function sha2563(text) {
|
|
16248
|
-
return
|
|
16793
|
+
return createHash10("sha256").update(text, "utf8").digest("hex");
|
|
16249
16794
|
}
|
|
16250
16795
|
|
|
16251
16796
|
// src/workflow/plan-store.js
|
|
16252
|
-
import { readFile as
|
|
16253
|
-
import { existsSync as
|
|
16254
|
-
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";
|
|
16255
16800
|
init_durable_store();
|
|
16256
|
-
import { createHash as
|
|
16801
|
+
import { createHash as createHash11 } from "node:crypto";
|
|
16257
16802
|
function revisionsDir(commonDir, workflowId) {
|
|
16258
|
-
return
|
|
16803
|
+
return join14(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions");
|
|
16259
16804
|
}
|
|
16260
16805
|
function revisionDir(commonDir, workflowId, revision) {
|
|
16261
16806
|
const n = String(revision).padStart(6, "0");
|
|
16262
|
-
return
|
|
16807
|
+
return join14(revisionsDir(commonDir, workflowId), n);
|
|
16263
16808
|
}
|
|
16264
16809
|
async function resolveCommon(repoRoot) {
|
|
16265
16810
|
return resolveGitCommonDir(repoRoot);
|
|
@@ -16273,9 +16818,9 @@ async function publishPlanRevision(repoRoot, plan) {
|
|
|
16273
16818
|
const common = await resolveCommon(repoRoot);
|
|
16274
16819
|
const dir = revisionDir(common, plan.workflowId, plan.revision);
|
|
16275
16820
|
await mkdir9(dir, { recursive: true });
|
|
16276
|
-
const planPath =
|
|
16277
|
-
if (
|
|
16278
|
-
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"));
|
|
16279
16824
|
if (existing?.plan?.workflowId !== plan.workflowId) {
|
|
16280
16825
|
throw new Error(`publishPlanRevision: workflowId mismatch on existing record at ${planPath}`);
|
|
16281
16826
|
}
|
|
@@ -16305,15 +16850,15 @@ async function publishApproval(repoRoot, approval) {
|
|
|
16305
16850
|
const common = await resolveCommon(repoRoot);
|
|
16306
16851
|
const dir = revisionDir(common, approval.workflowId, approval.revision);
|
|
16307
16852
|
await mkdir9(dir, { recursive: true });
|
|
16308
|
-
const path =
|
|
16309
|
-
const planPath =
|
|
16310
|
-
if (
|
|
16311
|
-
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"));
|
|
16312
16857
|
if (planRecord.hash !== approval.sha256) {
|
|
16313
16858
|
throw new Error(`publishApproval: sha256 mismatch with plan record (plan ${planRecord.hash?.slice(0, 8)}, approval ${approval.sha256.slice(0, 8)})`);
|
|
16314
16859
|
}
|
|
16315
16860
|
}
|
|
16316
|
-
if (
|
|
16861
|
+
if (existsSync11(path)) {
|
|
16317
16862
|
return { recorded: false, path };
|
|
16318
16863
|
}
|
|
16319
16864
|
await publishImmutableJson(path, { ...approval, publishedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
@@ -16321,10 +16866,10 @@ async function publishApproval(repoRoot, approval) {
|
|
|
16321
16866
|
}
|
|
16322
16867
|
async function readPlanRevision(repoRoot, workflowId, revision) {
|
|
16323
16868
|
const common = await resolveCommon(repoRoot);
|
|
16324
|
-
const path =
|
|
16325
|
-
if (!
|
|
16869
|
+
const path = join14(revisionDir(common, workflowId, revision), "plan.json");
|
|
16870
|
+
if (!existsSync11(path)) return null;
|
|
16326
16871
|
try {
|
|
16327
|
-
const raw = await
|
|
16872
|
+
const raw = await readFile11(path, "utf8");
|
|
16328
16873
|
return JSON.parse(raw);
|
|
16329
16874
|
} catch {
|
|
16330
16875
|
return null;
|
|
@@ -16332,14 +16877,14 @@ async function readPlanRevision(repoRoot, workflowId, revision) {
|
|
|
16332
16877
|
}
|
|
16333
16878
|
|
|
16334
16879
|
// src/tools/ship-plan-submit.js
|
|
16335
|
-
var
|
|
16880
|
+
var SAFE_ID_RE4 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
16336
16881
|
function createPlanSubmitTool(deps) {
|
|
16337
16882
|
return async function planSubmit(input) {
|
|
16338
16883
|
const opId = input.operationId ?? `plan-submit-${Date.now().toString(36)}`;
|
|
16339
16884
|
const workflowId = String(input.workflowId ?? "");
|
|
16340
16885
|
const revision = Number(input.revision);
|
|
16341
16886
|
const plan = input.plan;
|
|
16342
|
-
if (!workflowId || !
|
|
16887
|
+
if (!workflowId || !SAFE_ID_RE4.test(workflowId)) {
|
|
16343
16888
|
return failure("plan-submit", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
16344
16889
|
}
|
|
16345
16890
|
if (!Number.isInteger(revision) || revision <= 0) {
|
|
@@ -16390,7 +16935,7 @@ function createPlanSubmitTool(deps) {
|
|
|
16390
16935
|
}
|
|
16391
16936
|
|
|
16392
16937
|
// src/tools/ship-plan-approve.js
|
|
16393
|
-
var
|
|
16938
|
+
var SAFE_ID_RE5 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
16394
16939
|
function createPlanApproveTool(deps) {
|
|
16395
16940
|
return async function planApprove(input) {
|
|
16396
16941
|
const opId = input.operationId ?? `plan-approve-${Date.now().toString(36)}`;
|
|
@@ -16398,7 +16943,7 @@ function createPlanApproveTool(deps) {
|
|
|
16398
16943
|
const revision = Number(input.revision);
|
|
16399
16944
|
const sha2565 = String(input.sha256 ?? "");
|
|
16400
16945
|
const subject = String(input.subject ?? "");
|
|
16401
|
-
if (!workflowId || !
|
|
16946
|
+
if (!workflowId || !SAFE_ID_RE5.test(workflowId)) {
|
|
16402
16947
|
return failure("plan-approve", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
16403
16948
|
}
|
|
16404
16949
|
if (!Number.isInteger(revision) || revision <= 0) {
|
|
@@ -16446,20 +16991,20 @@ function createPlanApproveTool(deps) {
|
|
|
16446
16991
|
}
|
|
16447
16992
|
|
|
16448
16993
|
// src/tools/ship-run-start.js
|
|
16449
|
-
import { readFile as
|
|
16450
|
-
import { join as
|
|
16451
|
-
import { execFile } from "node:child_process";
|
|
16452
|
-
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}$/;
|
|
16453
16998
|
async function readRevisionRecord(repoRoot, workflowId, revision) {
|
|
16454
16999
|
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
16455
17000
|
const rev = String(revision).padStart(6, "0");
|
|
16456
|
-
const dir =
|
|
16457
|
-
const planRaw = await
|
|
17001
|
+
const dir = join15(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions", rev);
|
|
17002
|
+
const planRaw = await readFile12(join15(dir, "plan.json"), "utf8");
|
|
16458
17003
|
const plan = JSON.parse(planRaw);
|
|
16459
|
-
const approvalPath =
|
|
17004
|
+
const approvalPath = join15(dir, "approval.json");
|
|
16460
17005
|
let approval = null;
|
|
16461
17006
|
try {
|
|
16462
|
-
approval = JSON.parse(await
|
|
17007
|
+
approval = JSON.parse(await readFile12(approvalPath, "utf8"));
|
|
16463
17008
|
} catch {
|
|
16464
17009
|
approval = null;
|
|
16465
17010
|
}
|
|
@@ -16469,7 +17014,7 @@ function createRunStartTool(deps) {
|
|
|
16469
17014
|
return async function runStart(input) {
|
|
16470
17015
|
const opId = input.operationId ?? `run-start-${Date.now().toString(36)}`;
|
|
16471
17016
|
const workflowId = String(input.workflowId ?? "");
|
|
16472
|
-
if (!workflowId || !
|
|
17017
|
+
if (!workflowId || !SAFE_ID_RE6.test(workflowId)) {
|
|
16473
17018
|
return failure("run-start", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
16474
17019
|
}
|
|
16475
17020
|
const ctx = input.ctx ?? deps.ctx ?? null;
|
|
@@ -16527,7 +17072,7 @@ function createRunStartTool(deps) {
|
|
|
16527
17072
|
}
|
|
16528
17073
|
function gitHead(cwd) {
|
|
16529
17074
|
return new Promise((resolveP, rejectP) => {
|
|
16530
|
-
|
|
17075
|
+
execFile2("git", ["-C", cwd, "rev-parse", "HEAD"], { cwd, shell: false }, (err, stdout, stderr) => {
|
|
16531
17076
|
if (err) return rejectP(new Error(stderr || err.message));
|
|
16532
17077
|
resolveP(String(stdout).trim());
|
|
16533
17078
|
});
|
|
@@ -16535,20 +17080,94 @@ function gitHead(cwd) {
|
|
|
16535
17080
|
}
|
|
16536
17081
|
|
|
16537
17082
|
// src/tools/ship-task-start.js
|
|
16538
|
-
import { mkdir as mkdir10 } from "node:fs/promises";
|
|
16539
|
-
import { join as
|
|
16540
|
-
init_durable_store();
|
|
16541
|
-
import { createHash as
|
|
16542
|
-
|
|
17083
|
+
import { mkdir as mkdir10 } from "node:fs/promises";
|
|
17084
|
+
import { join as join16 } from "node:path";
|
|
17085
|
+
init_durable_store();
|
|
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}$/;
|
|
16543
17162
|
function createTaskStartTool(deps) {
|
|
16544
17163
|
return async function taskStart(input) {
|
|
16545
17164
|
const opId = input.operationId ?? `task-start-${Date.now().toString(36)}`;
|
|
16546
17165
|
const workflowId = String(input.workflowId ?? "");
|
|
16547
17166
|
const taskId = String(input.taskId ?? "");
|
|
16548
|
-
if (!workflowId || !
|
|
17167
|
+
if (!workflowId || !SAFE_ID_RE7.test(workflowId)) {
|
|
16549
17168
|
return failure("task-start", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
16550
17169
|
}
|
|
16551
|
-
if (!taskId || !
|
|
17170
|
+
if (!taskId || !SAFE_ID_RE7.test(taskId)) {
|
|
16552
17171
|
return failure("task-start", "taskId required (safe id)", { operationId: opId, retryable: false });
|
|
16553
17172
|
}
|
|
16554
17173
|
const ctx = input.ctx ?? deps.ctx ?? null;
|
|
@@ -16588,13 +17207,21 @@ function createTaskStartTool(deps) {
|
|
|
16588
17207
|
if (unsatisfied.length > 0) {
|
|
16589
17208
|
return failure("task-start", `task dependencies are incomplete: ${unsatisfied.join(", ")}`, { operationId: opId, retryable: false });
|
|
16590
17209
|
}
|
|
16591
|
-
const briefHash =
|
|
17210
|
+
const briefHash = createHash12("sha256").update(canonicalJson(task), "utf8").digest("hex");
|
|
16592
17211
|
const round = runState.round > 0 ? runState.round : 1;
|
|
16593
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
|
+
}
|
|
16594
17221
|
let dispatchResult = null;
|
|
16595
17222
|
if (deps.opencodeClient) {
|
|
16596
17223
|
dispatchResult = await dispatchWorker({
|
|
16597
|
-
repoRoot:
|
|
17224
|
+
repoRoot: resolved.worktreePath,
|
|
16598
17225
|
workflowId,
|
|
16599
17226
|
role: ROLES.BUILDER,
|
|
16600
17227
|
keyInput: { taskId, round },
|
|
@@ -16614,7 +17241,7 @@ ${JSON.stringify(task, null, 2)}`
|
|
|
16614
17241
|
});
|
|
16615
17242
|
}
|
|
16616
17243
|
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
16617
|
-
const dispatchDir2 =
|
|
17244
|
+
const dispatchDir2 = join16(
|
|
16618
17245
|
opencodeShipStateDir(commonDir),
|
|
16619
17246
|
"runs",
|
|
16620
17247
|
workflowId,
|
|
@@ -16637,7 +17264,7 @@ ${JSON.stringify(task, null, 2)}`
|
|
|
16637
17264
|
task,
|
|
16638
17265
|
dispatchedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
16639
17266
|
};
|
|
16640
|
-
await publishImmutableJson(
|
|
17267
|
+
await publishImmutableJson(join16(dispatchDir2, "dispatch.json"), record2);
|
|
16641
17268
|
const { state, event } = await appendRunEvent(
|
|
16642
17269
|
deps.repoRoot,
|
|
16643
17270
|
workflowId,
|
|
@@ -16663,15 +17290,15 @@ ${JSON.stringify(task, null, 2)}`
|
|
|
16663
17290
|
}
|
|
16664
17291
|
|
|
16665
17292
|
// src/tools/ship-task-commit.js
|
|
16666
|
-
import { execFile as
|
|
16667
|
-
import { mkdir as mkdir11, readFile as
|
|
16668
|
-
import { existsSync as
|
|
16669
|
-
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";
|
|
16670
17297
|
init_durable_store();
|
|
16671
|
-
var
|
|
17298
|
+
var SAFE_ID_RE8 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
16672
17299
|
function spawn5(cmd, args, cwd) {
|
|
16673
17300
|
return new Promise((resolveP, rejectP) => {
|
|
16674
|
-
|
|
17301
|
+
execFile3(cmd, args, { cwd, shell: false }, (err, stdout, stderr) => {
|
|
16675
17302
|
if (err) {
|
|
16676
17303
|
const msg = typeof stderr === "string" ? stderr : stderr ? String(stderr) : err.message;
|
|
16677
17304
|
return rejectP(new Error(`${cmd} failed: ${msg}`));
|
|
@@ -16690,10 +17317,10 @@ function createTaskCommitTool(deps) {
|
|
|
16690
17317
|
const planHash = String(input.planHash ?? "");
|
|
16691
17318
|
const reviewHash = String(input.reviewHash ?? "");
|
|
16692
17319
|
const round = Number(input.round ?? 1);
|
|
16693
|
-
if (!workflowId || !
|
|
17320
|
+
if (!workflowId || !SAFE_ID_RE8.test(workflowId)) {
|
|
16694
17321
|
return failure("task-commit", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
16695
17322
|
}
|
|
16696
|
-
if (!taskId || !
|
|
17323
|
+
if (!taskId || !SAFE_ID_RE8.test(taskId)) {
|
|
16697
17324
|
return failure("task-commit", "taskId required (safe id)", { operationId: opId, retryable: false });
|
|
16698
17325
|
}
|
|
16699
17326
|
if (!/^[0-9a-f]{40}$/.test(expectedHead)) {
|
|
@@ -16755,19 +17382,27 @@ function createTaskCommitTool(deps) {
|
|
|
16755
17382
|
return failure("task-commit", `round does not match the active run (${runState.round})`, { operationId: opId, retryable: false });
|
|
16756
17383
|
}
|
|
16757
17384
|
try {
|
|
16758
|
-
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();
|
|
16759
17394
|
if (actualHead !== expectedHead) {
|
|
16760
17395
|
return failure("task-commit", `HEAD drift (expected ${expectedHead.slice(0, 8)}, got ${actualHead.slice(0, 8)})`, { operationId: opId, retryable: false });
|
|
16761
17396
|
}
|
|
16762
17397
|
const trailers = buildCommitTrailers({ workflowId, planHash, taskId, round, reviewHash });
|
|
16763
|
-
const message = await spawn5("git", ["-C",
|
|
17398
|
+
const message = await spawn5("git", ["-C", resolved.worktreePath, "log", "-1", "--format=%B", expectedHead], resolved.worktreePath);
|
|
16764
17399
|
const trailerLines = trailers.map((t) => ` ${t}`).join("\n");
|
|
16765
17400
|
const missingTrailer = trailers.find((trailer) => !message.includes(trailer));
|
|
16766
17401
|
if (missingTrailer) {
|
|
16767
17402
|
return failure("task-commit", `commit ${expectedHead.slice(0, 8)} missing trailer: ${missingTrailer}`, { operationId: opId, retryable: false });
|
|
16768
17403
|
}
|
|
16769
17404
|
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
16770
|
-
const commitDir =
|
|
17405
|
+
const commitDir = join17(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "commit");
|
|
16771
17406
|
await mkdir11(commitDir, { recursive: true });
|
|
16772
17407
|
const record2 = {
|
|
16773
17408
|
workflowId,
|
|
@@ -16780,9 +17415,9 @@ function createTaskCommitTool(deps) {
|
|
|
16780
17415
|
trailerBlock: trailerLines,
|
|
16781
17416
|
committedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
16782
17417
|
};
|
|
16783
|
-
const commitPath =
|
|
16784
|
-
if (
|
|
16785
|
-
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"));
|
|
16786
17421
|
if (existing.workflowId !== workflowId || existing.taskId !== taskId || existing.round !== round || existing.commitSha !== commitSha || existing.planHash !== planHash || existing.reviewHash !== reviewHash) {
|
|
16787
17422
|
return failure("task-commit", "immutable task commit conflicts with retry", { operationId: opId, retryable: false });
|
|
16788
17423
|
}
|
|
@@ -16803,13 +17438,13 @@ function createTaskCommitTool(deps) {
|
|
|
16803
17438
|
}
|
|
16804
17439
|
|
|
16805
17440
|
// src/tools/ship-task-complete.js
|
|
16806
|
-
import { mkdir as mkdir12, readFile as
|
|
16807
|
-
import { existsSync as
|
|
16808
|
-
import { join as
|
|
16809
|
-
import { execFile as
|
|
16810
|
-
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";
|
|
16811
17446
|
init_durable_store();
|
|
16812
|
-
var
|
|
17447
|
+
var SAFE_ID_RE9 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
16813
17448
|
function createTaskCompleteTool(deps) {
|
|
16814
17449
|
return async function taskComplete(input) {
|
|
16815
17450
|
const opId = input.operationId ?? `task-complete-${Date.now().toString(36)}`;
|
|
@@ -16818,16 +17453,16 @@ function createTaskCompleteTool(deps) {
|
|
|
16818
17453
|
const moreTasks = input.moreTasks === false ? false : input.moreTasks === true ? true : null;
|
|
16819
17454
|
const nextTaskId = input.nextTaskId ? String(input.nextTaskId) : null;
|
|
16820
17455
|
const expectedHead = String(input.expectedHead ?? "");
|
|
16821
|
-
if (!workflowId || !
|
|
17456
|
+
if (!workflowId || !SAFE_ID_RE9.test(workflowId)) {
|
|
16822
17457
|
return failure("task-complete", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
16823
17458
|
}
|
|
16824
|
-
if (!taskId || !
|
|
17459
|
+
if (!taskId || !SAFE_ID_RE9.test(taskId)) {
|
|
16825
17460
|
return failure("task-complete", "taskId required (safe id)", { operationId: opId, retryable: false });
|
|
16826
17461
|
}
|
|
16827
17462
|
if (moreTasks === null) {
|
|
16828
17463
|
return failure("task-complete", "moreTasks must be explicitly true or false", { operationId: opId, retryable: false });
|
|
16829
17464
|
}
|
|
16830
|
-
if (moreTasks && (!nextTaskId || !
|
|
17465
|
+
if (moreTasks && (!nextTaskId || !SAFE_ID_RE9.test(nextTaskId))) {
|
|
16831
17466
|
return failure("task-complete", "nextTaskId required when moreTasks=true", { operationId: opId, retryable: false });
|
|
16832
17467
|
}
|
|
16833
17468
|
if (!moreTasks && !/^[0-9a-f]{40}$/.test(expectedHead)) {
|
|
@@ -16870,6 +17505,14 @@ function createTaskCompleteTool(deps) {
|
|
|
16870
17505
|
return failure("task-complete", `plan still has incomplete tasks: ${remainingTasks.map((task) => task.id).join(", ")}`, { operationId: opId, retryable: false });
|
|
16871
17506
|
}
|
|
16872
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
|
+
}
|
|
16873
17516
|
const gateEvidence = moreTasks ? null : await loadTrustedGateEvidence({
|
|
16874
17517
|
repoRoot: deps.repoRoot,
|
|
16875
17518
|
repoSlug: deps.repoSlug,
|
|
@@ -16881,7 +17524,7 @@ function createTaskCompleteTool(deps) {
|
|
|
16881
17524
|
});
|
|
16882
17525
|
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
16883
17526
|
const finalPackage = moreTasks ? null : await loadOrBuildFinalPackage({
|
|
16884
|
-
repoRoot:
|
|
17527
|
+
repoRoot: resolved.worktreePath,
|
|
16885
17528
|
commonDir,
|
|
16886
17529
|
workflowId,
|
|
16887
17530
|
runState,
|
|
@@ -16891,7 +17534,7 @@ function createTaskCompleteTool(deps) {
|
|
|
16891
17534
|
ciHash: gateEvidence?.ciHash ?? "",
|
|
16892
17535
|
gateTaskId: gateEvidence?.taskId ?? ""
|
|
16893
17536
|
});
|
|
16894
|
-
const completeDir =
|
|
17537
|
+
const completeDir = join18(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "complete");
|
|
16895
17538
|
await mkdir12(completeDir, { recursive: true });
|
|
16896
17539
|
const record2 = {
|
|
16897
17540
|
workflowId,
|
|
@@ -16901,9 +17544,9 @@ function createTaskCompleteTool(deps) {
|
|
|
16901
17544
|
finalReview: finalPackage,
|
|
16902
17545
|
completedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
16903
17546
|
};
|
|
16904
|
-
const completePath =
|
|
16905
|
-
if (
|
|
16906
|
-
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"));
|
|
16907
17550
|
if (existing.workflowId !== workflowId || existing.taskId !== taskId || existing.moreTasks !== moreTasks || (existing.nextTaskId ?? null) !== (nextTaskId ?? null) || (existing.finalReview?.packageHash ?? null) !== (finalPackage?.packageHash ?? null)) {
|
|
16908
17551
|
return failure("task-complete", "immutable task completion conflicts with retry", { operationId: opId, retryable: false });
|
|
16909
17552
|
}
|
|
@@ -16930,7 +17573,7 @@ function createTaskCompleteTool(deps) {
|
|
|
16930
17573
|
if (!moreTasks && deps.opencodeClient) {
|
|
16931
17574
|
const models = resolveModelRoles(deps.config?.workflow, { strict: true });
|
|
16932
17575
|
const packageHash = finalPackage.packageHash;
|
|
16933
|
-
const packagePath =
|
|
17576
|
+
const packagePath = join18(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", "package.json");
|
|
16934
17577
|
const promptText = [
|
|
16935
17578
|
`Review workflow ${workflowId} package ${packageHash} at HEAD ${finalPackage.headSha} against merge base ${finalPackage.mergeBaseSha}.`,
|
|
16936
17579
|
`Canonical package path: ${packagePath}`,
|
|
@@ -16939,7 +17582,7 @@ ${JSON.stringify(finalPackage, null, 2)}`
|
|
|
16939
17582
|
].join("\n\n");
|
|
16940
17583
|
const [standards, spec] = await Promise.all([
|
|
16941
17584
|
dispatchWorker({
|
|
16942
|
-
repoRoot:
|
|
17585
|
+
repoRoot: resolved.worktreePath,
|
|
16943
17586
|
workflowId,
|
|
16944
17587
|
role: ROLES.FINAL_STANDARDS,
|
|
16945
17588
|
keyInput: { packageHash },
|
|
@@ -16951,7 +17594,7 @@ ${JSON.stringify(finalPackage, null, 2)}`
|
|
|
16951
17594
|
model: models.finalReviewer
|
|
16952
17595
|
}),
|
|
16953
17596
|
dispatchWorker({
|
|
16954
|
-
repoRoot:
|
|
17597
|
+
repoRoot: resolved.worktreePath,
|
|
16955
17598
|
workflowId,
|
|
16956
17599
|
role: ROLES.FINAL_SPEC,
|
|
16957
17600
|
keyInput: { packageHash },
|
|
@@ -16989,7 +17632,7 @@ async function loadTrustedGateEvidence({ repoRoot, repoSlug, driver, adapter, wo
|
|
|
16989
17632
|
const manifests = (await listManifests(repoRoot)).filter((manifest2) => manifest2.issueNumber === issueNumber);
|
|
16990
17633
|
if (manifests.length !== 1) throw new Error(`expected one delivery manifest for issue #${issueNumber}, found ${manifests.length}`);
|
|
16991
17634
|
const manifest = manifests[0];
|
|
16992
|
-
if (manifest.schemaVersion
|
|
17635
|
+
if (manifest.schemaVersion !== 2 || manifest.workflowId !== workflowId) {
|
|
16993
17636
|
throw new Error("delivery manifest is not linked to the current workflow");
|
|
16994
17637
|
}
|
|
16995
17638
|
if (manifest.lastVerifierSha !== expectedHead || !/^[0-9a-f]{64}$/.test(manifest.lastVerificationHash ?? "")) {
|
|
@@ -17025,10 +17668,10 @@ async function loadTrustedGateEvidence({ repoRoot, repoSlug, driver, adapter, wo
|
|
|
17025
17668
|
return { verificationHash: verification.receiptHash, ciHash: ci.receiptHash, taskId: manifest.taskId };
|
|
17026
17669
|
}
|
|
17027
17670
|
async function loadOrBuildFinalPackage({ repoRoot, commonDir, workflowId, runState, planRecord, expectedHead, verificationHash, ciHash, gateTaskId }) {
|
|
17028
|
-
const reviewDir =
|
|
17029
|
-
const packagePath =
|
|
17030
|
-
if (
|
|
17031
|
-
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"));
|
|
17032
17675
|
if (existing.headSha !== expectedHead || existing.verificationHash !== verificationHash || existing.ciHash !== ciHash || existing.gateTaskId !== gateTaskId) {
|
|
17033
17676
|
throw new Error("final review package already exists with different gate evidence");
|
|
17034
17677
|
}
|
|
@@ -17045,10 +17688,10 @@ async function loadOrBuildFinalPackage({ repoRoot, commonDir, workflowId, runSta
|
|
|
17045
17688
|
throw new Error(`approved base ${mergeBaseSha.slice(0, 8)} is not the merge base of final HEAD`);
|
|
17046
17689
|
}
|
|
17047
17690
|
const revision = String(runState.revision).padStart(6, "0");
|
|
17048
|
-
const approvalRaw = await
|
|
17691
|
+
const approvalRaw = await readFile14(join18(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions", revision, "approval.json"), "utf8");
|
|
17049
17692
|
const tasks = [];
|
|
17050
17693
|
for (const completedTaskId of runState.completedTasks ?? []) {
|
|
17051
|
-
const commitRaw = await
|
|
17694
|
+
const commitRaw = await readFile14(join18(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", completedTaskId, "commit", "commit.json"), "utf8");
|
|
17052
17695
|
const commit = JSON.parse(commitRaw);
|
|
17053
17696
|
tasks.push({
|
|
17054
17697
|
taskId: completedTaskId,
|
|
@@ -17075,23 +17718,23 @@ async function loadOrBuildFinalPackage({ repoRoot, commonDir, workflowId, runSta
|
|
|
17075
17718
|
}
|
|
17076
17719
|
function git(cwd, args) {
|
|
17077
17720
|
return new Promise((resolveP, rejectP) => {
|
|
17078
|
-
|
|
17721
|
+
execFile4("git", ["-C", cwd, ...args], { cwd, shell: false }, (err, stdout, stderr) => {
|
|
17079
17722
|
if (err) return rejectP(new Error(`git ${args[0]} failed: ${stderr || err.message}`));
|
|
17080
17723
|
resolveP(String(stdout));
|
|
17081
17724
|
});
|
|
17082
17725
|
});
|
|
17083
17726
|
}
|
|
17084
17727
|
function sha2564(value) {
|
|
17085
|
-
return
|
|
17728
|
+
return createHash13("sha256").update(value, "utf8").digest("hex");
|
|
17086
17729
|
}
|
|
17087
17730
|
|
|
17088
17731
|
// src/tools/ship-task-report.js
|
|
17089
|
-
import { mkdir as mkdir13, readFile as
|
|
17090
|
-
import { existsSync as
|
|
17091
|
-
import { createHash as
|
|
17092
|
-
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";
|
|
17093
17736
|
init_durable_store();
|
|
17094
|
-
var
|
|
17737
|
+
var SAFE_ID_RE10 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
17095
17738
|
function createTaskReportTool(deps) {
|
|
17096
17739
|
return async function taskReport(input) {
|
|
17097
17740
|
const opId = input.operationId ?? `task-report-${Date.now().toString(36)}`;
|
|
@@ -17099,10 +17742,10 @@ function createTaskReportTool(deps) {
|
|
|
17099
17742
|
const taskId = String(input.taskId ?? "");
|
|
17100
17743
|
const round = Number(input.round ?? 1);
|
|
17101
17744
|
const summary = String(input.summary ?? "");
|
|
17102
|
-
if (!workflowId || !
|
|
17745
|
+
if (!workflowId || !SAFE_ID_RE10.test(workflowId)) {
|
|
17103
17746
|
return failure("task-report", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
17104
17747
|
}
|
|
17105
|
-
if (!taskId || !
|
|
17748
|
+
if (!taskId || !SAFE_ID_RE10.test(taskId)) {
|
|
17106
17749
|
return failure("task-report", "taskId required (safe id)", { operationId: opId, retryable: false });
|
|
17107
17750
|
}
|
|
17108
17751
|
if (!Number.isInteger(round) || round <= 0) {
|
|
@@ -17137,8 +17780,16 @@ function createTaskReportTool(deps) {
|
|
|
17137
17780
|
return failure("task-report", `another task is already active (${runState.activeTask})`, { operationId: opId, retryable: false });
|
|
17138
17781
|
}
|
|
17139
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
|
+
}
|
|
17140
17791
|
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
17141
|
-
const reportDir =
|
|
17792
|
+
const reportDir = join19(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
|
|
17142
17793
|
await mkdir13(reportDir, { recursive: true });
|
|
17143
17794
|
const record2 = {
|
|
17144
17795
|
workflowId,
|
|
@@ -17150,12 +17801,12 @@ function createTaskReportTool(deps) {
|
|
|
17150
17801
|
tests: Array.isArray(input.tests) ? input.tests : [],
|
|
17151
17802
|
submittedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
17152
17803
|
};
|
|
17153
|
-
const reportPath =
|
|
17804
|
+
const reportPath = join19(reportDir, "implementer-report.json");
|
|
17154
17805
|
let state = runState;
|
|
17155
17806
|
let event = runState.events.at(-1) ?? { sequence: 0 };
|
|
17156
17807
|
let persistedRecord = record2;
|
|
17157
|
-
if (
|
|
17158
|
-
persistedRecord = JSON.parse(await
|
|
17808
|
+
if (existsSync14(reportPath)) {
|
|
17809
|
+
persistedRecord = JSON.parse(await readFile15(reportPath, "utf8"));
|
|
17159
17810
|
if (!sameReport(persistedRecord, record2)) {
|
|
17160
17811
|
return failure("task-report", "immutable report already exists with different content", { operationId: opId, retryable: false });
|
|
17161
17812
|
}
|
|
@@ -17186,7 +17837,7 @@ function createTaskReportTool(deps) {
|
|
|
17186
17837
|
if (deps.opencodeClient) {
|
|
17187
17838
|
try {
|
|
17188
17839
|
reviewerDispatch = await dispatchWorker({
|
|
17189
|
-
repoRoot:
|
|
17840
|
+
repoRoot: resolved.worktreePath,
|
|
17190
17841
|
workflowId,
|
|
17191
17842
|
role: ROLES.TASK_REVIEWER,
|
|
17192
17843
|
keyInput: { taskId, round },
|
|
@@ -17230,7 +17881,7 @@ function reportHash(record2) {
|
|
|
17230
17881
|
const sorted = Object.keys(record2).sort();
|
|
17231
17882
|
const ordered = {};
|
|
17232
17883
|
for (const k of sorted) ordered[k] = record2[k];
|
|
17233
|
-
return
|
|
17884
|
+
return createHash14("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
|
|
17234
17885
|
}
|
|
17235
17886
|
function sameReport(left, right) {
|
|
17236
17887
|
for (const field of ["workflowId", "taskId", "round", "builderSessionID", "summary"]) {
|
|
@@ -17240,12 +17891,12 @@ function sameReport(left, right) {
|
|
|
17240
17891
|
}
|
|
17241
17892
|
|
|
17242
17893
|
// src/tools/ship-task-review.js
|
|
17243
|
-
import { createHash as
|
|
17244
|
-
import { mkdir as mkdir14, readFile as
|
|
17245
|
-
import { existsSync as
|
|
17246
|
-
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";
|
|
17247
17898
|
init_durable_store();
|
|
17248
|
-
var
|
|
17899
|
+
var SAFE_ID_RE11 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
17249
17900
|
var VERDICT_VALUES = /* @__PURE__ */ new Set(["pass", "fail", "none"]);
|
|
17250
17901
|
function createTaskReviewTool(deps) {
|
|
17251
17902
|
return async function taskReview(input) {
|
|
@@ -17255,10 +17906,10 @@ function createTaskReviewTool(deps) {
|
|
|
17255
17906
|
const round = Number(input.round ?? 1);
|
|
17256
17907
|
const spec = input.spec;
|
|
17257
17908
|
const quality = input.quality;
|
|
17258
|
-
if (!workflowId || !
|
|
17909
|
+
if (!workflowId || !SAFE_ID_RE11.test(workflowId)) {
|
|
17259
17910
|
return failure("task-review", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
17260
17911
|
}
|
|
17261
|
-
if (!taskId || !
|
|
17912
|
+
if (!taskId || !SAFE_ID_RE11.test(taskId)) {
|
|
17262
17913
|
return failure("task-review", "taskId required (safe id)", { operationId: opId, retryable: false });
|
|
17263
17914
|
}
|
|
17264
17915
|
if (!Number.isInteger(round) || round <= 0) {
|
|
@@ -17314,7 +17965,7 @@ function createTaskReviewTool(deps) {
|
|
|
17314
17965
|
const specPass = String(spec.verdict) === "pass";
|
|
17315
17966
|
const qualityPass = String(quality.verdict) === "pass";
|
|
17316
17967
|
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
17317
|
-
const reviewDir =
|
|
17968
|
+
const reviewDir = join20(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
|
|
17318
17969
|
await mkdir14(reviewDir, { recursive: true });
|
|
17319
17970
|
const record2 = {
|
|
17320
17971
|
workflowId,
|
|
@@ -17326,9 +17977,9 @@ function createTaskReviewTool(deps) {
|
|
|
17326
17977
|
state: specPass && qualityPass ? "commit-pending" : "fix-pending",
|
|
17327
17978
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
17328
17979
|
};
|
|
17329
|
-
const reviewPath =
|
|
17330
|
-
if (
|
|
17331
|
-
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"));
|
|
17332
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)) {
|
|
17333
17984
|
return failure("task-review", "immutable task review conflicts with retry", { operationId: opId, retryable: false });
|
|
17334
17985
|
}
|
|
@@ -17363,15 +18014,15 @@ function verdictHash(record2) {
|
|
|
17363
18014
|
const sorted = Object.keys(record2).sort();
|
|
17364
18015
|
const ordered = {};
|
|
17365
18016
|
for (const k of sorted) ordered[k] = record2[k];
|
|
17366
|
-
return
|
|
18017
|
+
return createHash15("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
|
|
17367
18018
|
}
|
|
17368
18019
|
|
|
17369
18020
|
// src/tools/ship-final-review.js
|
|
17370
|
-
import { mkdir as mkdir15, readFile as
|
|
17371
|
-
import { existsSync as
|
|
17372
|
-
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";
|
|
17373
18024
|
init_durable_store();
|
|
17374
|
-
var
|
|
18025
|
+
var SAFE_ID_RE12 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
17375
18026
|
var AXES = /* @__PURE__ */ new Set(["standards", "spec"]);
|
|
17376
18027
|
var VERDICTS = /* @__PURE__ */ new Set(["pass", "fail", "blocked"]);
|
|
17377
18028
|
var ROLE_FOR_AXIS = {
|
|
@@ -17388,7 +18039,7 @@ function createFinalReviewTool(deps) {
|
|
|
17388
18039
|
const mergeBaseSha = String(input.mergeBaseSha ?? "");
|
|
17389
18040
|
const packageHash = String(input.packageHash ?? "");
|
|
17390
18041
|
const findings = Array.isArray(input.findings) ? input.findings : [];
|
|
17391
|
-
if (!workflowId || !
|
|
18042
|
+
if (!workflowId || !SAFE_ID_RE12.test(workflowId)) {
|
|
17392
18043
|
return failure("final-review", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
17393
18044
|
}
|
|
17394
18045
|
if (!AXES.has(axis)) {
|
|
@@ -17435,18 +18086,18 @@ function createFinalReviewTool(deps) {
|
|
|
17435
18086
|
}
|
|
17436
18087
|
try {
|
|
17437
18088
|
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
17438
|
-
const packagePath =
|
|
17439
|
-
if (!
|
|
18089
|
+
const packagePath = join21(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", "package.json");
|
|
18090
|
+
if (!existsSync16(packagePath)) {
|
|
17440
18091
|
return failure("final-review", "canonical final review package is missing", { operationId: opId, retryable: false });
|
|
17441
18092
|
}
|
|
17442
|
-
const finalPackage = JSON.parse(await
|
|
18093
|
+
const finalPackage = JSON.parse(await readFile17(packagePath, "utf8"));
|
|
17443
18094
|
if (hashFinalReviewPackage(finalPackage) !== finalPackage.packageHash) {
|
|
17444
18095
|
return failure("final-review", "canonical final review package hash is invalid", { operationId: opId, retryable: false });
|
|
17445
18096
|
}
|
|
17446
18097
|
if (finalPackage.packageHash !== packageHash || finalPackage.headSha !== headSha || finalPackage.mergeBaseSha !== mergeBaseSha) {
|
|
17447
18098
|
return failure("final-review", "review input does not match the canonical final review package", { operationId: opId, retryable: false });
|
|
17448
18099
|
}
|
|
17449
|
-
const reviewDir =
|
|
18100
|
+
const reviewDir = join21(opencodeShipStateDir(commonDir), "runs", workflowId, "final-review", axis);
|
|
17450
18101
|
await mkdir15(reviewDir, { recursive: true });
|
|
17451
18102
|
let record2 = {
|
|
17452
18103
|
workflowId,
|
|
@@ -17460,9 +18111,9 @@ function createFinalReviewTool(deps) {
|
|
|
17460
18111
|
findings,
|
|
17461
18112
|
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
17462
18113
|
};
|
|
17463
|
-
const reviewPath =
|
|
17464
|
-
if (
|
|
17465
|
-
record2 = JSON.parse(await
|
|
18114
|
+
const reviewPath = join21(reviewDir, "review.json");
|
|
18115
|
+
if (existsSync16(reviewPath)) {
|
|
18116
|
+
record2 = JSON.parse(await readFile17(reviewPath, "utf8"));
|
|
17466
18117
|
if (record2.axis !== axis || record2.verdict !== verdict || record2.headSha !== headSha || record2.mergeBaseSha !== mergeBaseSha || record2.packageHash !== packageHash || record2.reviewerSessionID !== auth.sessionID || hashAxisRecord(
|
|
17467
18118
|
/** @type {any} */
|
|
17468
18119
|
record2
|
|
@@ -17523,8 +18174,8 @@ function createFinalReviewTool(deps) {
|
|
|
17523
18174
|
|
|
17524
18175
|
// src/workflow/resume.js
|
|
17525
18176
|
import { readdir as readdir6, mkdir as mkdir16 } from "node:fs/promises";
|
|
17526
|
-
import { existsSync as
|
|
17527
|
-
import { join as
|
|
18177
|
+
import { existsSync as existsSync17 } from "node:fs";
|
|
18178
|
+
import { join as join22 } from "node:path";
|
|
17528
18179
|
init_durable_store();
|
|
17529
18180
|
function parseTrailer(text, key) {
|
|
17530
18181
|
if (typeof text !== "string") return null;
|
|
@@ -17599,12 +18250,12 @@ async function resumeRun(repoRoot, workflowId) {
|
|
|
17599
18250
|
}
|
|
17600
18251
|
|
|
17601
18252
|
// src/tools/ship-resume.js
|
|
17602
|
-
var
|
|
18253
|
+
var SAFE_ID_RE13 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
17603
18254
|
function createResumeTool(deps) {
|
|
17604
18255
|
return async function resume(input) {
|
|
17605
18256
|
const opId = input.operationId ?? `resume-${Date.now().toString(36)}`;
|
|
17606
18257
|
const workflowId = String(input.workflowId ?? "");
|
|
17607
|
-
if (!workflowId || !
|
|
18258
|
+
if (!workflowId || !SAFE_ID_RE13.test(workflowId)) {
|
|
17608
18259
|
return failure("resume", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
17609
18260
|
}
|
|
17610
18261
|
const ctx = input.ctx ?? deps.ctx ?? null;
|
|
@@ -17631,9 +18282,9 @@ function createResumeTool(deps) {
|
|
|
17631
18282
|
}
|
|
17632
18283
|
|
|
17633
18284
|
// src/tools/ship-status.js
|
|
17634
|
-
import { readFile as
|
|
17635
|
-
import { existsSync as
|
|
17636
|
-
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";
|
|
17637
18288
|
function createStatusTool(deps) {
|
|
17638
18289
|
return async function status(input) {
|
|
17639
18290
|
const opId = input.operationId ?? `status-${Date.now().toString(36)}`;
|
|
@@ -17641,21 +18292,21 @@ function createStatusTool(deps) {
|
|
|
17641
18292
|
if (!workflowId) return failure("status", "workflowId required", { operationId: opId, retryable: false });
|
|
17642
18293
|
try {
|
|
17643
18294
|
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
17644
|
-
const planRoot =
|
|
17645
|
-
const runRoot =
|
|
17646
|
-
const indexPath =
|
|
17647
|
-
if (!
|
|
17648
|
-
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"));
|
|
17649
18300
|
let run = null;
|
|
17650
|
-
const runPath =
|
|
17651
|
-
if (
|
|
18301
|
+
const runPath = join23(runRoot, "run.json");
|
|
18302
|
+
if (existsSync18(runPath)) run = JSON.parse(await readFile18(runPath, "utf8"));
|
|
17652
18303
|
let lastEvent = null;
|
|
17653
|
-
const eventsDir =
|
|
17654
|
-
if (
|
|
18304
|
+
const eventsDir = join23(runRoot, "events");
|
|
18305
|
+
if (existsSync18(eventsDir)) {
|
|
17655
18306
|
const events = await readdir7(eventsDir);
|
|
17656
18307
|
const sorted = events.filter((n) => n.endsWith(".json")).sort();
|
|
17657
18308
|
if (sorted.length > 0) {
|
|
17658
|
-
lastEvent = JSON.parse(await
|
|
18309
|
+
lastEvent = JSON.parse(await readFile18(join23(eventsDir, sorted[sorted.length - 1]), "utf8"));
|
|
17659
18310
|
}
|
|
17660
18311
|
}
|
|
17661
18312
|
return success2("status", { workflowId, index, run, lastEvent }, { operationId: opId });
|
|
@@ -17667,14 +18318,14 @@ function createStatusTool(deps) {
|
|
|
17667
18318
|
|
|
17668
18319
|
// src/skills/registry.js
|
|
17669
18320
|
import { spawn as spawn7 } from "node:child_process";
|
|
17670
|
-
import { readFile as
|
|
17671
|
-
import { resolve as
|
|
17672
|
-
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";
|
|
17673
18324
|
|
|
17674
18325
|
// src/tools/skill-discovery.js
|
|
17675
18326
|
import { spawn as spawn6 } from "node:child_process";
|
|
17676
|
-
import { existsSync as
|
|
17677
|
-
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";
|
|
17678
18329
|
var DEFAULT_TRUSTED_OWNERS = Object.freeze([
|
|
17679
18330
|
"vercel-labs",
|
|
17680
18331
|
"anthropics",
|
|
@@ -17742,9 +18393,9 @@ async function listSkills({ repoRoot, query, npmBin = "npx" }) {
|
|
|
17742
18393
|
}
|
|
17743
18394
|
|
|
17744
18395
|
// src/skills/policy.js
|
|
17745
|
-
import { readFile as
|
|
17746
|
-
import { existsSync as
|
|
17747
|
-
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";
|
|
17748
18399
|
var DEFAULT_TRUSTED_OWNERS2 = Object.freeze([
|
|
17749
18400
|
"vercel-labs",
|
|
17750
18401
|
"anthropics",
|
|
@@ -17756,7 +18407,7 @@ var DEFAULT_MIN_INSTALLS = 1e3;
|
|
|
17756
18407
|
var MAX_TRUSTED_PER_RUN = 5;
|
|
17757
18408
|
var POLICY_PATH = ".opencode/ship.skills.policy.json";
|
|
17758
18409
|
function policyPath(repoRoot) {
|
|
17759
|
-
return
|
|
18410
|
+
return resolve13(repoRoot, POLICY_PATH);
|
|
17760
18411
|
}
|
|
17761
18412
|
function defaultPolicy() {
|
|
17762
18413
|
return {
|
|
@@ -17768,9 +18419,9 @@ function defaultPolicy() {
|
|
|
17768
18419
|
}
|
|
17769
18420
|
async function readPolicy(repoRoot) {
|
|
17770
18421
|
const path = policyPath(repoRoot);
|
|
17771
|
-
if (!
|
|
18422
|
+
if (!existsSync20(path)) return defaultPolicy();
|
|
17772
18423
|
try {
|
|
17773
|
-
const raw = await
|
|
18424
|
+
const raw = await readFile20(path, "utf8");
|
|
17774
18425
|
const parsed = JSON.parse(raw);
|
|
17775
18426
|
return mergePolicy(defaultPolicy(), parsed);
|
|
17776
18427
|
} catch {
|
|
@@ -17857,33 +18508,33 @@ function createSkillDiscoverTool(deps) {
|
|
|
17857
18508
|
}
|
|
17858
18509
|
|
|
17859
18510
|
// src/tools/ship-skill-install.js
|
|
17860
|
-
import { readFile as
|
|
17861
|
-
import { existsSync as
|
|
17862
|
-
import { resolve as resolve15, join as
|
|
17863
|
-
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";
|
|
17864
18515
|
import { execFile as execFile5 } from "node:child_process";
|
|
17865
18516
|
import { mkdtemp } from "node:fs/promises";
|
|
17866
18517
|
import { tmpdir } from "node:os";
|
|
17867
18518
|
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
17868
18519
|
|
|
17869
18520
|
// src/skills/inventory.js
|
|
17870
|
-
import { readFile as
|
|
17871
|
-
import { existsSync as
|
|
17872
|
-
import { resolve as
|
|
17873
|
-
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";
|
|
17874
18525
|
var INVENTORY_PATH = ".opencode/ship.skills.lock.json";
|
|
17875
18526
|
var INVENTORY_SCHEMA = 2;
|
|
17876
18527
|
function inventoryPath(repoRoot) {
|
|
17877
|
-
return
|
|
18528
|
+
return resolve14(repoRoot, INVENTORY_PATH);
|
|
17878
18529
|
}
|
|
17879
18530
|
async function readInventory(repoRoot) {
|
|
17880
18531
|
const path = inventoryPath(repoRoot);
|
|
17881
|
-
if (!
|
|
18532
|
+
if (!existsSync21(path)) {
|
|
17882
18533
|
return { schemaVersion: INVENTORY_SCHEMA, events: [] };
|
|
17883
18534
|
}
|
|
17884
18535
|
let raw;
|
|
17885
18536
|
try {
|
|
17886
|
-
raw = await
|
|
18537
|
+
raw = await readFile21(path, "utf8");
|
|
17887
18538
|
} catch (err) {
|
|
17888
18539
|
return { schemaVersion: INVENTORY_SCHEMA, events: [], parseError: `read failed: ${err?.message ?? err}` };
|
|
17889
18540
|
}
|
|
@@ -17910,7 +18561,7 @@ async function readInventory(repoRoot) {
|
|
|
17910
18561
|
}
|
|
17911
18562
|
async function writeInventory(repoRoot, inventory) {
|
|
17912
18563
|
const path = inventoryPath(repoRoot);
|
|
17913
|
-
await mkdir18(
|
|
18564
|
+
await mkdir18(dirname9(path), { recursive: true });
|
|
17914
18565
|
const tmp = `${path}.${Date.now().toString(36)}.tmp`;
|
|
17915
18566
|
await writeFile10(tmp, JSON.stringify({ schemaVersion: INVENTORY_SCHEMA, events: inventory.events }, null, 2) + "\n", "utf8");
|
|
17916
18567
|
await rename4(tmp, path);
|
|
@@ -17930,7 +18581,7 @@ function canonicalize3(value) {
|
|
|
17930
18581
|
return JSON.stringify(sort(value));
|
|
17931
18582
|
}
|
|
17932
18583
|
function hashEvent(event) {
|
|
17933
|
-
return
|
|
18584
|
+
return createHash17("sha256").update(canonicalize3(event), "utf8").digest("hex");
|
|
17934
18585
|
}
|
|
17935
18586
|
async function appendEvent2(repoRoot, eventInput) {
|
|
17936
18587
|
const inventory = await readInventory(repoRoot);
|
|
@@ -17943,7 +18594,7 @@ async function appendEvent2(repoRoot, eventInput) {
|
|
|
17943
18594
|
}
|
|
17944
18595
|
const previousHash = inventory.events.length > 0 ? inventory.events[inventory.events.length - 1].hash : "0".repeat(64);
|
|
17945
18596
|
const sequence = inventory.events.length + 1;
|
|
17946
|
-
if (eventInput.destination &&
|
|
18597
|
+
if (eventInput.destination && isAbsolute2(eventInput.destination)) {
|
|
17947
18598
|
throw new Error(`inventory refuses absolute destination: ${eventInput.destination}`);
|
|
17948
18599
|
}
|
|
17949
18600
|
const base = {
|
|
@@ -18043,146 +18694,8 @@ async function findActiveInstall(repoRoot, skillName) {
|
|
|
18043
18694
|
return { ok: true, install: null, uninstallHash: null };
|
|
18044
18695
|
}
|
|
18045
18696
|
|
|
18046
|
-
// src/skills/worktree.js
|
|
18047
|
-
import { execFile as execFile4 } from "node:child_process";
|
|
18048
|
-
import { promises as fs, existsSync as existsSync20 } from "node:fs";
|
|
18049
|
-
import { resolve as resolve14, dirname as dirname9, sep as sep2, isAbsolute as isAbsolute2, join as join22 } from "node:path";
|
|
18050
|
-
function listRegisteredWorktrees(mainRepo) {
|
|
18051
|
-
return new Promise((resolveP, rejectP) => {
|
|
18052
|
-
execFile4(
|
|
18053
|
-
"git",
|
|
18054
|
-
["-C", mainRepo, "worktree", "list", "--porcelain", "-z"],
|
|
18055
|
-
{ shell: false, maxBuffer: 1024 * 1024 },
|
|
18056
|
-
(err, stdout) => {
|
|
18057
|
-
if (err) return rejectP(err);
|
|
18058
|
-
const records = parsePorcelain(stdout);
|
|
18059
|
-
const mainRecord = records.shift();
|
|
18060
|
-
const mainPath = mainRecord?.worktree ? resolve14(mainRecord.worktree) : null;
|
|
18061
|
-
const linked = [];
|
|
18062
|
-
for (const r of records) {
|
|
18063
|
-
if (!r.worktree) continue;
|
|
18064
|
-
const p = resolve14(r.worktree);
|
|
18065
|
-
if (mainPath && p === mainPath) continue;
|
|
18066
|
-
linked.push({ path: p, branch: r.HEAD ?? null });
|
|
18067
|
-
}
|
|
18068
|
-
resolveP(linked);
|
|
18069
|
-
}
|
|
18070
|
-
);
|
|
18071
|
-
});
|
|
18072
|
-
}
|
|
18073
|
-
function parsePorcelain(text) {
|
|
18074
|
-
const tokens = text.split("\0");
|
|
18075
|
-
const out = [];
|
|
18076
|
-
let current = {};
|
|
18077
|
-
for (const tok of tokens) {
|
|
18078
|
-
if (tok.length === 0) {
|
|
18079
|
-
if (Object.keys(current).length > 0) {
|
|
18080
|
-
out.push(current);
|
|
18081
|
-
current = {};
|
|
18082
|
-
}
|
|
18083
|
-
continue;
|
|
18084
|
-
}
|
|
18085
|
-
const idx = tok.indexOf(" ");
|
|
18086
|
-
const key = idx === -1 ? tok : tok.slice(0, idx);
|
|
18087
|
-
const value = idx === -1 ? "" : tok.slice(idx + 1);
|
|
18088
|
-
if (key === "branch") {
|
|
18089
|
-
current.HEAD = value.startsWith("refs/heads/") ? value : `refs/heads/${value}`;
|
|
18090
|
-
} else {
|
|
18091
|
-
current[key] = value;
|
|
18092
|
-
}
|
|
18093
|
-
}
|
|
18094
|
-
if (Object.keys(current).length > 0) out.push(current);
|
|
18095
|
-
return out;
|
|
18096
|
-
}
|
|
18097
|
-
async function validateLinkedWorktree(mainRepo, worktreePath) {
|
|
18098
|
-
const main = resolve14(mainRepo);
|
|
18099
|
-
if (!existsSync20(main)) {
|
|
18100
|
-
return { ok: false, kind: "missing", message: `main repository ${main} does not exist` };
|
|
18101
|
-
}
|
|
18102
|
-
if (!worktreePath) {
|
|
18103
|
-
return { ok: false, kind: "unlinked", message: "worktreePath is required" };
|
|
18104
|
-
}
|
|
18105
|
-
const wt = resolve14(worktreePath);
|
|
18106
|
-
if (!existsSync20(wt)) {
|
|
18107
|
-
return { ok: false, kind: "missing", message: `worktree ${wt} does not exist` };
|
|
18108
|
-
}
|
|
18109
|
-
if (wt === main) {
|
|
18110
|
-
return { ok: false, kind: "main", message: "installs into the main worktree are forbidden" };
|
|
18111
|
-
}
|
|
18112
|
-
let cursor = wt;
|
|
18113
|
-
while (cursor !== dirname9(cursor)) {
|
|
18114
|
-
const stat3 = await fs.lstat(cursor).catch(() => null);
|
|
18115
|
-
if (stat3?.isSymbolicLink()) {
|
|
18116
|
-
return {
|
|
18117
|
-
ok: false,
|
|
18118
|
-
kind: "ancestor-symlink",
|
|
18119
|
-
message: `worktree path contains a symlink at ${cursor}`
|
|
18120
|
-
};
|
|
18121
|
-
}
|
|
18122
|
-
cursor = dirname9(cursor);
|
|
18123
|
-
}
|
|
18124
|
-
const real = await fs.realpath(wt).catch(() => null);
|
|
18125
|
-
if (real && real !== wt) {
|
|
18126
|
-
return {
|
|
18127
|
-
ok: false,
|
|
18128
|
-
kind: "symlink",
|
|
18129
|
-
message: `worktree ${wt} resolves through a symlink to ${real}`
|
|
18130
|
-
};
|
|
18131
|
-
}
|
|
18132
|
-
const linked = await listRegisteredWorktrees(main);
|
|
18133
|
-
const matched = linked.find((entry) => entry.path === wt);
|
|
18134
|
-
if (!matched) {
|
|
18135
|
-
return {
|
|
18136
|
-
ok: false,
|
|
18137
|
-
kind: "unlinked",
|
|
18138
|
-
message: `worktree ${wt} is not registered (git worktree list)`
|
|
18139
|
-
};
|
|
18140
|
-
}
|
|
18141
|
-
return { ok: true, path: wt, registered: !!matched };
|
|
18142
|
-
}
|
|
18143
|
-
function validateRelativeInstallPath(destRel) {
|
|
18144
|
-
if (typeof destRel !== "string" || destRel.length === 0) {
|
|
18145
|
-
return { ok: false, kind: "absolute", message: "destination path required" };
|
|
18146
|
-
}
|
|
18147
|
-
if (isAbsolute2(destRel)) {
|
|
18148
|
-
return { ok: false, kind: "absolute", message: `destination must be relative: ${destRel}` };
|
|
18149
|
-
}
|
|
18150
|
-
if (destRel.includes("\\")) {
|
|
18151
|
-
return { ok: false, kind: "parent-relative", message: `destination must use POSIX separators: ${destRel}` };
|
|
18152
|
-
}
|
|
18153
|
-
const parts = destRel.split("/");
|
|
18154
|
-
for (const p of parts) {
|
|
18155
|
-
if (p === "" || p === "." || p === "..") {
|
|
18156
|
-
return { ok: false, kind: "parent-relative", message: `destination escapes worktree: ${destRel}` };
|
|
18157
|
-
}
|
|
18158
|
-
}
|
|
18159
|
-
return { ok: true };
|
|
18160
|
-
}
|
|
18161
|
-
async function validateInstallDestination(worktreeRoot, destRel) {
|
|
18162
|
-
const relativeCheck = validateRelativeInstallPath(destRel);
|
|
18163
|
-
if (!relativeCheck.ok) return relativeCheck;
|
|
18164
|
-
const root = resolve14(worktreeRoot);
|
|
18165
|
-
const destination = resolve14(root, ...destRel.split("/"));
|
|
18166
|
-
if (destination !== root && !destination.startsWith(root + sep2)) {
|
|
18167
|
-
return { ok: false, kind: "escape", message: `destination escapes worktree: ${destRel}` };
|
|
18168
|
-
}
|
|
18169
|
-
let cursor = root;
|
|
18170
|
-
for (const part of destRel.split("/")) {
|
|
18171
|
-
cursor = join22(cursor, part);
|
|
18172
|
-
const entry = await fs.lstat(cursor).catch(() => null);
|
|
18173
|
-
if (!entry) continue;
|
|
18174
|
-
if (entry.isSymbolicLink()) {
|
|
18175
|
-
return { ok: false, kind: "symlink", message: `destination path contains a symlink at ${cursor}` };
|
|
18176
|
-
}
|
|
18177
|
-
if (cursor !== destination && !entry.isDirectory()) {
|
|
18178
|
-
return { ok: false, kind: "not-directory", message: `destination ancestor is not a directory: ${cursor}` };
|
|
18179
|
-
}
|
|
18180
|
-
}
|
|
18181
|
-
return { ok: true, path: destination };
|
|
18182
|
-
}
|
|
18183
|
-
|
|
18184
18697
|
// src/tools/ship-skill-install.js
|
|
18185
|
-
var
|
|
18698
|
+
var SAFE_ID_RE14 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
18186
18699
|
var SAFE_NAME_RE = /^[A-Za-z0-9._/-]{1,160}$/;
|
|
18187
18700
|
var SAFE_VERSION_RE = /^[A-Za-z0-9._+-]{1,64}$/;
|
|
18188
18701
|
var SKILLS_CLI_VERSION = "1.0.4";
|
|
@@ -18199,7 +18712,7 @@ function createSkillInstallTool(deps) {
|
|
|
18199
18712
|
if (!worktreePath) {
|
|
18200
18713
|
return failure("skill-install", "worktreePath required (must be the active issue worktree)", { operationId: opId, retryable: false });
|
|
18201
18714
|
}
|
|
18202
|
-
if (!skillName || !
|
|
18715
|
+
if (!skillName || !SAFE_ID_RE14.test(skillName)) {
|
|
18203
18716
|
return failure("skill-install", "skillName required (safe id)", { operationId: opId, retryable: false });
|
|
18204
18717
|
}
|
|
18205
18718
|
if (version2 && !SAFE_VERSION_RE.test(version2)) {
|
|
@@ -18229,7 +18742,7 @@ function createSkillInstallTool(deps) {
|
|
|
18229
18742
|
return failure("skill-install", `destination rejected: ${destinationCheck.message}`, { operationId: opId, retryable: false });
|
|
18230
18743
|
}
|
|
18231
18744
|
const destAbs = destinationCheck.path;
|
|
18232
|
-
if (
|
|
18745
|
+
if (existsSync22(destAbs)) {
|
|
18233
18746
|
return failure("skill-install", "destination already exists; use ship_skill_audit to detect drift", { operationId: opId, retryable: false });
|
|
18234
18747
|
}
|
|
18235
18748
|
const managedCatalog = (deps.config?.value?.skills ?? []).map((s) => s?.name).filter(Boolean);
|
|
@@ -18249,7 +18762,7 @@ function createSkillInstallTool(deps) {
|
|
|
18249
18762
|
if (!decision.ok) {
|
|
18250
18763
|
return failure("skill-install", `policy forbids install: ${decision.reason}`, { operationId: opId, retryable: false });
|
|
18251
18764
|
}
|
|
18252
|
-
const stage = await mkdtemp(
|
|
18765
|
+
const stage = await mkdtemp(join25(tmpdir(), `ship-skill-stage-${randomBytes3(4).toString("hex")}-`));
|
|
18253
18766
|
let installedFiles;
|
|
18254
18767
|
try {
|
|
18255
18768
|
const materialise = deps.materialiseFromSkillsCli ?? materialiseFromSkillsCli;
|
|
@@ -18300,7 +18813,7 @@ function createSkillInstallTool(deps) {
|
|
|
18300
18813
|
sequence: recorded.sequence
|
|
18301
18814
|
}, { operationId: opId });
|
|
18302
18815
|
} catch (err) {
|
|
18303
|
-
if (
|
|
18816
|
+
if (existsSync22(destAbs)) {
|
|
18304
18817
|
await rm(destAbs, { recursive: true, force: true }).catch(() => null);
|
|
18305
18818
|
}
|
|
18306
18819
|
return failure("skill-install", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
@@ -18346,16 +18859,16 @@ async function materialiseFromSkillsCli({ packageSpec, skillName, version: versi
|
|
|
18346
18859
|
);
|
|
18347
18860
|
});
|
|
18348
18861
|
if (!result.ok) return result;
|
|
18349
|
-
const stagedDir =
|
|
18350
|
-
if (!
|
|
18862
|
+
const stagedDir = join25(stageDir, ".opencode", "skills", skillName);
|
|
18863
|
+
if (!existsSync22(stagedDir)) {
|
|
18351
18864
|
return {
|
|
18352
18865
|
ok: false,
|
|
18353
18866
|
retryable: false,
|
|
18354
18867
|
message: `skills CLI did not produce ${stagedDir}`
|
|
18355
18868
|
};
|
|
18356
18869
|
}
|
|
18357
|
-
const skillMd =
|
|
18358
|
-
if (!
|
|
18870
|
+
const skillMd = join25(stagedDir, "SKILL.md");
|
|
18871
|
+
if (!existsSync22(skillMd)) {
|
|
18359
18872
|
return {
|
|
18360
18873
|
ok: false,
|
|
18361
18874
|
retryable: false,
|
|
@@ -18387,25 +18900,25 @@ async function walk(rootDir, currentDir, out) {
|
|
|
18387
18900
|
const { readdir: readdir10 } = await import("node:fs/promises");
|
|
18388
18901
|
const entries = await readdir10(currentDir, { withFileTypes: true });
|
|
18389
18902
|
for (const e of entries) {
|
|
18390
|
-
const abs =
|
|
18903
|
+
const abs = join25(currentDir, e.name);
|
|
18391
18904
|
if (e.isDirectory()) {
|
|
18392
18905
|
if (e.name === ".git") continue;
|
|
18393
18906
|
await walk(rootDir, abs, out);
|
|
18394
18907
|
continue;
|
|
18395
18908
|
}
|
|
18396
18909
|
if (!e.isFile()) continue;
|
|
18397
|
-
const raw = await
|
|
18910
|
+
const raw = await readFile22(abs);
|
|
18398
18911
|
const fileStat = await stat2(abs);
|
|
18399
18912
|
out.push({
|
|
18400
18913
|
path: abs.slice(rootDir.length + 1).split(sep3).join("/"),
|
|
18401
|
-
sha256:
|
|
18914
|
+
sha256: createHash18("sha256").update(raw).digest("hex"),
|
|
18402
18915
|
mode: fileStat.mode & 511,
|
|
18403
18916
|
size: fileStat.size
|
|
18404
18917
|
});
|
|
18405
18918
|
}
|
|
18406
18919
|
}
|
|
18407
18920
|
function hashBytes(bytes) {
|
|
18408
|
-
return
|
|
18921
|
+
return createHash18("sha256").update(bytes).digest("hex");
|
|
18409
18922
|
}
|
|
18410
18923
|
function hashesEqual(a, b) {
|
|
18411
18924
|
if (a.length !== b.length) return false;
|
|
@@ -18420,23 +18933,23 @@ async function copyDir(srcDir, destDir) {
|
|
|
18420
18933
|
const { readdir: readdir10 } = await import("node:fs/promises");
|
|
18421
18934
|
const entries = await readdir10(srcDir, { withFileTypes: true });
|
|
18422
18935
|
for (const e of entries) {
|
|
18423
|
-
const src =
|
|
18424
|
-
const dest =
|
|
18936
|
+
const src = join25(srcDir, e.name);
|
|
18937
|
+
const dest = join25(destDir, e.name);
|
|
18425
18938
|
if (e.isDirectory()) {
|
|
18426
18939
|
if (e.name === ".git") continue;
|
|
18427
18940
|
await copyDir(src, dest);
|
|
18428
18941
|
} else if (e.isFile()) {
|
|
18429
|
-
const raw = await
|
|
18942
|
+
const raw = await readFile22(src);
|
|
18430
18943
|
await writeFile11(dest, raw, { mode: 420 });
|
|
18431
18944
|
}
|
|
18432
18945
|
}
|
|
18433
18946
|
}
|
|
18434
18947
|
|
|
18435
18948
|
// src/tools/ship-skill-audit.js
|
|
18436
|
-
import { readdir as readdir8, readFile as
|
|
18437
|
-
import { existsSync as
|
|
18438
|
-
import { resolve as resolve16, join as
|
|
18439
|
-
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";
|
|
18440
18953
|
function createSkillAuditTool(deps) {
|
|
18441
18954
|
return async function skillAudit(input) {
|
|
18442
18955
|
const opId = input.operationId ?? `skill-audit-${Date.now().toString(36)}`;
|
|
@@ -18477,21 +18990,21 @@ function createSkillAuditTool(deps) {
|
|
|
18477
18990
|
continue;
|
|
18478
18991
|
}
|
|
18479
18992
|
for (const f of ev.files ?? []) {
|
|
18480
|
-
const filePath =
|
|
18481
|
-
if (!
|
|
18993
|
+
const filePath = join26(installRoot, ev.destination, f.path);
|
|
18994
|
+
if (!existsSync23(filePath)) {
|
|
18482
18995
|
missing.push({ skill: ev.skill, path: f.path, sequence: ev.sequence });
|
|
18483
18996
|
continue;
|
|
18484
18997
|
}
|
|
18485
|
-
const raw = await
|
|
18486
|
-
const sha =
|
|
18998
|
+
const raw = await readFile23(filePath);
|
|
18999
|
+
const sha = createHash19("sha256").update(raw).digest("hex");
|
|
18487
19000
|
if (sha !== f.sha256) {
|
|
18488
19001
|
drifted.push({ skill: ev.skill, path: f.path, expected: f.sha256, actual: sha, sequence: ev.sequence });
|
|
18489
19002
|
}
|
|
18490
19003
|
}
|
|
18491
19004
|
}
|
|
18492
19005
|
const untracked = [];
|
|
18493
|
-
const opencodeDir =
|
|
18494
|
-
if (
|
|
19006
|
+
const opencodeDir = join26(inventoryRoot, ".opencode", "skills");
|
|
19007
|
+
if (existsSync23(opencodeDir)) {
|
|
18495
19008
|
const entries = await readdir8(opencodeDir, { withFileTypes: true }).catch(() => []);
|
|
18496
19009
|
for (const e of entries) {
|
|
18497
19010
|
if (!e.isDirectory()) continue;
|
|
@@ -18513,16 +19026,16 @@ function createSkillAuditTool(deps) {
|
|
|
18513
19026
|
}
|
|
18514
19027
|
|
|
18515
19028
|
// src/tools/ship-skill-uninstall.js
|
|
18516
|
-
import { readFile as
|
|
18517
|
-
import { existsSync as
|
|
18518
|
-
import { resolve as resolve17, join as
|
|
18519
|
-
import { createHash as
|
|
18520
|
-
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}$/;
|
|
18521
19034
|
function createSkillUninstallTool(deps) {
|
|
18522
19035
|
return async function skillUninstall(input) {
|
|
18523
19036
|
const opId = input.operationId ?? `skill-uninstall-${Date.now().toString(36)}`;
|
|
18524
19037
|
const skillName = String(input.skill ?? "");
|
|
18525
|
-
if (!skillName || !
|
|
19038
|
+
if (!skillName || !SAFE_ID_RE15.test(skillName)) {
|
|
18526
19039
|
return failure("skill-uninstall", "skill required (safe id)", { operationId: opId, retryable: false });
|
|
18527
19040
|
}
|
|
18528
19041
|
const repoRoot = resolve17(deps.repoRoot);
|
|
@@ -18563,20 +19076,20 @@ function createSkillUninstallTool(deps) {
|
|
|
18563
19076
|
return failure("skill-uninstall", `recorded file rejected: ${fileCheck.message}`, { operationId: opId, retryable: false });
|
|
18564
19077
|
}
|
|
18565
19078
|
const filePath = fileCheck.path;
|
|
18566
|
-
if (!
|
|
19079
|
+
if (!existsSync24(filePath)) {
|
|
18567
19080
|
return failure("skill-uninstall", `recorded file missing: ${f.path}`, { operationId: opId, retryable: false });
|
|
18568
19081
|
}
|
|
18569
|
-
const raw = await
|
|
18570
|
-
const sha =
|
|
19082
|
+
const raw = await readFile24(filePath);
|
|
19083
|
+
const sha = createHash20("sha256").update(raw).digest("hex");
|
|
18571
19084
|
if (sha !== f.sha256) {
|
|
18572
19085
|
return failure("skill-uninstall", `recorded file drifted: ${f.path}`, { operationId: opId, retryable: false });
|
|
18573
19086
|
}
|
|
18574
19087
|
}
|
|
18575
19088
|
for (const f of found.install.files ?? []) {
|
|
18576
|
-
const filePath =
|
|
19089
|
+
const filePath = join27(skillDir, ...f.path.split("/"));
|
|
18577
19090
|
await unlink5(filePath).catch(() => null);
|
|
18578
19091
|
}
|
|
18579
|
-
if (
|
|
19092
|
+
if (existsSync24(skillDir)) {
|
|
18580
19093
|
await rm2(skillDir, { recursive: true, force: true });
|
|
18581
19094
|
}
|
|
18582
19095
|
const recorded = await appendEvent2(inventoryRoot, {
|
|
@@ -18595,12 +19108,12 @@ function createSkillUninstallTool(deps) {
|
|
|
18595
19108
|
};
|
|
18596
19109
|
}
|
|
18597
19110
|
async function listInstalledFiles(root) {
|
|
18598
|
-
if (!
|
|
19111
|
+
if (!existsSync24(root)) return { ok: true, paths: [] };
|
|
18599
19112
|
const paths = [];
|
|
18600
19113
|
const walk2 = async (dir, prefix = "") => {
|
|
18601
19114
|
for (const entry of await readdir9(dir, { withFileTypes: true })) {
|
|
18602
19115
|
const relative = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
18603
|
-
const absolute =
|
|
19116
|
+
const absolute = join27(dir, entry.name);
|
|
18604
19117
|
const info = await lstat(absolute);
|
|
18605
19118
|
if (info.isSymbolicLink()) return { ok: false, message: `symlinked content present: ${relative}` };
|
|
18606
19119
|
if (info.isDirectory()) {
|
|
@@ -18624,9 +19137,9 @@ function recoverManifestAfterCrash(manifest) {
|
|
|
18624
19137
|
}
|
|
18625
19138
|
|
|
18626
19139
|
// src/installer/plugin-owner.js
|
|
18627
|
-
import { spawnSync as
|
|
19140
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
18628
19141
|
async function reconcileOwner(repoRoot, adapter) {
|
|
18629
|
-
const r =
|
|
19142
|
+
const r = spawnSync5("git", ["-C", repoRoot, "config", "--get", "user.name"], {
|
|
18630
19143
|
encoding: "utf8"
|
|
18631
19144
|
});
|
|
18632
19145
|
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
|
|
@@ -18634,8 +19147,8 @@ async function reconcileOwner(repoRoot, adapter) {
|
|
|
18634
19147
|
}
|
|
18635
19148
|
|
|
18636
19149
|
// src/installer/config.js
|
|
18637
|
-
import { readFile as
|
|
18638
|
-
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";
|
|
18639
19152
|
import { dirname as dirname11, resolve as resolve18 } from "node:path";
|
|
18640
19153
|
|
|
18641
19154
|
// schema/ship-config.schema.json
|
|
@@ -18947,8 +19460,8 @@ function configPath(repoRoot) {
|
|
|
18947
19460
|
}
|
|
18948
19461
|
async function loadConfig(repoRoot) {
|
|
18949
19462
|
const path = configPath(repoRoot);
|
|
18950
|
-
if (!
|
|
18951
|
-
const raw = await
|
|
19463
|
+
if (!existsSync25(path)) return null;
|
|
19464
|
+
const raw = await readFile25(path, "utf8");
|
|
18952
19465
|
let parsed;
|
|
18953
19466
|
try {
|
|
18954
19467
|
parsed = JSON.parse(raw);
|
|
@@ -19013,26 +19526,26 @@ function renderDefaultConfig(detection, overrides = {}) {
|
|
|
19013
19526
|
}
|
|
19014
19527
|
|
|
19015
19528
|
// src/installer/detection/project.js
|
|
19016
|
-
import { spawnSync as
|
|
19017
|
-
import { existsSync as
|
|
19018
|
-
import { resolve as resolve19, join as
|
|
19019
|
-
function
|
|
19020
|
-
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], {
|
|
19021
19534
|
stdio: ["ignore", "pipe", "pipe"],
|
|
19022
19535
|
encoding: "utf8"
|
|
19023
19536
|
});
|
|
19024
19537
|
return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
19025
19538
|
}
|
|
19026
19539
|
function detectPackageManager(repoRoot) {
|
|
19027
|
-
if (
|
|
19028
|
-
if (
|
|
19029
|
-
if (
|
|
19030
|
-
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";
|
|
19031
19544
|
return null;
|
|
19032
19545
|
}
|
|
19033
19546
|
function readPackageJson(repoRoot) {
|
|
19034
|
-
const path =
|
|
19035
|
-
if (!
|
|
19547
|
+
const path = join28(repoRoot, "package.json");
|
|
19548
|
+
if (!existsSync26(path)) return null;
|
|
19036
19549
|
try {
|
|
19037
19550
|
return JSON.parse(readFileSync2(path, "utf8"));
|
|
19038
19551
|
} catch {
|
|
@@ -19070,7 +19583,7 @@ function parseRepoSlugFromRemote(url2) {
|
|
|
19070
19583
|
return null;
|
|
19071
19584
|
}
|
|
19072
19585
|
function detectRemote(repoRoot) {
|
|
19073
|
-
const remotes =
|
|
19586
|
+
const remotes = runGit3(repoRoot, ["remote", "-v"]);
|
|
19074
19587
|
if (remotes.status !== 0) return { candidates: [], primary: null };
|
|
19075
19588
|
const lines = remotes.stdout.split("\n").filter(Boolean);
|
|
19076
19589
|
const map2 = /* @__PURE__ */ new Map();
|
|
@@ -19086,23 +19599,23 @@ function detectRemote(repoRoot) {
|
|
|
19086
19599
|
return { candidates: list, primary: origin };
|
|
19087
19600
|
}
|
|
19088
19601
|
function detectDefaultBranch(repoRoot, remoteName) {
|
|
19089
|
-
const head =
|
|
19602
|
+
const head = runGit3(repoRoot, ["symbolic-ref", `refs/remotes/${remoteName}/HEAD`]);
|
|
19090
19603
|
if (head.status === 0) {
|
|
19091
19604
|
const ref = head.stdout.trim();
|
|
19092
19605
|
const match = ref.match(/^refs\/remotes\/[^/]+\/(.+)$/);
|
|
19093
19606
|
if (match) return match[1];
|
|
19094
19607
|
}
|
|
19095
|
-
const local =
|
|
19608
|
+
const local = runGit3(repoRoot, ["remote", "show", remoteName]);
|
|
19096
19609
|
if (local.status === 0) {
|
|
19097
19610
|
const match = local.stdout.match(/HEAD branch:\s*(\S+)/);
|
|
19098
19611
|
if (match) return match[1];
|
|
19099
19612
|
}
|
|
19100
|
-
const branch =
|
|
19613
|
+
const branch = runGit3(repoRoot, ["branch", "--list"]);
|
|
19101
19614
|
if (branch.status === 0 && /\*\s*main\b/.test(branch.stdout)) return "main";
|
|
19102
19615
|
return null;
|
|
19103
19616
|
}
|
|
19104
19617
|
function detectOwner(repoRoot) {
|
|
19105
|
-
const user =
|
|
19618
|
+
const user = runGit3(repoRoot, ["config", "--get", "user.name"]);
|
|
19106
19619
|
if (user.status === 0 && user.stdout.trim()) return user.stdout.trim();
|
|
19107
19620
|
const fallback = process.env.USER ?? process.env.USERNAME ?? "opencode-ship";
|
|
19108
19621
|
return fallback;
|
|
@@ -19110,13 +19623,13 @@ function detectOwner(repoRoot) {
|
|
|
19110
19623
|
function detectProject(repoRoot = process.cwd()) {
|
|
19111
19624
|
const errors = [];
|
|
19112
19625
|
const cwd = resolve19(repoRoot);
|
|
19113
|
-
const inside =
|
|
19626
|
+
const inside = runGit3(cwd, ["rev-parse", "--show-toplevel"]);
|
|
19114
19627
|
if (inside.status !== 0) {
|
|
19115
19628
|
errors.push({ kind: "not-a-git-repo", path: cwd, detail: inside.stderr.trim() });
|
|
19116
19629
|
return { repoRoot: cwd, errors };
|
|
19117
19630
|
}
|
|
19118
19631
|
const repoRootActual = inside.stdout.trim();
|
|
19119
|
-
const headBranch =
|
|
19632
|
+
const headBranch = runGit3(repoRootActual, ["symbolic-ref", "--short", "HEAD"]);
|
|
19120
19633
|
if (headBranch.status !== 0 || headBranch.stdout.trim().length === 0) {
|
|
19121
19634
|
errors.push({ kind: "detached-head", path: repoRootActual, detail: headBranch.stderr.trim() });
|
|
19122
19635
|
}
|
|
@@ -19156,11 +19669,11 @@ function detectProject(repoRoot = process.cwd()) {
|
|
|
19156
19669
|
}
|
|
19157
19670
|
|
|
19158
19671
|
// src/installer/cleanup.js
|
|
19159
|
-
import { spawnSync as
|
|
19672
|
+
import { spawnSync as spawnSync7 } from "node:child_process";
|
|
19160
19673
|
import { resolve as pathResolve } from "node:path";
|
|
19161
|
-
import { existsSync as
|
|
19674
|
+
import { existsSync as existsSync27, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2 } from "node:fs";
|
|
19162
19675
|
function spawn8(repoRoot, args) {
|
|
19163
|
-
const r =
|
|
19676
|
+
const r = spawnSync7("git", ["-C", repoRoot, ...args], { encoding: "utf8" });
|
|
19164
19677
|
return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
19165
19678
|
}
|
|
19166
19679
|
function casDeleteBranch2(repoRoot, branch, expectedSha) {
|
|
@@ -19183,7 +19696,7 @@ async function cleanupPendingPath(repoRoot) {
|
|
|
19183
19696
|
}
|
|
19184
19697
|
async function loadCleanupPending(repoRoot) {
|
|
19185
19698
|
const path = await cleanupPendingPath(repoRoot);
|
|
19186
|
-
if (!
|
|
19699
|
+
if (!existsSync27(path)) return [];
|
|
19187
19700
|
try {
|
|
19188
19701
|
const raw = await readFileSync3(path, "utf8");
|
|
19189
19702
|
const parsed = JSON.parse(raw);
|
|
@@ -19195,7 +19708,7 @@ async function loadCleanupPending(repoRoot) {
|
|
|
19195
19708
|
async function saveCleanupPending(repoRoot, entries) {
|
|
19196
19709
|
const path = await cleanupPendingPath(repoRoot);
|
|
19197
19710
|
const dir = pathResolve(path, "..");
|
|
19198
|
-
if (!
|
|
19711
|
+
if (!existsSync27(dir)) mkdirSync2(dir, { recursive: true });
|
|
19199
19712
|
writeFileSync2(path, JSON.stringify(dedupePending(entries), null, 2) + "\n", "utf8");
|
|
19200
19713
|
}
|
|
19201
19714
|
function dedupePending(entries) {
|
|
@@ -19380,10 +19893,10 @@ function selectRuntimeAdapter({ config: config2, shipAdapter, legacyAdapter }) {
|
|
|
19380
19893
|
}
|
|
19381
19894
|
|
|
19382
19895
|
// src/version.js
|
|
19383
|
-
import { readFileSync as readFileSync4, existsSync as
|
|
19896
|
+
import { readFileSync as readFileSync4, existsSync as existsSync28 } from "node:fs";
|
|
19384
19897
|
import { dirname as dirname12, resolve as resolve21 } from "node:path";
|
|
19385
19898
|
import { fileURLToPath } from "node:url";
|
|
19386
|
-
var PACKAGE_VERSION = "1.1.
|
|
19899
|
+
var PACKAGE_VERSION = "1.1.8";
|
|
19387
19900
|
var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
|
|
19388
19901
|
|
|
19389
19902
|
// src/plugin.js
|
|
@@ -19397,6 +19910,7 @@ var toolDefs = [
|
|
|
19397
19910
|
["delivery_ready", "Mark the PR ready after every required gate has passed.", "ready"],
|
|
19398
19911
|
["delivery_merge", "Squash merge the PR after an explicit user request.", "merge"],
|
|
19399
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"],
|
|
19400
19914
|
["delivery_github_read", "Typed read of issue, PR, or check data.", "githubRead"],
|
|
19401
19915
|
["delivery_issue_comment", "Idempotent typed comment on an issue.", "issueComment"],
|
|
19402
19916
|
["delivery_issue_labels", "Idempotent label add/remove on an issue.", "issueLabels"],
|
|
@@ -19404,6 +19918,7 @@ var toolDefs = [
|
|
|
19404
19918
|
["delivery_issue_close", "Close an issue with a recorded user permission subject.", "issueClose"],
|
|
19405
19919
|
["delivery_sync", "Fetch and merge base into the feature branch.", "sync"],
|
|
19406
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"],
|
|
19407
19922
|
["ship_plan_start", "Create a workflow and dispatch the configured planner.", "planStart"],
|
|
19408
19923
|
["ship_plan_submit", "Planner-only immutable PlanV2 submission.", "planSubmit"],
|
|
19409
19924
|
["ship_plan_approve", "Interactive approval + immutable local seal.", "planApprove"],
|
|
@@ -19472,7 +19987,7 @@ async function resolveRepoSlug(repoRoot, detection, config2) {
|
|
|
19472
19987
|
const fromConfig = config2?.value?.project?.repository;
|
|
19473
19988
|
if (typeof fromConfig === "string" && fromConfig.includes("/")) return fromConfig;
|
|
19474
19989
|
if (detection?.repository) return detection.repository;
|
|
19475
|
-
const gitConfig = await
|
|
19990
|
+
const gitConfig = await readFile26(resolve22(repoRoot, ".git/config"), "utf8").catch(() => null);
|
|
19476
19991
|
if (gitConfig) {
|
|
19477
19992
|
const m = gitConfig.match(/url\s*=\s*.*?github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\b/);
|
|
19478
19993
|
if (m) return `${m[1]}/${m[2]}`;
|
|
@@ -19657,6 +20172,21 @@ var factories = {
|
|
|
19657
20172
|
remote: "origin"
|
|
19658
20173
|
})
|
|
19659
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
|
+
},
|
|
19660
20190
|
githubRead: {
|
|
19661
20191
|
args: {
|
|
19662
20192
|
resource: tool.schema.enum(["issue", "pr", "checks"]),
|
|
@@ -19752,6 +20282,17 @@ var factories = {
|
|
|
19752
20282
|
owner: rt.owner
|
|
19753
20283
|
})
|
|
19754
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
|
+
},
|
|
19755
20296
|
planStart: {
|
|
19756
20297
|
args: {
|
|
19757
20298
|
issueNumber: tool.schema.number(),
|