opencode-ship 0.8.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.
Files changed (137) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/README.md +5 -3
  3. package/THIRD_PARTY_NOTICES.md +77 -18
  4. package/assets/agents/ship-controller.md +122 -0
  5. package/assets/agents/ship-final-spec-reviewer.md +87 -0
  6. package/assets/agents/ship-final-standards-reviewer.md +83 -0
  7. package/assets/agents/ship-planner.md +78 -0
  8. package/assets/agents/ship-task-builder.md +91 -0
  9. package/assets/agents/ship-task-reviewer.md +78 -0
  10. package/assets/commands/ship-deliver.md +30 -0
  11. package/assets/commands/ship-resume.md +28 -0
  12. package/assets/commands/ship-status.md +25 -0
  13. package/assets/skills/brainstorming/SKILL.md +160 -0
  14. package/assets/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
  15. package/assets/skills/brainstorming/visual-companion.md +298 -0
  16. package/assets/skills/code-review/SKILL.md +98 -0
  17. package/assets/skills/codebase-design/DEEPENING.md +37 -0
  18. package/assets/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  19. package/assets/skills/codebase-design/SKILL.md +123 -0
  20. package/assets/skills/dispatching-parallel-agents/SKILL.md +176 -0
  21. package/assets/skills/domain-modeling/ADR-FORMAT.md +47 -0
  22. package/assets/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  23. package/assets/skills/domain-modeling/SKILL.md +83 -0
  24. package/assets/skills/engineering-workflow/SKILL.md +125 -0
  25. package/assets/skills/engineering-workflow/domain.md +51 -0
  26. package/assets/skills/engineering-workflow/issue-tracker-github.md +45 -0
  27. package/assets/skills/engineering-workflow/issue-tracker-gitlab.md +46 -0
  28. package/assets/skills/engineering-workflow/issue-tracker-local.md +30 -0
  29. package/assets/skills/engineering-workflow/triage-labels.md +15 -0
  30. package/assets/skills/executing-plans/SKILL.md +73 -0
  31. package/assets/skills/grill-with-docs/SKILL.md +10 -14
  32. package/assets/skills/grilling/SKILL.md +21 -0
  33. package/assets/skills/handoff/SKILL.md +25 -0
  34. package/assets/skills/prototype/LOGIC.md +79 -0
  35. package/assets/skills/prototype/SKILL.md +35 -0
  36. package/assets/skills/prototype/UI.md +112 -0
  37. package/assets/skills/receiving-code-review/SKILL.md +214 -0
  38. package/assets/skills/requesting-code-review/SKILL.md +104 -0
  39. package/assets/skills/requesting-code-review/code-reviewer.md +172 -0
  40. package/assets/skills/research/SKILL.md +21 -0
  41. package/assets/skills/setup-engineering-workflow/SKILL.md +125 -0
  42. package/assets/skills/setup-engineering-workflow/domain.md +51 -0
  43. package/assets/skills/setup-engineering-workflow/issue-tracker-github.md +45 -0
  44. package/assets/skills/setup-engineering-workflow/issue-tracker-gitlab.md +46 -0
  45. package/assets/skills/setup-engineering-workflow/issue-tracker-local.md +30 -0
  46. package/assets/skills/setup-engineering-workflow/triage-labels.md +15 -0
  47. package/assets/skills/subagent-driven-development/SKILL.md +512 -0
  48. package/assets/skills/subagent-driven-development/implementer-prompt.md +142 -0
  49. package/assets/skills/subagent-driven-development/re-review-prompt.md +106 -0
  50. package/assets/skills/subagent-driven-development/task-reviewer-prompt.md +185 -0
  51. package/assets/skills/systematic-debugging/CREATION-LOG.md +119 -0
  52. package/assets/skills/systematic-debugging/SKILL.md +292 -0
  53. package/assets/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
  54. package/assets/skills/systematic-debugging/condition-based-waiting.md +115 -0
  55. package/assets/skills/systematic-debugging/defense-in-depth.md +122 -0
  56. package/assets/skills/systematic-debugging/find-polluter.sh +72 -0
  57. package/assets/skills/systematic-debugging/root-cause-tracing.md +169 -0
  58. package/assets/skills/systematic-debugging/test-academic.md +14 -0
  59. package/assets/skills/systematic-debugging/test-pressure-1.md +58 -0
  60. package/assets/skills/systematic-debugging/test-pressure-2.md +68 -0
  61. package/assets/skills/systematic-debugging/test-pressure-3.md +69 -0
  62. package/assets/skills/test-driven-development/SKILL.md +329 -0
  63. package/assets/skills/test-driven-development/writing-good-tests.md +198 -0
  64. package/assets/skills/to-spec/SKILL.md +84 -0
  65. package/assets/skills/to-tickets/SKILL.md +114 -0
  66. package/assets/skills/triage/AGENT-BRIEF.md +207 -0
  67. package/assets/skills/triage/OUT-OF-SCOPE.md +105 -0
  68. package/assets/skills/triage/SKILL.md +114 -15
  69. package/assets/skills/verification-before-completion/SKILL.md +129 -0
  70. package/assets/skills/wayfinder/SKILL.md +137 -0
  71. package/assets/skills/writing-plans/SKILL.md +177 -0
  72. package/assets/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
  73. package/dist/cli.js +1146 -435
  74. package/dist/core.js +210 -61
  75. package/dist/plugin.js +2368 -230
  76. package/package.json +3 -1
  77. package/schema/ship-config.schema.json +63 -2
  78. package/schema/ship-lock.schema.json +5 -3
  79. package/tests/plugin/plugin-load.test.mjs +85 -0
  80. package/vendor/mattpocock/LICENSE +30 -0
  81. package/vendor/obra/LICENSE +30 -0
  82. package/vendor/sources.json +976 -0
  83. package/vendor/superpowers/LICENSE +30 -0
  84. package/vendor/upstreams/mattpocock/skills/engineering/code-review/SKILL.md +89 -0
  85. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DEEPENING.md +37 -0
  86. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DESIGN-IT-TWICE.md +44 -0
  87. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/SKILL.md +114 -0
  88. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/ADR-FORMAT.md +47 -0
  89. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/CONTEXT-FORMAT.md +60 -0
  90. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/SKILL.md +74 -0
  91. package/vendor/upstreams/mattpocock/skills/engineering/grill-with-docs/SKILL.md +7 -0
  92. package/vendor/upstreams/mattpocock/skills/engineering/prototype/LOGIC.md +79 -0
  93. package/vendor/upstreams/mattpocock/skills/engineering/prototype/SKILL.md +26 -0
  94. package/vendor/upstreams/mattpocock/skills/engineering/prototype/UI.md +112 -0
  95. package/vendor/upstreams/mattpocock/skills/engineering/research/SKILL.md +12 -0
  96. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/SKILL.md +116 -0
  97. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/domain.md +51 -0
  98. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-github.md +45 -0
  99. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-gitlab.md +46 -0
  100. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-local.md +30 -0
  101. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/triage-labels.md +15 -0
  102. package/vendor/upstreams/mattpocock/skills/engineering/to-spec/SKILL.md +75 -0
  103. package/vendor/upstreams/mattpocock/skills/engineering/to-tickets/SKILL.md +105 -0
  104. package/vendor/upstreams/mattpocock/skills/engineering/triage/AGENT-BRIEF.md +207 -0
  105. package/vendor/upstreams/mattpocock/skills/engineering/triage/OUT-OF-SCOPE.md +105 -0
  106. package/vendor/upstreams/mattpocock/skills/engineering/triage/SKILL.md +112 -0
  107. package/vendor/upstreams/mattpocock/skills/engineering/wayfinder/SKILL.md +128 -0
  108. package/vendor/upstreams/mattpocock/skills/productivity/grilling/SKILL.md +12 -0
  109. package/vendor/upstreams/mattpocock/skills/productivity/handoff/SKILL.md +16 -0
  110. package/vendor/upstreams/obra/skills/brainstorming/SKILL.md +151 -0
  111. package/vendor/upstreams/obra/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
  112. package/vendor/upstreams/obra/skills/brainstorming/visual-companion.md +298 -0
  113. package/vendor/upstreams/obra/skills/dispatching-parallel-agents/SKILL.md +167 -0
  114. package/vendor/upstreams/obra/skills/executing-plans/SKILL.md +64 -0
  115. package/vendor/upstreams/obra/skills/receiving-code-review/SKILL.md +205 -0
  116. package/vendor/upstreams/obra/skills/requesting-code-review/SKILL.md +95 -0
  117. package/vendor/upstreams/obra/skills/requesting-code-review/code-reviewer.md +172 -0
  118. package/vendor/upstreams/obra/skills/subagent-driven-development/SKILL.md +503 -0
  119. package/vendor/upstreams/obra/skills/subagent-driven-development/implementer-prompt.md +142 -0
  120. package/vendor/upstreams/obra/skills/subagent-driven-development/re-review-prompt.md +106 -0
  121. package/vendor/upstreams/obra/skills/subagent-driven-development/task-reviewer-prompt.md +185 -0
  122. package/vendor/upstreams/obra/skills/systematic-debugging/CREATION-LOG.md +119 -0
  123. package/vendor/upstreams/obra/skills/systematic-debugging/SKILL.md +283 -0
  124. package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
  125. package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting.md +115 -0
  126. package/vendor/upstreams/obra/skills/systematic-debugging/defense-in-depth.md +122 -0
  127. package/vendor/upstreams/obra/skills/systematic-debugging/find-polluter.sh +72 -0
  128. package/vendor/upstreams/obra/skills/systematic-debugging/root-cause-tracing.md +169 -0
  129. package/vendor/upstreams/obra/skills/systematic-debugging/test-academic.md +14 -0
  130. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-1.md +58 -0
  131. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-2.md +68 -0
  132. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-3.md +69 -0
  133. package/vendor/upstreams/obra/skills/test-driven-development/SKILL.md +320 -0
  134. package/vendor/upstreams/obra/skills/test-driven-development/writing-good-tests.md +198 -0
  135. package/vendor/upstreams/obra/skills/verification-before-completion/SKILL.md +120 -0
  136. package/vendor/upstreams/obra/skills/writing-plans/SKILL.md +168 -0
  137. 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.8.0
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 isObject3(v) {
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 (isObject3(value)) {
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 createHash2 } from "node:crypto";
44
+ import { createHash as createHash8 } from "node:crypto";
31
45
  function bytesHash(buffer) {
32
- return createHash2("sha256").update(buffer).digest("hex");
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 readFile4, writeFile as writeFile4, rename as rename4, mkdir as mkdir4 } from "node:fs/promises";
69
- import { existsSync as existsSync4 } from "node:fs";
70
- import { dirname as dirname4, resolve as resolve8 } from "node:path";
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 resolve8(repoRoot, ".opencode", "ship.lock.json");
78
+ return resolve10(repoRoot, ".opencode", "ship.lock.json");
76
79
  }
