opencode-ship 1.1.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // opencode-ship CLI v1.1.0
2
+ // opencode-ship CLI v1.1.1
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __esm = (fn, res) => function __init() {
@@ -10,6 +10,351 @@ var __export = (target, all) => {
10
10
  __defProp(target, name, { get: all[name], enumerable: true });
11
11
  };
12
12
 
13
+ // src/profile.js
14
+ function isValidProfile(name) {
15
+ return typeof name === "string" && PROFILES.includes(name);
16
+ }
17
+ function isLegacyProfile(name) {
18
+ return typeof name === "string" && LEGACY_PROFILES.includes(name);
19
+ }
20
+ function normalizeProfile(name) {
21
+ if (name === void 0 || name === null) return DEFAULT_PROFILE;
22
+ if (isValidProfile(name)) return name;
23
+ if (isLegacyProfile(name)) return DEFAULT_PROFILE;
24
+ return null;
25
+ }
26
+ function isLegacyCoreProfile(name) {
27
+ return name === "core";
28
+ }
29
+ function resolveProfile({ cli = null, config = null, lock = null } = {}) {
30
+ if (cli !== null && cli !== void 0) {
31
+ if (isLegacyCoreProfile(cli)) {
32
+ throw new Error(
33
+ `unknown CLI profile 'core' (only 'engineering' is supported in this release; the 'core' profile was removed; existing persisted 'core' is promoted to engineering on next init/update)`
34
+ );
35
+ }
36
+ const v = normalizeProfile(cli);
37
+ if (v === null) {
38
+ throw new Error(
39
+ `unknown CLI profile '${cli}' (only 'engineering' is supported in current release)`
40
+ );
41
+ }
42
+ return { profile: v, source: "cli" };
43
+ }
44
+ if (config && typeof config === "object" && config.profile !== void 0 && config.profile !== null) {
45
+ if (isLegacyCoreProfile(config.profile)) {
46
+ return { profile: DEFAULT_PROFILE, source: "default", promotedFrom: "core" };
47
+ }
48
+ const v = normalizeProfile(config.profile);
49
+ if (v === null) {
50
+ throw new Error(
51
+ `unknown ship.config.json profile '${config.profile}' (only 'engineering' is supported in current release)`
52
+ );
53
+ }
54
+ return { profile: v, source: "config" };
55
+ }
56
+ if (lock && typeof lock === "object" && lock.manager && lock.manager.profile !== void 0 && lock.manager.profile !== null) {
57
+ if (isLegacyCoreProfile(lock.manager.profile)) {
58
+ return { profile: DEFAULT_PROFILE, source: "default", promotedFrom: "core" };
59
+ }
60
+ const v = normalizeProfile(lock.manager.profile);
61
+ if (v === null) {
62
+ throw new Error(
63
+ `unknown lock manager.profile '${lock.manager.profile}' (only 'engineering' is supported in current release)`
64
+ );
65
+ }
66
+ return { profile: v, source: "lock" };
67
+ }
68
+ return { profile: DEFAULT_PROFILE, source: "default" };
69
+ }
70
+ var PROFILES, DEFAULT_PROFILE, LEGACY_PROFILES;
71
+ var init_profile = __esm({
72
+ "src/profile.js"() {
73
+ PROFILES = Object.freeze(["engineering"]);
74
+ DEFAULT_PROFILE = "engineering";
75
+ LEGACY_PROFILES = Object.freeze(["core"]);
76
+ }
77
+ });
78
+
79
+ // src/installer/package-root.js
80
+ import { existsSync, readFileSync } from "node:fs";
81
+ import { fileURLToPath } from "node:url";
82
+ import { dirname, resolve } from "node:path";
83
+ function resolvePackageRoot(startUrl) {
84
+ let candidate = dirname(fileURLToPath(startUrl ?? import.meta.url));
85
+ while (candidate && candidate !== "/") {
86
+ const pkgPath = resolve(candidate, "package.json");
87
+ if (existsSync(pkgPath)) {
88
+ try {
89
+ const raw = readFileSync(pkgPath, "utf8");
90
+ const pkg = JSON.parse(raw);
91
+ if (pkg && pkg.name === PACKAGE_NAME) return candidate;
92
+ } catch {
93
+ }
94
+ }
95
+ candidate = dirname(candidate);
96
+ }
97
+ throw new Error(`opencode-ship package root not found from ${startUrl ?? import.meta.url}`);
98
+ }
99
+ var PACKAGE_NAME;
100
+ var init_package_root = __esm({
101
+ "src/installer/package-root.js"() {
102
+ PACKAGE_NAME = "opencode-ship";
103
+ }
104
+ });
105
+
106
+ // src/version.js
107
+ import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
108
+ import { dirname as dirname2, resolve as resolve2 } from "node:path";
109
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
110
+ var PACKAGE_VERSION, TEMPLATE_SET;
111
+ var init_version = __esm({
112
+ "src/version.js"() {
113
+ PACKAGE_VERSION = "1.1.1";
114
+ TEMPLATE_SET = `v${PACKAGE_VERSION}`;
115
+ }
116
+ });
117
+
118
+ // src/installer/catalog.js
119
+ var catalog_exports = {};
120
+ __export(catalog_exports, {
121
+ CATALOG: () => CATALOG,
122
+ PACKAGE_VERSION: () => PACKAGE_VERSION,
123
+ TEMPLATE_SET_ID: () => TEMPLATE_SET_ID,
124
+ filterCatalogByProfile: () => filterCatalogByProfile,
125
+ validateCatalog: () => validateCatalog
126
+ });
127
+ import { resolve as resolve3, relative, sep } from "node:path";
128
+ import { existsSync as existsSync3, statSync } from "node:fs";
129
+ function filterCatalogByProfile(catalog, profile) {
130
+ const effective = profile === void 0 || profile === null ? DEFAULT_PROFILE : isValidProfile(profile) ? profile : profile === "core" ? DEFAULT_PROFILE : null;
131
+ if (effective === null) {
132
+ throw new Error(
133
+ `filterCatalogByProfile: unknown profile '${profile}' (expected one of: ${PROFILES.join(", ")})`
134
+ );
135
+ }
136
+ return catalog.filter((entry) => Array.isArray(entry.profiles) && entry.profiles.includes(effective));
137
+ }
138
+ function validateCatalog({ catalog = CATALOG } = {}) {
139
+ const seenIds = /* @__PURE__ */ new Set();
140
+ const seenPaths = /* @__PURE__ */ new Set();
141
+ const issues = [];
142
+ for (const entry of catalog) {
143
+ if (!entry || typeof entry !== "object") {
144
+ issues.push({ id: null, kind: "shape", message: "catalog entry is not an object" });
145
+ continue;
146
+ }
147
+ const { id, kind, path, source, mode } = entry;
148
+ if (typeof id !== "string" || id.length === 0) {
149
+ issues.push({ id: null, kind: "id", message: `entry id missing: ${JSON.stringify(entry)}` });
150
+ } else if (seenIds.has(id)) {
151
+ issues.push({ id, kind: "duplicate-id", message: `duplicate catalog id: ${id}` });
152
+ } else {
153
+ seenIds.add(id);
154
+ }
155
+ if (typeof path !== "string" || !path.startsWith(".opencode" + sep)) {
156
+ issues.push({ id, kind: "path", message: `path must be rooted under .opencode/: ${path}` });
157
+ }
158
+ if (seenPaths.has(path)) {
159
+ issues.push({ id, kind: "duplicate-path", message: `duplicate target path: ${path}` });
160
+ } else {
161
+ seenPaths.add(path);
162
+ }
163
+ if (!ALLOWED_KINDS.has(kind)) {
164
+ issues.push({ id, kind: "kind", message: `unsupported entry kind: ${kind}` });
165
+ }
166
+ if (typeof source !== "string" || source.length === 0) {
167
+ issues.push({ id, kind: "source", message: `source path missing: ${id}` });
168
+ } else if (!existsSync3(source)) {
169
+ issues.push({ id, kind: "source-missing", message: `source file not found: ${source}` });
170
+ } else {
171
+ try {
172
+ const stats = statSync(source);
173
+ if (!stats.isFile()) {
174
+ issues.push({ id, kind: "source-not-file", message: `source is not a regular file: ${source}` });
175
+ } else if (stats.size === 0) {
176
+ issues.push({ id, kind: "source-empty", message: `source file is empty: ${source}` });
177
+ }
178
+ } catch (e) {
179
+ issues.push({ id, kind: "source-stat", message: `unable to stat source: ${e?.message ?? e}` });
180
+ }
181
+ const rel = relative(packageRoot, source);
182
+ if (rel.startsWith("..")) {
183
+ issues.push({ id, kind: "source-out-of-package", message: `source escapes package root: ${source}` });
184
+ }
185
+ }
186
+ if (mode !== 420) {
187
+ issues.push({ id, kind: "mode", message: `mode must be 0o644: ${id}` });
188
+ }
189
+ if (!Array.isArray(entry.profiles) || entry.profiles.length === 0) {
190
+ issues.push({ id, kind: "profiles", message: `profiles must be a non-empty array: ${id}` });
191
+ } else {
192
+ for (const p of entry.profiles) {
193
+ if (!isValidProfile(p)) {
194
+ issues.push({ id, kind: "profiles", message: `unknown profile in profiles[${entry.profiles.indexOf(p)}]: ${p} (expected one of: ${PROFILES.join(", ")})` });
195
+ }
196
+ }
197
+ }
198
+ }
199
+ if (issues.length > 0) {
200
+ const summary = issues.map((i) => i.message).join("; ");
201
+ const err = new Error(`opencode-ship catalog validation failed: ${summary}`);
202
+ err.issues = issues;
203
+ err.catalogValidation = true;
204
+ throw err;
205
+ }
206
+ return catalog;
207
+ }
208
+ var TEMPLATE_SET_ID, packageRoot, MATT_SKILLS, SUPER_SKILLS, ENGINEERING_AGENTS, ENGINEERING_COMMANDS, CATALOG, ALLOWED_KINDS;
209
+ var init_catalog = __esm({
210
+ "src/installer/catalog.js"() {
211
+ init_package_root();
212
+ init_version();
213
+ init_profile();
214
+ TEMPLATE_SET_ID = TEMPLATE_SET;
215
+ packageRoot = resolvePackageRoot(import.meta.url);
216
+ MATT_SKILLS = [
217
+ "engineering-workflow",
218
+ "grilling",
219
+ "domain-modeling",
220
+ "grill-with-docs",
221
+ "triage",
222
+ "to-spec",
223
+ "to-tickets",
224
+ "wayfinder",
225
+ "handoff",
226
+ "research",
227
+ "prototype",
228
+ "codebase-design",
229
+ "code-review"
230
+ ];
231
+ SUPER_SKILLS = [
232
+ "brainstorming",
233
+ "writing-plans",
234
+ "executing-plans",
235
+ "subagent-driven-development",
236
+ "dispatching-parallel-agents",
237
+ "test-driven-development",
238
+ "systematic-debugging",
239
+ "verification-before-completion",
240
+ "requesting-code-review",
241
+ "receiving-code-review"
242
+ ];
243
+ ENGINEERING_AGENTS = [
244
+ "ship-controller",
245
+ "ship-planner",
246
+ "ship-task-builder",
247
+ "ship-task-reviewer",
248
+ "ship-final-standards-reviewer",
249
+ "ship-final-spec-reviewer"
250
+ ];
251
+ ENGINEERING_COMMANDS = [
252
+ "ship-deliver",
253
+ "ship-resume",
254
+ "ship-status"
255
+ ];
256
+ CATALOG = [
257
+ {
258
+ id: "plugin:opencode-ship",
259
+ kind: "plugin",
260
+ path: ".opencode/plugins/opencode-ship.js",
261
+ source: resolve3(packageRoot, "dist/plugin.js"),
262
+ mode: 420,
263
+ profiles: ["engineering"]
264
+ },
265
+ {
266
+ id: "agent:delivery-reviewer",
267
+ kind: "agent",
268
+ path: ".opencode/agents/delivery-reviewer.md",
269
+ source: resolve3(packageRoot, "assets/agents/delivery-reviewer.md"),
270
+ mode: 420,
271
+ profiles: ["engineering"]
272
+ },
273
+ {
274
+ id: "agent:delivery-verifier",
275
+ kind: "agent",
276
+ path: ".opencode/agents/delivery-verifier.md",
277
+ source: resolve3(packageRoot, "assets/agents/delivery-verifier.md"),
278
+ mode: 420,
279
+ profiles: ["engineering"]
280
+ },
281
+ ...ENGINEERING_AGENTS.map((name) => ({
282
+ id: `agent:${name}`,
283
+ kind: "agent",
284
+ path: `.opencode/agents/${name}.md`,
285
+ source: resolve3(packageRoot, `assets/agents/${name}.md`),
286
+ mode: 420,
287
+ profiles: ["engineering"]
288
+ })),
289
+ ...ENGINEERING_COMMANDS.map((name) => ({
290
+ id: `command:${name}`,
291
+ kind: "support",
292
+ path: `.opencode/commands/${name}.md`,
293
+ source: resolve3(packageRoot, `assets/commands/${name}.md`),
294
+ mode: 420,
295
+ profiles: ["engineering"]
296
+ })),
297
+ {
298
+ id: "skill:delivery-workflow",
299
+ kind: "skill",
300
+ path: ".opencode/skills/delivery-workflow/SKILL.md",
301
+ source: resolve3(packageRoot, "assets/skills/delivery-workflow/SKILL.md"),
302
+ mode: 420,
303
+ profiles: ["engineering"]
304
+ },
305
+ {
306
+ id: "skill:planning-research-checkpoint",
307
+ kind: "skill",
308
+ path: ".opencode/skills/planning-research-checkpoint/SKILL.md",
309
+ source: resolve3(packageRoot, "assets/skills/planning-research-checkpoint/SKILL.md"),
310
+ mode: 420,
311
+ profiles: ["engineering"]
312
+ },
313
+ ...MATT_SKILLS.map((name) => ({
314
+ id: `skill:matt:${name}`,
315
+ kind: "skill",
316
+ path: `.opencode/skills/${name}/SKILL.md`,
317
+ source: resolve3(packageRoot, `assets/skills/${name}/SKILL.md`),
318
+ mode: 420,
319
+ profiles: ["engineering"]
320
+ })),
321
+ ...SUPER_SKILLS.map((name) => ({
322
+ id: `skill:super:${name}`,
323
+ kind: "skill",
324
+ path: `.opencode/skills/${name}/SKILL.md`,
325
+ source: resolve3(packageRoot, `assets/skills/${name}/SKILL.md`),
326
+ mode: 420,
327
+ profiles: ["engineering"]
328
+ })),
329
+ {
330
+ id: "skill:setup-ship-workflow",
331
+ kind: "skill",
332
+ path: ".opencode/skills/setup-ship-workflow/SKILL.md",
333
+ source: resolve3(packageRoot, "assets/skills/setup-ship-workflow/SKILL.md"),
334
+ mode: 420,
335
+ profiles: ["engineering"]
336
+ },
337
+ {
338
+ id: "skill:skill-discovery",
339
+ kind: "skill",
340
+ path: ".opencode/skills/skill-discovery/SKILL.md",
341
+ source: resolve3(packageRoot, "assets/skills/skill-discovery/SKILL.md"),
342
+ mode: 420,
343
+ profiles: ["engineering"]
344
+ },
345
+ {
346
+ id: "command:setup-ship-workflow",
347
+ kind: "support",
348
+ path: ".opencode/commands/setup-ship-workflow.md",
349
+ source: resolve3(packageRoot, "assets/commands/setup-ship-workflow.md"),
350
+ mode: 420,
351
+ profiles: ["engineering"]
352
+ }
353
+ ];
354
+ ALLOWED_KINDS = /* @__PURE__ */ new Set(["plugin", "agent", "skill", "support"]);
355
+ }
356
+ });
357
+
13
358
  // src/installer/json-pointer.js
14
359
  function unescape(token) {
15
360
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
@@ -95,1223 +440,1040 @@ var init_hash = __esm({
95
440
  }
96
441
  });
97
442
 
98
- // src/installer/plan-mode-permissions.js
99
- function planModePermissions() {
100
- return {
101
- build: {
102
- bash: DENY_DEFAULT,
103
- edit: {
104
- "*": DENY_DEFAULT,
105
- [PLANS_GLOB]: ALLOW_PLANS
106
- },
107
- webfetch: DENY_DEFAULT,
108
- task: DENY_DEFAULT,
109
- delivery_inspect: DENY_DEFAULT,
110
- delivery_issue: DENY_DEFAULT,
111
- delivery_worktree: DENY_DEFAULT,
112
- delivery_verify: DENY_DEFAULT,
113
- delivery_review: DENY_DEFAULT,
114
- delivery_pr: DENY_DEFAULT,
115
- delivery_ready: DENY_DEFAULT,
116
- delivery_merge: DENY_DEFAULT,
117
- delivery_cleanup: DENY_DEFAULT
118
- }
119
- };
120
- }
121
- var PLAN_PATH_PREFIX, DENY_DEFAULT, ALLOW_PLANS, PLANS_GLOB;
122
- var init_plan_mode_permissions = __esm({
123
- "src/installer/plan-mode-permissions.js"() {
124
- PLAN_PATH_PREFIX = ".git/opencode-ship/plans";
125
- DENY_DEFAULT = "deny";
126
- ALLOW_PLANS = "allow";
127
- PLANS_GLOB = `${PLAN_PATH_PREFIX}/**`;
128
- }
129
- });
130
-
131
- // src/installer/root-config.js
132
- var root_config_exports = {};
133
- __export(root_config_exports, {
134
- PLAN_MODE_POINTER: () => PLAN_MODE_POINTER,
135
- POINTER_ENTRIES: () => POINTER_ENTRIES,
136
- applyOwnedPointers: () => applyOwnedPointers,
137
- applyPlanModeOwnership: () => applyPlanModeOwnership,
138
- defaultRootConfigPath: () => defaultRootConfigPath,
139
- findRootConfig: () => findRootConfig,
140
- formatRootConfig: () => formatRootConfig,
141
- formatRootConfigPreserving: () => formatRootConfigPreserving,
142
- parseRootConfigPreservingOrder: () => parseRootConfigPreservingOrder,
143
- planModeBlock: () => planModeBlock,
144
- readRootConfig: () => readRootConfig,
145
- synthesizeDefaultRootConfig: () => synthesizeDefaultRootConfig
146
- });
147
- import { existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
148
- import { readFile as readFile2 } from "node:fs/promises";
149
- import { resolve as resolve5 } from "node:path";
150
- function findRootConfig(repoRoot) {
151
- for (const rel of ROOT_PATH_CANDIDATES) {
152
- const abs = resolve5(repoRoot, rel);
153
- if (existsSync5(abs)) return { path: abs, relative: rel, format: rel.endsWith(".jsonc") ? "jsonc" : "json" };
154
- }
155
- return { path: null, relative: ROOT_PATH_CANDIDATES[0], format: "json" };
156
- }
157
- function defaultRootConfigPath(repoRoot) {
158
- return resolve5(repoRoot, ROOT_PATH_CANDIDATES[0]);
159
- }
160
- function readRootConfig(absPath) {
161
- if (!existsSync5(absPath)) {
162
- return { ok: false, error: { kind: "missing", path: absPath } };
163
- }
164
- const raw = readFileSync3(absPath, "utf8");
165
- const stripped = stripJsonc(raw);
166
- try {
167
- const value = JSON.parse(stripped);
168
- return {
169
- ok: true,
170
- path: absPath,
171
- raw,
172
- sha256: bytesHashString(raw),
173
- value,
174
- before: snapshotValues(value),
175
- format: absPath.endsWith(".jsonc") ? "jsonc" : "json"
443
+ // schema/ship-config.schema.json
444
+ var ship_config_schema_default;
445
+ var init_ship_config_schema = __esm({
446
+ "schema/ship-config.schema.json"() {
447
+ ship_config_schema_default = {
448
+ $schema: "https://json-schema.org/draft/2020-12/schema",
449
+ $id: "https://github.com/Viktorxyz/opencode-ship/schema/ship-config.schema.json",
450
+ title: "opencode-ship user config",
451
+ type: "object",
452
+ required: ["schemaVersion"],
453
+ additionalProperties: false,
454
+ properties: {
455
+ schemaVersion: { enum: [1, 2] },
456
+ profile: {
457
+ type: "string",
458
+ enum: ["engineering", "core"],
459
+ description: "Active profile. Engineering is the only supported profile in 1.1.0; core is accepted on read for legacy consumer migration."
460
+ },
461
+ owner: {
462
+ type: "string",
463
+ description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
464
+ },
465
+ project: {
466
+ type: "object",
467
+ additionalProperties: false,
468
+ properties: {
469
+ remote: { type: "string", minLength: 1 },
470
+ repository: { type: "string", pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" },
471
+ defaultBranch: { type: "string", minLength: 1 },
472
+ packageManager: { enum: ["npm", "pnpm", "yarn", "bun"] },
473
+ detectOverrides: { type: "boolean", description: "Permit detection to refresh previously persisted values." }
474
+ }
475
+ },
476
+ delivery: {
477
+ type: "object",
478
+ additionalProperties: false,
479
+ properties: {
480
+ worktree: {
481
+ type: "object",
482
+ additionalProperties: false,
483
+ properties: {
484
+ root: { type: "string", minLength: 1 },
485
+ branchTemplate: { type: "string", minLength: 1 },
486
+ bootstrap: {
487
+ type: "array",
488
+ items: {
489
+ type: "array",
490
+ items: { type: "string", minLength: 1 },
491
+ minItems: 1
492
+ }
493
+ }
494
+ }
495
+ },
496
+ verification: {
497
+ type: "object",
498
+ additionalProperties: false,
499
+ properties: {
500
+ commands: {
501
+ type: "array",
502
+ minItems: 1,
503
+ items: {
504
+ type: "object",
505
+ required: ["id", "argv"],
506
+ additionalProperties: false,
507
+ properties: {
508
+ id: { type: "string", minLength: 1 },
509
+ argv: {
510
+ type: "array",
511
+ items: { type: "string", minLength: 1 },
512
+ minItems: 1
513
+ },
514
+ timeoutMs: { type: "integer", minimum: 1 }
515
+ }
516
+ }
517
+ },
518
+ requireCleanDiffAfter: { type: "boolean" },
519
+ invalidateOnHeadChange: { type: "boolean" }
520
+ }
521
+ },
522
+ review: {
523
+ type: "object",
524
+ additionalProperties: false,
525
+ properties: {
526
+ agent: { type: "string", minLength: 1 },
527
+ required: { type: "boolean" },
528
+ invalidateOnHeadChange: { type: "boolean" }
529
+ }
530
+ },
531
+ ci: {
532
+ type: "object",
533
+ additionalProperties: false,
534
+ properties: {
535
+ driver: { const: "github-status-checks" },
536
+ requiredChecks: {
537
+ type: "array",
538
+ items: { type: "string", minLength: 1 },
539
+ uniqueItems: true
540
+ },
541
+ wait: { type: "boolean" },
542
+ flakyRetry: { type: "integer", enum: [0, 1] }
543
+ }
544
+ },
545
+ ready: {
546
+ type: "object",
547
+ additionalProperties: false,
548
+ properties: {
549
+ requires: {
550
+ type: "array",
551
+ items: { enum: ["review", "local-verification", "remote-ci"] },
552
+ uniqueItems: true
553
+ },
554
+ stopAfterReady: { type: "boolean" }
555
+ }
556
+ },
557
+ merge: {
558
+ type: "object",
559
+ additionalProperties: false,
560
+ properties: {
561
+ strategy: { const: "squash" },
562
+ policy: { const: "explicit-user-request-only" },
563
+ requireFreshGates: { type: "boolean" }
564
+ }
565
+ },
566
+ cleanup: {
567
+ type: "object",
568
+ additionalProperties: false,
569
+ properties: {
570
+ when: { const: "next-task" },
571
+ requireUnpublishedGuard: { type: "boolean" }
572
+ }
573
+ }
574
+ }
575
+ },
576
+ tasks: {
577
+ type: "object",
578
+ description: "Optional override of the managed-file paths. Use only to relocate a target.",
579
+ additionalProperties: false,
580
+ properties: {
581
+ pluginPath: { type: "string", pattern: "^\\.opencode/.+\\.js$" },
582
+ agentsDir: { type: "string", pattern: "^\\.opencode/agents/?$" },
583
+ skillsDir: { type: "string", pattern: "^\\.opencode/skills/?$" }
584
+ }
585
+ },
586
+ workflow: {
587
+ type: "object",
588
+ description: "Workflow configuration. Models are optional at write time; the setup-ship-workflow skill fills them in. Once all three are present, ship-deliver can start.",
589
+ additionalProperties: false,
590
+ properties: {
591
+ models: {
592
+ type: "object",
593
+ additionalProperties: false,
594
+ description: "Optional model roles. All three roles must be present before ship-deliver can run.",
595
+ properties: {
596
+ planner: {
597
+ type: "string",
598
+ pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
599
+ description: "Provider/model id for the strong planning child session."
600
+ },
601
+ builder: {
602
+ type: "string",
603
+ pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
604
+ description: "Provider/model id for the cheap builder child session."
605
+ },
606
+ finalReviewer: {
607
+ type: "string",
608
+ pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
609
+ description: "Provider/model id for the final Standards + Spec reviewers."
610
+ }
611
+ }
612
+ },
613
+ approval: {
614
+ type: "object",
615
+ additionalProperties: false,
616
+ properties: {
617
+ mirrorToIssue: { const: true },
618
+ maxFailedRounds: { const: 3 }
619
+ }
620
+ }
621
+ }
622
+ },
623
+ skillDiscovery: {
624
+ type: "object",
625
+ additionalProperties: false,
626
+ description: "Trusted-auto skill discovery policy. Default mode is trusted-auto with the canonical owner allowlist and install-count threshold.",
627
+ properties: {
628
+ mode: {
629
+ type: "string",
630
+ enum: ["suggest-only", "trusted-auto", "disabled"]
631
+ },
632
+ trustedOwners: {
633
+ type: "array",
634
+ items: { type: "string", pattern: "^[A-Za-z0-9_.-]+$" }
635
+ },
636
+ minInstalls: { type: "integer", minimum: 0 },
637
+ maxAutoInstall: { type: "integer", minimum: 0, maximum: 20 },
638
+ blocklist: {
639
+ type: "array",
640
+ items: { type: "string", pattern: "^[A-Za-z0-9_./-]+$" }
641
+ },
642
+ requireImmutableRef: { type: "boolean" }
643
+ }
644
+ }
645
+ }
176
646
  };
177
- } catch (e) {
178
- return { ok: false, error: { kind: "parse", path: absPath, message: e.message } };
179
647
  }
648
+ });
649
+
650
+ // src/installer/validation.js
651
+ function isObject2(v) {
652
+ return v !== null && typeof v === "object" && !Array.isArray(v);
180
653
  }
181
- function snapshotValues(doc) {
182
- const out = {};
183
- for (const entry of POINTER_ENTRIES) {
184
- out[entry.pointer] = getPointer(doc, entry.pointer);
654
+ function validate(value, schema, pointer, issues) {
655
+ if (!isObject2(schema)) return;
656
+ if (schema.const !== void 0 && value !== schema.const) {
657
+ issues.push(`${pointer}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
658
+ return;
185
659
  }
186
- return out;
187
- }
188
- function stripJsonc(text) {
189
- let stripped = "";
190
- let i = 0;
191
- let inString = false;
192
- let escape = false;
193
- while (i < text.length) {
194
- const ch = text[i];
195
- if (inString) {
196
- stripped += ch;
197
- if (escape) {
198
- escape = false;
199
- } else if (ch === "\\") {
200
- escape = true;
201
- } else if (ch === '"') {
202
- inString = false;
660
+ if (schema.enum !== void 0 && !schema.enum.includes(value)) {
661
+ issues.push(`${pointer}: expected one of ${JSON.stringify(schema.enum)}, got ${JSON.stringify(value)}`);
662
+ return;
663
+ }
664
+ if (Array.isArray(schema.allOf)) {
665
+ for (const sub of schema.allOf) validate(value, sub, pointer, issues);
666
+ }
667
+ if (isObject2(schema.if)) {
668
+ const ifIssues = [];
669
+ validate(value, schema.if, pointer, ifIssues);
670
+ if (ifIssues.length === 0) {
671
+ if (isObject2(schema.then)) validate(value, schema.then, pointer, issues);
672
+ } else if (isObject2(schema.else)) {
673
+ validate(value, schema.else, pointer, issues);
674
+ }
675
+ }
676
+ const type = schema.type;
677
+ if (type !== void 0) {
678
+ const actual = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
679
+ if (type !== actual) {
680
+ if (!(type === "integer" && typeof value === "number" && Number.isInteger(value))) {
681
+ issues.push(`${pointer}: expected ${type}, got ${actual}`);
682
+ return;
203
683
  }
204
- i += 1;
205
- continue;
206
684
  }
207
- if (ch === '"') {
208
- inString = true;
209
- stripped += ch;
210
- i += 1;
211
- continue;
685
+ }
686
+ if (type === "string") {
687
+ if (schema.minLength !== void 0 && value.length < schema.minLength) {
688
+ issues.push(`${pointer}: shorter than minLength ${schema.minLength}`);
212
689
  }
213
- if (ch === "/" && text[i + 1] === "/") {
214
- while (i < text.length && text[i] !== "\n") i += 1;
215
- continue;
690
+ if (schema.pattern !== void 0) {
691
+ const re = new RegExp(schema.pattern);
692
+ if (!re.test(value)) issues.push(`${pointer}: does not match pattern ${schema.pattern}`);
216
693
  }
217
- if (ch === "/" && text[i + 1] === "*") {
218
- i += 2;
219
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
220
- i += 2;
221
- continue;
694
+ if (schema.format === "date-time" && !FORMAT_DATE_TIME.test(value)) {
695
+ issues.push(`${pointer}: not a date-time string`);
222
696
  }
223
- stripped += ch;
224
- i += 1;
225
697
  }
226
- return stripped.replace(/,\s*([}\]])/g, "$1");
227
- }
228
- function applyOwnedPointers(rootDoc, { pointerEntries = POINTER_ENTRIES, allowEqualValues = true } = {}) {
229
- const result = { doc: rootDoc, applied: [], skipped: [] };
230
- let doc = rootDoc;
231
- for (const entry of pointerEntries) {
232
- const existing = getPointer(doc, entry.pointer);
233
- if (existing === void 0) {
234
- doc = setPointer(doc, entry.pointer, entry.value);
235
- result.applied.push({ pointer: entry.pointer, value: entry.value });
236
- continue;
698
+ if (type === "integer" || type === "number") {
699
+ if (schema.minimum !== void 0 && value < schema.minimum) {
700
+ issues.push(`${pointer}: less than minimum ${schema.minimum}`);
237
701
  }
238
- if (existing === entry.value || stableStringify(existing) === stableStringify(entry.value)) {
239
- if (allowEqualValues) {
240
- result.skipped.push({ pointer: entry.pointer, reason: "already equal" });
702
+ if (schema.maximum !== void 0 && value > schema.maximum) {
703
+ issues.push(`${pointer}: greater than maximum ${schema.maximum}`);
704
+ }
705
+ if (schema.enum !== void 0) {
706
+ }
707
+ }
708
+ if (type === "array") {
709
+ if (schema.minItems !== void 0 && value.length < schema.minItems) {
710
+ issues.push(`${pointer}: fewer items than minItems ${schema.minItems}`);
711
+ }
712
+ if (Array.isArray(schema.items)) {
713
+ value.forEach((entry, i) => validate(entry, schema.items[i] ?? {}, `${pointer}/${i}`, issues));
714
+ } else if (schema.items) {
715
+ if (schema.uniqueItems) {
716
+ const seen = /* @__PURE__ */ new Set();
717
+ value.forEach((entry, i) => {
718
+ const key = JSON.stringify(entry);
719
+ if (seen.has(key)) issues.push(`${pointer}/${i}: duplicate unique item`);
720
+ seen.add(key);
721
+ });
241
722
  }
242
- continue;
723
+ value.forEach((entry, i) => validate(entry, schema.items, `${pointer}/${i}`, issues));
243
724
  }
244
- result.skipped.push({
245
- pointer: entry.pointer,
246
- reason: "different existing value",
247
- existing,
248
- desired: entry.value
249
- });
250
725
  }
251
- return result;
252
- }
253
- function applyPlanModeOwnership(rootDoc, { pointer = PLAN_MODE_POINTER, block = planModePermissions().build } = {}) {
254
- const previous = getPointer(rootDoc, pointer);
255
- const doc = setPointer(rootDoc, pointer, block);
256
- return { doc, previous: previous === void 0 ? null : previous, id: pointer };
257
- }
258
- function planModeBlock() {
259
- return planModePermissions().build;
260
- }
261
- function synthesizeDefaultRootConfig() {
262
- return {
263
- $schema: "https://opencode.ai/config.json",
264
- agent: {
265
- build: {
266
- permission: {
267
- delivery_inspect: "allow",
268
- delivery_issue: "allow",
269
- delivery_worktree: "allow",
270
- delivery_verify: "deny",
271
- delivery_review: "deny",
272
- delivery_pr: "allow",
273
- delivery_ready: "allow",
274
- delivery_merge: "ask",
275
- delivery_cleanup: "allow",
276
- task: {
277
- "delivery-reviewer": "allow",
278
- "delivery-verifier": "allow"
279
- }
280
- }
726
+ if (type === "object" || isObject2(schema.properties) || Array.isArray(schema.required)) {
727
+ if (Array.isArray(schema.required)) {
728
+ for (const key of schema.required) {
729
+ if (!(key in value)) issues.push(`${pointer}: missing required field ${key}`);
281
730
  }
282
731
  }
283
- };
284
- }
285
- function formatRootConfig(value) {
286
- return JSON.stringify(stripSourceOrder(value), null, 2) + "\n";
287
- }
288
- function stripSourceOrder(value) {
289
- if (!value || typeof value !== "object" || Array.isArray(value)) return value;
290
- const out = Array.isArray(value) ? [] : {};
291
- const order = Array.isArray(value.__sourceOrder__) ? value.__sourceOrder__ : null;
292
- const seen = /* @__PURE__ */ new Set();
293
- if (order) {
294
- for (const k of order) {
295
- if (k === "__sourceOrder__") continue;
296
- if (!(k in value)) continue;
297
- seen.add(k);
298
- out[k] = stripSourceOrder(value[k]);
732
+ if (schema.additionalProperties === false && isObject2(schema.properties)) {
733
+ for (const key of Object.keys(value)) {
734
+ if (!(key in schema.properties)) issues.push(`${pointer}: unknown field ${key}`);
735
+ }
736
+ }
737
+ if (isObject2(schema.properties)) {
738
+ for (const key of Object.keys(schema.properties)) {
739
+ if (key in value) validate(value[key], schema.properties[key], `${pointer}/${key}`, issues);
740
+ }
299
741
  }
300
742
  }
301
- for (const k of Object.keys(value)) {
302
- if (k === "__sourceOrder__") continue;
303
- if (seen.has(k)) continue;
304
- out[k] = stripSourceOrder(value[k]);
305
- }
306
- return out;
307
743
  }
308
- function formatRootConfigPreserving(value) {
309
- return formatRootConfig(value);
310
- }
311
- function parseRootConfigPreservingOrder(text) {
312
- if (typeof text !== "string" || text.length === 0) {
313
- return { value: {}, format: "json" };
314
- }
315
- const parser = new RootConfigParser(text);
316
- const value = parser.parseValue(
317
- 0,
318
- /*atTop*/
319
- true
320
- );
321
- const isJsonc = text.includes("//") || text.includes("/*");
322
- return { value, format: isJsonc ? "jsonc" : "json" };
744
+ function validateSchema(value, schema) {
745
+ const issues = [];
746
+ validate(value, schema, "#", issues);
747
+ return { ok: issues.length === 0, issues };
323
748
  }
324
- var POINTER_ENTRIES, ROOT_PATH_CANDIDATES, PLAN_MODE_POINTER, RootConfigParser;
325
- var init_root_config = __esm({
326
- "src/installer/root-config.js"() {
327
- init_json_pointer();
328
- init_hash();
329
- init_plan_mode_permissions();
330
- POINTER_ENTRIES = [
331
- {
332
- pointer: "/agent/build/permission/delivery_inspect",
333
- strategy: "value",
334
- value: "allow"
335
- },
336
- {
337
- pointer: "/agent/build/permission/delivery_issue",
338
- strategy: "value",
339
- value: "allow"
340
- },
341
- {
342
- pointer: "/agent/build/permission/delivery_worktree",
343
- strategy: "value",
344
- value: "allow"
345
- },
346
- {
347
- pointer: "/agent/build/permission/delivery_verify",
348
- strategy: "value",
349
- value: "deny"
350
- },
351
- {
352
- pointer: "/agent/build/permission/delivery_review",
353
- strategy: "value",
354
- value: "deny"
355
- },
356
- {
357
- pointer: "/agent/build/permission/delivery_pr",
358
- strategy: "value",
359
- value: "allow"
360
- },
361
- {
362
- pointer: "/agent/build/permission/delivery_ready",
363
- strategy: "value",
364
- value: "allow"
365
- },
366
- {
367
- pointer: "/agent/build/permission/delivery_merge",
368
- strategy: "value",
369
- value: "ask"
370
- },
371
- {
372
- pointer: "/agent/build/permission/delivery_cleanup",
373
- strategy: "value",
374
- value: "allow"
375
- },
376
- {
377
- pointer: "/agent/build/permission/task/delivery-reviewer",
378
- strategy: "value",
379
- value: "allow"
380
- },
381
- {
382
- pointer: "/agent/build/permission/task/delivery-verifier",
383
- strategy: "value",
384
- value: "allow"
385
- }
386
- ];
387
- ROOT_PATH_CANDIDATES = ["opencode.json", "opencode.jsonc"];
388
- PLAN_MODE_POINTER = "/agent/plan/permission";
389
- RootConfigParser = class {
390
- constructor(text) {
391
- this.text = text;
392
- this.pos = 0;
393
- }
394
- skipWS() {
395
- while (this.pos < this.text.length) {
396
- const ch = this.text[this.pos];
397
- if (ch === " " || ch === "\n" || ch === " " || ch === "\r") {
398
- this.pos += 1;
399
- continue;
400
- }
401
- if (ch === "/" && this.text[this.pos + 1] === "/") {
402
- while (this.pos < this.text.length && this.text[this.pos] !== "\n") this.pos += 1;
403
- continue;
404
- }
405
- if (ch === "/" && this.text[this.pos + 1] === "*") {
406
- this.pos += 2;
407
- while (this.pos < this.text.length && !(this.text[this.pos] === "*" && this.text[this.pos + 1] === "/")) this.pos += 1;
408
- this.pos += 2;
409
- continue;
410
- }
411
- break;
412
- }
413
- }
414
- parseValue(depth, atTop) {
415
- this.skipWS();
416
- const ch = this.text[this.pos];
417
- if (ch === "{") return this.parseObject(depth, atTop);
418
- if (ch === "[") return this.parseArray(depth);
419
- if (ch === '"') return this.parseString();
420
- if (ch === "-" || ch >= "0" && ch <= "9") return this.parseNumber();
421
- if (this.text.startsWith("true", this.pos)) {
422
- this.pos += 4;
423
- return true;
424
- }
425
- if (this.text.startsWith("false", this.pos)) {
426
- this.pos += 5;
427
- return false;
428
- }
429
- if (this.text.startsWith("null", this.pos)) {
430
- this.pos += 4;
431
- return null;
432
- }
433
- throw new Error(`unexpected token at ${this.pos}: ${this.text.slice(this.pos, this.pos + 8)}`);
434
- }
435
- parseObject(depth, atTop) {
436
- const out = /* @__PURE__ */ Object.create(null);
437
- out.__sourceOrder__ = [];
438
- this.pos += 1;
439
- while (this.pos < this.text.length) {
440
- this.skipWS();
441
- if (this.text[this.pos] === "}") {
442
- this.pos += 1;
443
- return out;
444
- }
445
- const key = this.parseString();
446
- out.__sourceOrder__.push(key);
447
- this.skipWS();
448
- if (this.text[this.pos] !== ":") throw new Error(`expected : at ${this.pos}`);
449
- this.pos += 1;
450
- out[key] = this.parseValue(depth + 1, false);
451
- this.skipWS();
452
- if (this.text[this.pos] === ",") {
453
- this.pos += 1;
454
- continue;
455
- }
456
- if (this.text[this.pos] === "}") {
457
- this.pos += 1;
458
- return out;
459
- }
460
- throw new Error(`expected , or } at ${this.pos}`);
461
- }
462
- throw new Error("unterminated object");
463
- }
464
- parseArray(depth) {
465
- const out = [];
466
- this.pos += 1;
467
- while (this.pos < this.text.length) {
468
- this.skipWS();
469
- if (this.text[this.pos] === "]") {
470
- this.pos += 1;
471
- return out;
472
- }
473
- out.push(this.parseValue(depth + 1, false));
474
- this.skipWS();
475
- if (this.text[this.pos] === ",") {
476
- this.pos += 1;
477
- continue;
478
- }
479
- if (this.text[this.pos] === "]") {
480
- this.pos += 1;
481
- return out;
482
- }
483
- throw new Error(`expected , or ] at ${this.pos}`);
484
- }
485
- throw new Error("unterminated array");
486
- }
487
- parseString() {
488
- if (this.text[this.pos] !== '"') throw new Error(`expected " at ${this.pos}`);
489
- this.pos += 1;
490
- let out = "";
491
- while (this.pos < this.text.length) {
492
- const ch = this.text[this.pos];
493
- if (ch === "\\") {
494
- const next = this.text[this.pos + 1];
495
- out += ch + next;
496
- this.pos += 2;
497
- continue;
498
- }
499
- if (ch === '"') {
500
- this.pos += 1;
501
- return JSON.parse('"' + out + '"');
502
- }
503
- out += ch;
504
- this.pos += 1;
505
- }
506
- throw new Error("unterminated string");
507
- }
508
- parseNumber() {
509
- const start = this.pos;
510
- if (this.text[this.pos] === "-") this.pos += 1;
511
- while (this.pos < this.text.length && /[0-9.eE+\-]/.test(this.text[this.pos])) this.pos += 1;
512
- return Number(this.text.slice(start, this.pos));
513
- }
514
- };
749
+ var FORMAT_DATE_TIME;
750
+ var init_validation = __esm({
751
+ "src/installer/validation.js"() {
752
+ FORMAT_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
515
753
  }
516
754
  });
517
755
 
518
- // src/profile.js
519
- var PROFILES = Object.freeze(["engineering"]);
520
- var DEFAULT_PROFILE = "engineering";
521
- function isValidProfile(name) {
522
- return typeof name === "string" && PROFILES.includes(name);
756
+ // src/installer/config.js
757
+ var config_exports = {};
758
+ __export(config_exports, {
759
+ configPath: () => configPath,
760
+ hasCompletedModels: () => hasCompletedModels,
761
+ loadConfig: () => loadConfig,
762
+ renderDefaultConfig: () => renderDefaultConfig,
763
+ writeConfig: () => writeConfig
764
+ });
765
+ import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
766
+ import { existsSync as existsSync4 } from "node:fs";
767
+ import { dirname as dirname3, resolve as resolve4 } from "node:path";
768
+ function configPath(repoRoot) {
769
+ return resolve4(repoRoot, ".opencode", "ship.config.json");
523
770
  }
524
- function normalizeProfile(name) {
525
- if (name === void 0 || name === null) return DEFAULT_PROFILE;
526
- if (!isValidProfile(name)) return null;
527
- return name;
771
+ async function loadConfig(repoRoot) {
772
+ const path = configPath(repoRoot);
773
+ if (!existsSync4(path)) return null;
774
+ const raw = await readFile(path, "utf8");
775
+ let parsed;
776
+ try {
777
+ parsed = JSON.parse(raw);
778
+ } catch (e) {
779
+ return { ok: false, error: { kind: "parse", path, message: e.message } };
780
+ }
781
+ const validation = validateSchema(parsed, ship_config_schema_default);
782
+ if (!validation.ok) {
783
+ return { ok: false, error: { kind: "contract", path, issues: validation.issues } };
784
+ }
785
+ return {
786
+ ok: true,
787
+ path,
788
+ raw,
789
+ sha256: bytesHashString(raw),
790
+ canonicalSha256: bytesHashString(stableStringify(parsed)),
791
+ value: parsed
792
+ };
528
793
  }
529
- function isLegacyCoreProfile(name) {
530
- return name === "core";
794
+ async function writeConfig(repoRoot, value) {
795
+ const path = configPath(repoRoot);
796
+ await mkdir(dirname3(path), { recursive: true });
797
+ const raw = JSON.stringify(value, null, 2) + "\n";
798
+ const tmp = `${path}.tmp`;
799
+ await writeFile(tmp, raw, "utf8");
800
+ await rename(tmp, path);
801
+ return { path, raw, sha256: bytesHashString(raw) };
531
802
  }
532
- function resolveProfile({ cli = null, config = null, lock = null } = {}) {
533
- if (cli !== null && cli !== void 0) {
534
- const v = normalizeProfile(cli);
535
- if (v === null) {
536
- throw new Error(
537
- `unknown CLI profile '${cli}' (only 'engineering' is supported in 1.1.0; the 'core' profile was removed)`
538
- );
539
- }
540
- return { profile: v, source: "cli" };
541
- }
542
- if (config && typeof config === "object" && config.profile !== void 0 && config.profile !== null) {
543
- const v = normalizeProfile(config.profile);
544
- if (v === null) {
545
- if (isLegacyCoreProfile(config.profile)) {
546
- return { profile: DEFAULT_PROFILE, source: "default" };
547
- }
548
- throw new Error(
549
- `unknown ship.config.json profile '${config.profile}' (only 'engineering' is supported in 1.1.0)`
550
- );
803
+ function renderDefaultConfig(detection, overrides = {}) {
804
+ const pm = detection?.packageManager ?? "npm";
805
+ const safeBootstrap = Array.isArray(detection?.worktreeBootstrap) && detection.worktreeBootstrap.length ? detection.worktreeBootstrap : [["npm", "install"]];
806
+ const safeVerification = Array.isArray(detection?.verificationPlan) && detection.verificationPlan.length ? detection.verificationPlan.map((step) => ({ id: step.id, argv: step.argv })) : [{ id: "typecheck", argv: ["npm", "run", "typecheck"] }];
807
+ const repo = detection?.repository ?? overrides.repository ?? "owner/repo";
808
+ return {
809
+ schemaVersion: 2,
810
+ profile: "engineering",
811
+ project: {
812
+ remote: detection?.remote ?? "origin",
813
+ repository: repo,
814
+ defaultBranch: detection?.defaultBranch ?? "main",
815
+ packageManager: pm,
816
+ detectOverrides: false
817
+ },
818
+ delivery: {
819
+ worktree: {
820
+ root: detection?.worktreeRoot ?? ".worktrees",
821
+ branchTemplate: "{actor}/{slug}",
822
+ bootstrap: safeBootstrap
823
+ },
824
+ verification: {
825
+ commands: safeVerification,
826
+ requireCleanDiffAfter: true,
827
+ invalidateOnHeadChange: true
828
+ },
829
+ review: { agent: "delivery-reviewer", required: true, invalidateOnHeadChange: true },
830
+ ci: {
831
+ driver: "github-status-checks",
832
+ requiredChecks: ["delivery-verify"],
833
+ wait: true,
834
+ flakyRetry: 1
835
+ },
836
+ ready: { requires: ["review", "local-verification", "remote-ci"], stopAfterReady: true },
837
+ merge: { strategy: "squash", policy: "explicit-user-request-only", requireFreshGates: true },
838
+ cleanup: { when: "next-task", requireUnpublishedGuard: true }
839
+ },
840
+ workflow: {
841
+ models: {},
842
+ approval: { mirrorToIssue: true, maxFailedRounds: 3 }
551
843
  }
552
- return { profile: v, source: "config" };
844
+ };
845
+ }
846
+ function hasCompletedModels(configValue) {
847
+ const models = configValue?.workflow?.models;
848
+ if (!models || typeof models !== "object") return false;
849
+ const idRe = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
850
+ return typeof models.planner === "string" && idRe.test(models.planner) && typeof models.builder === "string" && idRe.test(models.builder) && typeof models.finalReviewer === "string" && idRe.test(models.finalReviewer);
851
+ }
852
+ var init_config = __esm({
853
+ "src/installer/config.js"() {
854
+ init_ship_config_schema();
855
+ init_validation();
856
+ init_json_pointer();
857
+ init_hash();
553
858
  }
554
- if (lock && typeof lock === "object" && lock.manager && lock.manager.profile !== void 0) {
555
- const v = normalizeProfile(lock.manager.profile);
556
- if (v === null) {
557
- if (isLegacyCoreProfile(lock.manager.profile)) {
558
- return { profile: DEFAULT_PROFILE, source: "default" };
559
- }
560
- throw new Error(
561
- `unknown lock manager.profile '${lock.manager.profile}' (only 'engineering' is supported in 1.1.0)`
562
- );
859
+ });
860
+
861
+ // src/installer/plan-mode-permissions.js
862
+ function planModePermissions() {
863
+ return {
864
+ build: {
865
+ bash: DENY_DEFAULT,
866
+ edit: {
867
+ "*": DENY_DEFAULT,
868
+ [PLANS_GLOB]: ALLOW_PLANS
869
+ },
870
+ webfetch: DENY_DEFAULT,
871
+ task: DENY_DEFAULT,
872
+ delivery_inspect: DENY_DEFAULT,
873
+ delivery_issue: DENY_DEFAULT,
874
+ delivery_worktree: DENY_DEFAULT,
875
+ delivery_verify: DENY_DEFAULT,
876
+ delivery_review: DENY_DEFAULT,
877
+ delivery_pr: DENY_DEFAULT,
878
+ delivery_ready: DENY_DEFAULT,
879
+ delivery_merge: DENY_DEFAULT,
880
+ delivery_cleanup: DENY_DEFAULT
563
881
  }
564
- return { profile: v, source: "lock" };
565
- }
566
- return { profile: DEFAULT_PROFILE, source: "default" };
882
+ };
567
883
  }
884
+ var PLAN_PATH_PREFIX, DENY_DEFAULT, ALLOW_PLANS, PLANS_GLOB;
885
+ var init_plan_mode_permissions = __esm({
886
+ "src/installer/plan-mode-permissions.js"() {
887
+ PLAN_PATH_PREFIX = ".git/opencode-ship/plans";
888
+ DENY_DEFAULT = "deny";
889
+ ALLOW_PLANS = "allow";
890
+ PLANS_GLOB = `${PLAN_PATH_PREFIX}/**`;
891
+ }
892
+ });
568
893
 
569
- // src/installer/cli-args.js
570
- var USAGE = `opencode-ship <command> [options]
571
-
572
- Commands:
573
- init Install managed files in this project. One-liner: pnpm dlx opencode-ship@latest init
574
- diff Show what would change without writing.
575
- update Apply pending updates after recovering the journal.
576
- doctor Validate environment, lock, and references.
577
- uninstall Remove managed files that still match the lock.
578
- --version Print the version and exit.
579
- --help Show this usage and exit.
580
-
581
- Options:
582
- --root <path> Project root (defaults to cwd).
583
- --profile engineering Override active profile (engineering only).
584
- --force-config Rewrite the user config from detection (init only).
585
- --force-root-config Create opencode.json when absent (init only).
586
- --strict-doctor Fail init when doctor reports unhealthy checks.
587
- --replace-managed Replace locally-modified managed files (update only).
588
- --purge-config Remove ship.config.json when uninstalling.
589
- --planner-model <id> Strong planner model id (init only, optional).
590
- --builder-model <id> Cheap builder model id (init only, optional).
591
- --final-reviewer-model <id> Final Standards + Spec reviewer model id (init only, optional).
592
- --json Emit a JSON envelope instead of human output.
593
-
594
- After init succeeds, restart OpenCode and run /setup-ship-workflow to
595
- fill in the workflow.models fields and the per-repo docs.
596
- `;
597
- var MODEL_ID_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
598
- function parseFlags(argv) {
599
- const options = {
600
- rootPath: null,
601
- profile: null,
602
- json: false,
603
- replaceManaged: false,
604
- purgeConfig: false,
605
- forceConfig: false,
606
- forceRootConfig: false,
607
- strictDoctor: false,
608
- plannerModel: null,
609
- builderModel: null,
610
- finalReviewerModel: null
611
- };
612
- for (let i = 0; i < argv.length; i++) {
613
- const arg = argv[i];
614
- if (arg === "--json") options.json = true;
615
- else if (arg === "--replace-managed") options.replaceManaged = true;
616
- else if (arg === "--purge-config") options.purgeConfig = true;
617
- else if (arg === "--force-config") options.forceConfig = true;
618
- else if (arg === "--force-root-config") options.forceRootConfig = true;
619
- else if (arg === "--strict-doctor") options.strictDoctor = true;
620
- else if (arg === "--root") options.rootPath = argv[++i];
621
- else if (arg === "--profile") {
622
- const value = argv[++i];
623
- if (value === void 0) {
624
- return { error: "--profile requires a value" };
625
- }
626
- if (value === "core") {
627
- return {
628
- error: "the 'core' profile was removed in opencode-ship 1.1.0; only 'engineering' is supported. Run /setup-ship-workflow to migrate."
629
- };
630
- }
631
- if (!isValidProfile(value)) {
632
- return { error: `unknown profile '${value}' (expected one of: ${PROFILES.join(", ")})` };
633
- }
634
- options.profile = value;
635
- } else if (arg === "--planner-model" || arg === "--builder-model" || arg === "--final-reviewer-model") {
636
- const value = argv[++i];
637
- if (value === void 0) return { error: `${arg} requires a value` };
638
- if (!MODEL_ID_RE.test(value)) {
639
- return { error: `${arg} must be a "<provider>/<model>" id, got ${JSON.stringify(value)}` };
640
- }
641
- if (arg === "--planner-model") options.plannerModel = value;
642
- else if (arg === "--builder-model") options.builderModel = value;
643
- else options.finalReviewerModel = value;
644
- } else if (arg === "-h" || arg === "--help") return { help: true };
645
- else if (arg === "-v" || arg === "--version") return { version: true };
646
- else return { error: `unknown flag ${arg}` };
894
+ // src/installer/root-config.js
895
+ var root_config_exports = {};
896
+ __export(root_config_exports, {
897
+ PLAN_MODE_POINTER: () => PLAN_MODE_POINTER,
898
+ POINTER_ENTRIES: () => POINTER_ENTRIES,
899
+ applyOwnedPointers: () => applyOwnedPointers,
900
+ applyPlanModeOwnership: () => applyPlanModeOwnership,
901
+ defaultRootConfigPath: () => defaultRootConfigPath,
902
+ findRootConfig: () => findRootConfig,
903
+ formatRootConfig: () => formatRootConfig,
904
+ formatRootConfigPreserving: () => formatRootConfigPreserving,
905
+ parseRootConfigPreservingOrder: () => parseRootConfigPreservingOrder,
906
+ planModeBlock: () => planModeBlock,
907
+ readRootConfig: () => readRootConfig,
908
+ synthesizeDefaultRootConfig: () => synthesizeDefaultRootConfig
909
+ });
910
+ import { existsSync as existsSync5, readFileSync as readFileSync3 } from "node:fs";
911
+ import { readFile as readFile2 } from "node:fs/promises";
912
+ import { resolve as resolve5 } from "node:path";
913
+ function findRootConfig(repoRoot) {
914
+ for (const rel of ROOT_PATH_CANDIDATES) {
915
+ const abs = resolve5(repoRoot, rel);
916
+ if (existsSync5(abs)) return { path: abs, relative: rel, format: rel.endsWith(".jsonc") ? "jsonc" : "json" };
647
917
  }
648
- return options;
918
+ return { path: null, relative: ROOT_PATH_CANDIDATES[0], format: "json" };
649
919
  }
650
- function parseCommand(argv) {
651
- for (const arg of argv) {
652
- if (arg === "--help" || arg === "-h") return { command: "help" };
653
- if (arg === "--version" || arg === "-v") return { command: "version" };
920
+ function defaultRootConfigPath(repoRoot) {
921
+ return resolve5(repoRoot, ROOT_PATH_CANDIDATES[0]);
922
+ }
923
+ function readRootConfig(absPath) {
924
+ if (!existsSync5(absPath)) {
925
+ return { ok: false, error: { kind: "missing", path: absPath } };
654
926
  }
655
- const [cmd, ...rest] = argv;
656
- if (!cmd) return { command: "help" };
657
- const flags = parseFlags(rest);
658
- if ("help" in flags) return { command: "help" };
659
- if ("version" in flags) return { command: "version" };
660
- if ("error" in flags) return { error: flags.error };
661
- switch (cmd) {
662
- case "init":
663
- case "diff":
664
- case "update":
665
- case "doctor":
666
- case "uninstall":
667
- return { command: cmd, options: flags };
668
- default:
669
- return { error: `unknown command ${cmd}` };
927
+ const raw = readFileSync3(absPath, "utf8");
928
+ const stripped = stripJsonc(raw);
929
+ try {
930
+ const value = JSON.parse(stripped);
931
+ return {
932
+ ok: true,
933
+ path: absPath,
934
+ raw,
935
+ sha256: bytesHashString(raw),
936
+ value,
937
+ before: snapshotValues(value),
938
+ format: absPath.endsWith(".jsonc") ? "jsonc" : "json"
939
+ };
940
+ } catch (e) {
941
+ return { ok: false, error: { kind: "parse", path: absPath, message: e.message } };
670
942
  }
671
943
  }
672
- function helpText() {
673
- return USAGE;
944
+ function snapshotValues(doc) {
945
+ const out = {};
946
+ for (const entry of POINTER_ENTRIES) {
947
+ out[entry.pointer] = getPointer(doc, entry.pointer);
948
+ }
949
+ return out;
674
950
  }
675
-
676
- // src/installer/commands/init.js
677
- import { promisify as promisify2 } from "node:util";
678
- import { writeFile as writeFile7, mkdir as mkdirAsync2 } from "node:fs/promises";
679
- import { dirname as dirname9, resolve as resolvePath } from "node:path";
680
-
681
- // src/installer/executor.js
682
- import { existsSync as existsSync13 } from "node:fs";
683
- import { mkdir as mkdir5, readFile as readFile8, rename as rename5, unlink as unlink3, writeFile as writeFile5 } from "node:fs/promises";
684
- import { dirname as dirname7, resolve as resolve12 } from "node:path";
685
-
686
- // src/installer/catalog.js
687
- import { resolve as resolve3, relative, sep } from "node:path";
688
- import { existsSync as existsSync3, statSync } from "node:fs";
689
-
690
- // src/installer/package-root.js
691
- import { existsSync, readFileSync } from "node:fs";
692
- import { fileURLToPath } from "node:url";
693
- import { dirname, resolve } from "node:path";
694
- var PACKAGE_NAME = "opencode-ship";
695
- function resolvePackageRoot(startUrl) {
696
- let candidate = dirname(fileURLToPath(startUrl ?? import.meta.url));
697
- while (candidate && candidate !== "/") {
698
- const pkgPath = resolve(candidate, "package.json");
699
- if (existsSync(pkgPath)) {
700
- try {
701
- const raw = readFileSync(pkgPath, "utf8");
702
- const pkg = JSON.parse(raw);
703
- if (pkg && pkg.name === PACKAGE_NAME) return candidate;
704
- } catch {
951
+ function stripJsonc(text) {
952
+ let stripped = "";
953
+ let i = 0;
954
+ let inString = false;
955
+ let escape = false;
956
+ while (i < text.length) {
957
+ const ch = text[i];
958
+ if (inString) {
959
+ stripped += ch;
960
+ if (escape) {
961
+ escape = false;
962
+ } else if (ch === "\\") {
963
+ escape = true;
964
+ } else if (ch === '"') {
965
+ inString = false;
705
966
  }
967
+ i += 1;
968
+ continue;
706
969
  }
707
- candidate = dirname(candidate);
708
- }
709
- throw new Error(`opencode-ship package root not found from ${startUrl ?? import.meta.url}`);
710
- }
711
-
712
- // src/version.js
713
- import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
714
- import { dirname as dirname2, resolve as resolve2 } from "node:path";
715
- import { fileURLToPath as fileURLToPath2 } from "node:url";
716
- var PACKAGE_VERSION = "1.1.0";
717
- var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
718
-
719
- // src/installer/catalog.js
720
- var TEMPLATE_SET_ID = TEMPLATE_SET;
721
- var packageRoot = resolvePackageRoot(import.meta.url);
722
- var MATT_SKILLS = [
723
- "setup-engineering-workflow",
724
- "engineering-workflow",
725
- "grilling",
726
- "domain-modeling",
727
- "grill-with-docs",
728
- "triage",
729
- "to-spec",
730
- "to-tickets",
731
- "wayfinder",
732
- "handoff",
733
- "research",
734
- "prototype",
735
- "codebase-design",
736
- "code-review"
737
- ];
738
- var SUPER_SKILLS = [
739
- "brainstorming",
740
- "writing-plans",
741
- "executing-plans",
742
- "subagent-driven-development",
743
- "dispatching-parallel-agents",
744
- "test-driven-development",
745
- "systematic-debugging",
746
- "verification-before-completion",
747
- "requesting-code-review",
748
- "receiving-code-review"
749
- ];
750
- var ENGINEERING_AGENTS = [
751
- "ship-controller",
752
- "ship-planner",
753
- "ship-task-builder",
754
- "ship-task-reviewer",
755
- "ship-final-standards-reviewer",
756
- "ship-final-spec-reviewer"
757
- ];
758
- var ENGINEERING_COMMANDS = [
759
- "ship-deliver",
760
- "ship-resume",
761
- "ship-status"
762
- ];
763
- var CATALOG = [
764
- {
765
- id: "plugin:opencode-ship",
766
- kind: "plugin",
767
- path: ".opencode/plugins/opencode-ship.js",
768
- source: resolve3(packageRoot, "dist/plugin.js"),
769
- mode: 420,
770
- profiles: ["engineering"]
771
- },
772
- {
773
- id: "agent:delivery-reviewer",
774
- kind: "agent",
775
- path: ".opencode/agents/delivery-reviewer.md",
776
- source: resolve3(packageRoot, "assets/agents/delivery-reviewer.md"),
777
- mode: 420,
778
- profiles: ["engineering"]
779
- },
780
- {
781
- id: "agent:delivery-verifier",
782
- kind: "agent",
783
- path: ".opencode/agents/delivery-verifier.md",
784
- source: resolve3(packageRoot, "assets/agents/delivery-verifier.md"),
785
- mode: 420,
786
- profiles: ["engineering"]
787
- },
788
- ...ENGINEERING_AGENTS.map((name) => ({
789
- id: `agent:${name}`,
790
- kind: "agent",
791
- path: `.opencode/agents/${name}.md`,
792
- source: resolve3(packageRoot, `assets/agents/${name}.md`),
793
- mode: 420,
794
- profiles: ["engineering"]
795
- })),
796
- ...ENGINEERING_COMMANDS.map((name) => ({
797
- id: `command:${name}`,
798
- kind: "support",
799
- path: `.opencode/commands/${name}.md`,
800
- source: resolve3(packageRoot, `assets/commands/${name}.md`),
801
- mode: 420,
802
- profiles: ["engineering"]
803
- })),
804
- {
805
- id: "skill:delivery-workflow",
806
- kind: "skill",
807
- path: ".opencode/skills/delivery-workflow/SKILL.md",
808
- source: resolve3(packageRoot, "assets/skills/delivery-workflow/SKILL.md"),
809
- mode: 420,
810
- profiles: ["engineering"]
811
- },
812
- {
813
- id: "skill:planning-research-checkpoint",
814
- kind: "skill",
815
- path: ".opencode/skills/planning-research-checkpoint/SKILL.md",
816
- source: resolve3(packageRoot, "assets/skills/planning-research-checkpoint/SKILL.md"),
817
- mode: 420,
818
- profiles: ["engineering"]
819
- },
820
- ...MATT_SKILLS.map((name) => ({
821
- id: `skill:matt:${name}`,
822
- kind: "skill",
823
- path: `.opencode/skills/${name}/SKILL.md`,
824
- source: resolve3(packageRoot, `assets/skills/${name}/SKILL.md`),
825
- mode: 420,
826
- profiles: ["engineering"]
827
- })),
828
- ...SUPER_SKILLS.map((name) => ({
829
- id: `skill:super:${name}`,
830
- kind: "skill",
831
- path: `.opencode/skills/${name}/SKILL.md`,
832
- source: resolve3(packageRoot, `assets/skills/${name}/SKILL.md`),
833
- mode: 420,
834
- profiles: ["engineering"]
835
- })),
836
- {
837
- id: "skill:setup-ship-workflow",
838
- kind: "skill",
839
- path: ".opencode/skills/setup-ship-workflow/SKILL.md",
840
- source: resolve3(packageRoot, "assets/skills/setup-engineering-workflow/SKILL.md"),
841
- mode: 420,
842
- profiles: ["engineering"]
843
- },
844
- {
845
- id: "skill:skill-discovery",
846
- kind: "skill",
847
- path: ".opencode/skills/skill-discovery/SKILL.md",
848
- source: resolve3(packageRoot, "assets/skills/skill-discovery/SKILL.md"),
849
- mode: 420,
850
- profiles: ["engineering"]
851
- },
852
- {
853
- id: "command:setup-ship-workflow",
854
- kind: "support",
855
- path: ".opencode/commands/setup-ship-workflow.md",
856
- source: resolve3(packageRoot, "assets/commands/setup-ship-workflow.md"),
857
- mode: 420,
858
- profiles: ["engineering"]
859
- }
860
- ];
861
- function filterCatalogByProfile(catalog, profile) {
862
- const effective = profile === void 0 || profile === null ? DEFAULT_PROFILE : profile;
863
- if (!isValidProfile(effective)) {
864
- throw new Error(
865
- `filterCatalogByProfile: unknown profile '${profile}' (expected one of: ${PROFILES.join(", ")})`
866
- );
867
- }
868
- return catalog.filter((entry) => Array.isArray(entry.profiles) && entry.profiles.includes(effective));
869
- }
870
- var ALLOWED_KINDS = /* @__PURE__ */ new Set(["plugin", "agent", "skill", "support"]);
871
- function validateCatalog({ catalog = CATALOG } = {}) {
872
- const seenIds = /* @__PURE__ */ new Set();
873
- const seenPaths = /* @__PURE__ */ new Set();
874
- const issues = [];
875
- for (const entry of catalog) {
876
- if (!entry || typeof entry !== "object") {
877
- issues.push({ id: null, kind: "shape", message: "catalog entry is not an object" });
970
+ if (ch === '"') {
971
+ inString = true;
972
+ stripped += ch;
973
+ i += 1;
878
974
  continue;
879
975
  }
880
- const { id, kind, path, source, mode } = entry;
881
- if (typeof id !== "string" || id.length === 0) {
882
- issues.push({ id: null, kind: "id", message: `entry id missing: ${JSON.stringify(entry)}` });
883
- } else if (seenIds.has(id)) {
884
- issues.push({ id, kind: "duplicate-id", message: `duplicate catalog id: ${id}` });
885
- } else {
886
- seenIds.add(id);
976
+ if (ch === "/" && text[i + 1] === "/") {
977
+ while (i < text.length && text[i] !== "\n") i += 1;
978
+ continue;
887
979
  }
888
- if (typeof path !== "string" || !path.startsWith(".opencode" + sep)) {
889
- issues.push({ id, kind: "path", message: `path must be rooted under .opencode/: ${path}` });
980
+ if (ch === "/" && text[i + 1] === "*") {
981
+ i += 2;
982
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
983
+ i += 2;
984
+ continue;
890
985
  }
891
- if (seenPaths.has(path)) {
892
- issues.push({ id, kind: "duplicate-path", message: `duplicate target path: ${path}` });
893
- } else {
894
- seenPaths.add(path);
986
+ stripped += ch;
987
+ i += 1;
988
+ }
989
+ return stripped.replace(/,\s*([}\]])/g, "$1");
990
+ }
991
+ function applyOwnedPointers(rootDoc, { pointerEntries = POINTER_ENTRIES, allowEqualValues = true } = {}) {
992
+ const result = { doc: rootDoc, applied: [], skipped: [] };
993
+ let doc = rootDoc;
994
+ for (const entry of pointerEntries) {
995
+ const existing = getPointer(doc, entry.pointer);
996
+ if (existing === void 0) {
997
+ doc = setPointer(doc, entry.pointer, entry.value);
998
+ result.applied.push({ pointer: entry.pointer, value: entry.value });
999
+ continue;
895
1000
  }
896
- if (!ALLOWED_KINDS.has(kind)) {
897
- issues.push({ id, kind: "kind", message: `unsupported entry kind: ${kind}` });
1001
+ if (existing === entry.value || stableStringify(existing) === stableStringify(entry.value)) {
1002
+ if (allowEqualValues) {
1003
+ result.skipped.push({ pointer: entry.pointer, reason: "already equal" });
1004
+ }
1005
+ continue;
898
1006
  }
899
- if (typeof source !== "string" || source.length === 0) {
900
- issues.push({ id, kind: "source", message: `source path missing: ${id}` });
901
- } else if (!existsSync3(source)) {
902
- issues.push({ id, kind: "source-missing", message: `source file not found: ${source}` });
903
- } else {
904
- try {
905
- const stats = statSync(source);
906
- if (!stats.isFile()) {
907
- issues.push({ id, kind: "source-not-file", message: `source is not a regular file: ${source}` });
908
- } else if (stats.size === 0) {
909
- issues.push({ id, kind: "source-empty", message: `source file is empty: ${source}` });
1007
+ result.skipped.push({
1008
+ pointer: entry.pointer,
1009
+ reason: "different existing value",
1010
+ existing,
1011
+ desired: entry.value
1012
+ });
1013
+ }
1014
+ return result;
1015
+ }
1016
+ function applyPlanModeOwnership(rootDoc, { pointer = PLAN_MODE_POINTER, block = planModePermissions().build } = {}) {
1017
+ const previous = getPointer(rootDoc, pointer);
1018
+ const doc = setPointer(rootDoc, pointer, block);
1019
+ return { doc, previous: previous === void 0 ? null : previous, id: pointer };
1020
+ }
1021
+ function planModeBlock() {
1022
+ return planModePermissions().build;
1023
+ }
1024
+ function synthesizeDefaultRootConfig() {
1025
+ return {
1026
+ $schema: "https://opencode.ai/config.json",
1027
+ agent: {
1028
+ build: {
1029
+ permission: {
1030
+ delivery_inspect: "allow",
1031
+ delivery_issue: "allow",
1032
+ delivery_worktree: "allow",
1033
+ delivery_verify: "deny",
1034
+ delivery_review: "deny",
1035
+ delivery_pr: "allow",
1036
+ delivery_ready: "allow",
1037
+ delivery_merge: "ask",
1038
+ delivery_cleanup: "allow",
1039
+ task: {
1040
+ "delivery-reviewer": "allow",
1041
+ "delivery-verifier": "allow"
1042
+ }
910
1043
  }
911
- } catch (e) {
912
- issues.push({ id, kind: "source-stat", message: `unable to stat source: ${e?.message ?? e}` });
913
- }
914
- const rel = relative(packageRoot, source);
915
- if (rel.startsWith("..")) {
916
- issues.push({ id, kind: "source-out-of-package", message: `source escapes package root: ${source}` });
917
1044
  }
918
1045
  }
919
- if (mode !== 420) {
920
- issues.push({ id, kind: "mode", message: `mode must be 0o644: ${id}` });
1046
+ };
1047
+ }
1048
+ function formatRootConfig(value) {
1049
+ return JSON.stringify(stripSourceOrder(value), null, 2) + "\n";
1050
+ }
1051
+ function stripSourceOrder(value) {
1052
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
1053
+ const out = Array.isArray(value) ? [] : {};
1054
+ const order = Array.isArray(value.__sourceOrder__) ? value.__sourceOrder__ : null;
1055
+ const seen = /* @__PURE__ */ new Set();
1056
+ if (order) {
1057
+ for (const k of order) {
1058
+ if (k === "__sourceOrder__") continue;
1059
+ if (!(k in value)) continue;
1060
+ seen.add(k);
1061
+ out[k] = stripSourceOrder(value[k]);
921
1062
  }
922
- if (!Array.isArray(entry.profiles) || entry.profiles.length === 0) {
923
- issues.push({ id, kind: "profiles", message: `profiles must be a non-empty array: ${id}` });
924
- } else {
925
- for (const p of entry.profiles) {
926
- if (!isValidProfile(p)) {
927
- issues.push({ id, kind: "profiles", message: `unknown profile in profiles[${entry.profiles.indexOf(p)}]: ${p} (expected one of: ${PROFILES.join(", ")})` });
1063
+ }
1064
+ for (const k of Object.keys(value)) {
1065
+ if (k === "__sourceOrder__") continue;
1066
+ if (seen.has(k)) continue;
1067
+ out[k] = stripSourceOrder(value[k]);
1068
+ }
1069
+ return out;
1070
+ }
1071
+ function formatRootConfigPreserving(value) {
1072
+ return formatRootConfig(value);
1073
+ }
1074
+ function parseRootConfigPreservingOrder(text) {
1075
+ if (typeof text !== "string" || text.length === 0) {
1076
+ return { value: {}, format: "json" };
1077
+ }
1078
+ const parser = new RootConfigParser(text);
1079
+ const value = parser.parseValue(
1080
+ 0,
1081
+ /*atTop*/
1082
+ true
1083
+ );
1084
+ const isJsonc = text.includes("//") || text.includes("/*");
1085
+ return { value, format: isJsonc ? "jsonc" : "json" };
1086
+ }
1087
+ var POINTER_ENTRIES, ROOT_PATH_CANDIDATES, PLAN_MODE_POINTER, RootConfigParser;
1088
+ var init_root_config = __esm({
1089
+ "src/installer/root-config.js"() {
1090
+ init_json_pointer();
1091
+ init_hash();
1092
+ init_plan_mode_permissions();
1093
+ POINTER_ENTRIES = [
1094
+ {
1095
+ pointer: "/agent/build/permission/delivery_inspect",
1096
+ strategy: "value",
1097
+ value: "allow"
1098
+ },
1099
+ {
1100
+ pointer: "/agent/build/permission/delivery_issue",
1101
+ strategy: "value",
1102
+ value: "allow"
1103
+ },
1104
+ {
1105
+ pointer: "/agent/build/permission/delivery_worktree",
1106
+ strategy: "value",
1107
+ value: "allow"
1108
+ },
1109
+ {
1110
+ pointer: "/agent/build/permission/delivery_verify",
1111
+ strategy: "value",
1112
+ value: "deny"
1113
+ },
1114
+ {
1115
+ pointer: "/agent/build/permission/delivery_review",
1116
+ strategy: "value",
1117
+ value: "deny"
1118
+ },
1119
+ {
1120
+ pointer: "/agent/build/permission/delivery_pr",
1121
+ strategy: "value",
1122
+ value: "allow"
1123
+ },
1124
+ {
1125
+ pointer: "/agent/build/permission/delivery_ready",
1126
+ strategy: "value",
1127
+ value: "allow"
1128
+ },
1129
+ {
1130
+ pointer: "/agent/build/permission/delivery_merge",
1131
+ strategy: "value",
1132
+ value: "ask"
1133
+ },
1134
+ {
1135
+ pointer: "/agent/build/permission/delivery_cleanup",
1136
+ strategy: "value",
1137
+ value: "allow"
1138
+ },
1139
+ {
1140
+ pointer: "/agent/build/permission/task/delivery-reviewer",
1141
+ strategy: "value",
1142
+ value: "allow"
1143
+ },
1144
+ {
1145
+ pointer: "/agent/build/permission/task/delivery-verifier",
1146
+ strategy: "value",
1147
+ value: "allow"
1148
+ }
1149
+ ];
1150
+ ROOT_PATH_CANDIDATES = ["opencode.json", "opencode.jsonc"];
1151
+ PLAN_MODE_POINTER = "/agent/plan/permission";
1152
+ RootConfigParser = class {
1153
+ constructor(text) {
1154
+ this.text = text;
1155
+ this.pos = 0;
1156
+ }
1157
+ skipWS() {
1158
+ while (this.pos < this.text.length) {
1159
+ const ch = this.text[this.pos];
1160
+ if (ch === " " || ch === "\n" || ch === " " || ch === "\r") {
1161
+ this.pos += 1;
1162
+ continue;
1163
+ }
1164
+ if (ch === "/" && this.text[this.pos + 1] === "/") {
1165
+ while (this.pos < this.text.length && this.text[this.pos] !== "\n") this.pos += 1;
1166
+ continue;
1167
+ }
1168
+ if (ch === "/" && this.text[this.pos + 1] === "*") {
1169
+ this.pos += 2;
1170
+ while (this.pos < this.text.length && !(this.text[this.pos] === "*" && this.text[this.pos + 1] === "/")) this.pos += 1;
1171
+ this.pos += 2;
1172
+ continue;
1173
+ }
1174
+ break;
928
1175
  }
929
1176
  }
930
- }
931
- }
932
- if (issues.length > 0) {
933
- const summary = issues.map((i) => i.message).join("; ");
934
- const err = new Error(`opencode-ship catalog validation failed: ${summary}`);
935
- err.issues = issues;
936
- err.catalogValidation = true;
937
- throw err;
938
- }
939
- return catalog;
940
- }
941
-
942
- // src/installer/planner.js
943
- import { existsSync as existsSync7 } from "node:fs";
944
- import { readFile as readFile3, stat } from "node:fs/promises";
945
- init_hash();
946
-
947
- // src/installer/config.js
948
- import { readFile, writeFile, rename, mkdir } from "node:fs/promises";
949
- import { existsSync as existsSync4 } from "node:fs";
950
- import { dirname as dirname3, resolve as resolve4 } from "node:path";
951
-
952
- // schema/ship-config.schema.json
953
- var ship_config_schema_default = {
954
- $schema: "https://json-schema.org/draft/2020-12/schema",
955
- $id: "https://github.com/Viktorxyz/opencode-ship/schema/ship-config.schema.json",
956
- title: "opencode-ship user config",
957
- type: "object",
958
- required: ["schemaVersion"],
959
- additionalProperties: false,
960
- properties: {
961
- schemaVersion: { enum: [1, 2] },
962
- profile: {
963
- type: "string",
964
- enum: ["engineering"],
965
- description: "Active profile. Engineering is the only supported profile in 1.1.0."
966
- },
967
- owner: {
968
- type: "string",
969
- description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
970
- },
971
- project: {
972
- type: "object",
973
- additionalProperties: false,
974
- properties: {
975
- remote: { type: "string", minLength: 1 },
976
- repository: { type: "string", pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$" },
977
- defaultBranch: { type: "string", minLength: 1 },
978
- packageManager: { enum: ["npm", "pnpm", "yarn", "bun"] },
979
- detectOverrides: { type: "boolean", description: "Permit detection to refresh previously persisted values." }
1177
+ parseValue(depth, atTop) {
1178
+ this.skipWS();
1179
+ const ch = this.text[this.pos];
1180
+ if (ch === "{") return this.parseObject(depth, atTop);
1181
+ if (ch === "[") return this.parseArray(depth);
1182
+ if (ch === '"') return this.parseString();
1183
+ if (ch === "-" || ch >= "0" && ch <= "9") return this.parseNumber();
1184
+ if (this.text.startsWith("true", this.pos)) {
1185
+ this.pos += 4;
1186
+ return true;
1187
+ }
1188
+ if (this.text.startsWith("false", this.pos)) {
1189
+ this.pos += 5;
1190
+ return false;
1191
+ }
1192
+ if (this.text.startsWith("null", this.pos)) {
1193
+ this.pos += 4;
1194
+ return null;
1195
+ }
1196
+ throw new Error(`unexpected token at ${this.pos}: ${this.text.slice(this.pos, this.pos + 8)}`);
980
1197
  }
981
- },
982
- delivery: {
983
- type: "object",
984
- additionalProperties: false,
985
- properties: {
986
- worktree: {
987
- type: "object",
988
- additionalProperties: false,
989
- properties: {
990
- root: { type: "string", minLength: 1 },
991
- branchTemplate: { type: "string", minLength: 1 },
992
- bootstrap: {
993
- type: "array",
994
- items: {
995
- type: "array",
996
- items: { type: "string", minLength: 1 },
997
- minItems: 1
998
- }
999
- }
1000
- }
1001
- },
1002
- verification: {
1003
- type: "object",
1004
- additionalProperties: false,
1005
- properties: {
1006
- commands: {
1007
- type: "array",
1008
- minItems: 1,
1009
- items: {
1010
- type: "object",
1011
- required: ["id", "argv"],
1012
- additionalProperties: false,
1013
- properties: {
1014
- id: { type: "string", minLength: 1 },
1015
- argv: {
1016
- type: "array",
1017
- items: { type: "string", minLength: 1 },
1018
- minItems: 1
1019
- },
1020
- timeoutMs: { type: "integer", minimum: 1 }
1021
- }
1022
- }
1023
- },
1024
- requireCleanDiffAfter: { type: "boolean" },
1025
- invalidateOnHeadChange: { type: "boolean" }
1198
+ parseObject(depth, atTop) {
1199
+ const out = /* @__PURE__ */ Object.create(null);
1200
+ out.__sourceOrder__ = [];
1201
+ this.pos += 1;
1202
+ while (this.pos < this.text.length) {
1203
+ this.skipWS();
1204
+ if (this.text[this.pos] === "}") {
1205
+ this.pos += 1;
1206
+ return out;
1026
1207
  }
1027
- },
1028
- review: {
1029
- type: "object",
1030
- additionalProperties: false,
1031
- properties: {
1032
- agent: { type: "string", minLength: 1 },
1033
- required: { type: "boolean" },
1034
- invalidateOnHeadChange: { type: "boolean" }
1208
+ const key = this.parseString();
1209
+ out.__sourceOrder__.push(key);
1210
+ this.skipWS();
1211
+ if (this.text[this.pos] !== ":") throw new Error(`expected : at ${this.pos}`);
1212
+ this.pos += 1;
1213
+ out[key] = this.parseValue(depth + 1, false);
1214
+ this.skipWS();
1215
+ if (this.text[this.pos] === ",") {
1216
+ this.pos += 1;
1217
+ continue;
1035
1218
  }
1036
- },
1037
- ci: {
1038
- type: "object",
1039
- additionalProperties: false,
1040
- properties: {
1041
- driver: { const: "github-status-checks" },
1042
- requiredChecks: {
1043
- type: "array",
1044
- items: { type: "string", minLength: 1 },
1045
- uniqueItems: true
1046
- },
1047
- wait: { type: "boolean" },
1048
- flakyRetry: { type: "integer", enum: [0, 1] }
1219
+ if (this.text[this.pos] === "}") {
1220
+ this.pos += 1;
1221
+ return out;
1049
1222
  }
1050
- },
1051
- ready: {
1052
- type: "object",
1053
- additionalProperties: false,
1054
- properties: {
1055
- requires: {
1056
- type: "array",
1057
- items: { enum: ["review", "local-verification", "remote-ci"] },
1058
- uniqueItems: true
1059
- },
1060
- stopAfterReady: { type: "boolean" }
1223
+ throw new Error(`expected , or } at ${this.pos}`);
1224
+ }
1225
+ throw new Error("unterminated object");
1226
+ }
1227
+ parseArray(depth) {
1228
+ const out = [];
1229
+ this.pos += 1;
1230
+ while (this.pos < this.text.length) {
1231
+ this.skipWS();
1232
+ if (this.text[this.pos] === "]") {
1233
+ this.pos += 1;
1234
+ return out;
1061
1235
  }
1062
- },
1063
- merge: {
1064
- type: "object",
1065
- additionalProperties: false,
1066
- properties: {
1067
- strategy: { const: "squash" },
1068
- policy: { const: "explicit-user-request-only" },
1069
- requireFreshGates: { type: "boolean" }
1236
+ out.push(this.parseValue(depth + 1, false));
1237
+ this.skipWS();
1238
+ if (this.text[this.pos] === ",") {
1239
+ this.pos += 1;
1240
+ continue;
1070
1241
  }
1071
- },
1072
- cleanup: {
1073
- type: "object",
1074
- additionalProperties: false,
1075
- properties: {
1076
- when: { const: "next-task" },
1077
- requireUnpublishedGuard: { type: "boolean" }
1242
+ if (this.text[this.pos] === "]") {
1243
+ this.pos += 1;
1244
+ return out;
1078
1245
  }
1246
+ throw new Error(`expected , or ] at ${this.pos}`);
1079
1247
  }
1248
+ throw new Error("unterminated array");
1080
1249
  }
1081
- },
1082
- tasks: {
1083
- type: "object",
1084
- description: "Optional override of the managed-file paths. Use only to relocate a target.",
1085
- additionalProperties: false,
1086
- properties: {
1087
- pluginPath: { type: "string", pattern: "^\\.opencode/.+\\.js$" },
1088
- agentsDir: { type: "string", pattern: "^\\.opencode/agents/?$" },
1089
- skillsDir: { type: "string", pattern: "^\\.opencode/skills/?$" }
1090
- }
1091
- },
1092
- workflow: {
1093
- type: "object",
1094
- description: "Workflow configuration. Models are optional at write time; the setup-ship-workflow skill fills them in. Once all three are present, ship-deliver can start.",
1095
- additionalProperties: false,
1096
- properties: {
1097
- models: {
1098
- type: "object",
1099
- additionalProperties: false,
1100
- description: "Optional model roles. All three roles must be present before ship-deliver can run.",
1101
- properties: {
1102
- planner: {
1103
- type: "string",
1104
- pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
1105
- description: "Provider/model id for the strong planning child session."
1106
- },
1107
- builder: {
1108
- type: "string",
1109
- pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
1110
- description: "Provider/model id for the cheap builder child session."
1111
- },
1112
- finalReviewer: {
1113
- type: "string",
1114
- pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
1115
- description: "Provider/model id for the final Standards + Spec reviewers."
1116
- }
1250
+ parseString() {
1251
+ if (this.text[this.pos] !== '"') throw new Error(`expected " at ${this.pos}`);
1252
+ this.pos += 1;
1253
+ let out = "";
1254
+ while (this.pos < this.text.length) {
1255
+ const ch = this.text[this.pos];
1256
+ if (ch === "\\") {
1257
+ const next = this.text[this.pos + 1];
1258
+ out += ch + next;
1259
+ this.pos += 2;
1260
+ continue;
1117
1261
  }
1118
- },
1119
- approval: {
1120
- type: "object",
1121
- additionalProperties: false,
1122
- properties: {
1123
- mirrorToIssue: { const: true },
1124
- maxFailedRounds: { const: 3 }
1262
+ if (ch === '"') {
1263
+ this.pos += 1;
1264
+ return JSON.parse('"' + out + '"');
1125
1265
  }
1266
+ out += ch;
1267
+ this.pos += 1;
1126
1268
  }
1269
+ throw new Error("unterminated string");
1127
1270
  }
1128
- }
1271
+ parseNumber() {
1272
+ const start = this.pos;
1273
+ if (this.text[this.pos] === "-") this.pos += 1;
1274
+ while (this.pos < this.text.length && /[0-9.eE+\-]/.test(this.text[this.pos])) this.pos += 1;
1275
+ return Number(this.text.slice(start, this.pos));
1276
+ }
1277
+ };
1129
1278
  }
1130
- };
1279
+ });
1131
1280
 
1132
- // src/installer/validation.js
1133
- var FORMAT_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})$/;
1134
- function isObject2(v) {
1135
- return v !== null && typeof v === "object" && !Array.isArray(v);
1281
+ // src/installer/agent-renderer.js
1282
+ var agent_renderer_exports = {};
1283
+ __export(agent_renderer_exports, {
1284
+ AGENT_ROLE_MAP: () => AGENT_ROLE_MAP,
1285
+ buildRenderedOverride: () => buildRenderedOverride,
1286
+ computeRenderedAgents: () => computeRenderedAgents,
1287
+ modelMarker: () => modelMarker,
1288
+ renderAgentFrontmatter: () => renderAgentFrontmatter,
1289
+ renderedModelFor: () => renderedModelFor
1290
+ });
1291
+ import { dirname as dirname7, join as join6 } from "node:path";
1292
+ import { readFile as readFile8, writeFile as writeFile5, mkdir as mkdir5 } from "node:fs/promises";
1293
+ function modelMarker() {
1294
+ return MODEL_FROM_CONFIG;
1136
1295
  }
1137
- function validate(value, schema, pointer, issues) {
1138
- if (!isObject2(schema)) return;
1139
- if (schema.const !== void 0 && value !== schema.const) {
1140
- issues.push(`${pointer}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
1141
- return;
1142
- }
1143
- if (schema.enum !== void 0 && !schema.enum.includes(value)) {
1144
- issues.push(`${pointer}: expected one of ${JSON.stringify(schema.enum)}, got ${JSON.stringify(value)}`);
1145
- return;
1146
- }
1147
- if (Array.isArray(schema.allOf)) {
1148
- for (const sub of schema.allOf) validate(value, sub, pointer, issues);
1149
- }
1150
- if (isObject2(schema.if)) {
1151
- const ifIssues = [];
1152
- validate(value, schema.if, pointer, ifIssues);
1153
- if (ifIssues.length === 0) {
1154
- if (isObject2(schema.then)) validate(value, schema.then, pointer, issues);
1155
- } else if (isObject2(schema.else)) {
1156
- validate(value, schema.else, pointer, issues);
1157
- }
1158
- }
1159
- const type = schema.type;
1160
- if (type !== void 0) {
1161
- const actual = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
1162
- if (type !== actual) {
1163
- if (!(type === "integer" && typeof value === "number" && Number.isInteger(value))) {
1164
- issues.push(`${pointer}: expected ${type}, got ${actual}`);
1165
- return;
1166
- }
1167
- }
1168
- }
1169
- if (type === "string") {
1170
- if (schema.minLength !== void 0 && value.length < schema.minLength) {
1171
- issues.push(`${pointer}: shorter than minLength ${schema.minLength}`);
1172
- }
1173
- if (schema.pattern !== void 0) {
1174
- const re = new RegExp(schema.pattern);
1175
- if (!re.test(value)) issues.push(`${pointer}: does not match pattern ${schema.pattern}`);
1176
- }
1177
- if (schema.format === "date-time" && !FORMAT_DATE_TIME.test(value)) {
1178
- issues.push(`${pointer}: not a date-time string`);
1179
- }
1180
- }
1181
- if (type === "integer" || type === "number") {
1182
- if (schema.minimum !== void 0 && value < schema.minimum) {
1183
- issues.push(`${pointer}: less than minimum ${schema.minimum}`);
1184
- }
1185
- if (schema.maximum !== void 0 && value > schema.maximum) {
1186
- issues.push(`${pointer}: greater than maximum ${schema.maximum}`);
1187
- }
1188
- if (schema.enum !== void 0) {
1296
+ function renderAgentFrontmatter(source, model) {
1297
+ if (typeof source !== "string") return source;
1298
+ if (typeof model !== "string" || !model) return source;
1299
+ return source.replaceAll(MODEL_FROM_CONFIG, model);
1300
+ }
1301
+ async function computeRenderedAgents({ models, catalog }) {
1302
+ const out = [];
1303
+ if (!models || typeof models !== "object") return out;
1304
+ for (const [role, agentNames] of Object.entries(AGENT_ROLE_MAP)) {
1305
+ const model = models[role];
1306
+ if (typeof model !== "string" || !model) continue;
1307
+ for (const agentName of agentNames) {
1308
+ const entry = (catalog ?? []).find((c) => c.kind === "agent" && c.path.endsWith(`/${agentName}.md`));
1309
+ if (!entry) continue;
1310
+ const source = await readFile8(entry.source, "utf8");
1311
+ const renderedText = renderAgentFrontmatter(source, model);
1312
+ const bytes = Buffer.from(renderedText, "utf8");
1313
+ const sha256 = bytesHashString(renderedText);
1314
+ out.push({ relPath: entry.path, bytes, sha256, role, agentName, target: join6(process.cwd(), entry.path) });
1189
1315
  }
1190
1316
  }
1191
- if (type === "array") {
1192
- if (schema.minItems !== void 0 && value.length < schema.minItems) {
1193
- issues.push(`${pointer}: fewer items than minItems ${schema.minItems}`);
1194
- }
1195
- if (Array.isArray(schema.items)) {
1196
- value.forEach((entry, i) => validate(entry, schema.items[i] ?? {}, `${pointer}/${i}`, issues));
1197
- } else if (schema.items) {
1198
- if (schema.uniqueItems) {
1199
- const seen = /* @__PURE__ */ new Set();
1200
- value.forEach((entry, i) => {
1201
- const key = JSON.stringify(entry);
1202
- if (seen.has(key)) issues.push(`${pointer}/${i}: duplicate unique item`);
1203
- seen.add(key);
1204
- });
1205
- }
1206
- value.forEach((entry, i) => validate(entry, schema.items, `${pointer}/${i}`, issues));
1317
+ return out;
1318
+ }
1319
+ async function buildRenderedOverride({ models, catalog }) {
1320
+ const rendered = await computeRenderedAgents({ models, catalog });
1321
+ const map = /* @__PURE__ */ new Map();
1322
+ for (const entry of rendered) {
1323
+ map.set(entry.relPath, entry);
1324
+ }
1325
+ return { rendered, map };
1326
+ }
1327
+ function renderedModelFor({ agentName, models }) {
1328
+ for (const [role, agentNames] of Object.entries(AGENT_ROLE_MAP)) {
1329
+ if (agentNames.includes(agentName)) {
1330
+ const m = models?.[role];
1331
+ return typeof m === "string" && m ? m : MODEL_FROM_CONFIG;
1207
1332
  }
1208
1333
  }
1209
- if (type === "object" || isObject2(schema.properties) || Array.isArray(schema.required)) {
1210
- if (Array.isArray(schema.required)) {
1211
- for (const key of schema.required) {
1212
- if (!(key in value)) issues.push(`${pointer}: missing required field ${key}`);
1334
+ return MODEL_FROM_CONFIG;
1335
+ }
1336
+ var MODEL_FROM_CONFIG, AGENT_ROLE_MAP;
1337
+ var init_agent_renderer = __esm({
1338
+ "src/installer/agent-renderer.js"() {
1339
+ init_hash();
1340
+ MODEL_FROM_CONFIG = "<model-from-config>";
1341
+ AGENT_ROLE_MAP = Object.freeze({
1342
+ planner: ["ship-planner"],
1343
+ builder: ["ship-controller", "ship-task-builder", "ship-task-reviewer"],
1344
+ finalReviewer: ["ship-final-standards-reviewer", "ship-final-spec-reviewer"]
1345
+ });
1346
+ }
1347
+ });
1348
+
1349
+ // src/installer/cli-args.js
1350
+ init_profile();
1351
+ var USAGE = `opencode-ship <command> [options]
1352
+
1353
+ Commands:
1354
+ init Install managed files in this project. One-liner: pnpm dlx opencode-ship@latest init
1355
+ diff Show what would change without writing.
1356
+ update Apply pending updates after recovering the journal.
1357
+ doctor Validate environment, lock, and references.
1358
+ uninstall Remove managed files that still match the lock.
1359
+ --version Print the version and exit.
1360
+ --help Show this usage and exit.
1361
+
1362
+ Options:
1363
+ --root <path> Project root (defaults to cwd).
1364
+ --profile engineering Override active profile (engineering only).
1365
+ --force-config Rewrite the user config from detection (init only).
1366
+ --force-root-config Create opencode.json when absent (init only).
1367
+ --strict-doctor Fail init when doctor reports unhealthy checks.
1368
+ --replace-managed Replace locally-modified managed files (update only).
1369
+ --purge-config Remove ship.config.json when uninstalling.
1370
+ --planner-model <id> Strong planner model id (init only, optional).
1371
+ --builder-model <id> Cheap builder model id (init only, optional).
1372
+ --final-reviewer-model <id> Final Standards + Spec reviewer model id (init only, optional).
1373
+ --json Emit a JSON envelope instead of human output.
1374
+
1375
+ After init succeeds, restart OpenCode and run /setup-ship-workflow to
1376
+ fill in the workflow.models fields and the per-repo docs.
1377
+ `;
1378
+ var MODEL_ID_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
1379
+ function parseFlags(argv) {
1380
+ const options = {
1381
+ rootPath: null,
1382
+ profile: null,
1383
+ json: false,
1384
+ replaceManaged: false,
1385
+ purgeConfig: false,
1386
+ forceConfig: false,
1387
+ forceRootConfig: false,
1388
+ strictDoctor: false,
1389
+ plannerModel: null,
1390
+ builderModel: null,
1391
+ finalReviewerModel: null
1392
+ };
1393
+ for (let i = 0; i < argv.length; i++) {
1394
+ const arg = argv[i];
1395
+ if (arg === "--json") options.json = true;
1396
+ else if (arg === "--replace-managed") options.replaceManaged = true;
1397
+ else if (arg === "--purge-config") options.purgeConfig = true;
1398
+ else if (arg === "--force-config") options.forceConfig = true;
1399
+ else if (arg === "--force-root-config") options.forceRootConfig = true;
1400
+ else if (arg === "--strict-doctor") options.strictDoctor = true;
1401
+ else if (arg === "--root") options.rootPath = argv[++i];
1402
+ else if (arg === "--profile") {
1403
+ const value = argv[++i];
1404
+ if (value === void 0) {
1405
+ return { error: "--profile requires a value" };
1213
1406
  }
1214
- }
1215
- if (schema.additionalProperties === false && isObject2(schema.properties)) {
1216
- for (const key of Object.keys(value)) {
1217
- if (!(key in schema.properties)) issues.push(`${pointer}: unknown field ${key}`);
1407
+ if (value === "core") {
1408
+ return {
1409
+ error: "the 'core' profile was removed in opencode-ship 1.1.0; only 'engineering' is supported. Run /setup-ship-workflow to migrate."
1410
+ };
1218
1411
  }
1219
- }
1220
- if (isObject2(schema.properties)) {
1221
- for (const key of Object.keys(schema.properties)) {
1222
- if (key in value) validate(value[key], schema.properties[key], `${pointer}/${key}`, issues);
1412
+ if (!isValidProfile(value)) {
1413
+ return { error: `unknown profile '${value}' (expected one of: ${PROFILES.join(", ")})` };
1223
1414
  }
1224
- }
1415
+ options.profile = value;
1416
+ } else if (arg === "--planner-model" || arg === "--builder-model" || arg === "--final-reviewer-model") {
1417
+ const value = argv[++i];
1418
+ if (value === void 0) return { error: `${arg} requires a value` };
1419
+ if (!MODEL_ID_RE.test(value)) {
1420
+ return { error: `${arg} must be a "<provider>/<model>" id, got ${JSON.stringify(value)}` };
1421
+ }
1422
+ if (arg === "--planner-model") options.plannerModel = value;
1423
+ else if (arg === "--builder-model") options.builderModel = value;
1424
+ else options.finalReviewerModel = value;
1425
+ } else if (arg === "-h" || arg === "--help") return { help: true };
1426
+ else if (arg === "-v" || arg === "--version") return { version: true };
1427
+ else return { error: `unknown flag ${arg}` };
1225
1428
  }
1429
+ return options;
1226
1430
  }
1227
- function validateSchema(value, schema) {
1228
- const issues = [];
1229
- validate(value, schema, "#", issues);
1230
- return { ok: issues.length === 0, issues };
1231
- }
1232
-
1233
- // src/installer/config.js
1234
- init_json_pointer();
1235
- init_hash();
1236
- function configPath(repoRoot) {
1237
- return resolve4(repoRoot, ".opencode", "ship.config.json");
1238
- }
1239
- async function loadConfig(repoRoot) {
1240
- const path = configPath(repoRoot);
1241
- if (!existsSync4(path)) return null;
1242
- const raw = await readFile(path, "utf8");
1243
- let parsed;
1244
- try {
1245
- parsed = JSON.parse(raw);
1246
- } catch (e) {
1247
- return { ok: false, error: { kind: "parse", path, message: e.message } };
1431
+ function parseCommand(argv) {
1432
+ for (const arg of argv) {
1433
+ if (arg === "--help" || arg === "-h") return { command: "help" };
1434
+ if (arg === "--version" || arg === "-v") return { command: "version" };
1248
1435
  }
1249
- const validation = validateSchema(parsed, ship_config_schema_default);
1250
- if (!validation.ok) {
1251
- return { ok: false, error: { kind: "contract", path, issues: validation.issues } };
1436
+ const [cmd, ...rest] = argv;
1437
+ if (!cmd) return { command: "help" };
1438
+ const flags = parseFlags(rest);
1439
+ if ("help" in flags) return { command: "help" };
1440
+ if ("version" in flags) return { command: "version" };
1441
+ if ("error" in flags) return { error: flags.error };
1442
+ switch (cmd) {
1443
+ case "init":
1444
+ case "diff":
1445
+ case "update":
1446
+ case "doctor":
1447
+ case "uninstall":
1448
+ return { command: cmd, options: flags };
1449
+ default:
1450
+ return { error: `unknown command ${cmd}` };
1252
1451
  }
1253
- return {
1254
- ok: true,
1255
- path,
1256
- raw,
1257
- sha256: bytesHashString(raw),
1258
- canonicalSha256: bytesHashString(stableStringify(parsed)),
1259
- value: parsed
1260
- };
1261
- }
1262
- function renderDefaultConfig(detection, overrides = {}) {
1263
- const pm = detection?.packageManager ?? "npm";
1264
- const safeBootstrap = Array.isArray(detection?.worktreeBootstrap) && detection.worktreeBootstrap.length ? detection.worktreeBootstrap : [["npm", "install"]];
1265
- const safeVerification = Array.isArray(detection?.verificationPlan) && detection.verificationPlan.length ? detection.verificationPlan.map((step) => ({ id: step.id, argv: step.argv })) : [{ id: "typecheck", argv: ["npm", "run", "typecheck"] }];
1266
- const repo = detection?.repository ?? overrides.repository ?? "owner/repo";
1267
- return {
1268
- schemaVersion: 2,
1269
- profile: "engineering",
1270
- project: {
1271
- remote: detection?.remote ?? "origin",
1272
- repository: repo,
1273
- defaultBranch: detection?.defaultBranch ?? "main",
1274
- packageManager: pm,
1275
- detectOverrides: false
1276
- },
1277
- delivery: {
1278
- worktree: {
1279
- root: detection?.worktreeRoot ?? ".worktrees",
1280
- branchTemplate: "{actor}/{slug}",
1281
- bootstrap: safeBootstrap
1282
- },
1283
- verification: {
1284
- commands: safeVerification,
1285
- requireCleanDiffAfter: true,
1286
- invalidateOnHeadChange: true
1287
- },
1288
- review: { agent: "delivery-reviewer", required: true, invalidateOnHeadChange: true },
1289
- ci: {
1290
- driver: "github-status-checks",
1291
- requiredChecks: ["delivery-verify"],
1292
- wait: true,
1293
- flakyRetry: 1
1294
- },
1295
- ready: { requires: ["review", "local-verification", "remote-ci"], stopAfterReady: true },
1296
- merge: { strategy: "squash", policy: "explicit-user-request-only", requireFreshGates: true },
1297
- cleanup: { when: "next-task", requireUnpublishedGuard: true }
1298
- },
1299
- workflow: {
1300
- models: {},
1301
- approval: { mirrorToIssue: true, maxFailedRounds: 3 }
1302
- }
1303
- };
1304
1452
  }
1305
- function hasCompletedModels(configValue) {
1306
- const models = configValue?.workflow?.models;
1307
- if (!models || typeof models !== "object") return false;
1308
- const idRe = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
1309
- return typeof models.planner === "string" && idRe.test(models.planner) && typeof models.builder === "string" && idRe.test(models.builder) && typeof models.finalReviewer === "string" && idRe.test(models.finalReviewer);
1453
+ function helpText() {
1454
+ return USAGE;
1310
1455
  }
1311
1456
 
1457
+ // src/installer/commands/init.js
1458
+ import { promisify as promisify2 } from "node:util";
1459
+ import { writeFile as writeFile8, mkdir as mkdirAsync2 } from "node:fs/promises";
1460
+ import { dirname as dirname10, resolve as resolvePath } from "node:path";
1461
+
1462
+ // src/installer/executor.js
1463
+ init_catalog();
1464
+ init_version();
1465
+ import { existsSync as existsSync13 } from "node:fs";
1466
+ import { mkdir as mkdir6, readFile as readFile9, rename as rename5, unlink as unlink3, writeFile as writeFile6 } from "node:fs/promises";
1467
+ import { dirname as dirname8, resolve as resolve12 } from "node:path";
1468
+
1312
1469
  // src/installer/planner.js
1470
+ init_catalog();
1471
+ init_hash();
1472
+ init_config();
1313
1473
  init_json_pointer();
1314
1474
  init_root_config();
1475
+ import { existsSync as existsSync7 } from "node:fs";
1476
+ import { readFile as readFile3, stat } from "node:fs/promises";
1315
1477
 
1316
1478
  // src/installer/root-reconciliation.js
1317
1479
  init_root_config();
@@ -1320,9 +1482,8 @@ init_hash();
1320
1482
  init_plan_mode_permissions();
1321
1483
  import { existsSync as existsSync6 } from "node:fs";
1322
1484
  import { join } from "node:path";
1323
- var PLAN_MODE_POINTER2 = "/agent/plan/permission";
1324
1485
  function desiredPointersForProfile(profile) {
1325
- const out = POINTER_ENTRIES.map((entry) => ({
1486
+ return POINTER_ENTRIES.map((entry) => ({
1326
1487
  pointer: entry.pointer,
1327
1488
  strategy: (
1328
1489
  /** @type {"value" | "object-entry" | "array-member"} */
@@ -1330,28 +1491,10 @@ function desiredPointersForProfile(profile) {
1330
1491
  ),
1331
1492
  scope: (
1332
1493
  /** @type {Profile} */
1333
- "core"
1494
+ "engineering"
1334
1495
  ),
1335
1496
  value: entry.value
1336
1497
  }));
1337
- if (profile === "engineering") {
1338
- out.push({
1339
- pointer: PLAN_MODE_POINTER2,
1340
- strategy: (
1341
- /** @type {"value" | "object-entry" | "array-member"} */
1342
- "value"
1343
- ),
1344
- scope: (
1345
- /** @type {Profile} */
1346
- "engineering"
1347
- ),
1348
- value: (
1349
- /** @type {any} */
1350
- planModePermissions().build
1351
- )
1352
- });
1353
- }
1354
- return out;
1355
1498
  }
1356
1499
  async function planRootReconciliation(input) {
1357
1500
  const profile = input.profile;
@@ -1400,10 +1543,6 @@ async function planRootReconciliation(input) {
1400
1543
  let doc;
1401
1544
  if (fileMissing && input.forceRepair) {
1402
1545
  doc = synthesizeDefaultRootConfig();
1403
- if (mode === "install" && profile === "engineering") {
1404
- const applied = applyPlanModeOwnership(doc, { block: planModePermissions().build });
1405
- doc = applied.doc;
1406
- }
1407
1546
  const bytes = Buffer.from(formatRootConfig(doc), "utf8");
1408
1547
  return {
1409
1548
  kind: "create",
@@ -1823,11 +1962,12 @@ function lookupLockedFile(lock, targetPath) {
1823
1962
  if (!lock?.files) return null;
1824
1963
  return lock.files.find((entry) => entry.path === targetPath) ?? null;
1825
1964
  }
1826
- async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
1965
+ async function planManagedFile({ entry, repoRoot, lock, allowUnowned, renderedOverride = null }) {
1966
+ const override = renderedOverride && renderedOverride.get?.(entry.path);
1827
1967
  const targetPath = `${repoRoot}/${entry.path}`;
1828
1968
  const locked = lookupLockedFile(lock, entry.path);
1829
1969
  const current = await readBytes(targetPath);
1830
- const desired = await readDesiredBytes(entry.source);
1970
+ const desired = override ? { bytes: override.bytes, hash: override.sha256 } : await readDesiredBytes(entry.source);
1831
1971
  if (!current) {
1832
1972
  return {
1833
1973
  kind: "create",
@@ -1838,7 +1978,7 @@ async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
1838
1978
  bytes: desired?.bytes ?? Buffer.alloc(0),
1839
1979
  sha256: desired?.hash,
1840
1980
  mode: 420,
1841
- reason: "managed file missing"
1981
+ reason: override ? "rendered agent with configured model" : "managed file missing"
1842
1982
  };
1843
1983
  }
1844
1984
  if (desired.hash === current.hash) {
@@ -1862,7 +2002,7 @@ async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
1862
2002
  bytes: desired?.bytes ?? Buffer.alloc(0),
1863
2003
  sha256: desired?.hash,
1864
2004
  mode: 420,
1865
- reason: "safe update: previous lock matches current bytes"
2005
+ reason: override ? "rendered agent with new configured model" : "safe update: previous lock matches current bytes"
1866
2006
  };
1867
2007
  }
1868
2008
  if (locked?.sha256 && locked.sha256 !== current.hash && allowUnowned) {
@@ -1888,10 +2028,10 @@ async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
1888
2028
  reason: locked?.sha256 == null ? "managed file already exists; bytes differ from upstream" : "managed file is locally modified"
1889
2029
  };
1890
2030
  }
1891
- async function planFileInstall({ repoRoot, lock, allowUnowned = false, catalog = CATALOG }) {
2031
+ async function planFileInstall({ repoRoot, lock, allowUnowned = false, catalog = CATALOG, renderedOverride = null }) {
1892
2032
  const plan = [];
1893
2033
  for (const entry of catalog) {
1894
- plan.push(await planManagedFile({ entry, repoRoot, lock, allowUnowned }));
2034
+ plan.push(await planManagedFile({ entry, repoRoot, lock, allowUnowned, renderedOverride }));
1895
2035
  }
1896
2036
  return plan;
1897
2037
  }
@@ -2006,7 +2146,8 @@ async function planUninstall({ repoRoot, lock }) {
2006
2146
  }
2007
2147
  async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite, migrationSeed = null, models = null }) {
2008
2148
  const existing = await loadConfig(repoRoot);
2009
- if (existing?.ok && !forceOverwrite) {
2149
+ const hasModelFlags = Boolean(models && (models.planner || models.builder || models.finalReviewer));
2150
+ if (existing?.ok && !forceOverwrite && !hasModelFlags) {
2010
2151
  return {
2011
2152
  kind: "noop",
2012
2153
  op: "config",
@@ -2018,9 +2159,9 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite,
2018
2159
  reason: "user config already present"
2019
2160
  };
2020
2161
  }
2021
- let desiredValue = migrationSeed ?? renderDefaultConfig(detection);
2022
- if (models && (models.planner || models.builder || models.finalReviewer)) {
2023
- const merged = {
2162
+ let desiredValue = migrationSeed ?? (existing?.ok ? structuredClone(existing.value) : renderDefaultConfig(detection));
2163
+ if (hasModelFlags) {
2164
+ desiredValue = {
2024
2165
  ...desiredValue,
2025
2166
  schemaVersion: 2,
2026
2167
  profile: "engineering",
@@ -2038,12 +2179,12 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite,
2038
2179
  }
2039
2180
  }
2040
2181
  };
2041
- desiredValue = merged;
2042
2182
  }
2183
+ if (desiredValue.profile === "core") desiredValue.profile = "engineering";
2043
2184
  const desiredJson = JSON.stringify(desiredValue, null, 2) + "\n";
2044
2185
  const desiredSha = bytesHashString(desiredJson);
2045
- const kind = existing?.ok && forceOverwrite ? "update" : "create";
2046
- const reason = existing?.ok ? "user config overwritten via --force-config" : migrationSeed ? "synthesising a default config from legacy adapter migration" : "synthesising a default config from detection";
2186
+ const kind = existing?.ok && (forceOverwrite || hasModelFlags) ? "update" : "create";
2187
+ const reason = existing?.ok ? hasModelFlags && !forceOverwrite ? "patching workflow.models from CLI model flags" : "user config overwritten via --force-config" : migrationSeed ? "synthesising a default config from legacy adapter migration" : "synthesising a default config from detection";
2047
2188
  return {
2048
2189
  kind,
2049
2190
  op: "config",
@@ -2059,8 +2200,8 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite,
2059
2200
  async function planRootConfigApply({ repoRoot, lock, forceRepair, planMode = null }) {
2060
2201
  const previous = (lock?.manager?.rootDocuments ?? []).flatMap((d) => d.pointers ?? []);
2061
2202
  const previousProfile = lock?.manager?.profile ?? null;
2062
- const desiredProfile = planMode && planMode.scope === "engineering" ? "engineering" : "core";
2063
- const isTransition = previousProfile !== null && previousProfile !== desiredProfile;
2203
+ const desiredProfile = "engineering";
2204
+ const isTransition = previousProfile !== null && previousProfile !== "engineering" && previousProfile !== "core";
2064
2205
  const mode = previous.length === 0 ? "install" : isTransition ? "profile-transition" : "install";
2065
2206
  return planRootReconciliation({
2066
2207
  repoRoot,
@@ -2074,10 +2215,11 @@ async function planRootConfigApply({ repoRoot, lock, forceRepair, planMode = nul
2074
2215
  // src/installer/lock.js
2075
2216
  init_hash();
2076
2217
  init_json_pointer();
2218
+ init_profile();
2077
2219
  import { readFile as readFile4, writeFile as writeFile2, rename as rename2, mkdir as mkdir2 } from "node:fs/promises";
2078
2220
  import { existsSync as existsSync8 } from "node:fs";
2079
2221
  import { dirname as dirname4, resolve as resolve6 } from "node:path";
2080
- var CURRENT_LOCK_SCHEMA = 3;
2222
+ var CURRENT_LOCK_SCHEMA = 4;
2081
2223
  function lockPath(repoRoot) {
2082
2224
  return resolve6(repoRoot, ".opencode", "ship.lock.json");
2083
2225
  }
@@ -2088,6 +2230,12 @@ function computeIntegrity(lock) {
2088
2230
  lockSha256: bytesHashString(stableStringify(without))
2089
2231
  };
2090
2232
  }
2233
+ function normalizeLegacyLock(lock) {
2234
+ if (!lock || typeof lock !== "object") return lock;
2235
+ const { cleanupPending: _drop, ...rest } = lock;
2236
+ void _drop;
2237
+ return rest;
2238
+ }
2091
2239
  function validateLock(rawLock) {
2092
2240
  if (rawLock === null || rawLock === void 0) {
2093
2241
  return { ok: true, kind: "missing", issues: [] };
@@ -2097,8 +2245,8 @@ function validateLock(rawLock) {
2097
2245
  }
2098
2246
  const issues = [];
2099
2247
  let kind = "ok";
2100
- if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1 && rawLock.contractVersion !== 2) {
2101
- issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 2, or 1)`);
2248
+ if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 3 && rawLock.contractVersion !== 2 && rawLock.contractVersion !== 1) {
2249
+ issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 3, 2, or 1)`);
2102
2250
  kind = "schema";
2103
2251
  }
2104
2252
  const manager = rawLock.manager;
@@ -2108,14 +2256,14 @@ function validateLock(rawLock) {
2108
2256
  } else if (typeof manager !== "object" || manager === null) {
2109
2257
  issues.push("manager section must be an object");
2110
2258
  kind = kind === "ok" ? "shape" : kind;
2111
- } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 2 && manager.schemaVersion !== 1) {
2112
- issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 2, or 1)`);
2259
+ } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 3 && manager.schemaVersion !== 2 && manager.schemaVersion !== 1) {
2260
+ issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 3, 2, or 1)`);
2113
2261
  kind = "schema";
2114
2262
  } else if (manager.name !== "opencode-ship") {
2115
2263
  issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
2116
2264
  kind = "shape";
2117
- } else if (rawLock.contractVersion >= 2 && manager.schemaVersion >= 2 && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
2118
- issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: core, engineering)`);
2265
+ } else if (rawLock.contractVersion >= 2 && manager.schemaVersion >= 2 && manager.profile !== void 0 && manager.profile !== "core" && manager.profile !== "engineering") {
2266
+ issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: engineering, core [legacy])`);
2119
2267
  kind = "shape";
2120
2268
  }
2121
2269
  if (!rawLock.files || !Array.isArray(rawLock.files)) {
@@ -2151,10 +2299,18 @@ async function readValidatedLock(repoRoot) {
2151
2299
  };
2152
2300
  }
2153
2301
  const validation = validateLock(raw);
2154
- return { kind: validation.kind, lock: validation.ok ? raw : null, issues: validation.issues };
2302
+ if (validation.ok) {
2303
+ return {
2304
+ kind: validation.kind,
2305
+ lock: normalizeLegacyLock(raw),
2306
+ issues: []
2307
+ };
2308
+ }
2309
+ return { kind: validation.kind, lock: null, issues: validation.issues };
2155
2310
  }
2156
2311
 
2157
2312
  // src/installer/executor.js
2313
+ init_config();
2158
2314
  init_hash();
2159
2315
  init_json_pointer();
2160
2316
 
@@ -2744,6 +2900,7 @@ async function rollback(lockDir, journal) {
2744
2900
  import { readFile as readFile7 } from "node:fs/promises";
2745
2901
  import { existsSync as existsSync12 } from "node:fs";
2746
2902
  import { resolve as resolve11 } from "node:path";
2903
+ init_config();
2747
2904
  function legacyAdapterPath(repoRoot) {
2748
2905
  return resolve11(repoRoot, ".opencode", "delivery.json");
2749
2906
  }
@@ -2845,10 +3002,10 @@ function legacyToShipConfig(legacy, detection = null) {
2845
3002
  }
2846
3003
 
2847
3004
  // src/installer/executor.js
2848
- init_plan_mode_permissions();
3005
+ init_profile();
2849
3006
  async function readCurrentBytes(targetPath) {
2850
3007
  if (!existsSync13(targetPath)) return null;
2851
- const buf = await readFile8(targetPath);
3008
+ const buf = await readFile9(targetPath);
2852
3009
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
2853
3010
  }
2854
3011
  async function previewInstall({ rootPath, profile = null, replaceManaged, forceConfig, forceRootConfig, models = null }) {
@@ -2873,6 +3030,21 @@ async function previewInstall({ rootPath, profile = null, replaceManaged, forceC
2873
3030
  config: configValue,
2874
3031
  lock
2875
3032
  });
3033
+ if (resolved.profile === "engineering" && forceConfig) {
3034
+ const existingModels = configValue?.workflow?.models ?? {};
3035
+ const planner = models?.planner ?? existingModels.planner;
3036
+ const builder = models?.builder ?? existingModels.builder;
3037
+ const finalReviewer = models?.finalReviewer ?? existingModels.finalReviewer;
3038
+ if (!planner || !builder || !finalReviewer) {
3039
+ return {
3040
+ ok: false,
3041
+ error: {
3042
+ kind: "engineering-models-required",
3043
+ message: "engineering profile with --force-config requires --planner-model, --builder-model, and --final-reviewer-model"
3044
+ }
3045
+ };
3046
+ }
3047
+ }
2876
3048
  if (resolved.profile === "engineering") {
2877
3049
  const candidate = await planConfigSynthesis({
2878
3050
  repoRoot,
@@ -2901,7 +3073,19 @@ async function previewInstall({ rootPath, profile = null, replaceManaged, forceC
2901
3073
  migrationSeed: migrationReport?.proposedConfigSeed ?? null,
2902
3074
  models
2903
3075
  });
2904
- const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged), catalog: activeCatalog });
3076
+ const { buildRenderedOverride: buildRenderedOverride2 } = await Promise.resolve().then(() => (init_agent_renderer(), agent_renderer_exports));
3077
+ const configModels = configPlan?.configValue?.workflow?.models ?? {};
3078
+ const rendered = await buildRenderedOverride2({
3079
+ models: resolved.profile === "engineering" ? configModels : null,
3080
+ catalog: CATALOG
3081
+ });
3082
+ const filePlan = await planFileInstall({
3083
+ repoRoot,
3084
+ lock,
3085
+ allowUnowned: Boolean(replaceManaged),
3086
+ catalog: activeCatalog,
3087
+ renderedOverride: rendered.map
3088
+ });
2905
3089
  const staleFilePlan = await planStaleFileRemoval({ repoRoot, lock, staleCatalog });
2906
3090
  const migrationPlan = await planMigrationCleanup({
2907
3091
  repoRoot,
@@ -2909,9 +3093,9 @@ async function previewInstall({ rootPath, profile = null, replaceManaged, forceC
2909
3093
  migrationReport,
2910
3094
  allowUnowned: Boolean(replaceManaged)
2911
3095
  });
2912
- const planMode = resolved.profile === "engineering" ? { id: "/agent/plan/permission", block: planModePermissions().build, scope: "engineering" } : null;
3096
+ const planMode = null;
2913
3097
  const rootPlan = await planRootConfigApply({ repoRoot, lock, forceRepair: Boolean(forceRootConfig), planMode });
2914
- const setupPending = resolved.profile === "engineering" && !lock?.manager?.setupComplete && !hasCompletedModels(configValue?.workflow?.models ?? {}) && !models?.planner;
3098
+ const setupPending = resolved.profile === "engineering" && !lock?.manager?.setupComplete && !hasCompletedModels(configValue) && !models?.planner;
2915
3099
  const plan = [...filePlan ?? [], ...staleFilePlan, ...migrationPlan, configPlan, rootPlan];
2916
3100
  const conflicts = plan.filter((p) => p && p.kind === "conflict");
2917
3101
  const summary = summarise(plan);
@@ -2990,16 +3174,17 @@ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan, profil
2990
3174
  const rootPointers = rootPlan?.pointerRecords ?? lock?.manager?.rootDocuments?.[0]?.pointers ?? [];
2991
3175
  const hasRootPlan = Boolean(rootPlan?.target || rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0);
2992
3176
  const hasRootDocuments = rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 || lock?.manager?.rootDocuments && lock.manager.rootDocuments.length > 0;
2993
- const configModels = configPlan?.configValue?.workflow?.models ?? lock?.manager?.config?.models ?? {};
2994
- const completedModels = hasCompletedModels({ workflow: { models: configModels } });
3177
+ const assembledConfig = configPlan?.configValue ?? (lock?.manager?.config?.models ? { workflow: { models: lock.manager.config.models } } : null);
3178
+ const completedModels = hasCompletedModels(assembledConfig);
3179
+ const resolvedProfile = profile ?? (lock?.manager?.profile === "core" ? "engineering" : lock?.manager?.profile) ?? "engineering";
2995
3180
  return {
2996
3181
  contractVersion: CURRENT_LOCK_SCHEMA,
2997
3182
  manager: {
2998
3183
  schemaVersion: CURRENT_LOCK_SCHEMA,
2999
3184
  name: "opencode-ship",
3000
- version: "1.1.0",
3185
+ version: "1.1.1",
3001
3186
  templateSet: TEMPLATE_SET_ID,
3002
- profile: profile ?? lock?.manager?.profile,
3187
+ profile: resolvedProfile,
3003
3188
  appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
3004
3189
  setupComplete: completedModels,
3005
3190
  config: {
@@ -3013,8 +3198,7 @@ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan, profil
3013
3198
  pointers: rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 ? rootPlan.pointerRecords : lock?.manager?.rootDocuments?.[0]?.pointers ?? []
3014
3199
  }] : []
3015
3200
  },
3016
- files,
3017
- cleanupPending: lock?.cleanupPending ?? []
3201
+ files
3018
3202
  };
3019
3203
  }
3020
3204
  async function commitInstall(preview, { json, command }) {
@@ -3145,9 +3329,11 @@ function relativeTemplate(source) {
3145
3329
  // src/installer/commands/doctor.js
3146
3330
  import { existsSync as existsSync14, readFileSync as readFileSync5 } from "node:fs";
3147
3331
  import { spawnSync as spawnSync2 } from "node:child_process";
3332
+ init_config();
3148
3333
  init_hash();
3149
- import { resolve as resolve13 } from "node:path";
3334
+ init_catalog();
3150
3335
  init_root_config();
3336
+ import { resolve as resolve13 } from "node:path";
3151
3337
 
3152
3338
  // src/installer/report.js
3153
3339
  var REPORT_VERSION = 1;
@@ -3197,6 +3383,7 @@ function renderJson({ command, plan, conflicts, summary, diagnostics = [], exitC
3197
3383
  }
3198
3384
 
3199
3385
  // src/installer/commands/doctor.js
3386
+ init_profile();
3200
3387
  function checkNode() {
3201
3388
  return { name: "node>=22.6.0", ok: /^v2[2-9]/.test(process.version), detail: process.version };
3202
3389
  }
@@ -3244,7 +3431,7 @@ function buildSourceHashIndex() {
3244
3431
  }
3245
3432
  return idx;
3246
3433
  }
3247
- function checkCatalogInstall(repoRoot, sourceHashes, profile) {
3434
+ async function checkCatalogInstall(repoRoot, sourceHashes, profile, renderedAgentMap = /* @__PURE__ */ new Map()) {
3248
3435
  const rows = [];
3249
3436
  const scoped = profile ? filterCatalogByProfile(CATALOG, profile) : CATALOG;
3250
3437
  for (const entry of scoped) {
@@ -3256,7 +3443,8 @@ function checkCatalogInstall(repoRoot, sourceHashes, profile) {
3256
3443
  try {
3257
3444
  const buf = readFileSync5(target, "utf8");
3258
3445
  const actual = bytesHashString(buf);
3259
- const expected = sourceHashes.get(entry.source);
3446
+ const rendered = renderedAgentMap.get(entry.path);
3447
+ const expected = rendered ? rendered.sha256 : sourceHashes.get(entry.source);
3260
3448
  if (expected && expected !== actual) {
3261
3449
  rows.push(`${entry.id}: drift`);
3262
3450
  } else {
@@ -3307,6 +3495,7 @@ async function checkManagedHashes(repoRoot, validatedLock) {
3307
3495
  return { name: "managed hashes", ok: false, detail: "no usable lock" };
3308
3496
  }
3309
3497
  const drift = [];
3498
+ const renderedAgents = await loadRenderedAgentOverrides(repoRoot);
3310
3499
  for (const entry of validatedLock.lock.files ?? []) {
3311
3500
  const p = resolve13(repoRoot, entry.path);
3312
3501
  if (!existsSync14(p)) {
@@ -3319,6 +3508,18 @@ async function checkManagedHashes(repoRoot, validatedLock) {
3319
3508
  }
3320
3509
  return { name: "managed hashes", ok: drift.length === 0, detail: drift.length ? drift.join(",") : "match" };
3321
3510
  }
3511
+ async function loadRenderedAgentOverrides(repoRoot) {
3512
+ const { loadConfig: loadConfig2 } = await Promise.resolve().then(() => (init_config(), config_exports));
3513
+ const { CATALOG: CATALOG2 } = await Promise.resolve().then(() => (init_catalog(), catalog_exports));
3514
+ const { computeRenderedAgents: computeRenderedAgents2 } = await Promise.resolve().then(() => (init_agent_renderer(), agent_renderer_exports));
3515
+ const cfg = await loadConfig2(repoRoot);
3516
+ const models = cfg?.ok ? cfg.value?.workflow?.models : null;
3517
+ if (!models) return /* @__PURE__ */ new Map();
3518
+ const rendered = await computeRenderedAgents2({ models, catalog: CATALOG2 });
3519
+ const map = /* @__PURE__ */ new Map();
3520
+ for (const e of rendered) map.set(e.relPath, e);
3521
+ return map;
3522
+ }
3322
3523
  async function checkActiveProfileFootprint(repoRoot, validatedLock, profile) {
3323
3524
  if (validatedLock.kind !== "ok" || !validatedLock.lock) {
3324
3525
  return { name: "profile footprint", ok: true, detail: "no lock; n/a" };
@@ -3393,7 +3594,7 @@ async function runDoctor({ rootPath, profile, json, writeOutput = true }) {
3393
3594
  checkGh(),
3394
3595
  checkGhAuth(),
3395
3596
  packageIntegrity,
3396
- checkCatalogInstall(repoRoot, sourceHashes, resolved.profile),
3597
+ await checkCatalogInstall(repoRoot, sourceHashes, resolved.profile, await loadRenderedAgentOverrides(repoRoot)),
3397
3598
  await checkLock(repoRoot),
3398
3599
  await checkConfig(repoRoot),
3399
3600
  await checkManagedHashes(repoRoot, validatedLock),
@@ -3418,11 +3619,15 @@ async function runDoctor({ rootPath, profile, json, writeOutput = true }) {
3418
3619
  return { issues, exitCode, plan, checks, profile: resolved };
3419
3620
  }
3420
3621
 
3622
+ // src/installer/commands/init.js
3623
+ init_catalog();
3624
+ init_config();
3625
+
3421
3626
  // src/installer/setup-pending.js
3422
- import { existsSync as existsSync15, readFileSync as readFileSync6, unlinkSync, writeFile as writeFile6, mkdir as mkdirAsync } from "node:fs";
3627
+ import { existsSync as existsSync15, readFileSync as readFileSync6, unlinkSync, writeFile as writeFile7, mkdir as mkdirAsync } from "node:fs";
3423
3628
  import { promisify } from "node:util";
3424
- import { resolve as resolve14, dirname as dirname8 } from "node:path";
3425
- var writeFileAsync = promisify(writeFile6);
3629
+ import { resolve as resolve14, dirname as dirname9 } from "node:path";
3630
+ var writeFileAsync = promisify(writeFile7);
3426
3631
  var mkdirAsyncAsync = promisify(mkdirAsync);
3427
3632
  var REL_PATH = ".opencode/ship.setup-pending.json";
3428
3633
  function setupPendingPath(repoRoot) {
@@ -3430,7 +3635,7 @@ function setupPendingPath(repoRoot) {
3430
3635
  }
3431
3636
  async function writeSetupPending(repoRoot, payload) {
3432
3637
  const path = setupPendingPath(repoRoot);
3433
- await mkdirAsyncAsync(dirname8(path), { recursive: true });
3638
+ await mkdirAsyncAsync(dirname9(path), { recursive: true });
3434
3639
  await writeFileAsync(path, JSON.stringify(payload, null, 2) + "\n", "utf8");
3435
3640
  }
3436
3641
  function clearSetupPending(repoRoot) {
@@ -3445,7 +3650,7 @@ function clearSetupPending(repoRoot) {
3445
3650
  }
3446
3651
 
3447
3652
  // src/installer/commands/init.js
3448
- var writeFileAsync2 = promisify2(writeFile7);
3653
+ var writeFileAsync2 = promisify2(writeFile8);
3449
3654
  var mkdirAsyncAsync2 = promisify2(mkdirAsync2);
3450
3655
  async function runInit(options) {
3451
3656
  try {
@@ -3594,6 +3799,7 @@ function emitFailure(code, message, json, command) {
3594
3799
  }
3595
3800
 
3596
3801
  // src/installer/commands/diff.js
3802
+ init_catalog();
3597
3803
  function summarise3(plan) {
3598
3804
  const counts = { create: 0, update: 0, noop: 0, delete: 0, conflict: 0, converge: 0, lock: 0, config: 0, rootConfig: 0 };
3599
3805
  for (const op of plan) {
@@ -3707,6 +3913,7 @@ async function runDiff(options) {
3707
3913
  }
3708
3914
 
3709
3915
  // src/installer/commands/update.js
3916
+ init_catalog();
3710
3917
  async function runUpdate(options) {
3711
3918
  try {
3712
3919
  validateCatalog();
@@ -3785,6 +3992,7 @@ function emitFailure2(code, message, json, command) {
3785
3992
  }
3786
3993
 
3787
3994
  // src/installer/commands/uninstall.js
3995
+ init_config();
3788
3996
  import { unlink as unlink4 } from "node:fs/promises";
3789
3997
  async function runUninstall(options) {
3790
3998
  const preview = await previewUninstall({ rootPath: options.rootPath });
@@ -3868,6 +4076,7 @@ function emitReport(plan, conflicts, summary, json, exitCode, diagnostics) {
3868
4076
  }
3869
4077
 
3870
4078
  // src/cli.js
4079
+ init_version();
3871
4080
  var VERSION = PACKAGE_VERSION;
3872
4081
  async function main() {
3873
4082
  const parsed = parseCommand(process.argv.slice(2));