opencode-ship 0.9.0 → 0.10.0-rc.17
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 +96 -0
- package/README.md +3 -1
- package/THIRD_PARTY_NOTICES.md +77 -19
- package/assets/agents/ship-controller.md +122 -0
- package/assets/agents/ship-final-spec-reviewer.md +87 -0
- package/assets/agents/ship-final-standards-reviewer.md +83 -0
- package/assets/agents/ship-planner.md +78 -0
- package/assets/agents/ship-task-builder.md +91 -0
- package/assets/agents/ship-task-reviewer.md +78 -0
- package/assets/commands/ship-deliver.md +30 -0
- package/assets/commands/ship-resume.md +28 -0
- package/assets/commands/ship-status.md +25 -0
- package/assets/skills/brainstorming/SKILL.md +160 -0
- package/assets/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
- package/assets/skills/brainstorming/visual-companion.md +298 -0
- package/assets/skills/code-review/SKILL.md +98 -0
- package/assets/skills/codebase-design/DEEPENING.md +37 -0
- package/assets/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/assets/skills/codebase-design/SKILL.md +123 -0
- package/assets/skills/dispatching-parallel-agents/SKILL.md +176 -0
- package/assets/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/assets/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/assets/skills/domain-modeling/SKILL.md +83 -0
- package/assets/skills/engineering-workflow/SKILL.md +125 -0
- package/assets/skills/engineering-workflow/domain.md +51 -0
- package/assets/skills/engineering-workflow/issue-tracker-github.md +45 -0
- package/assets/skills/engineering-workflow/issue-tracker-gitlab.md +46 -0
- package/assets/skills/engineering-workflow/issue-tracker-local.md +30 -0
- package/assets/skills/engineering-workflow/triage-labels.md +15 -0
- package/assets/skills/executing-plans/SKILL.md +73 -0
- package/assets/skills/grill-with-docs/SKILL.md +10 -14
- package/assets/skills/grilling/SKILL.md +21 -0
- package/assets/skills/handoff/SKILL.md +25 -0
- package/assets/skills/prototype/LOGIC.md +79 -0
- package/assets/skills/prototype/SKILL.md +35 -0
- package/assets/skills/prototype/UI.md +112 -0
- package/assets/skills/receiving-code-review/SKILL.md +214 -0
- package/assets/skills/requesting-code-review/SKILL.md +104 -0
- package/assets/skills/requesting-code-review/code-reviewer.md +172 -0
- package/assets/skills/research/SKILL.md +21 -0
- package/assets/skills/setup-engineering-workflow/SKILL.md +125 -0
- package/assets/skills/setup-engineering-workflow/domain.md +51 -0
- package/assets/skills/setup-engineering-workflow/issue-tracker-github.md +45 -0
- package/assets/skills/setup-engineering-workflow/issue-tracker-gitlab.md +46 -0
- package/assets/skills/setup-engineering-workflow/issue-tracker-local.md +30 -0
- package/assets/skills/setup-engineering-workflow/triage-labels.md +15 -0
- package/assets/skills/subagent-driven-development/SKILL.md +512 -0
- package/assets/skills/subagent-driven-development/implementer-prompt.md +142 -0
- package/assets/skills/subagent-driven-development/re-review-prompt.md +106 -0
- package/assets/skills/subagent-driven-development/task-reviewer-prompt.md +185 -0
- package/assets/skills/systematic-debugging/CREATION-LOG.md +119 -0
- package/assets/skills/systematic-debugging/SKILL.md +292 -0
- package/assets/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
- package/assets/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/assets/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/assets/skills/systematic-debugging/find-polluter.sh +72 -0
- package/assets/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/assets/skills/systematic-debugging/test-academic.md +14 -0
- package/assets/skills/systematic-debugging/test-pressure-1.md +58 -0
- package/assets/skills/systematic-debugging/test-pressure-2.md +68 -0
- package/assets/skills/systematic-debugging/test-pressure-3.md +69 -0
- package/assets/skills/test-driven-development/SKILL.md +329 -0
- package/assets/skills/test-driven-development/writing-good-tests.md +198 -0
- package/assets/skills/to-spec/SKILL.md +84 -0
- package/assets/skills/to-tickets/SKILL.md +114 -0
- package/assets/skills/triage/AGENT-BRIEF.md +207 -0
- package/assets/skills/triage/OUT-OF-SCOPE.md +105 -0
- package/assets/skills/triage/SKILL.md +114 -15
- package/assets/skills/verification-before-completion/SKILL.md +129 -0
- package/assets/skills/wayfinder/SKILL.md +137 -0
- package/assets/skills/writing-plans/SKILL.md +177 -0
- package/assets/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
- package/dist/cli.js +1146 -435
- package/dist/core.js +210 -61
- package/dist/plugin.js +2368 -230
- package/package.json +3 -1
- package/schema/ship-config.schema.json +63 -2
- package/schema/ship-lock.schema.json +5 -3
- package/tests/plugin/plugin-load.test.mjs +85 -0
- package/vendor/mattpocock/LICENSE +30 -0
- package/vendor/obra/LICENSE +30 -0
- package/vendor/sources.json +976 -0
- package/vendor/superpowers/LICENSE +30 -0
- package/vendor/upstreams/mattpocock/skills/engineering/code-review/SKILL.md +89 -0
- package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DEEPENING.md +37 -0
- package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/SKILL.md +114 -0
- package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/ADR-FORMAT.md +47 -0
- package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/SKILL.md +74 -0
- package/vendor/upstreams/mattpocock/skills/engineering/grill-with-docs/SKILL.md +7 -0
- package/vendor/upstreams/mattpocock/skills/engineering/prototype/LOGIC.md +79 -0
- package/vendor/upstreams/mattpocock/skills/engineering/prototype/SKILL.md +26 -0
- package/vendor/upstreams/mattpocock/skills/engineering/prototype/UI.md +112 -0
- package/vendor/upstreams/mattpocock/skills/engineering/research/SKILL.md +12 -0
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/SKILL.md +116 -0
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/domain.md +51 -0
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-github.md +45 -0
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-gitlab.md +46 -0
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-local.md +30 -0
- package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/triage-labels.md +15 -0
- package/vendor/upstreams/mattpocock/skills/engineering/to-spec/SKILL.md +75 -0
- package/vendor/upstreams/mattpocock/skills/engineering/to-tickets/SKILL.md +105 -0
- package/vendor/upstreams/mattpocock/skills/engineering/triage/AGENT-BRIEF.md +207 -0
- package/vendor/upstreams/mattpocock/skills/engineering/triage/OUT-OF-SCOPE.md +105 -0
- package/vendor/upstreams/mattpocock/skills/engineering/triage/SKILL.md +112 -0
- package/vendor/upstreams/mattpocock/skills/engineering/wayfinder/SKILL.md +128 -0
- package/vendor/upstreams/mattpocock/skills/productivity/grilling/SKILL.md +12 -0
- package/vendor/upstreams/mattpocock/skills/productivity/handoff/SKILL.md +16 -0
- package/vendor/upstreams/obra/skills/brainstorming/SKILL.md +151 -0
- package/vendor/upstreams/obra/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
- package/vendor/upstreams/obra/skills/brainstorming/visual-companion.md +298 -0
- package/vendor/upstreams/obra/skills/dispatching-parallel-agents/SKILL.md +167 -0
- package/vendor/upstreams/obra/skills/executing-plans/SKILL.md +64 -0
- package/vendor/upstreams/obra/skills/receiving-code-review/SKILL.md +205 -0
- package/vendor/upstreams/obra/skills/requesting-code-review/SKILL.md +95 -0
- package/vendor/upstreams/obra/skills/requesting-code-review/code-reviewer.md +172 -0
- package/vendor/upstreams/obra/skills/subagent-driven-development/SKILL.md +503 -0
- package/vendor/upstreams/obra/skills/subagent-driven-development/implementer-prompt.md +142 -0
- package/vendor/upstreams/obra/skills/subagent-driven-development/re-review-prompt.md +106 -0
- package/vendor/upstreams/obra/skills/subagent-driven-development/task-reviewer-prompt.md +185 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/CREATION-LOG.md +119 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/SKILL.md +283 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting.md +115 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/defense-in-depth.md +122 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/find-polluter.sh +72 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/root-cause-tracing.md +169 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/test-academic.md +14 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-1.md +58 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-2.md +68 -0
- package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-3.md +69 -0
- package/vendor/upstreams/obra/skills/test-driven-development/SKILL.md +320 -0
- package/vendor/upstreams/obra/skills/test-driven-development/writing-good-tests.md +198 -0
- package/vendor/upstreams/obra/skills/verification-before-completion/SKILL.md +120 -0
- package/vendor/upstreams/obra/skills/writing-plans/SKILL.md +168 -0
- package/vendor/upstreams/obra/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
package/dist/plugin.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// opencode-ship v0.
|
|
1
|
+
// opencode-ship v0.10.0-rc.17
|
|
2
2
|
var __defProp = Object.defineProperty;
|
|
3
3
|
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
4
4
|
var __esm = (fn, res) => function __init() {
|
|
@@ -9,27 +9,41 @@ var __export = (target, all) => {
|
|
|
9
9
|
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
// src/profile.js
|
|
13
|
+
function isValidProfile(name) {
|
|
14
|
+
return typeof name === "string" && PROFILES.includes(name);
|
|
15
|
+
}
|
|
16
|
+
var PROFILES;
|
|
17
|
+
var init_profile = __esm({
|
|
18
|
+
"src/profile.js"() {
|
|
19
|
+
PROFILES = Object.freeze(["core", "engineering"]);
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
12
23
|
// src/installer/json-pointer.js
|
|
13
|
-
function
|
|
24
|
+
function isObject2(v) {
|
|
14
25
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
15
26
|
}
|
|
16
27
|
function stableStringify(value) {
|
|
17
28
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
18
|
-
if (
|
|
29
|
+
if (isObject2(value)) {
|
|
19
30
|
const keys = Object.keys(value).sort();
|
|
20
31
|
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(",")}}`;
|
|
21
32
|
}
|
|
22
33
|
return JSON.stringify(value);
|
|
23
34
|
}
|
|
35
|
+
function canonicalJson(value) {
|
|
36
|
+
return stableStringify(value);
|
|
37
|
+
}
|
|
24
38
|
var init_json_pointer = __esm({
|
|
25
39
|
"src/installer/json-pointer.js"() {
|
|
26
40
|
}
|
|
27
41
|
});
|
|
28
42
|
|
|
29
43
|
// src/installer/hash.js
|
|
30
|
-
import { createHash as
|
|
44
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
31
45
|
function bytesHash(buffer) {
|
|
32
|
-
return
|
|
46
|
+
return createHash8("sha256").update(buffer).digest("hex");
|
|
33
47
|
}
|
|
34
48
|
function bytesHashString(text) {
|
|
35
49
|
return bytesHash(Buffer.from(text, "utf8"));
|
|
@@ -40,17 +54,6 @@ var init_hash = __esm({
|
|
|
40
54
|
}
|
|
41
55
|
});
|
|
42
56
|
|
|
43
|
-
// src/profile.js
|
|
44
|
-
function isValidProfile(name) {
|
|
45
|
-
return typeof name === "string" && PROFILES.includes(name);
|
|
46
|
-
}
|
|
47
|
-
var PROFILES;
|
|
48
|
-
var init_profile = __esm({
|
|
49
|
-
"src/profile.js"() {
|
|
50
|
-
PROFILES = Object.freeze(["core", "engineering"]);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
57
|
// src/installer/lock.js
|
|
55
58
|
var lock_exports = {};
|
|
56
59
|
__export(lock_exports, {
|
|
@@ -65,20 +68,20 @@ __export(lock_exports, {
|
|
|
65
68
|
validateLock: () => validateLock,
|
|
66
69
|
writeLock: () => writeLock
|
|
67
70
|
});
|
|
68
|
-
import { readFile as
|
|
69
|
-
import { existsSync as
|
|
70
|
-
import { dirname as
|
|
71
|
+
import { readFile as readFile11, writeFile as writeFile10, rename as rename4, mkdir as mkdir12 } from "node:fs/promises";
|
|
72
|
+
import { existsSync as existsSync10 } from "node:fs";
|
|
73
|
+
import { dirname as dirname6, resolve as resolve10 } from "node:path";
|
|
71
74
|
function lockSchemaRevision() {
|
|
72
75
|
return CURRENT_LOCK_SCHEMA;
|
|
73
76
|
}
|
|
74
77
|
function lockPath(repoRoot) {
|
|
75
|
-
return
|
|
78
|
+
return resolve10(repoRoot, ".opencode", "ship.lock.json");
|
|
76
79
|
}
|
|
77
80
|
async function readLock2(repoRoot) {
|
|
78
81
|
const path = lockPath(repoRoot);
|
|
79
|
-
if (!
|
|
82
|
+
if (!existsSync10(path)) return null;
|
|
80
83
|
try {
|
|
81
|
-
const raw = await
|
|
84
|
+
const raw = await readFile11(path, "utf8");
|
|
82
85
|
const parsed = JSON.parse(raw);
|
|
83
86
|
return parsed;
|
|
84
87
|
} catch {
|
|
@@ -87,12 +90,12 @@ async function readLock2(repoRoot) {
|
|
|
87
90
|
}
|
|
88
91
|
async function writeLock(repoRoot, lock) {
|
|
89
92
|
const path = lockPath(repoRoot);
|
|
90
|
-
await
|
|
93
|
+
await mkdir12(dirname6(path), { recursive: true });
|
|
91
94
|
const integrity = computeIntegrity(lock);
|
|
92
95
|
const finalLock = { ...lock, integrity };
|
|
93
96
|
const raw = JSON.stringify(finalLock, null, 2) + "\n";
|
|
94
97
|
const tmp = `${path}.tmp`;
|
|
95
|
-
await
|
|
98
|
+
await writeFile10(tmp, raw, "utf8");
|
|
96
99
|
await rename4(tmp, path);
|
|
97
100
|
return path;
|
|
98
101
|
}
|
|
@@ -117,8 +120,8 @@ function validateLock(rawLock) {
|
|
|
117
120
|
}
|
|
118
121
|
const issues = [];
|
|
119
122
|
let kind = "ok";
|
|
120
|
-
if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1) {
|
|
121
|
-
issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
|
|
123
|
+
if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1 && rawLock.contractVersion !== 2) {
|
|
124
|
+
issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 2, or 1)`);
|
|
122
125
|
kind = "schema";
|
|
123
126
|
}
|
|
124
127
|
const manager = rawLock.manager;
|
|
@@ -128,13 +131,13 @@ function validateLock(rawLock) {
|
|
|
128
131
|
} else if (typeof manager !== "object" || manager === null) {
|
|
129
132
|
issues.push("manager section must be an object");
|
|
130
133
|
kind = kind === "ok" ? "shape" : kind;
|
|
131
|
-
} else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 1) {
|
|
132
|
-
issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
|
|
134
|
+
} else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 2 && manager.schemaVersion !== 1) {
|
|
135
|
+
issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 2, or 1)`);
|
|
133
136
|
kind = "schema";
|
|
134
137
|
} else if (manager.name !== "opencode-ship") {
|
|
135
138
|
issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
|
|
136
139
|
kind = "shape";
|
|
137
|
-
} else if (rawLock.contractVersion
|
|
140
|
+
} else if (rawLock.contractVersion >= 2 && manager.schemaVersion >= 2 && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
|
|
138
141
|
issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: core, engineering)`);
|
|
139
142
|
kind = "shape";
|
|
140
143
|
}
|
|
@@ -156,12 +159,12 @@ function validateLock(rawLock) {
|
|
|
156
159
|
}
|
|
157
160
|
async function readValidatedLock(repoRoot) {
|
|
158
161
|
const path = lockPath(repoRoot);
|
|
159
|
-
if (!
|
|
162
|
+
if (!existsSync10(path)) {
|
|
160
163
|
return { kind: "missing", lock: null, issues: [] };
|
|
161
164
|
}
|
|
162
165
|
let raw;
|
|
163
166
|
try {
|
|
164
|
-
const text = await
|
|
167
|
+
const text = await readFile11(path, "utf8");
|
|
165
168
|
raw = JSON.parse(text);
|
|
166
169
|
} catch (e) {
|
|
167
170
|
return {
|
|
@@ -174,10 +177,10 @@ async function readValidatedLock(repoRoot) {
|
|
|
174
177
|
return { kind: validation.kind, lock: validation.ok ? raw : null, issues: validation.issues };
|
|
175
178
|
}
|
|
176
179
|
async function migrateLegacyLock(repoRoot) {
|
|
177
|
-
const legacy =
|
|
178
|
-
if (!
|
|
180
|
+
const legacy = resolve10(repoRoot, ".opencode", "delivery.lock.json");
|
|
181
|
+
if (!existsSync10(legacy)) return null;
|
|
179
182
|
try {
|
|
180
|
-
const raw = await
|
|
183
|
+
const raw = await readFile11(legacy, "utf8");
|
|
181
184
|
const parsed = JSON.parse(raw);
|
|
182
185
|
if (parsed.contractVersion !== 1 || typeof parsed.adapterSha256 !== "string") return null;
|
|
183
186
|
return {
|
|
@@ -196,7 +199,7 @@ var init_lock = __esm({
|
|
|
196
199
|
init_hash();
|
|
197
200
|
init_json_pointer();
|
|
198
201
|
init_profile();
|
|
199
|
-
CURRENT_LOCK_SCHEMA =
|
|
202
|
+
CURRENT_LOCK_SCHEMA = 3;
|
|
200
203
|
}
|
|
201
204
|
});
|
|
202
205
|
|
|
@@ -12623,8 +12626,8 @@ function tool(input) {
|
|
|
12623
12626
|
tool.schema = external_exports;
|
|
12624
12627
|
|
|
12625
12628
|
// src/plugin.js
|
|
12626
|
-
import { resolve as
|
|
12627
|
-
import { readFile as
|
|
12629
|
+
import { resolve as resolve14 } from "node:path";
|
|
12630
|
+
import { readFile as readFile12 } from "node:fs/promises";
|
|
12628
12631
|
|
|
12629
12632
|
// src/adapter.js
|
|
12630
12633
|
import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
|
|
@@ -12821,8 +12824,8 @@ async function loadAdapter(repoRoot) {
|
|
|
12821
12824
|
}
|
|
12822
12825
|
const v = validateAdapter(parsed);
|
|
12823
12826
|
if (!v.ok) return { ok: false, error: { kind: "contract", path, issues: v.issues } };
|
|
12824
|
-
const
|
|
12825
|
-
return { ok: true, adapter: v.adapter, path, sha256 };
|
|
12827
|
+
const sha2563 = createHash("sha256").update(raw).digest("hex");
|
|
12828
|
+
return { ok: true, adapter: v.adapter, path, sha256: sha2563 };
|
|
12826
12829
|
}
|
|
12827
12830
|
async function readLock(repoRoot) {
|
|
12828
12831
|
const lockPath2 = resolve(repoRoot, ".opencode", LOCK_FILENAME);
|
|
@@ -12846,9 +12849,62 @@ function parseRepoSlug(slug) {
|
|
|
12846
12849
|
return { owner: slug.slice(0, slash), name: slug.slice(slash + 1) };
|
|
12847
12850
|
}
|
|
12848
12851
|
|
|
12852
|
+
// src/drivers/github-command-policy.js
|
|
12853
|
+
var ALLOWED_VERBS = /* @__PURE__ */ new Set([
|
|
12854
|
+
"issue list",
|
|
12855
|
+
"issue view",
|
|
12856
|
+
"issue create",
|
|
12857
|
+
"issue comment",
|
|
12858
|
+
"issue edit",
|
|
12859
|
+
"issue close",
|
|
12860
|
+
"pr list",
|
|
12861
|
+
"pr view",
|
|
12862
|
+
"pr create",
|
|
12863
|
+
"pr edit",
|
|
12864
|
+
"pr checks",
|
|
12865
|
+
"pr ready",
|
|
12866
|
+
"pr merge"
|
|
12867
|
+
]);
|
|
12868
|
+
var FORBIDDEN_FLAGS = /* @__PURE__ */ new Set([
|
|
12869
|
+
"--web",
|
|
12870
|
+
"--body-file",
|
|
12871
|
+
"--template"
|
|
12872
|
+
]);
|
|
12873
|
+
function validateGhArgv(argv) {
|
|
12874
|
+
if (!Array.isArray(argv) || argv.length < 2) {
|
|
12875
|
+
return { ok: false, reason: "argv must be a non-empty array starting with the binary" };
|
|
12876
|
+
}
|
|
12877
|
+
const [bin, ...rest] = argv;
|
|
12878
|
+
if (bin !== "gh") {
|
|
12879
|
+
return { ok: false, reason: `expected 'gh' binary, got ${JSON.stringify(bin)}` };
|
|
12880
|
+
}
|
|
12881
|
+
if (rest.length === 0) {
|
|
12882
|
+
return { ok: false, reason: "no gh subcommand" };
|
|
12883
|
+
}
|
|
12884
|
+
const verb = rest.slice(0, 2).join(" ");
|
|
12885
|
+
if (verb.includes("api")) {
|
|
12886
|
+
return { ok: false, reason: "gh api is not allowed (use typed Ship tools instead)" };
|
|
12887
|
+
}
|
|
12888
|
+
if (!ALLOWED_VERBS.has(verb)) {
|
|
12889
|
+
return { ok: false, reason: `gh subcommand not in the allowlist: ${verb}` };
|
|
12890
|
+
}
|
|
12891
|
+
for (const arg of rest.slice(2)) {
|
|
12892
|
+
if (FORBIDDEN_FLAGS.has(arg)) {
|
|
12893
|
+
return { ok: false, reason: `gh flag ${arg} is forbidden (use Ship's typed body argument instead)` };
|
|
12894
|
+
}
|
|
12895
|
+
if (typeof arg !== "string" || arg.length === 0) {
|
|
12896
|
+
return { ok: false, reason: "gh argv must contain only non-empty strings" };
|
|
12897
|
+
}
|
|
12898
|
+
}
|
|
12899
|
+
return { ok: true, verb: (
|
|
12900
|
+
/** @type {any} */
|
|
12901
|
+
verb
|
|
12902
|
+
) };
|
|
12903
|
+
}
|
|
12904
|
+
|
|
12849
12905
|
// src/drivers/gh-cli.js
|
|
12850
12906
|
function defaultRunner(cwd, env) {
|
|
12851
|
-
return (args) => new Promise((
|
|
12907
|
+
return (args) => new Promise((resolve15, reject2) => {
|
|
12852
12908
|
const proc = spawn("gh", args, {
|
|
12853
12909
|
cwd,
|
|
12854
12910
|
env,
|
|
@@ -12860,7 +12916,7 @@ function defaultRunner(cwd, env) {
|
|
|
12860
12916
|
proc.stdout.on("data", (d) => stdout += d.toString());
|
|
12861
12917
|
proc.stderr.on("data", (d) => stderr += d.toString());
|
|
12862
12918
|
proc.on("error", reject2);
|
|
12863
|
-
proc.on("close", (status) =>
|
|
12919
|
+
proc.on("close", (status) => resolve15({ status: status ?? -1, stdout, stderr }));
|
|
12864
12920
|
});
|
|
12865
12921
|
}
|
|
12866
12922
|
function viewFields() {
|
|
@@ -13096,6 +13152,20 @@ Closes #${issueNumber}`;
|
|
|
13096
13152
|
"headRefOid"
|
|
13097
13153
|
]);
|
|
13098
13154
|
return fields.headRefOid;
|
|
13155
|
+
},
|
|
13156
|
+
async runCommand(argv) {
|
|
13157
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
13158
|
+
throw new Error("runCommand: argv must be a non-empty array");
|
|
13159
|
+
}
|
|
13160
|
+
if (typeof argv[0] !== "string" || argv[0].length === 0) {
|
|
13161
|
+
throw new Error("runCommand: argv[0] must be a non-empty string");
|
|
13162
|
+
}
|
|
13163
|
+
const policy = validateGhArgv(argv);
|
|
13164
|
+
if (!policy.ok) {
|
|
13165
|
+
throw new Error(`runCommand: rejected by policy: ${policy.reason}`);
|
|
13166
|
+
}
|
|
13167
|
+
const r = await run(argv);
|
|
13168
|
+
return r;
|
|
13099
13169
|
}
|
|
13100
13170
|
};
|
|
13101
13171
|
}
|
|
@@ -13162,82 +13232,326 @@ async function doctor(repoRoot, packageVersion) {
|
|
|
13162
13232
|
}
|
|
13163
13233
|
|
|
13164
13234
|
// src/state/manifest-store.js
|
|
13165
|
-
import { readFile as
|
|
13166
|
-
import {
|
|
13235
|
+
import { readFile as readFile3, readdir as readdir2, unlink as unlink2 } from "node:fs/promises";
|
|
13236
|
+
import { join as join4, resolve as resolve5 } from "node:path";
|
|
13237
|
+
|
|
13238
|
+
// src/state/git-common-dir.js
|
|
13167
13239
|
import { spawn as spawn2 } from "node:child_process";
|
|
13168
|
-
|
|
13169
|
-
|
|
13240
|
+
import { resolve as resolve3, join as join2 } from "node:path";
|
|
13241
|
+
var STATE_DIRNAME = "opencode-ship";
|
|
13242
|
+
async function resolveGitCommonDir(repoRoot) {
|
|
13243
|
+
if (typeof repoRoot !== "string" || repoRoot.length === 0) {
|
|
13244
|
+
throw new Error("resolveGitCommonDir: repoRoot must be a non-empty string");
|
|
13245
|
+
}
|
|
13246
|
+
return new Promise((resolveP, reject2) => {
|
|
13170
13247
|
const proc = spawn2(
|
|
13171
13248
|
"git",
|
|
13172
|
-
["rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
13173
|
-
{
|
|
13249
|
+
["-C", repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
13250
|
+
{ stdio: ["ignore", "pipe", "pipe"], shell: false }
|
|
13174
13251
|
);
|
|
13175
13252
|
let out = "";
|
|
13176
|
-
|
|
13177
|
-
proc.on("
|
|
13253
|
+
let err = "";
|
|
13254
|
+
proc.stdout.on("data", (d) => {
|
|
13255
|
+
out += d.toString("utf8");
|
|
13256
|
+
});
|
|
13257
|
+
proc.stderr.on("data", (d) => {
|
|
13258
|
+
err += d.toString("utf8");
|
|
13259
|
+
});
|
|
13260
|
+
proc.on("error", reject2);
|
|
13178
13261
|
proc.on("close", (code) => {
|
|
13179
13262
|
if (code !== 0) {
|
|
13180
|
-
|
|
13263
|
+
reject2(new Error(
|
|
13264
|
+
`git rev-parse --git-common-dir failed (exit ${code}): ${err.trim() || "unknown error"}`
|
|
13265
|
+
));
|
|
13181
13266
|
return;
|
|
13182
13267
|
}
|
|
13183
13268
|
const trimmed = out.trim();
|
|
13184
13269
|
if (!trimmed) {
|
|
13185
|
-
|
|
13270
|
+
reject2(new Error("git rev-parse --git-common-dir returned an empty path"));
|
|
13186
13271
|
return;
|
|
13187
13272
|
}
|
|
13188
|
-
|
|
13273
|
+
resolveP(resolve3(repoRoot, trimmed));
|
|
13189
13274
|
});
|
|
13190
13275
|
});
|
|
13191
13276
|
}
|
|
13192
|
-
function
|
|
13193
|
-
|
|
13277
|
+
function opencodeShipStateDir(commonDir, ...segments) {
|
|
13278
|
+
if (typeof commonDir !== "string" || commonDir.length === 0) {
|
|
13279
|
+
throw new Error("opencodeShipStateDir: commonDir must be a non-empty string");
|
|
13280
|
+
}
|
|
13281
|
+
return join2(commonDir, STATE_DIRNAME, ...segments);
|
|
13194
13282
|
}
|
|
13195
|
-
|
|
13196
|
-
|
|
13197
|
-
|
|
13198
|
-
|
|
13199
|
-
|
|
13200
|
-
|
|
13201
|
-
|
|
13202
|
-
|
|
13283
|
+
|
|
13284
|
+
// src/state/durable-store.js
|
|
13285
|
+
import {
|
|
13286
|
+
open as fsOpen,
|
|
13287
|
+
writeFile as writeFile2,
|
|
13288
|
+
readFile as readFile2,
|
|
13289
|
+
rename as rename2,
|
|
13290
|
+
link,
|
|
13291
|
+
mkdir as mkdir2,
|
|
13292
|
+
readdir,
|
|
13293
|
+
unlink,
|
|
13294
|
+
stat
|
|
13295
|
+
} from "node:fs/promises";
|
|
13296
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
13297
|
+
import { dirname as dirname2, join as join3, resolve as resolve4 } from "node:path";
|
|
13298
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
13299
|
+
import { hostname as osHostname } from "node:os";
|
|
13300
|
+
var STALE_LOCK_MS = 120 * 1e3;
|
|
13301
|
+
function randomToken() {
|
|
13302
|
+
return randomBytes(8).toString("hex");
|
|
13203
13303
|
}
|
|
13204
|
-
|
|
13205
|
-
|
|
13206
|
-
|
|
13304
|
+
function ensureString(value) {
|
|
13305
|
+
return JSON.stringify(value, null, 2) + "\n";
|
|
13306
|
+
}
|
|
13307
|
+
async function fsyncDir(path) {
|
|
13207
13308
|
try {
|
|
13208
|
-
const
|
|
13209
|
-
|
|
13309
|
+
const handle = await fsOpen(path, "r");
|
|
13310
|
+
try {
|
|
13311
|
+
await handle.sync();
|
|
13312
|
+
} finally {
|
|
13313
|
+
await handle.close();
|
|
13314
|
+
}
|
|
13210
13315
|
} catch {
|
|
13211
|
-
return null;
|
|
13212
13316
|
}
|
|
13213
13317
|
}
|
|
13214
|
-
async function
|
|
13215
|
-
|
|
13216
|
-
|
|
13217
|
-
|
|
13318
|
+
async function atomicReplaceJson(path, value) {
|
|
13319
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
13320
|
+
throw new Error("atomicReplaceJson: path must be a non-empty string");
|
|
13321
|
+
}
|
|
13322
|
+
const target = resolve4(path);
|
|
13323
|
+
const parent = dirname2(target);
|
|
13324
|
+
await mkdir2(parent, { recursive: true });
|
|
13325
|
+
const tmp = `${target}.${randomToken()}.tmp`;
|
|
13326
|
+
const handle = await fsOpen(tmp, "w", 384);
|
|
13327
|
+
try {
|
|
13328
|
+
await handle.writeFile(ensureString(value), "utf8");
|
|
13329
|
+
await handle.sync();
|
|
13330
|
+
} finally {
|
|
13331
|
+
await handle.close();
|
|
13332
|
+
}
|
|
13333
|
+
await rename2(tmp, target);
|
|
13334
|
+
await fsyncDir(parent);
|
|
13335
|
+
}
|
|
13336
|
+
async function publishImmutableJson(path, value) {
|
|
13337
|
+
if (typeof path !== "string" || path.length === 0) {
|
|
13338
|
+
throw new Error("publishImmutableJson: path must be a non-empty string");
|
|
13339
|
+
}
|
|
13340
|
+
const target = resolve4(path);
|
|
13341
|
+
const parent = dirname2(target);
|
|
13342
|
+
await mkdir2(parent, { recursive: true });
|
|
13343
|
+
if (existsSync2(target)) {
|
|
13344
|
+
throw new Error(`publishImmutableJson: target already exists: ${target}`);
|
|
13345
|
+
}
|
|
13346
|
+
const tmp = `${target}.${randomToken()}.immutable.tmp`;
|
|
13347
|
+
const handle = await fsOpen(tmp, "w", 384);
|
|
13348
|
+
try {
|
|
13349
|
+
await handle.writeFile(ensureString(value), "utf8");
|
|
13350
|
+
await handle.sync();
|
|
13351
|
+
} finally {
|
|
13352
|
+
await handle.close();
|
|
13353
|
+
}
|
|
13354
|
+
const attempt = await tryHardLink(tmp, target);
|
|
13355
|
+
if (attempt === "exists") {
|
|
13356
|
+
await unlink(tmp).catch(() => null);
|
|
13357
|
+
throw new Error(`publishImmutableJson: target already exists: ${target}`);
|
|
13358
|
+
}
|
|
13359
|
+
if (attempt === "linked") {
|
|
13360
|
+
await unlink(tmp).catch(() => null);
|
|
13361
|
+
} else {
|
|
13362
|
+
try {
|
|
13363
|
+
await rename2(tmp, target);
|
|
13364
|
+
} catch (err) {
|
|
13365
|
+
await unlink(tmp).catch(() => null);
|
|
13366
|
+
if (err && err.code === "EEXIST") {
|
|
13367
|
+
throw new Error(`publishImmutableJson: target already exists: ${target}`);
|
|
13368
|
+
}
|
|
13369
|
+
throw err;
|
|
13370
|
+
}
|
|
13371
|
+
}
|
|
13372
|
+
await fsyncDir(parent);
|
|
13373
|
+
}
|
|
13374
|
+
async function tryHardLink(src, dst) {
|
|
13375
|
+
try {
|
|
13376
|
+
await link(src, dst);
|
|
13377
|
+
return "linked";
|
|
13378
|
+
} catch (err) {
|
|
13379
|
+
if (err && err.code === "EEXIST") return "exists";
|
|
13380
|
+
if (err && (err.code === "ENOTSUP" || err.code === "EPERM" || err.code === "ENOSYS" || err.code === "EOPNOTSUPP" || err.code === "EXDEV")) {
|
|
13381
|
+
return "fallback";
|
|
13382
|
+
}
|
|
13383
|
+
throw err;
|
|
13384
|
+
}
|
|
13385
|
+
}
|
|
13386
|
+
async function withResourceLock(stateDir, resourceKey, input) {
|
|
13387
|
+
if (typeof stateDir !== "string" || stateDir.length === 0) {
|
|
13388
|
+
throw new Error("withResourceLock: stateDir must be a non-empty string");
|
|
13389
|
+
}
|
|
13390
|
+
if (typeof resourceKey !== "string" || resourceKey.length === 0) {
|
|
13391
|
+
throw new Error("withResourceLock: resourceKey must be a non-empty string");
|
|
13392
|
+
}
|
|
13393
|
+
const callback = typeof input === "function" ? input : input?.callback;
|
|
13394
|
+
const options = typeof input === "function" ? {} : input?.options ?? {};
|
|
13395
|
+
if (typeof callback !== "function") {
|
|
13396
|
+
throw new Error("withResourceLock: callback must be a function");
|
|
13397
|
+
}
|
|
13398
|
+
const acquireMode = options.acquire ?? "wait";
|
|
13399
|
+
const waitMs = Number.isInteger(options.waitMs) && options.waitMs >= 0 ? options.waitMs : 30 * 1e3;
|
|
13400
|
+
const pollMs = Number.isInteger(options.pollMs) && options.pollMs > 0 ? options.pollMs : 25;
|
|
13401
|
+
const keyHash = createHash2("sha256").update(resourceKey).digest("hex");
|
|
13402
|
+
const lockDir = join3(stateDir, "locks", keyHash);
|
|
13403
|
+
const ownerPath = join3(lockDir, "owner.json");
|
|
13404
|
+
await mkdir2(stateDir, { recursive: true });
|
|
13405
|
+
const deadline = Date.now() + waitMs;
|
|
13406
|
+
let acquired = false;
|
|
13407
|
+
let quarantinedThisAcquire = false;
|
|
13408
|
+
while (!acquired) {
|
|
13409
|
+
try {
|
|
13410
|
+
await mkdir2(lockDir, { recursive: true });
|
|
13411
|
+
const handle = await fsOpen(ownerPath, "wx", 384);
|
|
13412
|
+
try {
|
|
13413
|
+
const owner = {
|
|
13414
|
+
pid: process.pid,
|
|
13415
|
+
hostname: osHostname(),
|
|
13416
|
+
resource: resourceKey,
|
|
13417
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13418
|
+
};
|
|
13419
|
+
await handle.writeFile(JSON.stringify(owner, null, 2) + "\n", "utf8");
|
|
13420
|
+
await handle.sync();
|
|
13421
|
+
} finally {
|
|
13422
|
+
await handle.close();
|
|
13423
|
+
}
|
|
13424
|
+
acquired = true;
|
|
13425
|
+
} catch (err) {
|
|
13426
|
+
if (err && err.code === "EEXIST") {
|
|
13427
|
+
const claimed = await maybeQuarantineStaleLock(lockDir, ownerPath, resourceKey);
|
|
13428
|
+
if (claimed) {
|
|
13429
|
+
quarantinedThisAcquire = true;
|
|
13430
|
+
continue;
|
|
13431
|
+
}
|
|
13432
|
+
if (acquireMode === "try") {
|
|
13433
|
+
throw new Error(`withResourceLock: resource is busy: ${resourceKey}`);
|
|
13434
|
+
}
|
|
13435
|
+
if (Date.now() >= deadline) {
|
|
13436
|
+
throw new Error(`withResourceLock: timed out waiting for ${resourceKey}`);
|
|
13437
|
+
}
|
|
13438
|
+
await new Promise((r) => setTimeout(r, pollMs));
|
|
13439
|
+
continue;
|
|
13440
|
+
}
|
|
13441
|
+
throw err;
|
|
13442
|
+
}
|
|
13443
|
+
}
|
|
13444
|
+
try {
|
|
13445
|
+
return await callback();
|
|
13446
|
+
} finally {
|
|
13447
|
+
await unlink(ownerPath).catch(() => null);
|
|
13448
|
+
if (!quarantinedThisAcquire) {
|
|
13449
|
+
await unlink(lockDir).catch(() => null);
|
|
13450
|
+
}
|
|
13451
|
+
}
|
|
13452
|
+
}
|
|
13453
|
+
async function maybeQuarantineStaleLock(lockDir, ownerPath, resourceKey) {
|
|
13454
|
+
let raw;
|
|
13455
|
+
try {
|
|
13456
|
+
raw = await readFile2(ownerPath, "utf8");
|
|
13457
|
+
} catch {
|
|
13458
|
+
return false;
|
|
13459
|
+
}
|
|
13460
|
+
let owner;
|
|
13461
|
+
try {
|
|
13462
|
+
owner = JSON.parse(raw);
|
|
13463
|
+
} catch {
|
|
13464
|
+
return false;
|
|
13465
|
+
}
|
|
13466
|
+
if (!owner || typeof owner !== "object") return false;
|
|
13467
|
+
if (!Number.isInteger(owner.pid) || owner.pid <= 0) return false;
|
|
13468
|
+
if (typeof owner.startedAt !== "string") return false;
|
|
13469
|
+
const start = Date.parse(owner.startedAt);
|
|
13470
|
+
if (!Number.isFinite(start)) return false;
|
|
13471
|
+
if (Date.now() - start < STALE_LOCK_MS) return false;
|
|
13472
|
+
if (owner.hostname !== osHostname()) return false;
|
|
13473
|
+
let alive = true;
|
|
13474
|
+
try {
|
|
13475
|
+
process.kill(owner.pid, 0);
|
|
13476
|
+
} catch (e) {
|
|
13477
|
+
alive = e?.code === "EPERM";
|
|
13478
|
+
}
|
|
13479
|
+
if (alive) return false;
|
|
13480
|
+
const stamp = new Date(start).toISOString().replace(/[:.]/g, "-");
|
|
13481
|
+
const quarantinePath = join3(lockDir, `stale-${stamp}-${resourceKey.replace(/[^A-Za-z0-9._-]+/g, "_")}.owner.json`);
|
|
13482
|
+
try {
|
|
13483
|
+
await rename2(ownerPath, quarantinePath);
|
|
13484
|
+
} catch {
|
|
13485
|
+
return false;
|
|
13486
|
+
}
|
|
13487
|
+
return "quarantined";
|
|
13488
|
+
}
|
|
13489
|
+
|
|
13490
|
+
// src/state/manifest-store.js
|
|
13491
|
+
var SHIP_DIRNAME = "opencode-ship";
|
|
13492
|
+
var LEGACY_DIRNAME = "opencode-delivery";
|
|
13493
|
+
function canonicalManifestPath(commonDir, taskId) {
|
|
13494
|
+
return join4(commonDir, SHIP_DIRNAME, "delivery", "manifests", `${taskId}.json`);
|
|
13495
|
+
}
|
|
13496
|
+
function legacyManifestPath(commonDir, taskId) {
|
|
13497
|
+
return join4(commonDir, LEGACY_DIRNAME, "manifests", `${taskId}.json`);
|
|
13498
|
+
}
|
|
13499
|
+
async function commonDirFromRepoRoot(repoRoot) {
|
|
13500
|
+
return resolveGitCommonDir(repoRoot);
|
|
13501
|
+
}
|
|
13502
|
+
async function readJsonOrNull(path) {
|
|
13218
13503
|
try {
|
|
13219
|
-
|
|
13504
|
+
const raw = await readFile3(path, "utf8");
|
|
13505
|
+
return JSON.parse(raw);
|
|
13220
13506
|
} catch {
|
|
13221
|
-
return
|
|
13507
|
+
return null;
|
|
13222
13508
|
}
|
|
13509
|
+
}
|
|
13510
|
+
async function writeManifest(repoRoot, manifest) {
|
|
13511
|
+
const commonDir = await commonDirFromRepoRoot(repoRoot);
|
|
13512
|
+
const path = canonicalManifestPath(commonDir, manifest.taskId);
|
|
13513
|
+
await atomicReplaceJson(path, manifest);
|
|
13514
|
+
return resolve5(path);
|
|
13515
|
+
}
|
|
13516
|
+
async function readManifest(repoRoot, taskId) {
|
|
13517
|
+
const commonDir = await commonDirFromRepoRoot(repoRoot);
|
|
13518
|
+
const canonical = await readJsonOrNull(canonicalManifestPath(commonDir, taskId));
|
|
13519
|
+
if (canonical !== null) return canonical;
|
|
13520
|
+
const legacy = await readJsonOrNull(legacyManifestPath(commonDir, taskId));
|
|
13521
|
+
if (legacy !== null) return legacy;
|
|
13522
|
+
return null;
|
|
13523
|
+
}
|
|
13524
|
+
async function listManifests(repoRoot) {
|
|
13525
|
+
const commonDir = await commonDirFromRepoRoot(repoRoot);
|
|
13526
|
+
const canonicalDir = join4(commonDir, SHIP_DIRNAME, "delivery", "manifests");
|
|
13527
|
+
const legacyDir = join4(commonDir, LEGACY_DIRNAME, "manifests");
|
|
13223
13528
|
const out = [];
|
|
13224
|
-
|
|
13225
|
-
|
|
13529
|
+
const seen = /* @__PURE__ */ new Set();
|
|
13530
|
+
for (const dir of [canonicalDir, legacyDir]) {
|
|
13531
|
+
let names;
|
|
13226
13532
|
try {
|
|
13227
|
-
|
|
13228
|
-
out.push(JSON.parse(raw));
|
|
13533
|
+
names = await readdir2(dir);
|
|
13229
13534
|
} catch {
|
|
13535
|
+
continue;
|
|
13536
|
+
}
|
|
13537
|
+
for (const name of names) {
|
|
13538
|
+
if (!name.endsWith(".json")) continue;
|
|
13539
|
+
if (seen.has(name)) continue;
|
|
13540
|
+
const parsed = await readJsonOrNull(join4(dir, name));
|
|
13541
|
+
if (parsed !== null) {
|
|
13542
|
+
seen.add(name);
|
|
13543
|
+
out.push(parsed);
|
|
13544
|
+
}
|
|
13230
13545
|
}
|
|
13231
13546
|
}
|
|
13232
13547
|
return out;
|
|
13233
13548
|
}
|
|
13234
13549
|
async function deleteManifest(repoRoot, taskId) {
|
|
13235
|
-
const commonDir = await
|
|
13236
|
-
const
|
|
13237
|
-
|
|
13238
|
-
|
|
13239
|
-
|
|
13240
|
-
}
|
|
13550
|
+
const commonDir = await commonDirFromRepoRoot(repoRoot);
|
|
13551
|
+
const canonical = canonicalManifestPath(commonDir, taskId);
|
|
13552
|
+
const legacy = legacyManifestPath(commonDir, taskId);
|
|
13553
|
+
await unlink2(canonical).catch(() => null);
|
|
13554
|
+
await unlink2(legacy).catch(() => null);
|
|
13241
13555
|
}
|
|
13242
13556
|
|
|
13243
13557
|
// src/tools/delivery-inspect.js
|
|
@@ -13395,13 +13709,13 @@ function createIssueTool(deps) {
|
|
|
13395
13709
|
}
|
|
13396
13710
|
|
|
13397
13711
|
// src/tools/delivery-worktree.js
|
|
13398
|
-
import { resolve as
|
|
13712
|
+
import { resolve as resolve7 } from "node:path";
|
|
13399
13713
|
import { spawn as spawn3 } from "node:child_process";
|
|
13400
13714
|
|
|
13401
13715
|
// src/drivers/git.js
|
|
13402
13716
|
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
13403
|
-
import { resolve as
|
|
13404
|
-
import { existsSync as
|
|
13717
|
+
import { resolve as resolve6 } from "node:path";
|
|
13718
|
+
import { existsSync as existsSync3 } from "node:fs";
|
|
13405
13719
|
function runGit(args, cwd) {
|
|
13406
13720
|
return spawnSync2("git", args, {
|
|
13407
13721
|
cwd,
|
|
@@ -13440,13 +13754,13 @@ function isWorktreeClean(cwd) {
|
|
|
13440
13754
|
function isRebaseInProgress(cwd) {
|
|
13441
13755
|
const merge2 = runGit(["rev-parse", "--git-path", "rebase-merge"], cwd);
|
|
13442
13756
|
const apply = runGit(["rev-parse", "--git-path", "rebase-apply"], cwd);
|
|
13443
|
-
const mergeExists = merge2.status === 0 && safeExists(
|
|
13444
|
-
const applyExists = apply.status === 0 && safeExists(
|
|
13757
|
+
const mergeExists = merge2.status === 0 && safeExists(resolve6(cwd, merge2.stdout.trim()));
|
|
13758
|
+
const applyExists = apply.status === 0 && safeExists(resolve6(cwd, apply.stdout.trim()));
|
|
13445
13759
|
return mergeExists || applyExists;
|
|
13446
13760
|
}
|
|
13447
13761
|
function safeExists(p) {
|
|
13448
13762
|
try {
|
|
13449
|
-
return
|
|
13763
|
+
return existsSync3(p);
|
|
13450
13764
|
} catch {
|
|
13451
13765
|
return false;
|
|
13452
13766
|
}
|
|
@@ -13506,8 +13820,8 @@ function runBootstrap(args, cwd) {
|
|
|
13506
13820
|
});
|
|
13507
13821
|
}
|
|
13508
13822
|
function isPathContained(repoRoot, worktreeRoot, candidatePath) {
|
|
13509
|
-
const rootAbs =
|
|
13510
|
-
const normalized =
|
|
13823
|
+
const rootAbs = resolve7(repoRoot, worktreeRoot);
|
|
13824
|
+
const normalized = resolve7(candidatePath);
|
|
13511
13825
|
if (normalized !== rootAbs && !normalized.startsWith(rootAbs + "/")) {
|
|
13512
13826
|
return false;
|
|
13513
13827
|
}
|
|
@@ -13543,12 +13857,12 @@ function createWorktreeTool(deps) {
|
|
|
13543
13857
|
return { kind: "missing-input", field: "worktreeRelativePath" };
|
|
13544
13858
|
}
|
|
13545
13859
|
const worktreeRoot = deps.adapter?.worktree?.root ?? ".worktrees";
|
|
13546
|
-
const worktreePath =
|
|
13860
|
+
const worktreePath = resolve7(deps.repoRoot, input.worktreeRelativePath);
|
|
13547
13861
|
if (!isPathContained(deps.repoRoot, worktreeRoot, worktreePath)) {
|
|
13548
13862
|
return {
|
|
13549
13863
|
kind: "path-escape",
|
|
13550
13864
|
resolvedPath: worktreePath,
|
|
13551
|
-
expectedRoot:
|
|
13865
|
+
expectedRoot: resolve7(deps.repoRoot, worktreeRoot)
|
|
13552
13866
|
};
|
|
13553
13867
|
}
|
|
13554
13868
|
const remote = deps.remote ?? "origin";
|
|
@@ -13719,7 +14033,7 @@ function createVerifyTool(deps) {
|
|
|
13719
14033
|
],
|
|
13720
14034
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
13721
14035
|
};
|
|
13722
|
-
const
|
|
14036
|
+
const manifestPath = await writeManifest(deps.repoRoot, next);
|
|
13723
14037
|
return {
|
|
13724
14038
|
contractVersion: 1,
|
|
13725
14039
|
commandId: cmd.id,
|
|
@@ -13727,7 +14041,7 @@ function createVerifyTool(deps) {
|
|
|
13727
14041
|
stdoutTail,
|
|
13728
14042
|
stderrTail,
|
|
13729
14043
|
headSha: head,
|
|
13730
|
-
manifestPath
|
|
14044
|
+
manifestPath
|
|
13731
14045
|
};
|
|
13732
14046
|
};
|
|
13733
14047
|
}
|
|
@@ -14109,7 +14423,7 @@ function createMergeTool(deps) {
|
|
|
14109
14423
|
}
|
|
14110
14424
|
|
|
14111
14425
|
// src/tools/delivery-cleanup.js
|
|
14112
|
-
import { resolve as
|
|
14426
|
+
import { resolve as resolve8 } from "node:path";
|
|
14113
14427
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
14114
14428
|
function safeRemoveWorktree(repoRoot, path) {
|
|
14115
14429
|
const r = spawnSync3("git", ["worktree", "remove", path], {
|
|
@@ -14170,8 +14484,8 @@ function createCleanupTool(deps) {
|
|
|
14170
14484
|
return { kind: "manifest-state", state: m.state };
|
|
14171
14485
|
}
|
|
14172
14486
|
if (!m.worktreePath) return { kind: "missing-worktree-path" };
|
|
14173
|
-
const wtPath =
|
|
14174
|
-
const mainCwd =
|
|
14487
|
+
const wtPath = resolve8(m.worktreePath);
|
|
14488
|
+
const mainCwd = resolve8(deps.repoRoot);
|
|
14175
14489
|
if (wtPath === mainCwd) return { kind: "current-checkout", worktreePath: wtPath };
|
|
14176
14490
|
if (!isWorktreeClean(wtPath)) return { kind: "dirty-worktree" };
|
|
14177
14491
|
if (isRebaseInProgress(wtPath)) return { kind: "rebase-in-progress" };
|
|
@@ -14267,106 +14581,1615 @@ function createCleanupTool(deps) {
|
|
|
14267
14581
|
};
|
|
14268
14582
|
}
|
|
14269
14583
|
|
|
14270
|
-
// src/
|
|
14271
|
-
|
|
14272
|
-
|
|
14584
|
+
// src/tools/envelope.js
|
|
14585
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
14586
|
+
var CONTRACT_VERSION = 2;
|
|
14587
|
+
function operationId(prefix = "op") {
|
|
14588
|
+
return `${prefix}-${Date.now().toString(36)}-${randomBytes2(4).toString("hex")}`;
|
|
14589
|
+
}
|
|
14590
|
+
function success2(kind, data, options = {}) {
|
|
14591
|
+
if (typeof kind !== "string" || kind.length === 0) {
|
|
14592
|
+
throw new Error("envelope.success: kind must be a non-empty string");
|
|
14593
|
+
}
|
|
14594
|
+
return {
|
|
14595
|
+
contractVersion: CONTRACT_VERSION,
|
|
14596
|
+
ok: true,
|
|
14597
|
+
kind,
|
|
14598
|
+
operationId: options.operationId ?? operationId(kind),
|
|
14599
|
+
idempotent: options.idempotent !== false,
|
|
14600
|
+
data
|
|
14601
|
+
};
|
|
14602
|
+
}
|
|
14603
|
+
function failure(kind, message, options = {}) {
|
|
14604
|
+
if (typeof kind !== "string" || kind.length === 0) {
|
|
14605
|
+
throw new Error("envelope.failure: kind must be a non-empty string");
|
|
14606
|
+
}
|
|
14607
|
+
if (typeof message !== "string" || message.length === 0) {
|
|
14608
|
+
throw new Error("envelope.failure: message must be a non-empty string");
|
|
14609
|
+
}
|
|
14610
|
+
const details = options.details ?? {};
|
|
14611
|
+
return {
|
|
14612
|
+
contractVersion: CONTRACT_VERSION,
|
|
14613
|
+
ok: false,
|
|
14614
|
+
kind,
|
|
14615
|
+
operationId: options.operationId ?? operationId(`${kind}-err`),
|
|
14616
|
+
retryable: options.retryable === true,
|
|
14617
|
+
message,
|
|
14618
|
+
details
|
|
14619
|
+
};
|
|
14273
14620
|
}
|
|
14274
14621
|
|
|
14275
|
-
// src/
|
|
14276
|
-
import {
|
|
14277
|
-
|
|
14278
|
-
|
|
14279
|
-
|
|
14280
|
-
|
|
14281
|
-
|
|
14282
|
-
|
|
14622
|
+
// src/state/github-operation-store.js
|
|
14623
|
+
import { readFile as readFile4, writeFile as writeFile3, mkdir as mkdir3, readdir as readdir3, unlink as unlink3 } from "node:fs/promises";
|
|
14624
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
14625
|
+
import { join as join5, dirname as dirname3 } from "node:path";
|
|
14626
|
+
async function operationsDir(repoRoot) {
|
|
14627
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
14628
|
+
return join5(opencodeShipStateDir(common), "github", "operations");
|
|
14629
|
+
}
|
|
14630
|
+
var SAFE_ID_RE = /^[A-Za-z0-9._-]{1,128}$/;
|
|
14631
|
+
function operationPath(dir, operationId2) {
|
|
14632
|
+
if (!SAFE_ID_RE.test(operationId2)) {
|
|
14633
|
+
throw new Error(`invalid operationId: ${JSON.stringify(operationId2)}`);
|
|
14634
|
+
}
|
|
14635
|
+
return join5(dir, `${operationId2}.json`);
|
|
14636
|
+
}
|
|
14637
|
+
async function hasOperation(repoRoot, operationId2) {
|
|
14638
|
+
const dir = await operationsDir(repoRoot);
|
|
14639
|
+
return existsSync4(operationPath(dir, operationId2));
|
|
14640
|
+
}
|
|
14641
|
+
async function recordOperation(repoRoot, operationId2, record2) {
|
|
14642
|
+
if (typeof operationId2 !== "string" || operationId2.length === 0) {
|
|
14643
|
+
throw new Error("recordOperation: operationId must be a non-empty string");
|
|
14644
|
+
}
|
|
14645
|
+
if (!record2 || typeof record2 !== "object") {
|
|
14646
|
+
throw new Error("recordOperation: record must be an object");
|
|
14647
|
+
}
|
|
14648
|
+
const dir = await operationsDir(repoRoot);
|
|
14649
|
+
await mkdir3(dir, { recursive: true });
|
|
14650
|
+
const path = operationPath(dir, operationId2);
|
|
14651
|
+
if (existsSync4(path)) {
|
|
14652
|
+
return { recorded: false, path };
|
|
14653
|
+
}
|
|
14654
|
+
const fullRecord = {
|
|
14655
|
+
operationId: operationId2,
|
|
14656
|
+
kind: record2.kind,
|
|
14657
|
+
ok: record2.ok,
|
|
14658
|
+
payload: record2.payload ?? null,
|
|
14659
|
+
recordedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
14660
|
+
};
|
|
14661
|
+
await publishImmutableJson(path, fullRecord);
|
|
14662
|
+
return { recorded: true, path };
|
|
14283
14663
|
}
|
|
14284
14664
|
|
|
14285
|
-
// src/
|
|
14286
|
-
|
|
14287
|
-
|
|
14288
|
-
|
|
14665
|
+
// src/tools/delivery-github-read.js
|
|
14666
|
+
function createGithubReadTool(deps) {
|
|
14667
|
+
return async function githubRead(input) {
|
|
14668
|
+
const opId = input.operationId ?? `gh-read-${Date.now().toString(36)}`;
|
|
14669
|
+
const resource = String(input.resource ?? "");
|
|
14670
|
+
const repo = deps.repoSlug;
|
|
14671
|
+
const number4 = Number(input.number);
|
|
14672
|
+
if (!repo) return failure("github-read", "repo slug missing", { operationId: opId, retryable: false });
|
|
14673
|
+
if (!["issue", "pr", "checks"].includes(resource)) {
|
|
14674
|
+
return failure("github-read", `unknown resource: ${resource}`, { operationId: opId, retryable: false });
|
|
14675
|
+
}
|
|
14676
|
+
if (resource !== "checks" && (!Number.isInteger(number4) || number4 <= 0)) {
|
|
14677
|
+
return failure("github-read", "issue/PR number required", { operationId: opId, retryable: false });
|
|
14678
|
+
}
|
|
14679
|
+
if (await hasOperation(deps.repoRoot, opId)) {
|
|
14680
|
+
const prior = await deps.operationStore.readOperation(deps.repoRoot, opId).catch(() => null);
|
|
14681
|
+
if (prior && prior.ok) return success2("github-read", prior.payload, { operationId: opId, idempotent: true });
|
|
14682
|
+
}
|
|
14683
|
+
let argv;
|
|
14684
|
+
if (resource === "issue") {
|
|
14685
|
+
argv = ["gh", "issue", "view", String(number4), "--repo", repo, "--json", "number,title,state,body,url"];
|
|
14686
|
+
} else if (resource === "pr") {
|
|
14687
|
+
argv = ["gh", "pr", "view", String(number4), "--repo", repo, "--json", "number,url,state,headRefOid,isDraft"];
|
|
14688
|
+
} else {
|
|
14689
|
+
const sha = input.sha ? String(input.sha) : null;
|
|
14690
|
+
if (!sha) return failure("github-read", "sha required for checks", { operationId: opId, retryable: false });
|
|
14691
|
+
argv = ["gh", "pr", "checks", String(number4), "--repo", repo, "--json", "name,state,conclusion"];
|
|
14692
|
+
}
|
|
14693
|
+
const policy = validateGhArgv(argv);
|
|
14694
|
+
if (!policy.ok) return failure("github-read", policy.reason, { operationId: opId, retryable: false });
|
|
14695
|
+
try {
|
|
14696
|
+
const result = await deps.driver.runCommand(argv);
|
|
14697
|
+
if (result.status !== 0) {
|
|
14698
|
+
return failure("github-read", `exit ${result.status}: ${result.stderr}`, { operationId: opId, retryable: false });
|
|
14699
|
+
}
|
|
14700
|
+
const payload = JSON.parse(result.stdout || "{}");
|
|
14701
|
+
await recordOperation(deps.repoRoot, opId, { kind: "github-read", ok: true, payload });
|
|
14702
|
+
return success2("github-read", { resource, number: number4, payload }, { operationId: opId });
|
|
14703
|
+
} catch (err) {
|
|
14704
|
+
return failure("github-read", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
14705
|
+
}
|
|
14706
|
+
};
|
|
14707
|
+
}
|
|
14289
14708
|
|
|
14290
|
-
//
|
|
14291
|
-
|
|
14292
|
-
|
|
14293
|
-
|
|
14294
|
-
|
|
14295
|
-
|
|
14296
|
-
|
|
14297
|
-
|
|
14298
|
-
|
|
14299
|
-
|
|
14300
|
-
|
|
14301
|
-
|
|
14302
|
-
|
|
14303
|
-
|
|
14304
|
-
}
|
|
14305
|
-
|
|
14306
|
-
|
|
14307
|
-
|
|
14308
|
-
|
|
14309
|
-
|
|
14310
|
-
|
|
14311
|
-
|
|
14312
|
-
properties: {
|
|
14313
|
-
remote: { type: "string", minLength: 1 },
|
|
14314
|
-
repository: { type: "string", pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" },
|
|
14315
|
-
defaultBranch: { type: "string", minLength: 1 },
|
|
14316
|
-
packageManager: { enum: ["npm", "pnpm", "yarn", "bun"] },
|
|
14317
|
-
detectOverrides: { type: "boolean", description: "Permit detection to refresh previously persisted values." }
|
|
14709
|
+
// src/tools/delivery-issue-comment.js
|
|
14710
|
+
function createIssueCommentTool(deps) {
|
|
14711
|
+
return async function issueComment(input) {
|
|
14712
|
+
const opId = input.operationId ?? `issue-comment-${Date.now().toString(36)}`;
|
|
14713
|
+
const number4 = Number(input.number);
|
|
14714
|
+
const body = String(input.body ?? "");
|
|
14715
|
+
if (!Number.isInteger(number4) || number4 <= 0) {
|
|
14716
|
+
return failure("issue-comment", "issue number required", { operationId: opId, retryable: false });
|
|
14717
|
+
}
|
|
14718
|
+
if (body.length === 0) {
|
|
14719
|
+
return failure("issue-comment", "comment body required", { operationId: opId, retryable: false });
|
|
14720
|
+
}
|
|
14721
|
+
if (await hasOperation(deps.repoRoot, opId)) {
|
|
14722
|
+
return success2("issue-comment", { number: number4, idempotent: true }, { operationId: opId, idempotent: true });
|
|
14723
|
+
}
|
|
14724
|
+
const argv = ["gh", "issue", "comment", String(number4), "--repo", deps.repoSlug, "--body", body];
|
|
14725
|
+
const policy = validateGhArgv(argv);
|
|
14726
|
+
if (!policy.ok) return failure("issue-comment", policy.reason, { operationId: opId, retryable: false });
|
|
14727
|
+
try {
|
|
14728
|
+
const result = await deps.driver.runCommand(argv);
|
|
14729
|
+
if (result.status !== 0) {
|
|
14730
|
+
return failure("issue-comment", `exit ${result.status}: ${result.stderr}`, { operationId: opId, retryable: false });
|
|
14318
14731
|
}
|
|
14319
|
-
|
|
14320
|
-
|
|
14321
|
-
|
|
14322
|
-
|
|
14323
|
-
|
|
14324
|
-
|
|
14325
|
-
|
|
14326
|
-
|
|
14327
|
-
|
|
14328
|
-
|
|
14329
|
-
|
|
14330
|
-
|
|
14331
|
-
|
|
14332
|
-
|
|
14333
|
-
|
|
14334
|
-
|
|
14335
|
-
|
|
14336
|
-
|
|
14337
|
-
|
|
14338
|
-
|
|
14339
|
-
|
|
14340
|
-
|
|
14341
|
-
|
|
14342
|
-
|
|
14343
|
-
|
|
14344
|
-
|
|
14345
|
-
|
|
14346
|
-
|
|
14347
|
-
|
|
14348
|
-
|
|
14349
|
-
|
|
14350
|
-
|
|
14351
|
-
|
|
14352
|
-
|
|
14353
|
-
|
|
14354
|
-
|
|
14355
|
-
|
|
14356
|
-
|
|
14357
|
-
|
|
14358
|
-
|
|
14359
|
-
|
|
14360
|
-
|
|
14361
|
-
|
|
14362
|
-
|
|
14363
|
-
|
|
14364
|
-
|
|
14365
|
-
|
|
14366
|
-
|
|
14367
|
-
|
|
14368
|
-
|
|
14369
|
-
|
|
14732
|
+
await recordOperation(deps.repoRoot, opId, { kind: "issue-comment", ok: true, payload: { number: number4 } });
|
|
14733
|
+
return success2("issue-comment", { number: number4 }, { operationId: opId });
|
|
14734
|
+
} catch (err) {
|
|
14735
|
+
return failure("issue-comment", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
14736
|
+
}
|
|
14737
|
+
};
|
|
14738
|
+
}
|
|
14739
|
+
|
|
14740
|
+
// src/tools/delivery-issue-labels.js
|
|
14741
|
+
function createIssueLabelsTool(deps) {
|
|
14742
|
+
return async function issueLabels(input) {
|
|
14743
|
+
const opId = input.operationId ?? `issue-labels-${Date.now().toString(36)}`;
|
|
14744
|
+
const number4 = Number(input.number);
|
|
14745
|
+
const add = Array.isArray(input.add) ? input.add : [];
|
|
14746
|
+
const remove = Array.isArray(input.remove) ? input.remove : [];
|
|
14747
|
+
if (!Number.isInteger(number4) || number4 <= 0) {
|
|
14748
|
+
return failure("issue-labels", "issue number required", { operationId: opId, retryable: false });
|
|
14749
|
+
}
|
|
14750
|
+
if (add.length === 0 && remove.length === 0) {
|
|
14751
|
+
return failure("issue-labels", "add or remove list required", { operationId: opId, retryable: false });
|
|
14752
|
+
}
|
|
14753
|
+
if (await hasOperation(deps.repoRoot, opId)) {
|
|
14754
|
+
return success2("issue-labels", { number: number4, idempotent: true }, { operationId: opId, idempotent: true });
|
|
14755
|
+
}
|
|
14756
|
+
const attempted = [];
|
|
14757
|
+
for (const label of add) {
|
|
14758
|
+
const argv = ["gh", "issue", "edit", String(number4), "--repo", deps.repoSlug, "--add-label", label];
|
|
14759
|
+
const policy = validateGhArgv(argv);
|
|
14760
|
+
if (!policy.ok) return failure("issue-labels", policy.reason, { operationId: opId, retryable: false });
|
|
14761
|
+
attempted.push({ action: "add", label });
|
|
14762
|
+
}
|
|
14763
|
+
for (const label of remove) {
|
|
14764
|
+
const argv = ["gh", "issue", "edit", String(number4), "--repo", deps.repoSlug, "--remove-label", label];
|
|
14765
|
+
const policy = validateGhArgv(argv);
|
|
14766
|
+
if (!policy.ok) return failure("issue-labels", policy.reason, { operationId: opId, retryable: false });
|
|
14767
|
+
attempted.push({ action: "remove", label });
|
|
14768
|
+
}
|
|
14769
|
+
try {
|
|
14770
|
+
for (const { action, label } of attempted) {
|
|
14771
|
+
const flag = action === "add" ? "--add-label" : "--remove-label";
|
|
14772
|
+
const result = await deps.driver.runCommand(["gh", "issue", "edit", String(number4), "--repo", deps.repoSlug, flag, label]);
|
|
14773
|
+
if (result.status !== 0) {
|
|
14774
|
+
return failure("issue-labels", `${action} ${label} failed: ${result.stderr}`, { operationId: opId, retryable: false });
|
|
14775
|
+
}
|
|
14776
|
+
}
|
|
14777
|
+
await recordOperation(deps.repoRoot, opId, { kind: "issue-labels", ok: true, payload: { number: number4, add, remove } });
|
|
14778
|
+
return success2("issue-labels", { number: number4, add, remove }, { operationId: opId });
|
|
14779
|
+
} catch (err) {
|
|
14780
|
+
return failure("issue-labels", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
14781
|
+
}
|
|
14782
|
+
};
|
|
14783
|
+
}
|
|
14784
|
+
|
|
14785
|
+
// src/tools/delivery-issue-link.js
|
|
14786
|
+
var RELATIONSHIPS = /* @__PURE__ */ new Set(["blocks", "is-blocked-by", "closes", "is-closed-by", "related"]);
|
|
14787
|
+
function createIssueLinkTool(deps) {
|
|
14788
|
+
return async function issueLink(input) {
|
|
14789
|
+
const opId = input.operationId ?? `issue-link-${Date.now().toString(36)}`;
|
|
14790
|
+
const from = Number(input.from);
|
|
14791
|
+
const to = Number(input.to);
|
|
14792
|
+
const relationship = String(input.relationship ?? "");
|
|
14793
|
+
if (!Number.isInteger(from) || from <= 0) return failure("issue-link", "from number required", { operationId: opId, retryable: false });
|
|
14794
|
+
if (!Number.isInteger(to) || to <= 0) return failure("issue-link", "to number required", { operationId: opId, retryable: false });
|
|
14795
|
+
if (!RELATIONSHIPS.has(relationship)) return failure("issue-link", `unknown relationship: ${relationship}`, { operationId: opId, retryable: false });
|
|
14796
|
+
if (await hasOperation(deps.repoRoot, opId)) {
|
|
14797
|
+
return success2("issue-link", { from, to, relationship, idempotent: true }, { operationId: opId, idempotent: true });
|
|
14798
|
+
}
|
|
14799
|
+
const body = `<!-- opencode-ship-link:${relationship} from=${from} to=${to} -->`;
|
|
14800
|
+
const argv = ["gh", "issue", "comment", String(from), "--repo", deps.repoSlug, "--body", body];
|
|
14801
|
+
const policy = validateGhArgv(argv);
|
|
14802
|
+
if (!policy.ok) return failure("issue-link", policy.reason, { operationId: opId, retryable: false });
|
|
14803
|
+
try {
|
|
14804
|
+
const result = await deps.driver.runCommand(argv);
|
|
14805
|
+
if (result.status !== 0) {
|
|
14806
|
+
return failure("issue-link", `exit ${result.status}: ${result.stderr}`, { operationId: opId, retryable: false });
|
|
14807
|
+
}
|
|
14808
|
+
await recordOperation(deps.repoRoot, opId, { kind: "issue-link", ok: true, payload: { from, to, relationship } });
|
|
14809
|
+
return success2("issue-link", { from, to, relationship }, { operationId: opId });
|
|
14810
|
+
} catch (err) {
|
|
14811
|
+
return failure("issue-link", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
14812
|
+
}
|
|
14813
|
+
};
|
|
14814
|
+
}
|
|
14815
|
+
|
|
14816
|
+
// src/tools/delivery-issue-close.js
|
|
14817
|
+
function createIssueCloseTool(deps) {
|
|
14818
|
+
return async function issueClose(input) {
|
|
14819
|
+
const opId = input.operationId ?? `issue-close-${Date.now().toString(36)}`;
|
|
14820
|
+
const number4 = Number(input.number);
|
|
14821
|
+
const subject = String(input.subject ?? "");
|
|
14822
|
+
if (!Number.isInteger(number4) || number4 <= 0) {
|
|
14823
|
+
return failure("issue-close", "issue number required", { operationId: opId, retryable: false });
|
|
14824
|
+
}
|
|
14825
|
+
if (subject.length === 0) {
|
|
14826
|
+
return failure("issue-close", "user permission subject required", { operationId: opId, retryable: false });
|
|
14827
|
+
}
|
|
14828
|
+
if (await hasOperation(deps.repoRoot, opId)) {
|
|
14829
|
+
return success2("issue-close", { number: number4, idempotent: true }, { operationId: opId, idempotent: true });
|
|
14830
|
+
}
|
|
14831
|
+
const argv = ["gh", "issue", "close", String(number4), "--repo", deps.repoSlug, "--comment", `closed via Ship (subject=${subject})`];
|
|
14832
|
+
const policy = validateGhArgv(argv);
|
|
14833
|
+
if (!policy.ok) return failure("issue-close", policy.reason, { operationId: opId, retryable: false });
|
|
14834
|
+
try {
|
|
14835
|
+
const result = await deps.driver.runCommand(argv);
|
|
14836
|
+
if (result.status !== 0) {
|
|
14837
|
+
return failure("issue-close", `exit ${result.status}: ${result.stderr}`, { operationId: opId, retryable: false });
|
|
14838
|
+
}
|
|
14839
|
+
await recordOperation(deps.repoRoot, opId, { kind: "issue-close", ok: true, payload: { number: number4, subject } });
|
|
14840
|
+
return success2("issue-close", { number: number4, subject }, { operationId: opId });
|
|
14841
|
+
} catch (err) {
|
|
14842
|
+
return failure("issue-close", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
14843
|
+
}
|
|
14844
|
+
};
|
|
14845
|
+
}
|
|
14846
|
+
|
|
14847
|
+
// src/tools/delivery-sync.js
|
|
14848
|
+
function createSyncTool(deps) {
|
|
14849
|
+
return async function sync(input) {
|
|
14850
|
+
const opId = input.operationId ?? `sync-${Date.now().toString(36)}`;
|
|
14851
|
+
const base = String(input.base ?? "");
|
|
14852
|
+
const branch = String(input.branch ?? "");
|
|
14853
|
+
if (!base || !branch) {
|
|
14854
|
+
return failure("sync", "base and branch required", { operationId: opId, retryable: false });
|
|
14855
|
+
}
|
|
14856
|
+
if (await hasOperation(deps.repoRoot, opId)) {
|
|
14857
|
+
return success2("sync", { base, branch, idempotent: true }, { operationId: opId, idempotent: true });
|
|
14858
|
+
}
|
|
14859
|
+
const argv = ["gh", "pr", "view", "--repo", deps.repoSlug, "--json", "headRefName,baseRefName"];
|
|
14860
|
+
const policy = validateGhArgv(argv);
|
|
14861
|
+
if (!policy.ok) return failure("sync", policy.reason, { operationId: opId, retryable: false });
|
|
14862
|
+
try {
|
|
14863
|
+
const fetchResult = await deps.driver.runCommand(["git", "fetch", "origin", base]);
|
|
14864
|
+
if (fetchResult.status !== 0) {
|
|
14865
|
+
return failure("sync", `fetch failed: ${fetchResult.stderr}`, { operationId: opId, retryable: true });
|
|
14866
|
+
}
|
|
14867
|
+
const mergeResult = await deps.driver.runCommand(["git", "merge", "--no-ff", `origin/${base}`]);
|
|
14868
|
+
if (mergeResult.status !== 0) {
|
|
14869
|
+
return failure("sync", `merge failed: ${mergeResult.stderr}`, { operationId: opId, retryable: true });
|
|
14870
|
+
}
|
|
14871
|
+
const head = await deps.driver.runCommand(["git", "rev-parse", "HEAD"]);
|
|
14872
|
+
const headSha = head.stdout.trim();
|
|
14873
|
+
await recordOperation(deps.repoRoot, opId, { kind: "sync", ok: true, payload: { base, branch, headSha } });
|
|
14874
|
+
return success2("sync", { base, branch, headSha }, { operationId: opId });
|
|
14875
|
+
} catch (err) {
|
|
14876
|
+
return failure("sync", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
14877
|
+
}
|
|
14878
|
+
};
|
|
14879
|
+
}
|
|
14880
|
+
|
|
14881
|
+
// src/tools/delivery-publish.js
|
|
14882
|
+
var FORBIDDEN_FLAGS2 = /* @__PURE__ */ new Set(["--force", "-f", "--force-with-lease"]);
|
|
14883
|
+
function createPublishTool(deps) {
|
|
14884
|
+
return async function publish(input) {
|
|
14885
|
+
const opId = input.operationId ?? `publish-${Date.now().toString(36)}`;
|
|
14886
|
+
const taskId = String(input.taskId ?? "");
|
|
14887
|
+
const expectedHead = String(input.expectedHead ?? "");
|
|
14888
|
+
if (!taskId) return failure("publish", "taskId required", { operationId: opId, retryable: false });
|
|
14889
|
+
if (!expectedHead) return failure("publish", "expectedHead required", { operationId: opId, retryable: false });
|
|
14890
|
+
if (await hasOperation(deps.repoRoot, opId)) {
|
|
14891
|
+
return success2("publish", { taskId, idempotent: true }, { operationId: opId, idempotent: true });
|
|
14892
|
+
}
|
|
14893
|
+
const manifest = await readManifest(deps.repoRoot, taskId);
|
|
14894
|
+
if (!manifest) return failure("publish", "no manifest for taskId", { operationId: opId, retryable: false });
|
|
14895
|
+
const branch = manifest.branch;
|
|
14896
|
+
if (!branch) return failure("publish", "manifest has no branch", { operationId: opId, retryable: false });
|
|
14897
|
+
const argv = ["git", "push", "origin", `HEAD:refs/heads/${branch}`];
|
|
14898
|
+
for (const arg of argv.slice(1)) {
|
|
14899
|
+
if (FORBIDDEN_FLAGS2.has(arg)) {
|
|
14900
|
+
return failure("publish", `forbidden push flag: ${arg}`, { operationId: opId, retryable: false });
|
|
14901
|
+
}
|
|
14902
|
+
}
|
|
14903
|
+
try {
|
|
14904
|
+
const head = await deps.driver.runCommand(["git", "rev-parse", "HEAD"]);
|
|
14905
|
+
const currentHead2 = head.stdout.trim();
|
|
14906
|
+
if (currentHead2 !== expectedHead) {
|
|
14907
|
+
return failure("publish", `HEAD drift (expected ${expectedHead.slice(0, 8)}, got ${currentHead2.slice(0, 8)})`, { operationId: opId, retryable: false });
|
|
14908
|
+
}
|
|
14909
|
+
const result = await deps.driver.runCommand(argv);
|
|
14910
|
+
if (result.status !== 0) {
|
|
14911
|
+
return failure("publish", `push failed: ${result.stderr}`, { operationId: opId, retryable: false });
|
|
14912
|
+
}
|
|
14913
|
+
await recordOperation(deps.repoRoot, opId, { kind: "publish", ok: true, payload: { taskId, branch, headSha: currentHead2 } });
|
|
14914
|
+
return success2("publish", { taskId, branch, headSha: currentHead2 }, { operationId: opId });
|
|
14915
|
+
} catch (err) {
|
|
14916
|
+
return failure("publish", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
14917
|
+
}
|
|
14918
|
+
};
|
|
14919
|
+
}
|
|
14920
|
+
|
|
14921
|
+
// src/installer/engineering-config.js
|
|
14922
|
+
init_profile();
|
|
14923
|
+
var MODEL_ID_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
|
|
14924
|
+
var DEFAULTS = Object.freeze({
|
|
14925
|
+
planner: "openai/gpt-5.6-sol",
|
|
14926
|
+
builder: "minimax/MiniMax-M3",
|
|
14927
|
+
finalReviewer: "openai/gpt-5.6-sol"
|
|
14928
|
+
});
|
|
14929
|
+
function resolveModelRoles(cfg, { strict = false } = {}) {
|
|
14930
|
+
const REQUIRED = ["planner", "builder", "finalReviewer"];
|
|
14931
|
+
if (strict) {
|
|
14932
|
+
const issues = [];
|
|
14933
|
+
for (const role of REQUIRED) {
|
|
14934
|
+
const id = cfg?.models?.[role];
|
|
14935
|
+
if (typeof id !== "string" || id.length === 0 || !MODEL_ID_RE.test(id)) {
|
|
14936
|
+
issues.push(role);
|
|
14937
|
+
}
|
|
14938
|
+
}
|
|
14939
|
+
if (issues.length > 0) {
|
|
14940
|
+
throw new Error(`resolveModelRoles: required role(s) missing or invalid: ${issues.join(", ")}`);
|
|
14941
|
+
}
|
|
14942
|
+
return { planner: cfg.models.planner, builder: cfg.models.builder, finalReviewer: cfg.models.finalReviewer };
|
|
14943
|
+
}
|
|
14944
|
+
const out = { ...DEFAULTS };
|
|
14945
|
+
if (cfg && cfg.models) {
|
|
14946
|
+
for (const [role, id] of Object.entries(cfg.models)) {
|
|
14947
|
+
if (id && typeof id === "string" && id.length > 0) {
|
|
14948
|
+
out[role] = id;
|
|
14949
|
+
} else if (strict && Object.prototype.hasOwnProperty.call(cfg.models, role)) {
|
|
14950
|
+
throw new Error(`resolveModelRoles: user provided empty model id for '${role}'`);
|
|
14951
|
+
}
|
|
14952
|
+
}
|
|
14953
|
+
}
|
|
14954
|
+
for (const role of REQUIRED) {
|
|
14955
|
+
if (!out[role]) {
|
|
14956
|
+
throw new Error(`resolveModelRoles: required role '${role}' missing and no default available`);
|
|
14957
|
+
}
|
|
14958
|
+
}
|
|
14959
|
+
return out;
|
|
14960
|
+
}
|
|
14961
|
+
|
|
14962
|
+
// src/tools/ship-plan-start.js
|
|
14963
|
+
import { mkdir as mkdir4, writeFile as writeFile4 } from "node:fs/promises";
|
|
14964
|
+
import { join as join6 } from "node:path";
|
|
14965
|
+
function normalizeWorkflowId(issueNumber) {
|
|
14966
|
+
return `wf-${issueNumber}`;
|
|
14967
|
+
}
|
|
14968
|
+
function createPlanStartTool(deps) {
|
|
14969
|
+
return async function planStart(input) {
|
|
14970
|
+
const opId = input.operationId ?? `plan-start-${Date.now().toString(36)}`;
|
|
14971
|
+
const issueNumber = Number(input.issueNumber);
|
|
14972
|
+
if (!Number.isInteger(issueNumber) || issueNumber <= 0) {
|
|
14973
|
+
return failure("plan-start", "issueNumber required", { operationId: opId, retryable: false });
|
|
14974
|
+
}
|
|
14975
|
+
let models;
|
|
14976
|
+
try {
|
|
14977
|
+
models = resolveModelRoles(deps.config?.workflow, { strict: true });
|
|
14978
|
+
} catch (err) {
|
|
14979
|
+
return failure("plan-start", `model roles unresolved: ${err?.message ?? err}`, { operationId: opId, retryable: false });
|
|
14980
|
+
}
|
|
14981
|
+
const workflowId = normalizeWorkflowId(issueNumber);
|
|
14982
|
+
const repoRoot = deps.repoRoot;
|
|
14983
|
+
try {
|
|
14984
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
14985
|
+
const wfDir = join6(opencodeShipStateDir(commonDir), "plans", workflowId);
|
|
14986
|
+
await mkdir4(wfDir, { recursive: true });
|
|
14987
|
+
const indexRecord = {
|
|
14988
|
+
workflowId,
|
|
14989
|
+
issueNumber,
|
|
14990
|
+
owner: deps.owner,
|
|
14991
|
+
planner: models.planner,
|
|
14992
|
+
builder: models.builder,
|
|
14993
|
+
finalReviewer: models.finalReviewer,
|
|
14994
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
14995
|
+
state: "drafting"
|
|
14996
|
+
};
|
|
14997
|
+
await writeFile4(join6(wfDir, "index.json"), JSON.stringify(indexRecord, null, 2), "utf8");
|
|
14998
|
+
return success2("plan-start", { workflowId, issueNumber, models: { planner: models.planner, builder: models.builder, finalReviewer: models.finalReviewer } }, { operationId: opId });
|
|
14999
|
+
} catch (err) {
|
|
15000
|
+
return failure("plan-start", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
15001
|
+
}
|
|
15002
|
+
};
|
|
15003
|
+
}
|
|
15004
|
+
|
|
15005
|
+
// src/workflow/plan.js
|
|
15006
|
+
init_json_pointer();
|
|
15007
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
15008
|
+
function isPlainObject2(v) {
|
|
15009
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
15010
|
+
}
|
|
15011
|
+
function expectString(obj, key, issues) {
|
|
15012
|
+
const v = obj?.[key];
|
|
15013
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
15014
|
+
issues.push(`field ${key} must be a non-empty string`);
|
|
15015
|
+
return null;
|
|
15016
|
+
}
|
|
15017
|
+
return v;
|
|
15018
|
+
}
|
|
15019
|
+
function isAbsoluteOrParentPath(path) {
|
|
15020
|
+
if (typeof path !== "string") return true;
|
|
15021
|
+
if (path.startsWith("/") || path.startsWith("\\")) return true;
|
|
15022
|
+
if (path === ".." || path.startsWith("../") || path.startsWith("..\\")) return true;
|
|
15023
|
+
return false;
|
|
15024
|
+
}
|
|
15025
|
+
function isGitPath(path) {
|
|
15026
|
+
return typeof path === "string" && (path === ".git" || path === "/.git" || path.startsWith(".git/") || path.startsWith(".git\\") || path.startsWith("/.git/") || path.startsWith("/.git\\"));
|
|
15027
|
+
}
|
|
15028
|
+
function looksLikeShellCommand(s) {
|
|
15029
|
+
if (typeof s !== "string") return false;
|
|
15030
|
+
if (s.length > 256) return false;
|
|
15031
|
+
if (/^[/~]/.test(s)) return true;
|
|
15032
|
+
if (/[|&;()<>`$]/.test(s)) return true;
|
|
15033
|
+
if (/\b(?:rm|chmod|chown|curl|wget|sudo|bash|sh)\b/.test(s)) return true;
|
|
15034
|
+
return false;
|
|
15035
|
+
}
|
|
15036
|
+
function validatePlanV2(raw) {
|
|
15037
|
+
const issues = [];
|
|
15038
|
+
if (!isPlainObject2(raw)) {
|
|
15039
|
+
return { ok: false, kind: "shape", issues: ["plan root must be an object"] };
|
|
15040
|
+
}
|
|
15041
|
+
const plan = (
|
|
15042
|
+
/** @type {any} */
|
|
15043
|
+
raw
|
|
15044
|
+
);
|
|
15045
|
+
if (plan.schemaVersion !== 2) {
|
|
15046
|
+
issues.push(`unsupported schemaVersion: ${JSON.stringify(plan.schemaVersion)} (expected 2)`);
|
|
15047
|
+
}
|
|
15048
|
+
expectString(plan, "workflowId", issues);
|
|
15049
|
+
if (typeof plan.revision !== "number" || !Number.isInteger(plan.revision) || plan.revision < 1) {
|
|
15050
|
+
issues.push("revision must be a positive integer");
|
|
15051
|
+
}
|
|
15052
|
+
if (plan.supersedes !== null && plan.supersedes !== void 0) {
|
|
15053
|
+
if (!isPlainObject2(plan.supersedes)) {
|
|
15054
|
+
issues.push("supersedes must be null or an object");
|
|
15055
|
+
} else {
|
|
15056
|
+
expectString(plan.supersedes, "revision", issues);
|
|
15057
|
+
expectString(plan.supersedes, "sha256", issues);
|
|
15058
|
+
}
|
|
15059
|
+
}
|
|
15060
|
+
if (!isPlainObject2(plan.authoredBy)) {
|
|
15061
|
+
issues.push("authoredBy must be an object");
|
|
15062
|
+
} else {
|
|
15063
|
+
expectString(plan.authoredBy, "sessionID", issues);
|
|
15064
|
+
const model = plan.authoredBy.model;
|
|
15065
|
+
if (typeof model !== "string" || !/^[^/]+\/[^/]+$/.test(model)) {
|
|
15066
|
+
issues.push("authoredBy.model must be a <provider>/<model> id");
|
|
15067
|
+
}
|
|
15068
|
+
expectString(plan.authoredBy, "createdAt", issues);
|
|
15069
|
+
}
|
|
15070
|
+
if (!isPlainObject2(plan.source)) {
|
|
15071
|
+
issues.push("source must be an object");
|
|
15072
|
+
} else {
|
|
15073
|
+
expectString(plan.source, "repository", issues);
|
|
15074
|
+
if (typeof plan.source.issueNumber !== "number" || !Number.isInteger(plan.source.issueNumber) || plan.source.issueNumber < 1) {
|
|
15075
|
+
issues.push("source.issueNumber must be a positive integer");
|
|
15076
|
+
}
|
|
15077
|
+
expectString(plan.source, "issueUrl", issues);
|
|
15078
|
+
expectString(plan.source, "baseBranch", issues);
|
|
15079
|
+
if (typeof plan.source.baseSha !== "string" || !/^[0-9a-f]{40}$/.test(plan.source.baseSha)) {
|
|
15080
|
+
issues.push("source.baseSha must be a 40-char commit SHA");
|
|
15081
|
+
}
|
|
15082
|
+
}
|
|
15083
|
+
if (typeof plan.goal !== "string" || plan.goal.length < 8) {
|
|
15084
|
+
issues.push("goal must be a non-trivial string");
|
|
15085
|
+
}
|
|
15086
|
+
if (!isPlainObject2(plan.architecture)) {
|
|
15087
|
+
issues.push("architecture must be an object");
|
|
15088
|
+
} else {
|
|
15089
|
+
expectString(plan.architecture, "summary", issues);
|
|
15090
|
+
if (!Array.isArray(plan.architecture.decisions)) {
|
|
15091
|
+
issues.push("architecture.decisions must be an array");
|
|
15092
|
+
}
|
|
15093
|
+
}
|
|
15094
|
+
if (!Array.isArray(plan.constraints)) {
|
|
15095
|
+
issues.push("constraints must be an array");
|
|
15096
|
+
}
|
|
15097
|
+
if (!Array.isArray(plan.files)) {
|
|
15098
|
+
issues.push("files must be an array");
|
|
15099
|
+
} else {
|
|
15100
|
+
for (const f of plan.files) {
|
|
15101
|
+
if (!isPlainObject2(f)) {
|
|
15102
|
+
issues.push("file entry is not an object");
|
|
15103
|
+
continue;
|
|
15104
|
+
}
|
|
15105
|
+
if (!["create", "modify", "delete"].includes(f.action)) {
|
|
15106
|
+
issues.push(`file action must be one of create|modify|delete, got ${JSON.stringify(f.action)}`);
|
|
15107
|
+
}
|
|
15108
|
+
if (isAbsoluteOrParentPath(f.path)) {
|
|
15109
|
+
issues.push(`file path must be a relative package path, got ${JSON.stringify(f.path)}`);
|
|
15110
|
+
}
|
|
15111
|
+
}
|
|
15112
|
+
}
|
|
15113
|
+
if (!Array.isArray(plan.tasks)) {
|
|
15114
|
+
issues.push("tasks must be an array");
|
|
15115
|
+
return finalize(plan, issues);
|
|
15116
|
+
}
|
|
15117
|
+
if (plan.tasks.length === 0) {
|
|
15118
|
+
issues.push("tasks must contain at least one task");
|
|
15119
|
+
}
|
|
15120
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
15121
|
+
for (const t of plan.tasks) {
|
|
15122
|
+
validateTask(t, issues, seenIds, plan);
|
|
15123
|
+
}
|
|
15124
|
+
return finalize(plan, issues);
|
|
15125
|
+
}
|
|
15126
|
+
function finalize(raw, issues) {
|
|
15127
|
+
if (Array.isArray(raw.tasks)) {
|
|
15128
|
+
const ids = new Set(raw.tasks.map((t) => t.id));
|
|
15129
|
+
for (const t of raw.tasks) {
|
|
15130
|
+
if (!Array.isArray(t.dependsOn)) continue;
|
|
15131
|
+
for (const dep of t.dependsOn) {
|
|
15132
|
+
if (!ids.has(dep)) {
|
|
15133
|
+
issues.push(`task ${t.id} depends on unknown task ${dep}`);
|
|
15134
|
+
}
|
|
15135
|
+
}
|
|
15136
|
+
}
|
|
15137
|
+
}
|
|
15138
|
+
if (Array.isArray(raw.tasks) && Array.isArray(raw.files)) {
|
|
15139
|
+
const declared = new Set(raw.files.map((f) => f.path));
|
|
15140
|
+
for (const t of raw.tasks) {
|
|
15141
|
+
for (const c of t.changes ?? []) {
|
|
15142
|
+
if (!declared.has(c.path)) {
|
|
15143
|
+
issues.push(`task ${t.id} changes undeclared file: ${c.path}`);
|
|
15144
|
+
}
|
|
15145
|
+
}
|
|
15146
|
+
}
|
|
15147
|
+
}
|
|
15148
|
+
return {
|
|
15149
|
+
ok: issues.length === 0,
|
|
15150
|
+
kind: issues.length === 0 ? "ok" : "shape",
|
|
15151
|
+
issues
|
|
15152
|
+
};
|
|
15153
|
+
}
|
|
15154
|
+
function validateTask(t, issues, seenIds, plan) {
|
|
15155
|
+
if (!isPlainObject2(t)) {
|
|
15156
|
+
issues.push("task entry is not an object");
|
|
15157
|
+
return;
|
|
15158
|
+
}
|
|
15159
|
+
if (typeof t.id !== "string" || t.id.length === 0) {
|
|
15160
|
+
issues.push("task id must be a non-empty string");
|
|
15161
|
+
} else if (seenIds.has(t.id)) {
|
|
15162
|
+
issues.push(`duplicate task id: ${t.id}`);
|
|
15163
|
+
} else {
|
|
15164
|
+
seenIds.add(t.id);
|
|
15165
|
+
}
|
|
15166
|
+
if (typeof t.ordinal !== "number" || !Number.isInteger(t.ordinal) || t.ordinal < 1) {
|
|
15167
|
+
issues.push(`task ${t.id}: ordinal must be a positive integer`);
|
|
15168
|
+
}
|
|
15169
|
+
expectString(t, "title", issues);
|
|
15170
|
+
expectString(t, "objective", issues);
|
|
15171
|
+
if (!Array.isArray(t.dependsOn)) {
|
|
15172
|
+
issues.push(`task ${t.id}: dependsOn must be an array`);
|
|
15173
|
+
}
|
|
15174
|
+
if (!Array.isArray(t.preconditions)) {
|
|
15175
|
+
issues.push(`task ${t.id}: preconditions must be an array`);
|
|
15176
|
+
}
|
|
15177
|
+
if (!Array.isArray(t.changes)) {
|
|
15178
|
+
issues.push(`task ${t.id}: changes must be an array`);
|
|
15179
|
+
} else {
|
|
15180
|
+
for (const c of t.changes) {
|
|
15181
|
+
if (!isPlainObject2(c)) continue;
|
|
15182
|
+
if (!["create", "modify", "delete"].includes(c.operation)) {
|
|
15183
|
+
issues.push(`task ${t.id}: change operation must be create|modify|delete, got ${JSON.stringify(c.operation)}`);
|
|
15184
|
+
}
|
|
15185
|
+
if (isAbsoluteOrParentPath(c.path)) {
|
|
15186
|
+
issues.push(`task ${t.id}: change path must be relative, got ${JSON.stringify(c.path)}`);
|
|
15187
|
+
}
|
|
15188
|
+
if (isGitPath(c.path)) {
|
|
15189
|
+
issues.push(`task ${t.id}: change path must not target .git, got ${JSON.stringify(c.path)}`);
|
|
15190
|
+
}
|
|
15191
|
+
if (!Array.isArray(c.instructions) || c.instructions.length === 0) {
|
|
15192
|
+
issues.push(`task ${t.id}: change ${c.path} must declare at least one instruction`);
|
|
15193
|
+
} else {
|
|
15194
|
+
for (const ins of c.instructions) {
|
|
15195
|
+
if (typeof ins !== "string" || ins.length === 0) {
|
|
15196
|
+
issues.push(`task ${t.id}: change ${c.path} instructions must be non-empty strings`);
|
|
15197
|
+
break;
|
|
15198
|
+
}
|
|
15199
|
+
}
|
|
15200
|
+
}
|
|
15201
|
+
if (!Array.isArray(c.preserve) || c.preserve.length === 0) {
|
|
15202
|
+
issues.push(`task ${t.id}: change ${c.path} must declare at least one preserve entry`);
|
|
15203
|
+
}
|
|
15204
|
+
}
|
|
15205
|
+
}
|
|
15206
|
+
if (!Array.isArray(t.interfaces)) {
|
|
15207
|
+
issues.push(`task ${t.id}: interfaces must be an array`);
|
|
15208
|
+
}
|
|
15209
|
+
if (!Array.isArray(t.tests)) {
|
|
15210
|
+
issues.push(`task ${t.id}: tests must be an array`);
|
|
15211
|
+
} else {
|
|
15212
|
+
for (const tc of t.tests) {
|
|
15213
|
+
if (!isPlainObject2(tc)) continue;
|
|
15214
|
+
if (typeof tc.file !== "string" || tc.file.length === 0) {
|
|
15215
|
+
issues.push(`task ${t.id}: test file must be a non-empty string`);
|
|
15216
|
+
}
|
|
15217
|
+
if (!Array.isArray(tc.cases) || tc.cases.length === 0) {
|
|
15218
|
+
issues.push(`task ${t.id}: test ${tc.file} must declare at least one case`);
|
|
15219
|
+
}
|
|
15220
|
+
}
|
|
15221
|
+
}
|
|
15222
|
+
if (!Array.isArray(t.commands)) {
|
|
15223
|
+
issues.push(`task ${t.id}: commands must be an array`);
|
|
15224
|
+
} else {
|
|
15225
|
+
for (const cmd of t.commands) {
|
|
15226
|
+
if (!isPlainObject2(cmd)) continue;
|
|
15227
|
+
if (!Array.isArray(cmd.argv) || cmd.argv.length < 1) {
|
|
15228
|
+
issues.push(`task ${t.id}: command argv must be a non-empty array`);
|
|
15229
|
+
continue;
|
|
15230
|
+
}
|
|
15231
|
+
for (const a of cmd.argv) {
|
|
15232
|
+
if (typeof a !== "string") {
|
|
15233
|
+
issues.push(`task ${t.id}: command argv entries must be strings`);
|
|
15234
|
+
break;
|
|
15235
|
+
}
|
|
15236
|
+
if (looksLikeShellCommand(a)) {
|
|
15237
|
+
issues.push(`task ${t.id}: command argv entry looks like a shell command: ${JSON.stringify(a)}`);
|
|
15238
|
+
break;
|
|
15239
|
+
}
|
|
15240
|
+
}
|
|
15241
|
+
}
|
|
15242
|
+
}
|
|
15243
|
+
if (!Array.isArray(t.acceptance) || t.acceptance.length === 0) {
|
|
15244
|
+
issues.push(`task ${t.id}: acceptance must be a non-empty array`);
|
|
15245
|
+
} else {
|
|
15246
|
+
for (const a of t.acceptance) {
|
|
15247
|
+
if (!isPlainObject2(a)) continue;
|
|
15248
|
+
expectString(a, "id", issues);
|
|
15249
|
+
expectString(a, "assertion", issues);
|
|
15250
|
+
if (!Array.isArray(a.evidence) || a.evidence.length === 0) {
|
|
15251
|
+
issues.push(`task ${t.id}: acceptance ${a.id} must declare non-empty evidence`);
|
|
15252
|
+
}
|
|
15253
|
+
}
|
|
15254
|
+
}
|
|
15255
|
+
if (!isPlainObject2(t.commit) || typeof t.commit.message !== "string" || t.commit.message.length === 0) {
|
|
15256
|
+
issues.push(`task ${t.id}: commit.message must be a non-empty string`);
|
|
15257
|
+
}
|
|
15258
|
+
}
|
|
15259
|
+
function computePlanHash(plan) {
|
|
15260
|
+
const json2 = canonicalJson(plan);
|
|
15261
|
+
return sha256(json2);
|
|
15262
|
+
}
|
|
15263
|
+
function sha256(text) {
|
|
15264
|
+
return createHash3("sha256").update(text, "utf8").digest("hex");
|
|
15265
|
+
}
|
|
15266
|
+
|
|
15267
|
+
// src/workflow/plan-store.js
|
|
15268
|
+
import { readFile as readFile5, writeFile as writeFile5, mkdir as mkdir5, readdir as readdir4, unlink as unlink4 } from "node:fs/promises";
|
|
15269
|
+
import { existsSync as existsSync5 } from "node:fs";
|
|
15270
|
+
import { join as join7, dirname as dirname4 } from "node:path";
|
|
15271
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
15272
|
+
function revisionsDir(commonDir, workflowId) {
|
|
15273
|
+
return join7(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions");
|
|
15274
|
+
}
|
|
15275
|
+
function revisionDir(commonDir, workflowId, revision) {
|
|
15276
|
+
const n = String(revision).padStart(6, "0");
|
|
15277
|
+
return join7(revisionsDir(commonDir, workflowId), n);
|
|
15278
|
+
}
|
|
15279
|
+
async function resolveCommon(repoRoot) {
|
|
15280
|
+
return resolveGitCommonDir(repoRoot);
|
|
15281
|
+
}
|
|
15282
|
+
async function publishPlanRevision(repoRoot, plan) {
|
|
15283
|
+
const v = validatePlanV2(plan);
|
|
15284
|
+
if (!v.ok) {
|
|
15285
|
+
throw new Error(`publishPlanRevision: invalid plan: ${v.issues.join("; ")}`);
|
|
15286
|
+
}
|
|
15287
|
+
const hash2 = computePlanHash(plan);
|
|
15288
|
+
const common = await resolveCommon(repoRoot);
|
|
15289
|
+
const dir = revisionDir(common, plan.workflowId, plan.revision);
|
|
15290
|
+
await mkdir5(dir, { recursive: true });
|
|
15291
|
+
const planPath = join7(dir, "plan.json");
|
|
15292
|
+
if (existsSync5(planPath)) {
|
|
15293
|
+
const existing = JSON.parse(await readFile5(planPath, "utf8"));
|
|
15294
|
+
if (existing?.plan?.workflowId !== plan.workflowId) {
|
|
15295
|
+
throw new Error(`publishPlanRevision: workflowId mismatch on existing record at ${planPath}`);
|
|
15296
|
+
}
|
|
15297
|
+
if (existing?.hash !== hash2) {
|
|
15298
|
+
throw new Error(`publishPlanRevision: hash mismatch on existing record at ${planPath} (existing ${existing.hash}, new ${hash2})`);
|
|
15299
|
+
}
|
|
15300
|
+
return { recorded: false, path: planPath, hash: hash2 };
|
|
15301
|
+
}
|
|
15302
|
+
const record2 = {
|
|
15303
|
+
plan,
|
|
15304
|
+
hash: hash2,
|
|
15305
|
+
publishedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15306
|
+
};
|
|
15307
|
+
await publishImmutableJson(planPath, record2);
|
|
15308
|
+
return { recorded: true, path: planPath, hash: hash2 };
|
|
15309
|
+
}
|
|
15310
|
+
async function publishApproval(repoRoot, approval) {
|
|
15311
|
+
if (!approval || typeof approval !== "object") {
|
|
15312
|
+
throw new Error("publishApproval: approval must be an object");
|
|
15313
|
+
}
|
|
15314
|
+
if (approval.decision !== "approved") {
|
|
15315
|
+
throw new Error(`publishApproval: unsupported decision ${approval.decision}`);
|
|
15316
|
+
}
|
|
15317
|
+
if (typeof approval.sha256 !== "string" || approval.sha256.length !== 64) {
|
|
15318
|
+
throw new Error("publishApproval: sha256 required");
|
|
15319
|
+
}
|
|
15320
|
+
const common = await resolveCommon(repoRoot);
|
|
15321
|
+
const dir = revisionDir(common, approval.workflowId, approval.revision);
|
|
15322
|
+
await mkdir5(dir, { recursive: true });
|
|
15323
|
+
const path = join7(dir, "approval.json");
|
|
15324
|
+
const planPath = join7(dir, "plan.json");
|
|
15325
|
+
if (existsSync5(planPath)) {
|
|
15326
|
+
const planRecord = JSON.parse(await readFile5(planPath, "utf8"));
|
|
15327
|
+
if (planRecord.hash !== approval.sha256) {
|
|
15328
|
+
throw new Error(`publishApproval: sha256 mismatch with plan record (plan ${planRecord.hash?.slice(0, 8)}, approval ${approval.sha256.slice(0, 8)})`);
|
|
15329
|
+
}
|
|
15330
|
+
}
|
|
15331
|
+
if (existsSync5(path)) {
|
|
15332
|
+
return { recorded: false, path };
|
|
15333
|
+
}
|
|
15334
|
+
await publishImmutableJson(path, { ...approval, publishedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
15335
|
+
return { recorded: true, path };
|
|
15336
|
+
}
|
|
15337
|
+
async function readPlanRevision(repoRoot, workflowId, revision) {
|
|
15338
|
+
const common = await resolveCommon(repoRoot);
|
|
15339
|
+
const path = join7(revisionDir(common, workflowId, revision), "plan.json");
|
|
15340
|
+
if (!existsSync5(path)) return null;
|
|
15341
|
+
try {
|
|
15342
|
+
const raw = await readFile5(path, "utf8");
|
|
15343
|
+
return JSON.parse(raw);
|
|
15344
|
+
} catch {
|
|
15345
|
+
return null;
|
|
15346
|
+
}
|
|
15347
|
+
}
|
|
15348
|
+
|
|
15349
|
+
// src/tools/ship-plan-submit.js
|
|
15350
|
+
var SAFE_ID_RE2 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15351
|
+
function createPlanSubmitTool(deps) {
|
|
15352
|
+
return async function planSubmit(input) {
|
|
15353
|
+
const opId = input.operationId ?? `plan-submit-${Date.now().toString(36)}`;
|
|
15354
|
+
const workflowId = String(input.workflowId ?? "");
|
|
15355
|
+
const revision = Number(input.revision);
|
|
15356
|
+
const plan = input.plan;
|
|
15357
|
+
const submittedBy = String(input.submittedBy ?? "");
|
|
15358
|
+
if (!workflowId || !SAFE_ID_RE2.test(workflowId)) {
|
|
15359
|
+
return failure("plan-submit", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
15360
|
+
}
|
|
15361
|
+
if (!Number.isInteger(revision) || revision <= 0) {
|
|
15362
|
+
return failure("plan-submit", "revision must be a positive integer", { operationId: opId, retryable: false });
|
|
15363
|
+
}
|
|
15364
|
+
if (!plan || typeof plan !== "object") {
|
|
15365
|
+
return failure("plan-submit", "plan object required", { operationId: opId, retryable: false });
|
|
15366
|
+
}
|
|
15367
|
+
const v = validatePlanV2(plan);
|
|
15368
|
+
if (!v.ok) {
|
|
15369
|
+
return failure("plan-submit", `plan validation failed: ${v.issues.join("; ")}`, { operationId: opId, retryable: false });
|
|
15370
|
+
}
|
|
15371
|
+
const expectedHash = computePlanHash(plan);
|
|
15372
|
+
const providedHash = String(input.sha256 ?? "");
|
|
15373
|
+
if (providedHash && providedHash !== expectedHash) {
|
|
15374
|
+
return failure("plan-submit", `sha256 mismatch (expected ${expectedHash.slice(0, 8)}, got ${providedHash.slice(0, 8)})`, { operationId: opId, retryable: false });
|
|
15375
|
+
}
|
|
15376
|
+
try {
|
|
15377
|
+
const result = await publishPlanRevision(deps.repoRoot, plan);
|
|
15378
|
+
return success2("plan-submit", {
|
|
15379
|
+
workflowId,
|
|
15380
|
+
revision,
|
|
15381
|
+
sha256: result.hash,
|
|
15382
|
+
recorded: result.recorded
|
|
15383
|
+
}, { operationId: opId });
|
|
15384
|
+
} catch (err) {
|
|
15385
|
+
return failure("plan-submit", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
15386
|
+
}
|
|
15387
|
+
};
|
|
15388
|
+
}
|
|
15389
|
+
|
|
15390
|
+
// src/tools/ship-plan-approve.js
|
|
15391
|
+
var SAFE_ID_RE3 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15392
|
+
function createPlanApproveTool(deps) {
|
|
15393
|
+
return async function planApprove(input) {
|
|
15394
|
+
const opId = input.operationId ?? `plan-approve-${Date.now().toString(36)}`;
|
|
15395
|
+
const workflowId = String(input.workflowId ?? "");
|
|
15396
|
+
const revision = Number(input.revision);
|
|
15397
|
+
const sha2563 = String(input.sha256 ?? "");
|
|
15398
|
+
const subject = String(input.subject ?? "");
|
|
15399
|
+
if (!workflowId || !SAFE_ID_RE3.test(workflowId)) {
|
|
15400
|
+
return failure("plan-approve", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
15401
|
+
}
|
|
15402
|
+
if (!Number.isInteger(revision) || revision <= 0) {
|
|
15403
|
+
return failure("plan-approve", "revision required", { operationId: opId, retryable: false });
|
|
15404
|
+
}
|
|
15405
|
+
if (!/^[0-9a-f]{64}$/.test(sha2563)) {
|
|
15406
|
+
return failure("plan-approve", "sha256 required (64 hex chars)", { operationId: opId, retryable: false });
|
|
15407
|
+
}
|
|
15408
|
+
if (!subject) {
|
|
15409
|
+
return failure("plan-approve", "user permission subject required", { operationId: opId, retryable: false });
|
|
15410
|
+
}
|
|
15411
|
+
try {
|
|
15412
|
+
const result = await publishApproval(deps.repoRoot, {
|
|
15413
|
+
workflowId,
|
|
15414
|
+
revision,
|
|
15415
|
+
decision: "approved",
|
|
15416
|
+
sessionID: input.sessionID ?? "ship-controller",
|
|
15417
|
+
approvedBy: subject,
|
|
15418
|
+
approvedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15419
|
+
chunkIds: Array.isArray(input.chunkIds) ? input.chunkIds : [],
|
|
15420
|
+
chunkHashes: Array.isArray(input.chunkHashes) ? input.chunkHashes : [],
|
|
15421
|
+
baseSha: input.baseSha ?? "",
|
|
15422
|
+
models: input.models ?? null,
|
|
15423
|
+
sha256: sha2563
|
|
15424
|
+
});
|
|
15425
|
+
return success2("plan-approve", {
|
|
15426
|
+
workflowId,
|
|
15427
|
+
revision,
|
|
15428
|
+
sha256: sha2563,
|
|
15429
|
+
recorded: result.recorded
|
|
15430
|
+
}, { operationId: opId });
|
|
15431
|
+
} catch (err) {
|
|
15432
|
+
return failure("plan-approve", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
15433
|
+
}
|
|
15434
|
+
};
|
|
15435
|
+
}
|
|
15436
|
+
|
|
15437
|
+
// src/tools/ship-run-start.js
|
|
15438
|
+
import { readFile as readFile6, mkdir as mkdir6, writeFile as writeFile6 } from "node:fs/promises";
|
|
15439
|
+
import { join as join8 } from "node:path";
|
|
15440
|
+
var SAFE_ID_RE4 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15441
|
+
async function readRevisionRecord(repoRoot, workflowId, revision) {
|
|
15442
|
+
const commonDir = await resolveGitCommonDir(repoRoot);
|
|
15443
|
+
const rev = String(revision).padStart(6, "0");
|
|
15444
|
+
const dir = join8(opencodeShipStateDir(commonDir), "plans", workflowId, "revisions", rev);
|
|
15445
|
+
const planRaw = await readFile6(join8(dir, "plan.json"), "utf8");
|
|
15446
|
+
const plan = JSON.parse(planRaw);
|
|
15447
|
+
const approvalPath = join8(dir, "approval.json");
|
|
15448
|
+
let approval = null;
|
|
15449
|
+
try {
|
|
15450
|
+
approval = JSON.parse(await readFile6(approvalPath, "utf8"));
|
|
15451
|
+
} catch {
|
|
15452
|
+
approval = null;
|
|
15453
|
+
}
|
|
15454
|
+
return { dir, plan, approval };
|
|
15455
|
+
}
|
|
15456
|
+
function createRunStartTool(deps) {
|
|
15457
|
+
return async function runStart(input) {
|
|
15458
|
+
const opId = input.operationId ?? `run-start-${Date.now().toString(36)}`;
|
|
15459
|
+
const workflowId = String(input.workflowId ?? "");
|
|
15460
|
+
if (!workflowId || !SAFE_ID_RE4.test(workflowId)) {
|
|
15461
|
+
return failure("run-start", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
15462
|
+
}
|
|
15463
|
+
const config2 = deps.config ?? null;
|
|
15464
|
+
const expectedModels = config2?.value?.workflow?.models ?? null;
|
|
15465
|
+
if (!expectedModels) {
|
|
15466
|
+
return failure("run-start", "run-start requires configured workflow.models", { operationId: opId, retryable: false });
|
|
15467
|
+
}
|
|
15468
|
+
const revision = Number(input.revision ?? 0);
|
|
15469
|
+
if (!Number.isInteger(revision) || revision <= 0) {
|
|
15470
|
+
return failure("run-start", "revision required", { operationId: opId, retryable: false });
|
|
15471
|
+
}
|
|
15472
|
+
let records;
|
|
15473
|
+
try {
|
|
15474
|
+
records = await readRevisionRecord(deps.repoRoot, workflowId, revision);
|
|
15475
|
+
} catch (err) {
|
|
15476
|
+
return failure("run-start", `plan revision not found: ${err?.message ?? err}`, { operationId: opId, retryable: false });
|
|
15477
|
+
}
|
|
15478
|
+
if (!records.approval) {
|
|
15479
|
+
return failure("run-start", "no approval record for this revision", { operationId: opId, retryable: false });
|
|
15480
|
+
}
|
|
15481
|
+
if (records.approval.sha256 !== records.plan.hash) {
|
|
15482
|
+
return failure("run-start", `approval sha256 mismatch: plan ${records.plan.hash?.slice(0, 8)} vs approval ${records.approval.sha256?.slice(0, 8)}`, { operationId: opId, retryable: false });
|
|
15483
|
+
}
|
|
15484
|
+
const expectedHash = records.approval.sha256;
|
|
15485
|
+
if (input.sha256 && input.sha256 !== expectedHash) {
|
|
15486
|
+
return failure("run-start", `sha256 mismatch: expected ${expectedHash.slice(0, 8)}, got ${String(input.sha256).slice(0, 8)}`, { operationId: opId, retryable: false });
|
|
15487
|
+
}
|
|
15488
|
+
if (records.approval.models) {
|
|
15489
|
+
const a = records.approval.models;
|
|
15490
|
+
if (a.planner !== expectedModels.planner || a.builder !== expectedModels.builder || a.finalReviewer !== expectedModels.finalReviewer) {
|
|
15491
|
+
return failure("run-start", "approval models no longer match configured workflow.models", { operationId: opId, retryable: false });
|
|
15492
|
+
}
|
|
15493
|
+
}
|
|
15494
|
+
try {
|
|
15495
|
+
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
15496
|
+
const runDir2 = join8(opencodeShipStateDir(commonDir), "runs", workflowId);
|
|
15497
|
+
await mkdir6(join8(runDir2, "events"), { recursive: true });
|
|
15498
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
15499
|
+
const runRecord = {
|
|
15500
|
+
workflowId,
|
|
15501
|
+
revision,
|
|
15502
|
+
sha256: expectedHash,
|
|
15503
|
+
startedAt,
|
|
15504
|
+
state: "running",
|
|
15505
|
+
activeTask: null,
|
|
15506
|
+
round: 0,
|
|
15507
|
+
models: expectedModels
|
|
15508
|
+
};
|
|
15509
|
+
await publishImmutableJson(join8(runDir2, "run.json"), runRecord);
|
|
15510
|
+
const event = { sequence: 1, kind: "run-start", at: startedAt, data: { revision, sha256: expectedHash } };
|
|
15511
|
+
await publishImmutableJson(join8(runDir2, "events", "00000001.json"), event);
|
|
15512
|
+
return success2("run-start", { workflowId, revision, sha256: expectedHash }, { operationId: opId });
|
|
15513
|
+
} catch (err) {
|
|
15514
|
+
return failure("run-start", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
15515
|
+
}
|
|
15516
|
+
};
|
|
15517
|
+
}
|
|
15518
|
+
|
|
15519
|
+
// src/tools/ship-task-report.js
|
|
15520
|
+
import { mkdir as mkdir8 } from "node:fs/promises";
|
|
15521
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
15522
|
+
import { join as join10 } from "node:path";
|
|
15523
|
+
|
|
15524
|
+
// src/workflow/run-controller.js
|
|
15525
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
15526
|
+
import { readFile as readFile7, writeFile as writeFile7, mkdir as mkdir7, readdir as readdir5 } from "node:fs/promises";
|
|
15527
|
+
import { existsSync as existsSync6 } from "node:fs";
|
|
15528
|
+
import { join as join9 } from "node:path";
|
|
15529
|
+
async function readSnapshotFromDisk(repoRoot, workflowId) {
|
|
15530
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
15531
|
+
const runPath = join9(opencodeShipStateDir(common), "runs", workflowId, "run.json");
|
|
15532
|
+
if (!existsSync6(runPath)) return null;
|
|
15533
|
+
try {
|
|
15534
|
+
const raw = await readFile7(runPath, "utf8");
|
|
15535
|
+
return JSON.parse(raw);
|
|
15536
|
+
} catch {
|
|
15537
|
+
return null;
|
|
15538
|
+
}
|
|
15539
|
+
}
|
|
15540
|
+
async function readEventsFromDisk(repoRoot, workflowId) {
|
|
15541
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
15542
|
+
const dir = join9(opencodeShipStateDir(common), "runs", workflowId, "events");
|
|
15543
|
+
if (!existsSync6(dir)) return [];
|
|
15544
|
+
const entries = await readdir5(dir);
|
|
15545
|
+
const sorted = entries.filter((n) => n.endsWith(".json")).sort();
|
|
15546
|
+
const out = [];
|
|
15547
|
+
for (const name of sorted) {
|
|
15548
|
+
try {
|
|
15549
|
+
const raw = await readFile7(join9(dir, name), "utf8");
|
|
15550
|
+
out.push(JSON.parse(raw));
|
|
15551
|
+
} catch {
|
|
15552
|
+
}
|
|
15553
|
+
}
|
|
15554
|
+
return out;
|
|
15555
|
+
}
|
|
15556
|
+
var STATES2 = Object.freeze({
|
|
15557
|
+
CREATED: "created",
|
|
15558
|
+
RUNNING: "running",
|
|
15559
|
+
COMMIT_PENDING: "commit-pending",
|
|
15560
|
+
COMMITTED: "committed",
|
|
15561
|
+
FIX_PENDING: "fix-pending",
|
|
15562
|
+
REVISION_REQUIRED: "revision-required",
|
|
15563
|
+
BLOCKED: "blocked",
|
|
15564
|
+
ALL_TASKS_DONE: "all-tasks-done",
|
|
15565
|
+
READY_PENDING: "ready-pending",
|
|
15566
|
+
READY: "ready",
|
|
15567
|
+
MERGED: "merged",
|
|
15568
|
+
DONE: "done"
|
|
15569
|
+
});
|
|
15570
|
+
var EVENT_KINDS = Object.freeze({
|
|
15571
|
+
RUN_START: "run-start",
|
|
15572
|
+
TASK_DISPATCH: "task-dispatch",
|
|
15573
|
+
TASK_REPORT: "task-report",
|
|
15574
|
+
TASK_REVIEW: "task-review",
|
|
15575
|
+
COMMIT: "commit",
|
|
15576
|
+
TASK_COMPLETE: "task-complete",
|
|
15577
|
+
FINAL_REVIEW: "final-review",
|
|
15578
|
+
READY: "ready",
|
|
15579
|
+
MERGE: "merge",
|
|
15580
|
+
BLOCKED: "blocked"
|
|
15581
|
+
});
|
|
15582
|
+
var MAX_FIX_ROUNDS = 3;
|
|
15583
|
+
function sha2562(value) {
|
|
15584
|
+
return createHash5("sha256").update(value, "utf8").digest("hex");
|
|
15585
|
+
}
|
|
15586
|
+
function canonicalize(value) {
|
|
15587
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
15588
|
+
const sort = (v) => {
|
|
15589
|
+
if (v === null || typeof v !== "object") return v;
|
|
15590
|
+
if (seen.has(v)) return null;
|
|
15591
|
+
seen.add(v);
|
|
15592
|
+
if (Array.isArray(v)) return v.map(sort);
|
|
15593
|
+
const out = {};
|
|
15594
|
+
for (const k of Object.keys(v).sort()) out[k] = sort(v[k]);
|
|
15595
|
+
return out;
|
|
15596
|
+
};
|
|
15597
|
+
return JSON.stringify(sort(value));
|
|
15598
|
+
}
|
|
15599
|
+
function nextRound(failures) {
|
|
15600
|
+
return (failures ?? 0) + 1;
|
|
15601
|
+
}
|
|
15602
|
+
function nextSequence(events) {
|
|
15603
|
+
return events.length + 1;
|
|
15604
|
+
}
|
|
15605
|
+
function appendEvent(state, recorded) {
|
|
15606
|
+
const prev = state.events[state.events.length - 1];
|
|
15607
|
+
const priorHash = prev?.hash ?? "0".repeat(64);
|
|
15608
|
+
const hash2 = sha2562(canonicalize({ kind: recorded.kind, data: recorded.data, at: recorded.at, sequence: recorded.sequence, priorHash }));
|
|
15609
|
+
const withHash = { ...recorded, priorHash, hash: hash2 };
|
|
15610
|
+
return [...state.events, withHash];
|
|
15611
|
+
}
|
|
15612
|
+
function normalizeState(state) {
|
|
15613
|
+
return {
|
|
15614
|
+
...state,
|
|
15615
|
+
completedTasks: Array.isArray(state.completedTasks) ? state.completedTasks : [],
|
|
15616
|
+
taskReady: state.taskReady ?? null,
|
|
15617
|
+
events: Array.isArray(state.events) ? state.events : [],
|
|
15618
|
+
round: Number.isInteger(state.round) ? state.round : 0,
|
|
15619
|
+
failures: Number.isInteger(state.failures) ? state.failures : 0
|
|
15620
|
+
};
|
|
15621
|
+
}
|
|
15622
|
+
function ensureActiveTask(state, taskId) {
|
|
15623
|
+
if (state.activeTask === null) return;
|
|
15624
|
+
if (state.activeTask !== taskId) {
|
|
15625
|
+
throw new Error(`run reducer: another task is active (${state.activeTask}), refusing ${taskId}`);
|
|
15626
|
+
}
|
|
15627
|
+
}
|
|
15628
|
+
function reduce(state, event) {
|
|
15629
|
+
const ev = { ...event, at: event.at ?? (/* @__PURE__ */ new Date()).toISOString() };
|
|
15630
|
+
const sequence = nextSequence(state.events);
|
|
15631
|
+
const recorded = (kind, data) => ({ sequence, kind, at: ev.at, data });
|
|
15632
|
+
const nextState = (extra) => ({ ...state, ...extra, events: appendEvent(state, recorded(event.kind, event.data)) });
|
|
15633
|
+
switch (event.kind) {
|
|
15634
|
+
case EVENT_KINDS.RUN_START: {
|
|
15635
|
+
if (state.state !== STATES2.CREATED) {
|
|
15636
|
+
throw new Error(`run reducer: RUN_START requires state=created, got ${state.state}`);
|
|
15637
|
+
}
|
|
15638
|
+
return {
|
|
15639
|
+
state: nextState({ state: STATES2.RUNNING, activeTask: null, round: 0, completedTasks: [] }),
|
|
15640
|
+
event: recorded(EVENT_KINDS.RUN_START, { revision: event.data.revision, sha256: event.data.sha256 })
|
|
15641
|
+
};
|
|
15642
|
+
}
|
|
15643
|
+
case EVENT_KINDS.TASK_DISPATCH: {
|
|
15644
|
+
if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING) {
|
|
15645
|
+
throw new Error(`run reducer: TASK_DISPATCH requires state=running|fix-pending, got ${state.state}`);
|
|
15646
|
+
}
|
|
15647
|
+
if (state.activeTask !== null) {
|
|
15648
|
+
throw new Error(`run reducer: TASK_DISPATCH while another task is active (${state.activeTask})`);
|
|
15649
|
+
}
|
|
15650
|
+
const round = state.round > 0 ? state.round : 1;
|
|
15651
|
+
return {
|
|
15652
|
+
state: nextState({ state: STATES2.RUNNING, activeTask: event.data.taskId, round }),
|
|
15653
|
+
event: recorded(EVENT_KINDS.TASK_DISPATCH, { taskId: event.data.taskId, briefHash: event.data.briefHash, round })
|
|
15654
|
+
};
|
|
15655
|
+
}
|
|
15656
|
+
case EVENT_KINDS.TASK_REVIEW: {
|
|
15657
|
+
if (state.state !== STATES2.RUNNING && state.state !== STATES2.FIX_PENDING || state.activeTask === null) {
|
|
15658
|
+
throw new Error(`run reducer: TASK_REVIEW requires running with active task`);
|
|
15659
|
+
}
|
|
15660
|
+
ensureActiveTask(state, event.data.taskId);
|
|
15661
|
+
const failures = event.data.verdict === "pass" ? 0 : nextRound(state.failures);
|
|
15662
|
+
const next = state.round + 1;
|
|
15663
|
+
if (event.data.verdict === "pass") {
|
|
15664
|
+
return {
|
|
15665
|
+
state: nextState({ state: STATES2.COMMIT_PENDING, round: state.round, taskReady: { taskId: event.data.taskId, reviewHash: event.data.reviewHash } }),
|
|
15666
|
+
event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "pass", reviewHash: event.data.reviewHash })
|
|
15667
|
+
};
|
|
15668
|
+
}
|
|
15669
|
+
if (failures >= MAX_FIX_ROUNDS) {
|
|
15670
|
+
return {
|
|
15671
|
+
state: nextState({ state: STATES2.REVISION_REQUIRED, failures, round: next, activeTask: null }),
|
|
15672
|
+
event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "fail", failures })
|
|
15673
|
+
};
|
|
15674
|
+
}
|
|
15675
|
+
return {
|
|
15676
|
+
state: nextState({ state: STATES2.FIX_PENDING, failures, round: next, activeTask: null }),
|
|
15677
|
+
event: recorded(EVENT_KINDS.TASK_REVIEW, { taskId: event.data.taskId, verdict: "fail", round: next, failures })
|
|
15678
|
+
};
|
|
15679
|
+
}
|
|
15680
|
+
case EVENT_KINDS.COMMIT: {
|
|
15681
|
+
if (state.state !== STATES2.COMMIT_PENDING) {
|
|
15682
|
+
throw new Error(`run reducer: COMMIT requires state=commit-pending, got ${state.state}`);
|
|
15683
|
+
}
|
|
15684
|
+
return {
|
|
15685
|
+
state: nextState({ state: STATES2.COMMITTED, activeTask: null, round: 0, failures: 0, completedTasks: [...state.completedTasks, state.taskReady?.taskId].filter(Boolean) }),
|
|
15686
|
+
event: recorded(EVENT_KINDS.COMMIT, { taskId: state.taskReady?.taskId, commitSha: event.data.commitSha })
|
|
15687
|
+
};
|
|
15688
|
+
}
|
|
15689
|
+
case EVENT_KINDS.TASK_COMPLETE: {
|
|
15690
|
+
if (state.state !== STATES2.COMMITTED) {
|
|
15691
|
+
throw new Error(`run reducer: TASK_COMPLETE requires state=committed, got ${state.state}`);
|
|
15692
|
+
}
|
|
15693
|
+
return {
|
|
15694
|
+
state: nextState({ state: STATES2.RUNNING, activeTask: null, taskReady: null, round: 0, failures: 0 }),
|
|
15695
|
+
event: recorded(EVENT_KINDS.TASK_COMPLETE, { taskId: event.data.taskId })
|
|
15696
|
+
};
|
|
15697
|
+
}
|
|
15698
|
+
case EVENT_KINDS.READY: {
|
|
15699
|
+
if (state.state !== STATES2.COMMITTED && state.state !== STATES2.READY_PENDING) {
|
|
15700
|
+
throw new Error(`run reducer: READY requires state=committed|ready-pending, got ${state.state}`);
|
|
15701
|
+
}
|
|
15702
|
+
return {
|
|
15703
|
+
state: nextState({ state: STATES2.READY, activeTask: null, completedTasks: state.completedTasks }),
|
|
15704
|
+
event: recorded(EVENT_KINDS.READY, { headSha: event.data.headSha })
|
|
15705
|
+
};
|
|
15706
|
+
}
|
|
15707
|
+
case EVENT_KINDS.MERGE: {
|
|
15708
|
+
if (state.state !== STATES2.READY) {
|
|
15709
|
+
throw new Error(`run reducer: MERGE requires state=ready, got ${state.state}`);
|
|
15710
|
+
}
|
|
15711
|
+
return {
|
|
15712
|
+
state: nextState({ state: STATES2.MERGED, mergedAt: ev.at, mergeSha: event.data.mergeSha }),
|
|
15713
|
+
event: recorded(EVENT_KINDS.MERGE, { mergeSha: event.data.mergeSha })
|
|
15714
|
+
};
|
|
15715
|
+
}
|
|
15716
|
+
case EVENT_KINDS.BLOCKED: {
|
|
15717
|
+
return {
|
|
15718
|
+
state: nextState({ state: STATES2.BLOCKED, blockedReason: event.data.reason }),
|
|
15719
|
+
event: recorded(EVENT_KINDS.BLOCKED, { reason: event.data.reason })
|
|
15720
|
+
};
|
|
15721
|
+
}
|
|
15722
|
+
default:
|
|
15723
|
+
throw new Error(`run reducer: unknown event kind ${event.kind}`);
|
|
15724
|
+
}
|
|
15725
|
+
}
|
|
15726
|
+
var RUN_EVENT_KINDS = EVENT_KINDS;
|
|
15727
|
+
async function runDir(repoRoot, workflowId) {
|
|
15728
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
15729
|
+
return join9(opencodeShipStateDir(common), "runs", workflowId);
|
|
15730
|
+
}
|
|
15731
|
+
async function appendRunEvent(repoRoot, workflowId, state, event) {
|
|
15732
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
15733
|
+
const dir = join9(opencodeShipStateDir(common), "runs", workflowId, "events");
|
|
15734
|
+
await mkdir7(dir, { recursive: true });
|
|
15735
|
+
const lockKey = `run:${workflowId}`;
|
|
15736
|
+
return withResourceLock(opencodeShipStateDir(common), lockKey, async () => {
|
|
15737
|
+
const persistedEvents = await readEventsFromDisk(repoRoot, workflowId);
|
|
15738
|
+
const persistedSnapshot = await readSnapshotFromDisk(repoRoot, workflowId);
|
|
15739
|
+
const liveState = normalizeState({
|
|
15740
|
+
...persistedSnapshot ?? state,
|
|
15741
|
+
events: persistedEvents.length > 0 ? persistedEvents : state.events ?? []
|
|
15742
|
+
});
|
|
15743
|
+
const { state: next, event: recorded } = reduce(liveState, event);
|
|
15744
|
+
const sequence = String(recorded.sequence).padStart(8, "0");
|
|
15745
|
+
const path = join9(dir, `${sequence}.json`);
|
|
15746
|
+
await publishImmutableJson(path, recorded);
|
|
15747
|
+
const runPath = join9(opencodeShipStateDir(common), "runs", workflowId, "run.json");
|
|
15748
|
+
const snapshot = {
|
|
15749
|
+
workflowId,
|
|
15750
|
+
revision: next.revision,
|
|
15751
|
+
sha256: next.sha256,
|
|
15752
|
+
state: next.state,
|
|
15753
|
+
activeTask: next.activeTask,
|
|
15754
|
+
taskReady: next.taskReady ?? null,
|
|
15755
|
+
failures: next.failures,
|
|
15756
|
+
round: next.round,
|
|
15757
|
+
completedTasks: next.completedTasks,
|
|
15758
|
+
lastEvent: recorded,
|
|
15759
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15760
|
+
};
|
|
15761
|
+
await writeFile7(runPath, JSON.stringify(snapshot, null, 2), "utf8");
|
|
15762
|
+
return { state: next, event: recorded };
|
|
15763
|
+
});
|
|
15764
|
+
}
|
|
15765
|
+
async function readRunState(repoRoot, workflowId) {
|
|
15766
|
+
const dir = await runDir(repoRoot, workflowId);
|
|
15767
|
+
const runPath = join9(dir, "run.json");
|
|
15768
|
+
if (!existsSync6(runPath)) return null;
|
|
15769
|
+
const snapshot = JSON.parse(await readFile7(runPath, "utf8"));
|
|
15770
|
+
const eventsDir = join9(dir, "events");
|
|
15771
|
+
const events = existsSync6(eventsDir) ? await Promise.all(
|
|
15772
|
+
(await readdir5(eventsDir)).filter((n) => n.endsWith(".json")).sort().map(async (n) => JSON.parse(await readFile7(join9(eventsDir, n), "utf8")))
|
|
15773
|
+
) : [];
|
|
15774
|
+
return {
|
|
15775
|
+
workflowId,
|
|
15776
|
+
revision: snapshot.revision,
|
|
15777
|
+
sha256: snapshot.sha256,
|
|
15778
|
+
state: snapshot.state,
|
|
15779
|
+
activeTask: snapshot.activeTask,
|
|
15780
|
+
failures: snapshot.failures,
|
|
15781
|
+
round: snapshot.round,
|
|
15782
|
+
completedTasks: snapshot.completedTasks,
|
|
15783
|
+
events
|
|
15784
|
+
};
|
|
15785
|
+
}
|
|
15786
|
+
|
|
15787
|
+
// src/tools/ship-task-report.js
|
|
15788
|
+
var SAFE_ID_RE5 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15789
|
+
function createTaskReportTool(deps) {
|
|
15790
|
+
return async function taskReport(input) {
|
|
15791
|
+
const opId = input.operationId ?? `task-report-${Date.now().toString(36)}`;
|
|
15792
|
+
const workflowId = String(input.workflowId ?? "");
|
|
15793
|
+
const taskId = String(input.taskId ?? "");
|
|
15794
|
+
const round = Number(input.round ?? 1);
|
|
15795
|
+
const submittedBy = String(input.submittedBy ?? "");
|
|
15796
|
+
const summary = String(input.summary ?? "");
|
|
15797
|
+
if (!workflowId || !SAFE_ID_RE5.test(workflowId)) {
|
|
15798
|
+
return failure("task-report", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
15799
|
+
}
|
|
15800
|
+
if (!taskId || !SAFE_ID_RE5.test(taskId)) {
|
|
15801
|
+
return failure("task-report", "taskId required (safe id)", { operationId: opId, retryable: false });
|
|
15802
|
+
}
|
|
15803
|
+
if (!Number.isInteger(round) || round <= 0) {
|
|
15804
|
+
return failure("task-report", "round must be a positive integer", { operationId: opId, retryable: false });
|
|
15805
|
+
}
|
|
15806
|
+
if (!summary) return failure("task-report", "summary required", { operationId: opId, retryable: false });
|
|
15807
|
+
if (!submittedBy) {
|
|
15808
|
+
return failure("task-report", "submittedBy required (must identify builder model)", { operationId: opId, retryable: false });
|
|
15809
|
+
}
|
|
15810
|
+
let models;
|
|
15811
|
+
try {
|
|
15812
|
+
models = resolveModelRoles(deps.config?.workflow, { strict: true });
|
|
15813
|
+
} catch (err) {
|
|
15814
|
+
return failure("task-report", `builder model unresolved: ${err?.message ?? err}`, { operationId: opId, retryable: false });
|
|
15815
|
+
}
|
|
15816
|
+
if (!submittedBy.startsWith(models.builder)) {
|
|
15817
|
+
return failure("task-report", `submittedBy must be the configured builder model ${models.builder}`, { operationId: opId, retryable: false });
|
|
15818
|
+
}
|
|
15819
|
+
let runState;
|
|
15820
|
+
try {
|
|
15821
|
+
runState = await readRunState(deps.repoRoot, workflowId);
|
|
15822
|
+
} catch (err) {
|
|
15823
|
+
return failure("task-report", `run state unreadable: ${err?.message ?? err}`, { operationId: opId, retryable: false });
|
|
15824
|
+
}
|
|
15825
|
+
if (!runState) {
|
|
15826
|
+
return failure("task-report", "run not started", { operationId: opId, retryable: false });
|
|
15827
|
+
}
|
|
15828
|
+
if (runState.activeTask !== null && runState.activeTask !== taskId) {
|
|
15829
|
+
return failure("task-report", `another task is already active (${runState.activeTask})`, { operationId: opId, retryable: false });
|
|
15830
|
+
}
|
|
15831
|
+
try {
|
|
15832
|
+
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
15833
|
+
const reportDir = join10(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
|
|
15834
|
+
await mkdir8(reportDir, { recursive: true });
|
|
15835
|
+
const record2 = {
|
|
15836
|
+
workflowId,
|
|
15837
|
+
taskId,
|
|
15838
|
+
round,
|
|
15839
|
+
submittedBy,
|
|
15840
|
+
builder: models.builder,
|
|
15841
|
+
summary,
|
|
15842
|
+
changes: Array.isArray(input.changes) ? input.changes : [],
|
|
15843
|
+
tests: Array.isArray(input.tests) ? input.tests : [],
|
|
15844
|
+
submittedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15845
|
+
};
|
|
15846
|
+
await publishImmutableJson(join10(reportDir, "implementer-report.json"), record2);
|
|
15847
|
+
const { state, event } = await appendRunEvent(
|
|
15848
|
+
deps.repoRoot,
|
|
15849
|
+
workflowId,
|
|
15850
|
+
runState,
|
|
15851
|
+
{ kind: RUN_EVENT_KINDS.TASK_REPORT, data: { taskId, round, summary, reportHash: reportHash(record2) } }
|
|
15852
|
+
);
|
|
15853
|
+
return success2("task-report", { workflowId, taskId, round, state: state.state, sequence: event.sequence }, { operationId: opId });
|
|
15854
|
+
} catch (err) {
|
|
15855
|
+
return failure("task-report", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
15856
|
+
}
|
|
15857
|
+
};
|
|
15858
|
+
}
|
|
15859
|
+
function reportHash(record2) {
|
|
15860
|
+
const sorted = Object.keys(record2).sort();
|
|
15861
|
+
const ordered = {};
|
|
15862
|
+
for (const k of sorted) ordered[k] = record2[k];
|
|
15863
|
+
return createHash6("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
|
|
15864
|
+
}
|
|
15865
|
+
|
|
15866
|
+
// src/tools/ship-task-review.js
|
|
15867
|
+
import { createHash as createHash7 } from "node:crypto";
|
|
15868
|
+
import { mkdir as mkdir9 } from "node:fs/promises";
|
|
15869
|
+
import { join as join11 } from "node:path";
|
|
15870
|
+
var SAFE_ID_RE6 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
15871
|
+
var VERDICT_VALUES = /* @__PURE__ */ new Set(["pass", "fail", "none"]);
|
|
15872
|
+
function createTaskReviewTool(deps) {
|
|
15873
|
+
return async function taskReview(input) {
|
|
15874
|
+
const opId = input.operationId ?? `task-review-${Date.now().toString(36)}`;
|
|
15875
|
+
const workflowId = String(input.workflowId ?? "");
|
|
15876
|
+
const taskId = String(input.taskId ?? "");
|
|
15877
|
+
const round = Number(input.round ?? 1);
|
|
15878
|
+
const spec = input.spec;
|
|
15879
|
+
const quality = input.quality;
|
|
15880
|
+
const submittedBy = String(input.submittedBy ?? "");
|
|
15881
|
+
if (!workflowId || !SAFE_ID_RE6.test(workflowId)) {
|
|
15882
|
+
return failure("task-review", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
15883
|
+
}
|
|
15884
|
+
if (!taskId || !SAFE_ID_RE6.test(taskId)) {
|
|
15885
|
+
return failure("task-review", "taskId required (safe id)", { operationId: opId, retryable: false });
|
|
15886
|
+
}
|
|
15887
|
+
if (!Number.isInteger(round) || round <= 0) {
|
|
15888
|
+
return failure("task-review", "round must be a positive integer", { operationId: opId, retryable: false });
|
|
15889
|
+
}
|
|
15890
|
+
if (!spec || typeof spec !== "object" || !VERDICT_VALUES.has(String(spec.verdict ?? ""))) {
|
|
15891
|
+
return failure("task-review", "spec verdict required (pass|fail|none)", { operationId: opId, retryable: false });
|
|
15892
|
+
}
|
|
15893
|
+
if (!quality || typeof quality !== "object" || !VERDICT_VALUES.has(String(quality.verdict ?? ""))) {
|
|
15894
|
+
return failure("task-review", "quality verdict required (pass|fail|none)", { operationId: opId, retryable: false });
|
|
15895
|
+
}
|
|
15896
|
+
if (!submittedBy) {
|
|
15897
|
+
return failure("task-review", "submittedBy required (must identify reviewer model)", { operationId: opId, retryable: false });
|
|
15898
|
+
}
|
|
15899
|
+
let models;
|
|
15900
|
+
try {
|
|
15901
|
+
models = resolveModelRoles(deps.config?.workflow, { strict: true });
|
|
15902
|
+
} catch (err) {
|
|
15903
|
+
return failure("task-review", `reviewer model unresolved: ${err?.message ?? err}`, { operationId: opId, retryable: false });
|
|
15904
|
+
}
|
|
15905
|
+
if (!submittedBy.startsWith(models.finalReviewer)) {
|
|
15906
|
+
return failure("task-review", `submittedBy must be the configured finalReviewer model ${models.finalReviewer}`, { operationId: opId, retryable: false });
|
|
15907
|
+
}
|
|
15908
|
+
let runState;
|
|
15909
|
+
try {
|
|
15910
|
+
runState = await readRunState(deps.repoRoot, workflowId);
|
|
15911
|
+
} catch (err) {
|
|
15912
|
+
return failure("task-review", `run state unreadable: ${err?.message ?? err}`, { operationId: opId, retryable: false });
|
|
15913
|
+
}
|
|
15914
|
+
if (!runState) {
|
|
15915
|
+
return failure("task-review", "run not started", { operationId: opId, retryable: false });
|
|
15916
|
+
}
|
|
15917
|
+
if (runState.activeTask !== taskId) {
|
|
15918
|
+
return failure("task-review", `no active task ${taskId} (active=${runState.activeTask})`, { operationId: opId, retryable: false });
|
|
15919
|
+
}
|
|
15920
|
+
try {
|
|
15921
|
+
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
15922
|
+
const reviewDir = join11(opencodeShipStateDir(commonDir), "runs", workflowId, "tasks", taskId, "rounds", `${String(round).padStart(4, "0")}`);
|
|
15923
|
+
await mkdir9(reviewDir, { recursive: true });
|
|
15924
|
+
const specPass = String(spec.verdict) === "pass";
|
|
15925
|
+
const qualityPass = String(quality.verdict) === "pass";
|
|
15926
|
+
const reviewHash = verdictHash({ spec, quality, submittedBy, taskId, round });
|
|
15927
|
+
const record2 = {
|
|
15928
|
+
workflowId,
|
|
15929
|
+
taskId,
|
|
15930
|
+
round,
|
|
15931
|
+
submittedBy,
|
|
15932
|
+
reviewer: models.finalReviewer,
|
|
15933
|
+
spec,
|
|
15934
|
+
quality,
|
|
15935
|
+
state: specPass && qualityPass ? "commit-pending" : "fix-pending",
|
|
15936
|
+
reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
15937
|
+
};
|
|
15938
|
+
await publishImmutableJson(join11(reviewDir, "review.json"), record2);
|
|
15939
|
+
const verdict = specPass && qualityPass ? "pass" : "fail";
|
|
15940
|
+
const { state, event } = await appendRunEvent(
|
|
15941
|
+
deps.repoRoot,
|
|
15942
|
+
workflowId,
|
|
15943
|
+
runState,
|
|
15944
|
+
{ kind: RUN_EVENT_KINDS.TASK_REVIEW, data: { taskId, verdict, reviewHash, round } }
|
|
15945
|
+
);
|
|
15946
|
+
return success2("task-review", { workflowId, taskId, round, state: state.state, sequence: event.sequence }, { operationId: opId });
|
|
15947
|
+
} catch (err) {
|
|
15948
|
+
return failure("task-review", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
15949
|
+
}
|
|
15950
|
+
};
|
|
15951
|
+
}
|
|
15952
|
+
function verdictHash(record2) {
|
|
15953
|
+
const sorted = Object.keys(record2).sort();
|
|
15954
|
+
const ordered = {};
|
|
15955
|
+
for (const k of sorted) ordered[k] = record2[k];
|
|
15956
|
+
return createHash7("sha256").update(JSON.stringify(ordered), "utf8").digest("hex");
|
|
15957
|
+
}
|
|
15958
|
+
|
|
15959
|
+
// src/workflow/resume.js
|
|
15960
|
+
import { readFile as readFile8, writeFile as writeFile8, readdir as readdir6, mkdir as mkdir10 } from "node:fs/promises";
|
|
15961
|
+
import { existsSync as existsSync7 } from "node:fs";
|
|
15962
|
+
import { join as join12 } from "node:path";
|
|
15963
|
+
function parseTrailer(text, key) {
|
|
15964
|
+
if (typeof text !== "string") return null;
|
|
15965
|
+
const re = new RegExp(`^${key}:\\s*(.+)$`, "m");
|
|
15966
|
+
const m = text.match(re);
|
|
15967
|
+
return m ? m[1].trim() : null;
|
|
15968
|
+
}
|
|
15969
|
+
async function reconstructCompletedTasksFromGitTrailers(repoRoot, workflowId) {
|
|
15970
|
+
const cp = await import("node:child_process");
|
|
15971
|
+
return new Promise((resolveRun) => {
|
|
15972
|
+
cp.execFile("git", ["log", "-n", "200", "--format=%H%n%B%n--END--"], { cwd: repoRoot }, (err, stdout) => {
|
|
15973
|
+
if (err || !stdout) return resolveRun([]);
|
|
15974
|
+
const commits = stdout.split("--END--").map((s) => s.trim()).filter(Boolean);
|
|
15975
|
+
const completed = [];
|
|
15976
|
+
for (const block of commits) {
|
|
15977
|
+
const [sha, ...body] = block.split("\n");
|
|
15978
|
+
const bodyText = body.join("\n");
|
|
15979
|
+
const wf = parseTrailer(bodyText, "Opencode-Ship-Workflow");
|
|
15980
|
+
const taskId = parseTrailer(bodyText, "Opencode-Ship-Task");
|
|
15981
|
+
if (wf === workflowId && taskId) {
|
|
15982
|
+
completed.push([taskId, sha]);
|
|
15983
|
+
}
|
|
15984
|
+
}
|
|
15985
|
+
resolveRun(completed);
|
|
15986
|
+
});
|
|
15987
|
+
});
|
|
15988
|
+
}
|
|
15989
|
+
async function lockRun(repoRoot, workflowId, callback) {
|
|
15990
|
+
const common = await resolveGitCommonDir(repoRoot);
|
|
15991
|
+
const stateRoot = opencodeShipStateDir(common);
|
|
15992
|
+
const lockKey = `run:${workflowId}`;
|
|
15993
|
+
return withResourceLock(stateRoot, lockKey, callback);
|
|
15994
|
+
}
|
|
15995
|
+
async function resumeRun(repoRoot, workflowId) {
|
|
15996
|
+
return lockRun(repoRoot, workflowId, async () => {
|
|
15997
|
+
const run = await readRunState(repoRoot, workflowId);
|
|
15998
|
+
if (!run) {
|
|
15999
|
+
const plan = await readPlanRevision(repoRoot, workflowId, 1);
|
|
16000
|
+
if (plan) {
|
|
16001
|
+
const completed = await reconstructCompletedTasksFromGitTrailers(repoRoot, workflowId);
|
|
16002
|
+
return {
|
|
16003
|
+
state: {
|
|
16004
|
+
workflowId,
|
|
16005
|
+
revision: 1,
|
|
16006
|
+
sha256: plan.hash,
|
|
16007
|
+
state: "reconstructed",
|
|
16008
|
+
activeTask: null,
|
|
16009
|
+
round: 0,
|
|
16010
|
+
completedTasks: completed,
|
|
16011
|
+
events: []
|
|
16012
|
+
},
|
|
16013
|
+
nextAction: "run-start",
|
|
16014
|
+
mirrored: false
|
|
16015
|
+
};
|
|
16016
|
+
}
|
|
16017
|
+
return {
|
|
16018
|
+
state: { workflowId, state: "missing", completedTasks: [], events: [] },
|
|
16019
|
+
nextAction: "plan-start",
|
|
16020
|
+
mirrored: false
|
|
16021
|
+
};
|
|
16022
|
+
}
|
|
16023
|
+
let nextAction = "task-report";
|
|
16024
|
+
if (run.state === "running") nextAction = "task-report";
|
|
16025
|
+
else if (run.state === "commit-pending") nextAction = "commit";
|
|
16026
|
+
else if (run.state === "committed") nextAction = "task-complete";
|
|
16027
|
+
else if (run.state === "fix-pending") nextAction = "task-dispatch";
|
|
16028
|
+
else if (run.state === "ready") nextAction = "merge";
|
|
16029
|
+
else if (run.state === "merged") nextAction = "done";
|
|
16030
|
+
else if (run.state === "blocked") nextAction = "blocked";
|
|
16031
|
+
return { state: run, nextAction, mirrored: false };
|
|
16032
|
+
});
|
|
16033
|
+
}
|
|
16034
|
+
|
|
16035
|
+
// src/tools/ship-resume.js
|
|
16036
|
+
var SAFE_ID_RE7 = /^[A-Za-z0-9._-]{1,128}$/;
|
|
16037
|
+
function createResumeTool(deps) {
|
|
16038
|
+
return async function resume(input) {
|
|
16039
|
+
const opId = input.operationId ?? `resume-${Date.now().toString(36)}`;
|
|
16040
|
+
const workflowId = String(input.workflowId ?? "");
|
|
16041
|
+
if (!workflowId || !SAFE_ID_RE7.test(workflowId)) {
|
|
16042
|
+
return failure("resume", "workflowId required (safe id)", { operationId: opId, retryable: false });
|
|
16043
|
+
}
|
|
16044
|
+
try {
|
|
16045
|
+
const result = await resumeRun(deps.repoRoot, workflowId);
|
|
16046
|
+
return success2("resume", {
|
|
16047
|
+
workflowId,
|
|
16048
|
+
state: result.state,
|
|
16049
|
+
nextAction: result.nextAction,
|
|
16050
|
+
mirrored: result.mirrored ?? false
|
|
16051
|
+
}, { operationId: opId });
|
|
16052
|
+
} catch (err) {
|
|
16053
|
+
return failure("resume", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
16054
|
+
}
|
|
16055
|
+
};
|
|
16056
|
+
}
|
|
16057
|
+
|
|
16058
|
+
// src/tools/ship-status.js
|
|
16059
|
+
import { readFile as readFile9, readdir as readdir7 } from "node:fs/promises";
|
|
16060
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
16061
|
+
import { join as join13 } from "node:path";
|
|
16062
|
+
function createStatusTool(deps) {
|
|
16063
|
+
return async function status(input) {
|
|
16064
|
+
const opId = input.operationId ?? `status-${Date.now().toString(36)}`;
|
|
16065
|
+
const workflowId = String(input.workflowId ?? "");
|
|
16066
|
+
if (!workflowId) return failure("status", "workflowId required", { operationId: opId, retryable: false });
|
|
16067
|
+
try {
|
|
16068
|
+
const commonDir = await resolveGitCommonDir(deps.repoRoot);
|
|
16069
|
+
const planRoot = join13(opencodeShipStateDir(commonDir), "plans", workflowId);
|
|
16070
|
+
const runRoot = join13(opencodeShipStateDir(commonDir), "runs", workflowId);
|
|
16071
|
+
const indexPath = join13(planRoot, "index.json");
|
|
16072
|
+
if (!existsSync8(indexPath)) return failure("status", "no workflow record", { operationId: opId, retryable: false });
|
|
16073
|
+
const index = JSON.parse(await readFile9(indexPath, "utf8"));
|
|
16074
|
+
let run = null;
|
|
16075
|
+
const runPath = join13(runRoot, "run.json");
|
|
16076
|
+
if (existsSync8(runPath)) run = JSON.parse(await readFile9(runPath, "utf8"));
|
|
16077
|
+
let lastEvent = null;
|
|
16078
|
+
const eventsDir = join13(runRoot, "events");
|
|
16079
|
+
if (existsSync8(eventsDir)) {
|
|
16080
|
+
const events = await readdir7(eventsDir);
|
|
16081
|
+
const sorted = events.filter((n) => n.endsWith(".json")).sort();
|
|
16082
|
+
if (sorted.length > 0) {
|
|
16083
|
+
lastEvent = JSON.parse(await readFile9(join13(eventsDir, sorted[sorted.length - 1]), "utf8"));
|
|
16084
|
+
}
|
|
16085
|
+
}
|
|
16086
|
+
return success2("status", { workflowId, index, run, lastEvent }, { operationId: opId });
|
|
16087
|
+
} catch (err) {
|
|
16088
|
+
return failure("status", String(err?.message ?? err), { operationId: opId, retryable: true });
|
|
16089
|
+
}
|
|
16090
|
+
};
|
|
16091
|
+
}
|
|
16092
|
+
|
|
16093
|
+
// src/recovery.js
|
|
16094
|
+
function recoverManifestAfterCrash(manifest) {
|
|
16095
|
+
return manifest;
|
|
16096
|
+
}
|
|
16097
|
+
|
|
16098
|
+
// src/installer/plugin-owner.js
|
|
16099
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
16100
|
+
async function reconcileOwner(repoRoot, adapter) {
|
|
16101
|
+
const r = spawnSync4("git", ["-C", repoRoot, "config", "--get", "user.name"], {
|
|
16102
|
+
encoding: "utf8"
|
|
16103
|
+
});
|
|
16104
|
+
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
|
|
16105
|
+
return process.env.USER ?? process.env.USERNAME ?? "opencode-ship";
|
|
16106
|
+
}
|
|
16107
|
+
|
|
16108
|
+
// src/installer/config.js
|
|
16109
|
+
import { readFile as readFile10, writeFile as writeFile9, rename as rename3, mkdir as mkdir11 } from "node:fs/promises";
|
|
16110
|
+
import { existsSync as existsSync9 } from "node:fs";
|
|
16111
|
+
import { dirname as dirname5, resolve as resolve9 } from "node:path";
|
|
16112
|
+
|
|
16113
|
+
// schema/ship-config.schema.json
|
|
16114
|
+
var ship_config_schema_default = {
|
|
16115
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
16116
|
+
$id: "https://github.com/Viktorxyz/opencode-ship/schema/ship-config.schema.json",
|
|
16117
|
+
title: "opencode-ship user config",
|
|
16118
|
+
type: "object",
|
|
16119
|
+
required: ["schemaVersion"],
|
|
16120
|
+
additionalProperties: false,
|
|
16121
|
+
properties: {
|
|
16122
|
+
schemaVersion: { enum: [1, 2] },
|
|
16123
|
+
profile: {
|
|
16124
|
+
type: "string",
|
|
16125
|
+
enum: ["core", "engineering"],
|
|
16126
|
+
description: "Active profile (precedence layer 2: ship.config > lock > core default)."
|
|
16127
|
+
},
|
|
16128
|
+
owner: {
|
|
16129
|
+
type: "string",
|
|
16130
|
+
description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
|
|
16131
|
+
},
|
|
16132
|
+
project: {
|
|
16133
|
+
type: "object",
|
|
16134
|
+
additionalProperties: false,
|
|
16135
|
+
properties: {
|
|
16136
|
+
remote: { type: "string", minLength: 1 },
|
|
16137
|
+
repository: { type: "string", pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" },
|
|
16138
|
+
defaultBranch: { type: "string", minLength: 1 },
|
|
16139
|
+
packageManager: { enum: ["npm", "pnpm", "yarn", "bun"] },
|
|
16140
|
+
detectOverrides: { type: "boolean", description: "Permit detection to refresh previously persisted values." }
|
|
16141
|
+
}
|
|
16142
|
+
},
|
|
16143
|
+
delivery: {
|
|
16144
|
+
type: "object",
|
|
16145
|
+
additionalProperties: false,
|
|
16146
|
+
properties: {
|
|
16147
|
+
worktree: {
|
|
16148
|
+
type: "object",
|
|
16149
|
+
additionalProperties: false,
|
|
16150
|
+
properties: {
|
|
16151
|
+
root: { type: "string", minLength: 1 },
|
|
16152
|
+
branchTemplate: { type: "string", minLength: 1 },
|
|
16153
|
+
bootstrap: {
|
|
16154
|
+
type: "array",
|
|
16155
|
+
items: {
|
|
16156
|
+
type: "array",
|
|
16157
|
+
items: { type: "string", minLength: 1 },
|
|
16158
|
+
minItems: 1
|
|
16159
|
+
}
|
|
16160
|
+
}
|
|
16161
|
+
}
|
|
16162
|
+
},
|
|
16163
|
+
verification: {
|
|
16164
|
+
type: "object",
|
|
16165
|
+
additionalProperties: false,
|
|
16166
|
+
properties: {
|
|
16167
|
+
commands: {
|
|
16168
|
+
type: "array",
|
|
16169
|
+
minItems: 1,
|
|
16170
|
+
items: {
|
|
16171
|
+
type: "object",
|
|
16172
|
+
required: ["id", "argv"],
|
|
16173
|
+
additionalProperties: false,
|
|
16174
|
+
properties: {
|
|
16175
|
+
id: { type: "string", minLength: 1 },
|
|
16176
|
+
argv: {
|
|
16177
|
+
type: "array",
|
|
16178
|
+
items: { type: "string", minLength: 1 },
|
|
16179
|
+
minItems: 1
|
|
16180
|
+
},
|
|
16181
|
+
timeoutMs: { type: "integer", minimum: 1 }
|
|
16182
|
+
}
|
|
16183
|
+
}
|
|
16184
|
+
},
|
|
16185
|
+
requireCleanDiffAfter: { type: "boolean" },
|
|
16186
|
+
invalidateOnHeadChange: { type: "boolean" }
|
|
16187
|
+
}
|
|
16188
|
+
},
|
|
16189
|
+
review: {
|
|
16190
|
+
type: "object",
|
|
16191
|
+
additionalProperties: false,
|
|
16192
|
+
properties: {
|
|
14370
16193
|
agent: { type: "string", minLength: 1 },
|
|
14371
16194
|
required: { type: "boolean" },
|
|
14372
16195
|
invalidateOnHeadChange: { type: "boolean" }
|
|
@@ -14426,17 +16249,78 @@ var ship_config_schema_default = {
|
|
|
14426
16249
|
agentsDir: { type: "string", pattern: "^\\.opencode/agents/?$" },
|
|
14427
16250
|
skillsDir: { type: "string", pattern: "^\\.opencode/skills/?$" }
|
|
14428
16251
|
}
|
|
16252
|
+
},
|
|
16253
|
+
workflow: {
|
|
16254
|
+
type: "object",
|
|
16255
|
+
description: "Required when profile is engineering. Ignored when profile is core.",
|
|
16256
|
+
additionalProperties: false,
|
|
16257
|
+
properties: {
|
|
16258
|
+
models: {
|
|
16259
|
+
type: "object",
|
|
16260
|
+
additionalProperties: false,
|
|
16261
|
+
required: ["planner", "builder", "finalReviewer"],
|
|
16262
|
+
properties: {
|
|
16263
|
+
planner: {
|
|
16264
|
+
type: "string",
|
|
16265
|
+
pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
|
16266
|
+
description: "Provider/model id for the strong planning child session."
|
|
16267
|
+
},
|
|
16268
|
+
builder: {
|
|
16269
|
+
type: "string",
|
|
16270
|
+
pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
|
16271
|
+
description: "Provider/model id for the cheap builder child session."
|
|
16272
|
+
},
|
|
16273
|
+
finalReviewer: {
|
|
16274
|
+
type: "string",
|
|
16275
|
+
pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
|
|
16276
|
+
description: "Provider/model id for the final Standards + Spec reviewers."
|
|
16277
|
+
}
|
|
16278
|
+
}
|
|
16279
|
+
},
|
|
16280
|
+
approval: {
|
|
16281
|
+
type: "object",
|
|
16282
|
+
additionalProperties: false,
|
|
16283
|
+
properties: {
|
|
16284
|
+
mirrorToIssue: { const: true },
|
|
16285
|
+
maxFailedRounds: { const: 3 }
|
|
16286
|
+
}
|
|
16287
|
+
}
|
|
16288
|
+
}
|
|
14429
16289
|
}
|
|
14430
|
-
}
|
|
16290
|
+
},
|
|
16291
|
+
allOf: [
|
|
16292
|
+
{
|
|
16293
|
+
if: {
|
|
16294
|
+
required: ["profile"],
|
|
16295
|
+
properties: { profile: { const: "engineering" } }
|
|
16296
|
+
},
|
|
16297
|
+
then: {
|
|
16298
|
+
required: ["workflow"],
|
|
16299
|
+
properties: {
|
|
16300
|
+
workflow: {
|
|
16301
|
+
required: ["models", "approval"],
|
|
16302
|
+
properties: {
|
|
16303
|
+
models: {
|
|
16304
|
+
required: ["planner", "builder", "finalReviewer"]
|
|
16305
|
+
},
|
|
16306
|
+
approval: {
|
|
16307
|
+
required: ["mirrorToIssue", "maxFailedRounds"]
|
|
16308
|
+
}
|
|
16309
|
+
}
|
|
16310
|
+
}
|
|
16311
|
+
}
|
|
16312
|
+
}
|
|
16313
|
+
}
|
|
16314
|
+
]
|
|
14431
16315
|
};
|
|
14432
16316
|
|
|
14433
16317
|
// src/installer/validation.js
|
|
14434
16318
|
var FORMAT_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
|
|
14435
|
-
function
|
|
16319
|
+
function isObject3(v) {
|
|
14436
16320
|
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
14437
16321
|
}
|
|
14438
16322
|
function validate(value, schema, pointer, issues) {
|
|
14439
|
-
if (!
|
|
16323
|
+
if (!isObject3(schema)) return;
|
|
14440
16324
|
if (schema.const !== void 0 && value !== schema.const) {
|
|
14441
16325
|
issues.push(`${pointer}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
|
|
14442
16326
|
return;
|
|
@@ -14445,6 +16329,18 @@ function validate(value, schema, pointer, issues) {
|
|
|
14445
16329
|
issues.push(`${pointer}: expected one of ${JSON.stringify(schema.enum)}, got ${JSON.stringify(value)}`);
|
|
14446
16330
|
return;
|
|
14447
16331
|
}
|
|
16332
|
+
if (Array.isArray(schema.allOf)) {
|
|
16333
|
+
for (const sub of schema.allOf) validate(value, sub, pointer, issues);
|
|
16334
|
+
}
|
|
16335
|
+
if (isObject3(schema.if)) {
|
|
16336
|
+
const ifIssues = [];
|
|
16337
|
+
validate(value, schema.if, pointer, ifIssues);
|
|
16338
|
+
if (ifIssues.length === 0) {
|
|
16339
|
+
if (isObject3(schema.then)) validate(value, schema.then, pointer, issues);
|
|
16340
|
+
} else if (isObject3(schema.else)) {
|
|
16341
|
+
validate(value, schema.else, pointer, issues);
|
|
16342
|
+
}
|
|
16343
|
+
}
|
|
14448
16344
|
const type = schema.type;
|
|
14449
16345
|
if (type !== void 0) {
|
|
14450
16346
|
const actual = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
|
|
@@ -14495,18 +16391,18 @@ function validate(value, schema, pointer, issues) {
|
|
|
14495
16391
|
value.forEach((entry, i) => validate(entry, schema.items, `${pointer}/${i}`, issues));
|
|
14496
16392
|
}
|
|
14497
16393
|
}
|
|
14498
|
-
if (type === "object") {
|
|
16394
|
+
if (type === "object" || isObject3(schema.properties) || Array.isArray(schema.required)) {
|
|
14499
16395
|
if (Array.isArray(schema.required)) {
|
|
14500
16396
|
for (const key of schema.required) {
|
|
14501
16397
|
if (!(key in value)) issues.push(`${pointer}: missing required field ${key}`);
|
|
14502
16398
|
}
|
|
14503
16399
|
}
|
|
14504
|
-
if (schema.additionalProperties === false &&
|
|
16400
|
+
if (schema.additionalProperties === false && isObject3(schema.properties)) {
|
|
14505
16401
|
for (const key of Object.keys(value)) {
|
|
14506
16402
|
if (!(key in schema.properties)) issues.push(`${pointer}: unknown field ${key}`);
|
|
14507
16403
|
}
|
|
14508
16404
|
}
|
|
14509
|
-
if (
|
|
16405
|
+
if (isObject3(schema.properties)) {
|
|
14510
16406
|
for (const key of Object.keys(schema.properties)) {
|
|
14511
16407
|
if (key in value) validate(value[key], schema.properties[key], `${pointer}/${key}`, issues);
|
|
14512
16408
|
}
|
|
@@ -14523,12 +16419,12 @@ function validateSchema(value, schema) {
|
|
|
14523
16419
|
init_json_pointer();
|
|
14524
16420
|
init_hash();
|
|
14525
16421
|
function configPath(repoRoot) {
|
|
14526
|
-
return
|
|
16422
|
+
return resolve9(repoRoot, ".opencode", "ship.config.json");
|
|
14527
16423
|
}
|
|
14528
16424
|
async function loadConfig(repoRoot) {
|
|
14529
16425
|
const path = configPath(repoRoot);
|
|
14530
|
-
if (!
|
|
14531
|
-
const raw = await
|
|
16426
|
+
if (!existsSync9(path)) return null;
|
|
16427
|
+
const raw = await readFile10(path, "utf8");
|
|
14532
16428
|
let parsed;
|
|
14533
16429
|
try {
|
|
14534
16430
|
parsed = JSON.parse(raw);
|
|
@@ -14592,8 +16488,8 @@ init_lock();
|
|
|
14592
16488
|
|
|
14593
16489
|
// src/installer/detection/project.js
|
|
14594
16490
|
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
14595
|
-
import { existsSync as
|
|
14596
|
-
import { resolve as
|
|
16491
|
+
import { existsSync as existsSync11, readFileSync } from "node:fs";
|
|
16492
|
+
import { resolve as resolve11, join as join14 } from "node:path";
|
|
14597
16493
|
function runGit2(cwd, args) {
|
|
14598
16494
|
const r = spawnSync5("git", ["-C", cwd, ...args], {
|
|
14599
16495
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -14602,15 +16498,15 @@ function runGit2(cwd, args) {
|
|
|
14602
16498
|
return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
14603
16499
|
}
|
|
14604
16500
|
function detectPackageManager(repoRoot) {
|
|
14605
|
-
if (
|
|
14606
|
-
if (
|
|
14607
|
-
if (
|
|
14608
|
-
if (
|
|
16501
|
+
if (existsSync11(join14(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
|
|
16502
|
+
if (existsSync11(join14(repoRoot, "yarn.lock"))) return "yarn";
|
|
16503
|
+
if (existsSync11(join14(repoRoot, "bun.lockb"))) return "bun";
|
|
16504
|
+
if (existsSync11(join14(repoRoot, "package-lock.json"))) return "npm";
|
|
14609
16505
|
return null;
|
|
14610
16506
|
}
|
|
14611
16507
|
function readPackageJson(repoRoot) {
|
|
14612
|
-
const path =
|
|
14613
|
-
if (!
|
|
16508
|
+
const path = join14(repoRoot, "package.json");
|
|
16509
|
+
if (!existsSync11(path)) return null;
|
|
14614
16510
|
try {
|
|
14615
16511
|
return JSON.parse(readFileSync(path, "utf8"));
|
|
14616
16512
|
} catch {
|
|
@@ -14687,7 +16583,7 @@ function detectOwner(repoRoot) {
|
|
|
14687
16583
|
}
|
|
14688
16584
|
function detectProject(repoRoot = process.cwd()) {
|
|
14689
16585
|
const errors = [];
|
|
14690
|
-
const cwd =
|
|
16586
|
+
const cwd = resolve11(repoRoot);
|
|
14691
16587
|
const inside = runGit2(cwd, ["rev-parse", "--show-toplevel"]);
|
|
14692
16588
|
if (inside.status !== 0) {
|
|
14693
16589
|
errors.push({ kind: "not-a-git-repo", path: cwd, detail: inside.stderr.trim() });
|
|
@@ -14853,7 +16749,7 @@ async function listPending(repoRoot) {
|
|
|
14853
16749
|
}
|
|
14854
16750
|
|
|
14855
16751
|
// src/installer/ship-adapter.js
|
|
14856
|
-
import { resolve as
|
|
16752
|
+
import { resolve as resolve12 } from "node:path";
|
|
14857
16753
|
var REQUIRED_DEFAULTS = {
|
|
14858
16754
|
review: { agent: "delivery-reviewer", required: true, invalidateOnHeadChange: true },
|
|
14859
16755
|
ci: { driver: "github-status-checks", requiredChecks: ["delivery-verify"], wait: true, flakyRetry: 1 },
|
|
@@ -14894,10 +16790,10 @@ function flattenShipConfig(ship) {
|
|
|
14894
16790
|
}
|
|
14895
16791
|
|
|
14896
16792
|
// src/version.js
|
|
14897
|
-
import { readFileSync as readFileSync2, existsSync as
|
|
14898
|
-
import { dirname as
|
|
16793
|
+
import { readFileSync as readFileSync2, existsSync as existsSync12 } from "node:fs";
|
|
16794
|
+
import { dirname as dirname7, resolve as resolve13 } from "node:path";
|
|
14899
16795
|
import { fileURLToPath } from "node:url";
|
|
14900
|
-
var PACKAGE_VERSION = "0.
|
|
16796
|
+
var PACKAGE_VERSION = "0.10.0-rc.17";
|
|
14901
16797
|
var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
|
|
14902
16798
|
|
|
14903
16799
|
// src/plugin.js
|
|
@@ -14910,26 +16806,67 @@ var toolDefs = [
|
|
|
14910
16806
|
["delivery_pr", "Open a draft PR linked to the issue.", "pr"],
|
|
14911
16807
|
["delivery_ready", "Mark the PR ready after every required gate has passed.", "ready"],
|
|
14912
16808
|
["delivery_merge", "Squash merge the PR after an explicit user request.", "merge"],
|
|
14913
|
-
["delivery_cleanup", "Remove the agent-owned worktree and branch after merge.", "cleanup"]
|
|
16809
|
+
["delivery_cleanup", "Remove the agent-owned worktree and branch after merge.", "cleanup"],
|
|
16810
|
+
["delivery_github_read", "Typed read of issue, PR, or check data.", "githubRead"],
|
|
16811
|
+
["delivery_issue_comment", "Idempotent typed comment on an issue.", "issueComment"],
|
|
16812
|
+
["delivery_issue_labels", "Idempotent label add/remove on an issue.", "issueLabels"],
|
|
16813
|
+
["delivery_issue_link", "Mark a relationship between two issues.", "issueLink"],
|
|
16814
|
+
["delivery_issue_close", "Close an issue with a recorded user permission subject.", "issueClose"],
|
|
16815
|
+
["delivery_sync", "Fetch and merge base into the feature branch.", "sync"],
|
|
16816
|
+
["delivery_publish", "Push the manifest branch to origin with HEAD verification.", "publish"],
|
|
16817
|
+
["ship_plan_start", "Create a workflow and dispatch the configured planner.", "planStart"],
|
|
16818
|
+
["ship_plan_submit", "Planner-only immutable PlanV2 submission.", "planSubmit"],
|
|
16819
|
+
["ship_plan_approve", "Interactive approval + immutable local seal.", "planApprove"],
|
|
16820
|
+
["ship_run_start", "Start execution of an approved plan.", "runStart"],
|
|
16821
|
+
["ship_task_report", "Builder-only immutable task report.", "taskReport"],
|
|
16822
|
+
["ship_task_review", "Task reviewer Spec/Quality verdict.", "taskReview"],
|
|
16823
|
+
["ship_resume", "Restore, reconcile, and continue idempotently.", "resume"],
|
|
16824
|
+
["ship_status", "Read-only compact workflow state.", "status"]
|
|
14914
16825
|
];
|
|
16826
|
+
function wrapEnvelopeV2(id, result) {
|
|
16827
|
+
if (result && typeof result === "object" && result.contractVersion === 2) {
|
|
16828
|
+
return result;
|
|
16829
|
+
}
|
|
16830
|
+
if (result && typeof result === "object" && result.contractVersion === 1) {
|
|
16831
|
+
const { contractVersion: _cv, ...rest } = result;
|
|
16832
|
+
return {
|
|
16833
|
+
contractVersion: 2,
|
|
16834
|
+
ok: true,
|
|
16835
|
+
kind: id,
|
|
16836
|
+
operationId: `legacy-${Date.now().toString(36)}`,
|
|
16837
|
+
idempotent: false,
|
|
16838
|
+
data: rest
|
|
16839
|
+
};
|
|
16840
|
+
}
|
|
16841
|
+
if (result && typeof result === "object" && typeof result.kind === "string") {
|
|
16842
|
+
return {
|
|
16843
|
+
contractVersion: 2,
|
|
16844
|
+
ok: false,
|
|
16845
|
+
kind: id,
|
|
16846
|
+
operationId: `legacy-${Date.now().toString(36)}`,
|
|
16847
|
+
retryable: false,
|
|
16848
|
+
message: result.kind,
|
|
16849
|
+
details: result
|
|
16850
|
+
};
|
|
16851
|
+
}
|
|
16852
|
+
return {
|
|
16853
|
+
contractVersion: 2,
|
|
16854
|
+
ok: true,
|
|
16855
|
+
kind: id,
|
|
16856
|
+
operationId: `legacy-${Date.now().toString(36)}`,
|
|
16857
|
+
idempotent: false,
|
|
16858
|
+
data: result
|
|
16859
|
+
};
|
|
16860
|
+
}
|
|
14915
16861
|
function makeTool(id, description, factory, runtime) {
|
|
14916
16862
|
return tool({
|
|
14917
16863
|
description,
|
|
14918
16864
|
args: factory.args,
|
|
14919
16865
|
async execute(args, ctx) {
|
|
14920
16866
|
const runner = factory.build(runtime, ctx);
|
|
14921
|
-
|
|
14922
|
-
const
|
|
14923
|
-
|
|
14924
|
-
runtime.lastTaskId = taskId;
|
|
14925
|
-
const result = await tryImmediateCleanup({
|
|
14926
|
-
repoRoot: runtime.repoRoot,
|
|
14927
|
-
taskId,
|
|
14928
|
-
adapter: runtime.adapter
|
|
14929
|
-
}).catch((e) => ({ ok: false, reason: String(e?.message ?? e) }));
|
|
14930
|
-
env = { ...env, cleanup: result };
|
|
14931
|
-
}
|
|
14932
|
-
return JSON.stringify(env, null, 2);
|
|
16867
|
+
const env = await runner(args);
|
|
16868
|
+
const wrapped = wrapEnvelopeV2(id, env);
|
|
16869
|
+
return JSON.stringify(wrapped, null, 2);
|
|
14933
16870
|
}
|
|
14934
16871
|
});
|
|
14935
16872
|
}
|
|
@@ -14937,7 +16874,7 @@ async function resolveRepoSlug(repoRoot, detection, config2) {
|
|
|
14937
16874
|
const fromConfig = config2?.value?.project?.repository;
|
|
14938
16875
|
if (typeof fromConfig === "string" && fromConfig.includes("/")) return fromConfig;
|
|
14939
16876
|
if (detection?.repository) return detection.repository;
|
|
14940
|
-
const gitConfig = await
|
|
16877
|
+
const gitConfig = await readFile12(resolve14(repoRoot, ".git/config"), "utf8").catch(() => null);
|
|
14941
16878
|
if (gitConfig) {
|
|
14942
16879
|
const m = gitConfig.match(/url\s*=\s*.*?github\.com[:/]([^/]+)\/([^/\s]+?)(?:\.git)?\b/);
|
|
14943
16880
|
if (m) return `${m[1]}/${m[2]}`;
|
|
@@ -14969,7 +16906,7 @@ async function bestEffortCleanupQueue(repoRoot, adapter) {
|
|
|
14969
16906
|
return { pending, manifestTasks: tasks.map((t) => t.taskId), ...out };
|
|
14970
16907
|
}
|
|
14971
16908
|
async function buildRuntime(worktree) {
|
|
14972
|
-
const repoRootAbs =
|
|
16909
|
+
const repoRootAbs = resolve14(worktree ?? process.cwd());
|
|
14973
16910
|
const detection = detectProject(repoRootAbs);
|
|
14974
16911
|
const legacyAdapter = await loadAdapter(repoRootAbs);
|
|
14975
16912
|
const config2 = await loadConfig(repoRootAbs);
|
|
@@ -15120,6 +17057,207 @@ var factories = {
|
|
|
15120
17057
|
adapter: rt.adapter,
|
|
15121
17058
|
remote: "origin"
|
|
15122
17059
|
})
|
|
17060
|
+
},
|
|
17061
|
+
githubRead: {
|
|
17062
|
+
args: {
|
|
17063
|
+
resource: tool.schema.enum(["issue", "pr", "checks"]),
|
|
17064
|
+
number: tool.schema.number().optional(),
|
|
17065
|
+
sha: tool.schema.string().optional(),
|
|
17066
|
+
operationId: tool.schema.string().optional()
|
|
17067
|
+
},
|
|
17068
|
+
build: (rt) => createGithubReadTool({
|
|
17069
|
+
driver: rt.driver,
|
|
17070
|
+
repoRoot: rt.repoRoot,
|
|
17071
|
+
repoSlug: rt.repoSlug,
|
|
17072
|
+
owner: rt.owner,
|
|
17073
|
+
operationStore: { readOperation: async () => null }
|
|
17074
|
+
})
|
|
17075
|
+
},
|
|
17076
|
+
issueComment: {
|
|
17077
|
+
args: {
|
|
17078
|
+
number: tool.schema.number(),
|
|
17079
|
+
body: tool.schema.string(),
|
|
17080
|
+
operationId: tool.schema.string().optional()
|
|
17081
|
+
},
|
|
17082
|
+
build: (rt) => createIssueCommentTool({
|
|
17083
|
+
driver: rt.driver,
|
|
17084
|
+
repoRoot: rt.repoRoot,
|
|
17085
|
+
repoSlug: rt.repoSlug,
|
|
17086
|
+
owner: rt.owner
|
|
17087
|
+
})
|
|
17088
|
+
},
|
|
17089
|
+
issueLabels: {
|
|
17090
|
+
args: {
|
|
17091
|
+
number: tool.schema.number(),
|
|
17092
|
+
add: tool.schema.array(tool.schema.string()).optional(),
|
|
17093
|
+
remove: tool.schema.array(tool.schema.string()).optional(),
|
|
17094
|
+
operationId: tool.schema.string().optional()
|
|
17095
|
+
},
|
|
17096
|
+
build: (rt) => createIssueLabelsTool({
|
|
17097
|
+
driver: rt.driver,
|
|
17098
|
+
repoRoot: rt.repoRoot,
|
|
17099
|
+
repoSlug: rt.repoSlug,
|
|
17100
|
+
owner: rt.owner
|
|
17101
|
+
})
|
|
17102
|
+
},
|
|
17103
|
+
issueLink: {
|
|
17104
|
+
args: {
|
|
17105
|
+
from: tool.schema.number(),
|
|
17106
|
+
to: tool.schema.number(),
|
|
17107
|
+
relationship: tool.schema.enum(["blocks", "is-blocked-by", "closes", "is-closed-by", "related"]),
|
|
17108
|
+
operationId: tool.schema.string().optional()
|
|
17109
|
+
},
|
|
17110
|
+
build: (rt) => createIssueLinkTool({
|
|
17111
|
+
driver: rt.driver,
|
|
17112
|
+
repoRoot: rt.repoRoot,
|
|
17113
|
+
repoSlug: rt.repoSlug,
|
|
17114
|
+
owner: rt.owner
|
|
17115
|
+
})
|
|
17116
|
+
},
|
|
17117
|
+
issueClose: {
|
|
17118
|
+
args: {
|
|
17119
|
+
number: tool.schema.number(),
|
|
17120
|
+
subject: tool.schema.string(),
|
|
17121
|
+
operationId: tool.schema.string().optional()
|
|
17122
|
+
},
|
|
17123
|
+
build: (rt) => createIssueCloseTool({
|
|
17124
|
+
driver: rt.driver,
|
|
17125
|
+
repoRoot: rt.repoRoot,
|
|
17126
|
+
repoSlug: rt.repoSlug,
|
|
17127
|
+
owner: rt.owner
|
|
17128
|
+
})
|
|
17129
|
+
},
|
|
17130
|
+
sync: {
|
|
17131
|
+
args: {
|
|
17132
|
+
base: tool.schema.string(),
|
|
17133
|
+
branch: tool.schema.string(),
|
|
17134
|
+
operationId: tool.schema.string().optional()
|
|
17135
|
+
},
|
|
17136
|
+
build: (rt) => createSyncTool({
|
|
17137
|
+
driver: rt.driver,
|
|
17138
|
+
repoRoot: rt.repoRoot,
|
|
17139
|
+
repoSlug: rt.repoSlug,
|
|
17140
|
+
owner: rt.owner
|
|
17141
|
+
})
|
|
17142
|
+
},
|
|
17143
|
+
publish: {
|
|
17144
|
+
args: {
|
|
17145
|
+
taskId: tool.schema.string(),
|
|
17146
|
+
expectedHead: tool.schema.string(),
|
|
17147
|
+
operationId: tool.schema.string().optional()
|
|
17148
|
+
},
|
|
17149
|
+
build: (rt) => createPublishTool({
|
|
17150
|
+
driver: rt.driver,
|
|
17151
|
+
repoRoot: rt.repoRoot,
|
|
17152
|
+
repoSlug: rt.repoSlug,
|
|
17153
|
+
owner: rt.owner
|
|
17154
|
+
})
|
|
17155
|
+
},
|
|
17156
|
+
planStart: {
|
|
17157
|
+
args: {
|
|
17158
|
+
issueNumber: tool.schema.number(),
|
|
17159
|
+
operationId: tool.schema.string().optional()
|
|
17160
|
+
},
|
|
17161
|
+
build: (rt) => createPlanStartTool({
|
|
17162
|
+
repoRoot: rt.repoRoot,
|
|
17163
|
+
owner: rt.owner,
|
|
17164
|
+
config: rt.configValue
|
|
17165
|
+
})
|
|
17166
|
+
},
|
|
17167
|
+
planSubmit: {
|
|
17168
|
+
args: {
|
|
17169
|
+
workflowId: tool.schema.string(),
|
|
17170
|
+
revision: tool.schema.number(),
|
|
17171
|
+
plan: tool.schema.unknown(),
|
|
17172
|
+
sha256: tool.schema.string().optional(),
|
|
17173
|
+
submittedBy: tool.schema.string(),
|
|
17174
|
+
operationId: tool.schema.string().optional()
|
|
17175
|
+
},
|
|
17176
|
+
build: (rt) => createPlanSubmitTool({
|
|
17177
|
+
repoRoot: rt.repoRoot,
|
|
17178
|
+
owner: rt.owner
|
|
17179
|
+
})
|
|
17180
|
+
},
|
|
17181
|
+
planApprove: {
|
|
17182
|
+
args: {
|
|
17183
|
+
workflowId: tool.schema.string(),
|
|
17184
|
+
revision: tool.schema.number(),
|
|
17185
|
+
sha256: tool.schema.string(),
|
|
17186
|
+
subject: tool.schema.string(),
|
|
17187
|
+
operationId: tool.schema.string().optional()
|
|
17188
|
+
},
|
|
17189
|
+
build: (rt) => createPlanApproveTool({
|
|
17190
|
+
repoRoot: rt.repoRoot,
|
|
17191
|
+
owner: rt.owner
|
|
17192
|
+
})
|
|
17193
|
+
},
|
|
17194
|
+
runStart: {
|
|
17195
|
+
args: {
|
|
17196
|
+
workflowId: tool.schema.string(),
|
|
17197
|
+
operationId: tool.schema.string().optional()
|
|
17198
|
+
},
|
|
17199
|
+
build: (rt) => createRunStartTool({
|
|
17200
|
+
repoRoot: rt.repoRoot,
|
|
17201
|
+
owner: rt.owner
|
|
17202
|
+
})
|
|
17203
|
+
},
|
|
17204
|
+
taskReport: {
|
|
17205
|
+
args: {
|
|
17206
|
+
workflowId: tool.schema.string(),
|
|
17207
|
+
taskId: tool.schema.string(),
|
|
17208
|
+
round: tool.schema.number(),
|
|
17209
|
+
submittedBy: tool.schema.string(),
|
|
17210
|
+
summary: tool.schema.string(),
|
|
17211
|
+
changes: tool.schema.array(tool.schema.unknown()).optional(),
|
|
17212
|
+
tests: tool.schema.array(tool.schema.unknown()).optional(),
|
|
17213
|
+
operationId: tool.schema.string().optional()
|
|
17214
|
+
},
|
|
17215
|
+
build: (rt) => createTaskReportTool({
|
|
17216
|
+
repoRoot: rt.repoRoot,
|
|
17217
|
+
owner: rt.owner,
|
|
17218
|
+
config: rt.configValue
|
|
17219
|
+
})
|
|
17220
|
+
},
|
|
17221
|
+
taskReview: {
|
|
17222
|
+
args: {
|
|
17223
|
+
workflowId: tool.schema.string(),
|
|
17224
|
+
taskId: tool.schema.string(),
|
|
17225
|
+
round: tool.schema.number(),
|
|
17226
|
+
submittedBy: tool.schema.string(),
|
|
17227
|
+
spec: tool.schema.object({
|
|
17228
|
+
verdict: tool.schema.enum(["pass", "none", "fail"]),
|
|
17229
|
+
notes: tool.schema.string().optional()
|
|
17230
|
+
}),
|
|
17231
|
+
quality: tool.schema.object({
|
|
17232
|
+
verdict: tool.schema.enum(["pass", "none", "fail"]),
|
|
17233
|
+
notes: tool.schema.string().optional()
|
|
17234
|
+
}),
|
|
17235
|
+
operationId: tool.schema.string().optional()
|
|
17236
|
+
},
|
|
17237
|
+
build: (rt) => createTaskReviewTool({
|
|
17238
|
+
repoRoot: rt.repoRoot,
|
|
17239
|
+
owner: rt.owner
|
|
17240
|
+
})
|
|
17241
|
+
},
|
|
17242
|
+
resume: {
|
|
17243
|
+
args: {
|
|
17244
|
+
workflowId: tool.schema.string(),
|
|
17245
|
+
operationId: tool.schema.string().optional()
|
|
17246
|
+
},
|
|
17247
|
+
build: (rt) => createResumeTool({
|
|
17248
|
+
repoRoot: rt.repoRoot,
|
|
17249
|
+
owner: rt.owner
|
|
17250
|
+
})
|
|
17251
|
+
},
|
|
17252
|
+
status: {
|
|
17253
|
+
args: {
|
|
17254
|
+
workflowId: tool.schema.string(),
|
|
17255
|
+
operationId: tool.schema.string().optional()
|
|
17256
|
+
},
|
|
17257
|
+
build: (rt) => createStatusTool({
|
|
17258
|
+
repoRoot: rt.repoRoot,
|
|
17259
|
+
owner: rt.owner
|
|
17260
|
+
})
|
|
15123
17261
|
}
|
|
15124
17262
|
};
|
|
15125
17263
|
var ShipPlugin = async (ctx) => {
|