77
80
  async function readLock2(repoRoot) {
78
81
  const path = lockPath(repoRoot);
79
- if (!existsSync4(path)) return null;
82
+ if (!existsSync10(path)) return null;
80
83
  try {
81
- const raw = await readFile4(path, "utf8");
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 mkdir4(dirname4(path), { recursive: true });
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 writeFile4(tmp, raw, "utf8");
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 === CURRENT_LOCK_SCHEMA && manager.schemaVersion === CURRENT_LOCK_SCHEMA && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
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 (!existsSync4(path)) {
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 readFile4(path, "utf8");
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 = resolve8(repoRoot, ".opencode", "delivery.lock.json");
178
- if (!existsSync4(legacy)) return null;
180
+ const legacy = resolve10(repoRoot, ".opencode", "delivery.lock.json");
181
+ if (!existsSync10(legacy)) return null;
179
182
  try {
180
- const raw = await readFile4(legacy, "utf8");
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 = 2;
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 resolve12 } from "node:path";
12627
- import { readFile as readFile5 } from "node:fs/promises";
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 sha256 = createHash("sha256").update(raw).digest("hex");
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((resolve13, reject2) => {
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) => resolve13({ status: status ?? -1, stdout, stderr }));
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 readFile2, writeFile as writeFile2, rename as rename2, mkdir as mkdir2, readdir, unlink } from "node:fs/promises";
13166
- import { dirname as dirname2, join as join2, resolve as resolve3 } from "node:path";
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
- async function runGitCommonDir(repoRoot) {
13169
- return new Promise((res, rej) => {
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
- { cwd: repoRoot, stdio: ["ignore", "pipe", "pipe"], shell: false }
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
- proc.stdout.on("data", (d) => out += d.toString());
13177
- proc.on("error", rej);
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
- rej(new Error(`git rev-parse --git-common-dir failed with ${code}`));
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
- rej(new Error("git rev-parse returned an empty path"));
13270
+ reject2(new Error("git rev-parse --git-common-dir returned an empty path"));
13186
13271
  return;
13187
13272
  }
13188
- res(resolve3(repoRoot, trimmed));
13273
+ resolveP(resolve3(repoRoot, trimmed));
13189
13274
  });
13190
13275
  });
13191
13276
  }
13192
- function manifestPath(commonDir, taskId) {
13193
- return join2(commonDir, "opencode-delivery", "manifests", `${taskId}.json`);
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
- async function writeManifest(repoRoot, manifest) {
13196
- const commonDir = await runGitCommonDir(repoRoot);
13197
- const path = manifestPath(commonDir, manifest.taskId);
13198
- await mkdir2(dirname2(path), { recursive: true });
13199
- const tmp = `${path}.tmp`;
13200
- await writeFile2(tmp, JSON.stringify(manifest, null, 2) + "\n", "utf8");
13201
- await rename2(tmp, path);
13202
- return resolve3(path);
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
- async function readManifest(repoRoot, taskId) {
13205
- const commonDir = await runGitCommonDir(repoRoot);
13206
- const path = manifestPath(commonDir, taskId);
13304
+ function ensureString(value) {
13305
+ return JSON.stringify(value, null, 2) + "\n";
13306
+ }
13307
+ async function fsyncDir(path) {
13207
13308
  try {
13208
- const raw = await readFile2(path, "utf8");
13209
- return JSON.parse(raw);
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 listManifests(repoRoot) {
13215
- const commonDir = await runGitCommonDir(repoRoot);
13216
- const dir = join2(commonDir, "opencode-delivery", "manifests");
13217
- let names;
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
- names = await readdir(dir);
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
- for (const name of names) {
13225
- if (!name.endsWith(".json")) continue;
13529
+ const seen = /* @__PURE__ */ new Set();
13530
+ for (const dir of [canonicalDir, legacyDir]) {
13531
+ let names;
13226
13532
  try {
13227
- const raw = await readFile2(join2(dir, name), "utf8");
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 runGitCommonDir(repoRoot);
13236
- const path = manifestPath(commonDir, taskId);
13237
- try {
13238
- await unlink(path);
13239
- } catch {
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 resolve5 } from "node:path";
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 resolve4 } from "node:path";
13404
- import { existsSync as existsSync2 } from "node:fs";
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(resolve4(cwd, merge2.stdout.trim()));
13444
- const applyExists = apply.status === 0 && safeExists(resolve4(cwd, apply.stdout.trim()));
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 existsSync2(p);
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 = resolve5(repoRoot, worktreeRoot);
13510
- const normalized = resolve5(candidatePath);
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 = resolve5(deps.repoRoot, input.worktreeRelativePath);
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: resolve5(deps.repoRoot, worktreeRoot)
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 manifestPath2 = await writeManifest(deps.repoRoot, next);
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: manifestPath2
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 resolve6 } from "node:path";
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 = resolve6(m.worktreePath);
14174
- const mainCwd = resolve6(deps.repoRoot);
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/recovery.js
14271
- function recoverManifestAfterCrash(manifest) {
14272
- return manifest;
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/installer/plugin-owner.js
14276
- import { spawnSync as spawnSync4 } from "node:child_process";
14277
- async function reconcileOwner(repoRoot, adapter) {
14278
- const r = spawnSync4("git", ["-C", repoRoot, "config", "--get", "user.name"], {
14279
- encoding: "utf8"
14280
- });
14281
- if (r.status === 0 && r.stdout.trim()) return r.stdout.trim();
14282
- return process.env.USER ?? process.env.USERNAME ?? "opencode-ship";
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/installer/config.js
14286
- import { readFile as readFile3, writeFile as writeFile3, rename as rename3, mkdir as mkdir3 } from "node:fs/promises";
14287
- import { existsSync as existsSync3 } from "node:fs";
14288
- import { dirname as dirname3, resolve as resolve7 } from "node:path";
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
- // schema/ship-config.schema.json
14291
- var ship_config_schema_default = {
14292
- $schema: "https://json-schema.org/draft/2020-12/schema",
14293
- $id: "https://github.com/Viktorxyz/opencode-ship/schema/ship-config.schema.json",
14294
- title: "opencode-ship user config",
14295
- type: "object",
14296
- required: ["schemaVersion"],
14297
- additionalProperties: false,
14298
- properties: {
14299
- schemaVersion: { const: 1 },
14300
- profile: {
14301
- type: "string",
14302
- enum: ["core", "engineering"],
14303
- description: "Active profile (precedence layer 2: ship.config > lock > core default)."
14304
- },
14305
- owner: {
14306
- type: "string",
14307
- description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
14308
- },
14309
- project: {
14310
- type: "object",
14311
- additionalProperties: false,
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
- delivery: {
14321
- type: "object",
14322
- additionalProperties: false,
14323
- properties: {
14324
- worktree: {
14325
- type: "object",
14326
- additionalProperties: false,
14327
- properties: {
14328
- root: { type: "string", minLength: 1 },
14329
- branchTemplate: { type: "string", minLength: 1 },
14330
- bootstrap: {
14331
- type: "array",
14332
- items: {
14333
- type: "array",
14334
- items: { type: "string", minLength: 1 },
14335
- minItems: 1
14336
- }
14337
- }
14338
- }
14339
- },
14340
- verification: {
14341
- type: "object",
14342
- additionalProperties: false,
14343
- properties: {
14344
- commands: {
14345
- type: "array",
14346
- minItems: 1,
14347
- items: {
14348
- type: "object",
14349
- required: ["id", "argv"],
14350
- additionalProperties: false,
14351
- properties: {
14352
- id: { type: "string", minLength: 1 },
14353
- argv: {
14354
- type: "array",
14355
- items: { type: "string", minLength: 1 },
14356
- minItems: 1
14357
- },
14358
- timeoutMs: { type: "integer", minimum: 1 }
14359
- }
14360
- }
14361
- },
14362
- requireCleanDiffAfter: { type: "boolean" },
14363
- invalidateOnHeadChange: { type: "boolean" }
14364
- }
14365
- },
14366
- review: {
14367
- type: "object",
14368
- additionalProperties: false,
14369
- properties: {
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 isObject2(v) {
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 (!isObject2(schema)) return;
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 && isObject2(schema.properties)) {
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 (isObject2(schema.properties)) {
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 resolve7(repoRoot, ".opencode", "ship.config.json");
16422
+ return resolve9(repoRoot, ".opencode", "ship.config.json");
14527
16423
  }
14528
16424
  async function loadConfig(repoRoot) {
14529
16425
  const path = configPath(repoRoot);
14530
- if (!existsSync3(path)) return null;
14531
- const raw = await readFile3(path, "utf8");
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 existsSync5, readFileSync } from "node:fs";
14596
- import { resolve as resolve9, join as join3 } from "node:path";
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 (existsSync5(join3(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
14606
- if (existsSync5(join3(repoRoot, "yarn.lock"))) return "yarn";
14607
- if (existsSync5(join3(repoRoot, "bun.lockb"))) return "bun";
14608
- if (existsSync5(join3(repoRoot, "package-lock.json"))) return "npm";
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 = join3(repoRoot, "package.json");
14613
- if (!existsSync5(path)) return null;
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 = resolve9(repoRoot);
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 resolve10 } from "node:path";
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 existsSync6 } from "node:fs";
14898
- import { dirname as dirname5, resolve as resolve11 } from "node:path";
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.8.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
- let env = await runner(args);
14922
- const taskId = typeof args?.taskId === "string" ? args.taskId : null;
14923
- if (env?.kind === "merge" && taskId) {
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 readFile5(resolve12(repoRoot, ".git/config"), "utf8").catch(() => null);
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 = resolve12(worktree ?? process.cwd());
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) => {