opencode-ship 0.9.0 → 0.10.0-rc.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/README.md +3 -1
  3. package/THIRD_PARTY_NOTICES.md +77 -19
  4. package/assets/agents/ship-controller.md +122 -0
  5. package/assets/agents/ship-final-spec-reviewer.md +87 -0
  6. package/assets/agents/ship-final-standards-reviewer.md +83 -0
  7. package/assets/agents/ship-planner.md +78 -0
  8. package/assets/agents/ship-task-builder.md +91 -0
  9. package/assets/agents/ship-task-reviewer.md +78 -0
  10. package/assets/commands/ship-deliver.md +30 -0
  11. package/assets/commands/ship-resume.md +28 -0
  12. package/assets/commands/ship-status.md +25 -0
  13. package/assets/skills/brainstorming/SKILL.md +160 -0
  14. package/assets/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
  15. package/assets/skills/brainstorming/visual-companion.md +298 -0
  16. package/assets/skills/code-review/SKILL.md +98 -0
  17. package/assets/skills/codebase-design/DEEPENING.md +37 -0
  18. package/assets/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
  19. package/assets/skills/codebase-design/SKILL.md +123 -0
  20. package/assets/skills/dispatching-parallel-agents/SKILL.md +176 -0
  21. package/assets/skills/domain-modeling/ADR-FORMAT.md +47 -0
  22. package/assets/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
  23. package/assets/skills/domain-modeling/SKILL.md +83 -0
  24. package/assets/skills/engineering-workflow/SKILL.md +125 -0
  25. package/assets/skills/engineering-workflow/domain.md +51 -0
  26. package/assets/skills/engineering-workflow/issue-tracker-github.md +45 -0
  27. package/assets/skills/engineering-workflow/issue-tracker-gitlab.md +46 -0
  28. package/assets/skills/engineering-workflow/issue-tracker-local.md +30 -0
  29. package/assets/skills/engineering-workflow/triage-labels.md +15 -0
  30. package/assets/skills/executing-plans/SKILL.md +73 -0
  31. package/assets/skills/grill-with-docs/SKILL.md +10 -14
  32. package/assets/skills/grilling/SKILL.md +21 -0
  33. package/assets/skills/handoff/SKILL.md +25 -0
  34. package/assets/skills/prototype/LOGIC.md +79 -0
  35. package/assets/skills/prototype/SKILL.md +35 -0
  36. package/assets/skills/prototype/UI.md +112 -0
  37. package/assets/skills/receiving-code-review/SKILL.md +214 -0
  38. package/assets/skills/requesting-code-review/SKILL.md +104 -0
  39. package/assets/skills/requesting-code-review/code-reviewer.md +172 -0
  40. package/assets/skills/research/SKILL.md +21 -0
  41. package/assets/skills/setup-engineering-workflow/SKILL.md +125 -0
  42. package/assets/skills/setup-engineering-workflow/domain.md +51 -0
  43. package/assets/skills/setup-engineering-workflow/issue-tracker-github.md +45 -0
  44. package/assets/skills/setup-engineering-workflow/issue-tracker-gitlab.md +46 -0
  45. package/assets/skills/setup-engineering-workflow/issue-tracker-local.md +30 -0
  46. package/assets/skills/setup-engineering-workflow/triage-labels.md +15 -0
  47. package/assets/skills/subagent-driven-development/SKILL.md +512 -0
  48. package/assets/skills/subagent-driven-development/implementer-prompt.md +142 -0
  49. package/assets/skills/subagent-driven-development/re-review-prompt.md +106 -0
  50. package/assets/skills/subagent-driven-development/task-reviewer-prompt.md +185 -0
  51. package/assets/skills/systematic-debugging/CREATION-LOG.md +119 -0
  52. package/assets/skills/systematic-debugging/SKILL.md +292 -0
  53. package/assets/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
  54. package/assets/skills/systematic-debugging/condition-based-waiting.md +115 -0
  55. package/assets/skills/systematic-debugging/defense-in-depth.md +122 -0
  56. package/assets/skills/systematic-debugging/find-polluter.sh +72 -0
  57. package/assets/skills/systematic-debugging/root-cause-tracing.md +169 -0
  58. package/assets/skills/systematic-debugging/test-academic.md +14 -0
  59. package/assets/skills/systematic-debugging/test-pressure-1.md +58 -0
  60. package/assets/skills/systematic-debugging/test-pressure-2.md +68 -0
  61. package/assets/skills/systematic-debugging/test-pressure-3.md +69 -0
  62. package/assets/skills/test-driven-development/SKILL.md +329 -0
  63. package/assets/skills/test-driven-development/writing-good-tests.md +198 -0
  64. package/assets/skills/to-spec/SKILL.md +84 -0
  65. package/assets/skills/to-tickets/SKILL.md +114 -0
  66. package/assets/skills/triage/AGENT-BRIEF.md +207 -0
  67. package/assets/skills/triage/OUT-OF-SCOPE.md +105 -0
  68. package/assets/skills/triage/SKILL.md +114 -15
  69. package/assets/skills/verification-before-completion/SKILL.md +129 -0
  70. package/assets/skills/wayfinder/SKILL.md +137 -0
  71. package/assets/skills/writing-plans/SKILL.md +177 -0
  72. package/assets/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
  73. package/dist/cli.js +1146 -435
  74. package/dist/core.js +210 -61
  75. package/dist/plugin.js +2368 -230
  76. package/package.json +3 -1
  77. package/schema/ship-config.schema.json +63 -2
  78. package/schema/ship-lock.schema.json +5 -3
  79. package/tests/plugin/plugin-load.test.mjs +85 -0
  80. package/vendor/mattpocock/LICENSE +30 -0
  81. package/vendor/obra/LICENSE +30 -0
  82. package/vendor/sources.json +976 -0
  83. package/vendor/superpowers/LICENSE +30 -0
  84. package/vendor/upstreams/mattpocock/skills/engineering/code-review/SKILL.md +89 -0
  85. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DEEPENING.md +37 -0
  86. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/DESIGN-IT-TWICE.md +44 -0
  87. package/vendor/upstreams/mattpocock/skills/engineering/codebase-design/SKILL.md +114 -0
  88. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/ADR-FORMAT.md +47 -0
  89. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/CONTEXT-FORMAT.md +60 -0
  90. package/vendor/upstreams/mattpocock/skills/engineering/domain-modeling/SKILL.md +74 -0
  91. package/vendor/upstreams/mattpocock/skills/engineering/grill-with-docs/SKILL.md +7 -0
  92. package/vendor/upstreams/mattpocock/skills/engineering/prototype/LOGIC.md +79 -0
  93. package/vendor/upstreams/mattpocock/skills/engineering/prototype/SKILL.md +26 -0
  94. package/vendor/upstreams/mattpocock/skills/engineering/prototype/UI.md +112 -0
  95. package/vendor/upstreams/mattpocock/skills/engineering/research/SKILL.md +12 -0
  96. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/SKILL.md +116 -0
  97. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/domain.md +51 -0
  98. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-github.md +45 -0
  99. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-gitlab.md +46 -0
  100. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/issue-tracker-local.md +30 -0
  101. package/vendor/upstreams/mattpocock/skills/engineering/setup-matt-pocock-skills/triage-labels.md +15 -0
  102. package/vendor/upstreams/mattpocock/skills/engineering/to-spec/SKILL.md +75 -0
  103. package/vendor/upstreams/mattpocock/skills/engineering/to-tickets/SKILL.md +105 -0
  104. package/vendor/upstreams/mattpocock/skills/engineering/triage/AGENT-BRIEF.md +207 -0
  105. package/vendor/upstreams/mattpocock/skills/engineering/triage/OUT-OF-SCOPE.md +105 -0
  106. package/vendor/upstreams/mattpocock/skills/engineering/triage/SKILL.md +112 -0
  107. package/vendor/upstreams/mattpocock/skills/engineering/wayfinder/SKILL.md +128 -0
  108. package/vendor/upstreams/mattpocock/skills/productivity/grilling/SKILL.md +12 -0
  109. package/vendor/upstreams/mattpocock/skills/productivity/handoff/SKILL.md +16 -0
  110. package/vendor/upstreams/obra/skills/brainstorming/SKILL.md +151 -0
  111. package/vendor/upstreams/obra/skills/brainstorming/spec-document-reviewer-prompt.md +49 -0
  112. package/vendor/upstreams/obra/skills/brainstorming/visual-companion.md +298 -0
  113. package/vendor/upstreams/obra/skills/dispatching-parallel-agents/SKILL.md +167 -0
  114. package/vendor/upstreams/obra/skills/executing-plans/SKILL.md +64 -0
  115. package/vendor/upstreams/obra/skills/receiving-code-review/SKILL.md +205 -0
  116. package/vendor/upstreams/obra/skills/requesting-code-review/SKILL.md +95 -0
  117. package/vendor/upstreams/obra/skills/requesting-code-review/code-reviewer.md +172 -0
  118. package/vendor/upstreams/obra/skills/subagent-driven-development/SKILL.md +503 -0
  119. package/vendor/upstreams/obra/skills/subagent-driven-development/implementer-prompt.md +142 -0
  120. package/vendor/upstreams/obra/skills/subagent-driven-development/re-review-prompt.md +106 -0
  121. package/vendor/upstreams/obra/skills/subagent-driven-development/task-reviewer-prompt.md +185 -0
  122. package/vendor/upstreams/obra/skills/systematic-debugging/CREATION-LOG.md +119 -0
  123. package/vendor/upstreams/obra/skills/systematic-debugging/SKILL.md +283 -0
  124. package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting-example.ts +158 -0
  125. package/vendor/upstreams/obra/skills/systematic-debugging/condition-based-waiting.md +115 -0
  126. package/vendor/upstreams/obra/skills/systematic-debugging/defense-in-depth.md +122 -0
  127. package/vendor/upstreams/obra/skills/systematic-debugging/find-polluter.sh +72 -0
  128. package/vendor/upstreams/obra/skills/systematic-debugging/root-cause-tracing.md +169 -0
  129. package/vendor/upstreams/obra/skills/systematic-debugging/test-academic.md +14 -0
  130. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-1.md +58 -0
  131. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-2.md +68 -0
  132. package/vendor/upstreams/obra/skills/systematic-debugging/test-pressure-3.md +69 -0
  133. package/vendor/upstreams/obra/skills/test-driven-development/SKILL.md +320 -0
  134. package/vendor/upstreams/obra/skills/test-driven-development/writing-good-tests.md +198 -0
  135. package/vendor/upstreams/obra/skills/verification-before-completion/SKILL.md +120 -0
  136. package/vendor/upstreams/obra/skills/writing-plans/SKILL.md +168 -0
  137. package/vendor/upstreams/obra/skills/writing-plans/plan-document-reviewer-prompt.md +49 -0
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // opencode-ship CLI v0.9.0
2
+ // opencode-ship CLI v0.10.0-rc.18
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __esm = (fn, res) => function __init() {
@@ -11,21 +11,9 @@ var __export = (target, all) => {
11
11
  };
12
12
 
13
13
  // src/installer/json-pointer.js
14
- var json_pointer_exports = {};
15
- __export(json_pointer_exports, {
16
- getPointer: () => getPointer,
17
- parsePointer: () => parsePointer,
18
- pointerPath: () => pointerPath,
19
- removePointer: () => removePointer,
20
- setPointer: () => setPointer,
21
- stableStringify: () => stableStringify
22
- });
23
14
  function unescape(token) {
24
15
  return token.replace(/~1/g, "/").replace(/~0/g, "~");
25
16
  }
26
- function escape(token) {
27
- return token.replace(/~/g, "~0").replace(/\//g, "~1");
28
- }
29
17
  function* parsePointer(pointer) {
30
18
  if (typeof pointer !== "string" || pointer === "") return;
31
19
  if (pointer[0] !== "/") {
@@ -88,9 +76,6 @@ function stableStringify(value) {
88
76
  }
89
77
  return JSON.stringify(value);
90
78
  }
91
- function pointerPath(segments) {
92
- return "/" + segments.map((s) => escape(String(s))).join("/");
93
- }
94
79
  var init_json_pointer = __esm({
95
80
  "src/installer/json-pointer.js"() {
96
81
  }
@@ -114,21 +99,22 @@ var init_hash = __esm({
114
99
  function planModePermissions() {
115
100
  return {
116
101
  build: {
117
- // Broad-deny entries for the standard Build verbs. These
118
- // cover bash, source edits, and writes outside the plans
119
- // path. opencode.js evaluates the last matching rule, so
120
- // a narrow object-typed entry after a string-typed deny
121
- // creates a per-path exception.
122
102
  bash: DENY_DEFAULT,
123
- edit: { [PLANS_GLOB]: ALLOW_PLANS },
124
- write: { [PLANS_GLOB]: ALLOW_PLANS },
103
+ edit: {
104
+ "*": DENY_DEFAULT,
105
+ [PLANS_GLOB]: ALLOW_PLANS
106
+ },
125
107
  webfetch: DENY_DEFAULT,
126
- // The Plan Mode sub-agent also has no business calling
127
- // delivery tools itself; that is the Build controller's
128
- // job. Keep the canonical nine tools as deny so a Plan
129
- // Mode prompt injection cannot trigger them.
130
- "task.plan-agent": DENY_DEFAULT,
131
- "task.build-agent": 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
132
118
  }
133
119
  };
134
120
  }
@@ -203,15 +189,15 @@ function stripJsonc(text) {
203
189
  let stripped = "";
204
190
  let i = 0;
205
191
  let inString = false;
206
- let escape2 = false;
192
+ let escape = false;
207
193
  while (i < text.length) {
208
194
  const ch = text[i];
209
195
  if (inString) {
210
196
  stripped += ch;
211
- if (escape2) {
212
- escape2 = false;
197
+ if (escape) {
198
+ escape = false;
213
199
  } else if (ch === "\\") {
214
- escape2 = true;
200
+ escape = true;
215
201
  } else if (ch === '"') {
216
202
  inString = false;
217
203
  }
@@ -582,15 +568,19 @@ Commands:
582
568
  --help Show this usage and exit.
583
569
 
584
570
  Options:
585
- --root <path> Project root (defaults to cwd).
586
- --profile <name> Override active profile: ${PROFILES.join(", ")}.
587
- --force-config Rewrite the user config from detection (init only).
588
- --force-root-config Create opencode.json when absent (init only).
589
- --strict-doctor Fail init when doctor reports unhealthy checks.
590
- --replace-managed Replace locally-modified managed files (update only).
591
- --purge-config Remove ship.config.json when uninstalling.
592
- --json Emit a JSON envelope instead of human output.
571
+ --root <path> Project root (defaults to cwd).
572
+ --profile <name> Override active profile: ${PROFILES.join(", ")}.
573
+ --force-config Rewrite the user config from detection (init only).
574
+ --force-root-config Create opencode.json when absent (init only).
575
+ --strict-doctor Fail init when doctor reports unhealthy checks.
576
+ --replace-managed Replace locally-modified managed files (update only).
577
+ --purge-config Remove ship.config.json when uninstalling.
578
+ --planner-model <id> Engineering model id for the strong planner.
579
+ --builder-model <id> Engineering model id for the cheap builder.
580
+ --final-reviewer-model <id> Engineering model id for the Standards + Spec reviewer.
581
+ --json Emit a JSON envelope instead of human output.
593
582
  `;
583
+ var MODEL_ID_RE = /^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+$/;
594
584
  function parseFlags(argv) {
595
585
  const options = {
596
586
  rootPath: null,
@@ -600,7 +590,10 @@ function parseFlags(argv) {
600
590
  purgeConfig: false,
601
591
  forceConfig: false,
602
592
  forceRootConfig: false,
603
- strictDoctor: false
593
+ strictDoctor: false,
594
+ plannerModel: null,
595
+ builderModel: null,
596
+ finalReviewerModel: null
604
597
  };
605
598
  for (let i = 0; i < argv.length; i++) {
606
599
  const arg = argv[i];
@@ -620,6 +613,15 @@ function parseFlags(argv) {
620
613
  return { error: `unknown profile '${value}' (expected one of: ${PROFILES.join(", ")})` };
621
614
  }
622
615
  options.profile = value;
616
+ } else if (arg === "--planner-model" || arg === "--builder-model" || arg === "--final-reviewer-model") {
617
+ const value = argv[++i];
618
+ if (value === void 0) return { error: `${arg} requires a value` };
619
+ if (!MODEL_ID_RE.test(value)) {
620
+ return { error: `${arg} must be a "<provider>/<model>" id, got ${JSON.stringify(value)}` };
621
+ }
622
+ if (arg === "--planner-model") options.plannerModel = value;
623
+ else if (arg === "--builder-model") options.builderModel = value;
624
+ else options.finalReviewerModel = value;
623
625
  } else if (arg === "-h" || arg === "--help") return { help: true };
624
626
  else if (arg === "-v" || arg === "--version") return { version: true };
625
627
  else return { error: `unknown flag ${arg}` };
@@ -653,9 +655,9 @@ function helpText() {
653
655
  }
654
656
 
655
657
  // src/installer/executor.js
656
- import { existsSync as existsSync11 } from "node:fs";
657
- import { mkdir as mkdir4, readFile as readFile7, rename as rename4, unlink as unlink2, writeFile as writeFile4 } from "node:fs/promises";
658
- import { dirname as dirname6, resolve as resolve10 } from "node:path";
658
+ import { existsSync as existsSync13 } from "node:fs";
659
+ import { mkdir as mkdir5, readFile as readFile8, rename as rename5, unlink as unlink3, writeFile as writeFile5 } from "node:fs/promises";
660
+ import { dirname as dirname7, resolve as resolve12 } from "node:path";
659
661
 
660
662
  // src/installer/catalog.js
661
663
  import { resolve as resolve3, relative, sep } from "node:path";
@@ -687,12 +689,53 @@ function resolvePackageRoot(startUrl) {
687
689
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
688
690
  import { dirname as dirname2, resolve as resolve2 } from "node:path";
689
691
  import { fileURLToPath as fileURLToPath2 } from "node:url";
690
- var PACKAGE_VERSION = "0.9.0";
692
+ var PACKAGE_VERSION = "0.10.0-rc.18";
691
693
  var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
692
694
 
693
695
  // src/installer/catalog.js
694
696
  var TEMPLATE_SET_ID = TEMPLATE_SET;
695
697
  var packageRoot = resolvePackageRoot(import.meta.url);
698
+ var MATT_SKILLS = [
699
+ "setup-engineering-workflow",
700
+ "engineering-workflow",
701
+ "grilling",
702
+ "domain-modeling",
703
+ "grill-with-docs",
704
+ "triage",
705
+ "to-spec",
706
+ "to-tickets",
707
+ "wayfinder",
708
+ "handoff",
709
+ "research",
710
+ "prototype",
711
+ "codebase-design",
712
+ "code-review"
713
+ ];
714
+ var SUPER_SKILLS = [
715
+ "brainstorming",
716
+ "writing-plans",
717
+ "executing-plans",
718
+ "subagent-driven-development",
719
+ "dispatching-parallel-agents",
720
+ "test-driven-development",
721
+ "systematic-debugging",
722
+ "verification-before-completion",
723
+ "requesting-code-review",
724
+ "receiving-code-review"
725
+ ];
726
+ var ENGINEERING_AGENTS = [
727
+ "ship-controller",
728
+ "ship-planner",
729
+ "ship-task-builder",
730
+ "ship-task-reviewer",
731
+ "ship-final-standards-reviewer",
732
+ "ship-final-spec-reviewer"
733
+ ];
734
+ var ENGINEERING_COMMANDS = [
735
+ "ship-deliver",
736
+ "ship-resume",
737
+ "ship-status"
738
+ ];
696
739
  var CATALOG = [
697
740
  {
698
741
  id: "plugin:opencode-ship",
@@ -718,6 +761,22 @@ var CATALOG = [
718
761
  mode: 420,
719
762
  profiles: ["core", "engineering"]
720
763
  },
764
+ ...ENGINEERING_AGENTS.map((name) => ({
765
+ id: `agent:${name}`,
766
+ kind: "agent",
767
+ path: `.opencode/agents/${name}.md`,
768
+ source: resolve3(packageRoot, `assets/agents/${name}.md`),
769
+ mode: 420,
770
+ profiles: ["engineering"]
771
+ })),
772
+ ...ENGINEERING_COMMANDS.map((name) => ({
773
+ id: `command:${name}`,
774
+ kind: "support",
775
+ path: `.opencode/commands/${name}.md`,
776
+ source: resolve3(packageRoot, `assets/commands/${name}.md`),
777
+ mode: 420,
778
+ profiles: ["engineering"]
779
+ })),
721
780
  {
722
781
  id: "skill:delivery-workflow",
723
782
  kind: "skill",
@@ -734,22 +793,22 @@ var CATALOG = [
734
793
  mode: 420,
735
794
  profiles: ["core", "engineering"]
736
795
  },
737
- {
738
- id: "skill:triage",
796
+ ...MATT_SKILLS.map((name) => ({
797
+ id: `skill:matt:${name}`,
739
798
  kind: "skill",
740
- path: ".opencode/skills/triage/SKILL.md",
741
- source: resolve3(packageRoot, "assets/skills/triage/SKILL.md"),
799
+ path: `.opencode/skills/${name}/SKILL.md`,
800
+ source: resolve3(packageRoot, `assets/skills/${name}/SKILL.md`),
742
801
  mode: 420,
743
802
  profiles: ["engineering"]
744
- },
745
- {
746
- id: "skill:grill-with-docs",
803
+ })),
804
+ ...SUPER_SKILLS.map((name) => ({
805
+ id: `skill:super:${name}`,
747
806
  kind: "skill",
748
- path: ".opencode/skills/grill-with-docs/SKILL.md",
749
- source: resolve3(packageRoot, "assets/skills/grill-with-docs/SKILL.md"),
807
+ path: `.opencode/skills/${name}/SKILL.md`,
808
+ source: resolve3(packageRoot, `assets/skills/${name}/SKILL.md`),
750
809
  mode: 420,
751
810
  profiles: ["engineering"]
752
- }
811
+ }))
753
812
  ];
754
813
  function filterCatalogByProfile(catalog, profile) {
755
814
  const effective = profile === void 0 || profile === null ? DEFAULT_PROFILE : profile;
@@ -833,7 +892,7 @@ function validateCatalog({ catalog = CATALOG } = {}) {
833
892
  }
834
893
 
835
894
  // src/installer/planner.js
836
- import { existsSync as existsSync6 } from "node:fs";
895
+ import { existsSync as existsSync7 } from "node:fs";
837
896
  import { readFile as readFile3, stat } from "node:fs/promises";
838
897
  init_hash();
839
898
 
@@ -851,7 +910,7 @@ var ship_config_schema_default = {
851
910
  required: ["schemaVersion"],
852
911
  additionalProperties: false,
853
912
  properties: {
854
- schemaVersion: { const: 1 },
913
+ schemaVersion: { enum: [1, 2] },
855
914
  profile: {
856
915
  type: "string",
857
916
  enum: ["core", "engineering"],
@@ -981,8 +1040,69 @@ var ship_config_schema_default = {
981
1040
  agentsDir: { type: "string", pattern: "^\\.opencode/agents/?$" },
982
1041
  skillsDir: { type: "string", pattern: "^\\.opencode/skills/?$" }
983
1042
  }
1043
+ },
1044
+ workflow: {
1045
+ type: "object",
1046
+ description: "Required when profile is engineering. Ignored when profile is core.",
1047
+ additionalProperties: false,
1048
+ properties: {
1049
+ models: {
1050
+ type: "object",
1051
+ additionalProperties: false,
1052
+ required: ["planner", "builder", "finalReviewer"],
1053
+ properties: {
1054
+ planner: {
1055
+ type: "string",
1056
+ pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
1057
+ description: "Provider/model id for the strong planning child session."
1058
+ },
1059
+ builder: {
1060
+ type: "string",
1061
+ pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
1062
+ description: "Provider/model id for the cheap builder child session."
1063
+ },
1064
+ finalReviewer: {
1065
+ type: "string",
1066
+ pattern: "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$",
1067
+ description: "Provider/model id for the final Standards + Spec reviewers."
1068
+ }
1069
+ }
1070
+ },
1071
+ approval: {
1072
+ type: "object",
1073
+ additionalProperties: false,
1074
+ properties: {
1075
+ mirrorToIssue: { const: true },
1076
+ maxFailedRounds: { const: 3 }
1077
+ }
1078
+ }
1079
+ }
984
1080
  }
985
- }
1081
+ },
1082
+ allOf: [
1083
+ {
1084
+ if: {
1085
+ required: ["profile"],
1086
+ properties: { profile: { const: "engineering" } }
1087
+ },
1088
+ then: {
1089
+ required: ["workflow"],
1090
+ properties: {
1091
+ workflow: {
1092
+ required: ["models", "approval"],
1093
+ properties: {
1094
+ models: {
1095
+ required: ["planner", "builder", "finalReviewer"]
1096
+ },
1097
+ approval: {
1098
+ required: ["mirrorToIssue", "maxFailedRounds"]
1099
+ }
1100
+ }
1101
+ }
1102
+ }
1103
+ }
1104
+ }
1105
+ ]
986
1106
  };
987
1107
 
988
1108
  // src/installer/validation.js
@@ -1000,6 +1120,18 @@ function validate(value, schema, pointer, issues) {
1000
1120
  issues.push(`${pointer}: expected one of ${JSON.stringify(schema.enum)}, got ${JSON.stringify(value)}`);
1001
1121
  return;
1002
1122
  }
1123
+ if (Array.isArray(schema.allOf)) {
1124
+ for (const sub of schema.allOf) validate(value, sub, pointer, issues);
1125
+ }
1126
+ if (isObject2(schema.if)) {
1127
+ const ifIssues = [];
1128
+ validate(value, schema.if, pointer, ifIssues);
1129
+ if (ifIssues.length === 0) {
1130
+ if (isObject2(schema.then)) validate(value, schema.then, pointer, issues);
1131
+ } else if (isObject2(schema.else)) {
1132
+ validate(value, schema.else, pointer, issues);
1133
+ }
1134
+ }
1003
1135
  const type = schema.type;
1004
1136
  if (type !== void 0) {
1005
1137
  const actual = Array.isArray(value) ? "array" : value === null ? "null" : typeof value;
@@ -1050,7 +1182,7 @@ function validate(value, schema, pointer, issues) {
1050
1182
  value.forEach((entry, i) => validate(entry, schema.items, `${pointer}/${i}`, issues));
1051
1183
  }
1052
1184
  }
1053
- if (type === "object") {
1185
+ if (type === "object" || isObject2(schema.properties) || Array.isArray(schema.required)) {
1054
1186
  if (Array.isArray(schema.required)) {
1055
1187
  for (const key of schema.required) {
1056
1188
  if (!(key in value)) issues.push(`${pointer}: missing required field ${key}`);
@@ -1145,15 +1277,510 @@ function renderDefaultConfig(detection, overrides = {}) {
1145
1277
  // src/installer/planner.js
1146
1278
  init_json_pointer();
1147
1279
  init_root_config();
1280
+
1281
+ // src/installer/root-reconciliation.js
1148
1282
  init_root_config();
1283
+ init_json_pointer();
1284
+ init_hash();
1285
+ init_plan_mode_permissions();
1286
+ import { existsSync as existsSync6 } from "node:fs";
1287
+ import { join } from "node:path";
1288
+ var PLAN_MODE_POINTER2 = "/agent/plan/permission";
1289
+ function desiredPointersForProfile(profile) {
1290
+ const out = POINTER_ENTRIES.map((entry) => ({
1291
+ pointer: entry.pointer,
1292
+ strategy: (
1293
+ /** @type {"value" | "object-entry" | "array-member"} */
1294
+ entry.strategy
1295
+ ),
1296
+ scope: (
1297
+ /** @type {Profile} */
1298
+ "core"
1299
+ ),
1300
+ value: entry.value
1301
+ }));
1302
+ if (profile === "engineering") {
1303
+ out.push({
1304
+ pointer: PLAN_MODE_POINTER2,
1305
+ strategy: (
1306
+ /** @type {"value" | "object-entry" | "array-member"} */
1307
+ "value"
1308
+ ),
1309
+ scope: (
1310
+ /** @type {Profile} */
1311
+ "engineering"
1312
+ ),
1313
+ value: (
1314
+ /** @type {any} */
1315
+ planModePermissions().build
1316
+ )
1317
+ });
1318
+ }
1319
+ return out;
1320
+ }
1321
+ async function planRootReconciliation(input) {
1322
+ const profile = input.profile;
1323
+ const mode = input.mode;
1324
+ if (!["core", "engineering"].includes(profile)) {
1325
+ throw new Error(`planRootReconciliation: invalid profile ${profile}`);
1326
+ }
1327
+ if (!["install", "profile-transition", "uninstall"].includes(mode)) {
1328
+ throw new Error(`planRootReconciliation: invalid mode ${mode}`);
1329
+ }
1330
+ const detected = findRootConfig(input.repoRoot);
1331
+ const target = detected.path ?? join(input.repoRoot, detected.relative);
1332
+ const descriptors = input.pointerDescriptors ?? desiredPointersForProfile(profile);
1333
+ const previousRecords = input.previousRecords ?? [];
1334
+ if (mode === "uninstall") {
1335
+ return planUninstallRoot({
1336
+ target,
1337
+ relPath: detected.relative,
1338
+ previousRecords,
1339
+ previousDocument: input.previousDocument
1340
+ });
1341
+ }
1342
+ const fileMissing = !existsSync6(target);
1343
+ if (fileMissing && !input.forceRepair) {
1344
+ if (mode === "profile-transition") {
1345
+ return {
1346
+ kind: "noop",
1347
+ op: "root-config",
1348
+ target,
1349
+ relPath: detected.relative,
1350
+ reason: "no root opencode.json present",
1351
+ edits: [],
1352
+ pointerRecords: reconcileRecordsAfterTransition(descriptors, previousRecords)
1353
+ };
1354
+ }
1355
+ return {
1356
+ kind: "noop",
1357
+ op: "root-config",
1358
+ target,
1359
+ relPath: detected.relative,
1360
+ reason: "no root opencode.json present",
1361
+ edits: [],
1362
+ pointerRecords: seedPointerRecords(descriptors, previousRecords)
1363
+ };
1364
+ }
1365
+ let doc;
1366
+ if (fileMissing && input.forceRepair) {
1367
+ doc = synthesizeDefaultRootConfig();
1368
+ if (mode === "install" && profile === "engineering") {
1369
+ const applied = applyPlanModeOwnership(doc, { block: planModePermissions().build });
1370
+ doc = applied.doc;
1371
+ }
1372
+ const bytes = Buffer.from(formatRootConfig(doc), "utf8");
1373
+ return {
1374
+ kind: "create",
1375
+ op: "root-config",
1376
+ target,
1377
+ relPath: detected.relative,
1378
+ bytes,
1379
+ desiredSha: bytesHashString(stableStringify(doc)),
1380
+ currentSha: null,
1381
+ edits: descriptors.map((d) => ({ kind: "create", pointer: d.pointer, value: "(synthesized)" })),
1382
+ pointerRecords: seedPointerRecords(descriptors, previousRecords),
1383
+ format: "json",
1384
+ document: doc,
1385
+ reason: "creating root opencode.json with installer-owned Build permissions"
1386
+ };
1387
+ }
1388
+ const docResult = readRootConfig(target);
1389
+ if (!docResult.ok) {
1390
+ return {
1391
+ kind: "noop",
1392
+ op: "root-config",
1393
+ target,
1394
+ relPath: detected.relative,
1395
+ reason: `root config ${docResult.error.kind}`,
1396
+ edits: [],
1397
+ pointerRecords: previousRecords
1398
+ };
1399
+ }
1400
+ if (mode === "profile-transition") {
1401
+ return planProfileTransitionRoot({
1402
+ doc: docResult,
1403
+ target,
1404
+ relPath: detected.relative,
1405
+ descriptors,
1406
+ previousRecords
1407
+ });
1408
+ }
1409
+ return planInstallRoot({
1410
+ doc: docResult,
1411
+ target,
1412
+ relPath: detected.relative,
1413
+ descriptors,
1414
+ previousRecords
1415
+ });
1416
+ }
1417
+ function reconcileRecordsAfterTransition(descriptors, previousRecords) {
1418
+ const desiredCore = new Set(descriptors.filter((d) => d.scope === "core").map((d) => d.pointer));
1419
+ const out = previousRecords.filter((r) => r.scope === "core" || r.scope === "engineering" && desiredCore.has(r.pointer));
1420
+ const seen = new Set(out.map((r) => r.pointer));
1421
+ for (const d of descriptors) {
1422
+ if (seen.has(d.pointer)) continue;
1423
+ out.push({
1424
+ pointer: d.pointer,
1425
+ strategy: d.strategy,
1426
+ scope: d.scope,
1427
+ installedSha256: d.value !== void 0 ? bytesHashString(stableStringify(d.value)) : null,
1428
+ previous: { existed: false }
1429
+ });
1430
+ }
1431
+ return out;
1432
+ }
1433
+ function planInstallRoot({ doc, target, relPath, descriptors, previousRecords }) {
1434
+ const result = applyOwnedPointers(doc.value, {
1435
+ pointerEntries: descriptors.map((d) => ({ pointer: d.pointer, strategy: d.strategy, value: d.value })),
1436
+ allowEqualValues: true
1437
+ });
1438
+ const edits = [];
1439
+ for (const a of result.applied) edits.push({ kind: "create", pointer: a.pointer, value: a.value });
1440
+ for (const s of result.skipped) {
1441
+ if (s.reason === "already equal") continue;
1442
+ edits.push({ kind: "conflict", pointer: s.pointer, reason: s.reason, existing: s.existing, desired: s.desired });
1443
+ }
1444
+ let docForWrite = result.doc;
1445
+ let bytes;
1446
+ try {
1447
+ const { value: sourceValue } = parseRootConfigPreservingOrder(doc.raw ?? "");
1448
+ if (sourceValue && typeof sourceValue === "object") {
1449
+ docForWrite = sourceValue;
1450
+ for (const a of result.applied) {
1451
+ docForWrite = setPointer(docForWrite, a.pointer, a.value);
1452
+ }
1453
+ }
1454
+ bytes = Buffer.from(formatRootConfigPreserving(docForWrite), "utf8");
1455
+ } catch {
1456
+ bytes = Buffer.from(formatRootConfig(result.doc), "utf8");
1457
+ }
1458
+ const records = mergePointerRecords(descriptors, previousRecords, result, doc.before);
1459
+ return {
1460
+ kind: edits.some((e) => e.kind === "conflict") ? "conflict" : edits.length ? "update" : "noop",
1461
+ op: "root-config",
1462
+ target,
1463
+ relPath,
1464
+ bytes,
1465
+ desiredSha: bytesHashString(stableStringify(docForWrite)),
1466
+ currentSha: doc.sha256 ?? null,
1467
+ edits,
1468
+ pointerRecords: records,
1469
+ format: doc.format,
1470
+ document: docForWrite,
1471
+ reason: edits.length === 0 ? "no installer-owned entries missing" : `apply ${result.applied.length} / skip ${result.skipped.length}`
1472
+ };
1473
+ }
1474
+ function planProfileTransitionRoot({ doc, target, relPath, descriptors, previousRecords }) {
1475
+ const desired = descriptors;
1476
+ const drift = [];
1477
+ for (const rec of previousRecords) {
1478
+ const stillDesired = desired.some((d) => d.pointer === rec.pointer && d.scope === rec.scope);
1479
+ if (stillDesired) continue;
1480
+ if (typeof rec.installedSha256 !== "string" || rec.installedSha256.length === 0) continue;
1481
+ const currentValue = getPointer(doc.value, rec.pointer);
1482
+ if (currentValue === void 0) {
1483
+ continue;
1484
+ }
1485
+ const currentHash = bytesHashString(stableStringify(currentValue));
1486
+ if (currentHash !== rec.installedSha256) {
1487
+ drift.push({ pointer: rec.pointer, recorded: rec.installedSha256, current: currentHash });
1488
+ }
1489
+ }
1490
+ if (drift.length > 0) {
1491
+ return {
1492
+ kind: "conflict",
1493
+ op: "root-config",
1494
+ target,
1495
+ relPath,
1496
+ reason: `installed pointer drift: ${drift.map((d) => `${d.pointer} (recorded ${d.recorded.slice(0, 8)}\u2026, current ${d.current.slice(0, 8)}\u2026)`).join("; ")}`,
1497
+ edits: drift.map((d) => ({ kind: "conflict", pointer: d.pointer, reason: "installed-pointer-drift" })),
1498
+ pointerRecords: previousRecords
1499
+ };
1500
+ }
1501
+ let next = JSON.parse(JSON.stringify(doc.value));
1502
+ const edits = [];
1503
+ const mergedRecords = previousRecords.map((r) => ({ ...r }));
1504
+ const recordIndex = new Map(mergedRecords.map((r, idx) => [r.pointer, idx]));
1505
+ for (let i = mergedRecords.length - 1; i >= 0; i--) {
1506
+ const rec = mergedRecords[i];
1507
+ const isStillDesired = desired.some((d) => d.pointer === rec.pointer && d.scope === rec.scope);
1508
+ if (isStillDesired) continue;
1509
+ if (rec.previous && rec.previous.existed) {
1510
+ next = setPointer(next, rec.pointer, rec.previous.value);
1511
+ edits.push({ kind: "restore", pointer: rec.pointer, value: rec.previous.value });
1512
+ } else {
1513
+ if (getPointer(next, rec.pointer) !== void 0) {
1514
+ next = removePointer(next, rec.pointer);
1515
+ edits.push({ kind: "remove", pointer: rec.pointer });
1516
+ }
1517
+ }
1518
+ mergedRecords.splice(i, 1);
1519
+ recordIndex.delete(rec.pointer);
1520
+ }
1521
+ for (const d of desired) {
1522
+ const existing = getPointer(next, d.pointer);
1523
+ if (existing === d.value) {
1524
+ const idx = recordIndex.get(d.pointer);
1525
+ if (idx != null) {
1526
+ mergedRecords[idx] = {
1527
+ ...mergedRecords[idx],
1528
+ scope: d.scope,
1529
+ installedSha256: bytesHashString(stableStringify(d.value))
1530
+ };
1531
+ } else {
1532
+ mergedRecords.push({
1533
+ pointer: d.pointer,
1534
+ strategy: d.strategy,
1535
+ scope: d.scope,
1536
+ installedSha256: bytesHashString(stableStringify(d.value)),
1537
+ previous: { existed: existing !== void 0, value: existing ?? null }
1538
+ });
1539
+ }
1540
+ continue;
1541
+ }
1542
+ if (existing === void 0) {
1543
+ next = setPointer(next, d.pointer, d.value);
1544
+ edits.push({ kind: "create", pointer: d.pointer, value: d.value });
1545
+ const idx = recordIndex.get(d.pointer);
1546
+ if (idx != null) {
1547
+ mergedRecords[idx] = {
1548
+ ...mergedRecords[idx],
1549
+ scope: d.scope,
1550
+ installedSha256: bytesHashString(stableStringify(d.value)),
1551
+ previous: { existed: false }
1552
+ };
1553
+ } else {
1554
+ mergedRecords.push({
1555
+ pointer: d.pointer,
1556
+ strategy: d.strategy,
1557
+ scope: d.scope,
1558
+ installedSha256: bytesHashString(stableStringify(d.value)),
1559
+ previous: { existed: false }
1560
+ });
1561
+ }
1562
+ } else {
1563
+ edits.push({
1564
+ kind: "conflict",
1565
+ pointer: d.pointer,
1566
+ reason: "different existing value",
1567
+ existing,
1568
+ desired: d.value
1569
+ });
1570
+ }
1571
+ }
1572
+ let docForWrite = next;
1573
+ let bytes;
1574
+ try {
1575
+ const { value: sourceValue } = parseRootConfigPreservingOrder(doc.raw ?? "");
1576
+ if (sourceValue && typeof sourceValue === "object") {
1577
+ docForWrite = sourceValue;
1578
+ for (const e of edits) {
1579
+ if (e.kind === "create" || e.kind === "restore") {
1580
+ docForWrite = setPointer(docForWrite, e.pointer, e.value);
1581
+ } else if (e.kind === "remove") {
1582
+ docForWrite = removePointer(docForWrite, e.pointer);
1583
+ }
1584
+ }
1585
+ }
1586
+ docForWrite = collapseEmptyAncestors(docForWrite) ?? docForWrite;
1587
+ bytes = Buffer.from(formatRootConfigPreserving(docForWrite), "utf8");
1588
+ } catch {
1589
+ bytes = Buffer.from(formatRootConfig(collapseEmptyAncestors(next) ?? next), "utf8");
1590
+ }
1591
+ return {
1592
+ kind: edits.some((e) => e.kind === "conflict") ? "conflict" : edits.some((e) => e.kind === "create" || e.kind === "remove" || e.kind === "restore") ? "update" : "noop",
1593
+ op: "root-config",
1594
+ target,
1595
+ relPath,
1596
+ bytes,
1597
+ desiredSha: bytesHashString(stableStringify(docForWrite)),
1598
+ currentSha: doc.sha256 ?? null,
1599
+ edits,
1600
+ pointerRecords: mergedRecords,
1601
+ format: doc.format,
1602
+ document: docForWrite,
1603
+ reason: "profile transition reconciliation"
1604
+ };
1605
+ }
1606
+ function planUninstallRoot({ target, relPath, previousRecords, previousDocument }) {
1607
+ if (previousRecords.length === 0) {
1608
+ return {
1609
+ kind: "noop",
1610
+ op: "root-config",
1611
+ target,
1612
+ relPath,
1613
+ reason: "no installer-owned pointers recorded",
1614
+ edits: [],
1615
+ pointerRecords: []
1616
+ };
1617
+ }
1618
+ const docResult = readRootConfig(target);
1619
+ if (!docResult.ok) {
1620
+ return {
1621
+ kind: "noop",
1622
+ op: "root-config",
1623
+ target,
1624
+ relPath,
1625
+ reason: `root config ${docResult.error.kind}`,
1626
+ edits: [],
1627
+ pointerRecords: previousRecords
1628
+ };
1629
+ }
1630
+ const drift = [];
1631
+ for (const rec of previousRecords) {
1632
+ if (typeof rec.installedSha256 !== "string" || rec.installedSha256.length === 0) continue;
1633
+ const currentValue = getPointer(docResult.value, rec.pointer);
1634
+ if (currentValue === void 0) continue;
1635
+ const currentHash = bytesHashString(stableStringify(currentValue));
1636
+ if (currentHash !== rec.installedSha256) {
1637
+ drift.push({ pointer: rec.pointer, recorded: rec.installedSha256, current: currentHash });
1638
+ }
1639
+ }
1640
+ if (drift.length > 0) {
1641
+ return {
1642
+ kind: "conflict",
1643
+ op: "root-config",
1644
+ target,
1645
+ relPath,
1646
+ reason: `installed pointer drift: ${drift.map((d) => `${d.pointer} (recorded ${d.recorded.slice(0, 8)}\u2026, current ${d.current.slice(0, 8)}\u2026)`).join("; ")}`,
1647
+ edits: drift.map((d) => ({ kind: "conflict", pointer: d.pointer, reason: "installed-pointer-drift" })),
1648
+ pointerRecords: previousRecords
1649
+ };
1650
+ }
1651
+ let doc = docResult.value;
1652
+ const edits = [];
1653
+ for (const rec of previousRecords) {
1654
+ if (rec.previous && rec.previous.existed) {
1655
+ doc = setPointer(doc, rec.pointer, rec.previous.value);
1656
+ edits.push({ kind: "restore", pointer: rec.pointer, value: rec.previous.value });
1657
+ } else {
1658
+ const current = getPointer(doc, rec.pointer);
1659
+ if (current !== void 0) {
1660
+ doc = removePointer(doc, rec.pointer);
1661
+ edits.push({ kind: "remove", pointer: rec.pointer });
1662
+ }
1663
+ }
1664
+ }
1665
+ let docForWrite = doc;
1666
+ let bytes;
1667
+ try {
1668
+ const { value: sourceValue } = parseRootConfigPreservingOrder(docResult.raw ?? "");
1669
+ if (sourceValue && typeof sourceValue === "object") {
1670
+ docForWrite = sourceValue;
1671
+ for (const e of edits) {
1672
+ if (e.kind === "restore") {
1673
+ docForWrite = setPointer(docForWrite, e.pointer, e.value);
1674
+ } else if (e.kind === "remove") {
1675
+ docForWrite = removePointer(docForWrite, e.pointer);
1676
+ }
1677
+ }
1678
+ }
1679
+ docForWrite = collapseEmptyAncestors(docForWrite);
1680
+ bytes = Buffer.from(formatRootConfigPreserving(docForWrite), "utf8");
1681
+ } catch {
1682
+ bytes = Buffer.from(formatRootConfig(collapseEmptyAncestors(doc)), "utf8");
1683
+ }
1684
+ return {
1685
+ kind: edits.length ? "update" : "noop",
1686
+ op: "root-config",
1687
+ target,
1688
+ relPath,
1689
+ bytes,
1690
+ desiredSha: bytesHashString(stableStringify(docForWrite)),
1691
+ currentSha: docResult.sha256 ?? null,
1692
+ edits,
1693
+ pointerRecords: [],
1694
+ format: docResult.format,
1695
+ document: docForWrite,
1696
+ reason: "uninstall restores the preinstall root config"
1697
+ };
1698
+ }
1699
+ function collapseEmptyAncestors(doc) {
1700
+ if (!doc || typeof doc !== "object" || Array.isArray(doc)) return doc;
1701
+ const out = {};
1702
+ for (const k of Object.keys(doc)) {
1703
+ if (k === "__sourceOrder__") continue;
1704
+ out[k] = collapseEmptyAncestors(doc[k]);
1705
+ }
1706
+ if (Array.isArray(doc.__sourceOrder__)) {
1707
+ out.__sourceOrder__ = doc.__sourceOrder__.filter((k) => k in out);
1708
+ }
1709
+ const remainingKeys = Object.keys(out).filter((k) => k !== "__sourceOrder__");
1710
+ if (remainingKeys.length === 0) return void 0;
1711
+ return out;
1712
+ }
1713
+ function seedPointerRecords(descriptors, previousRecords) {
1714
+ const out = previousRecords.map((r) => ({ ...r }));
1715
+ const seen = new Set(out.map((r) => r.pointer));
1716
+ for (const d of descriptors) {
1717
+ if (seen.has(d.pointer)) continue;
1718
+ out.push({
1719
+ pointer: d.pointer,
1720
+ strategy: (
1721
+ /** @type {any} */
1722
+ d.strategy
1723
+ ),
1724
+ scope: d.scope,
1725
+ installedSha256: d.value !== void 0 ? bytesHashString(stableStringify(d.value)) : null,
1726
+ previous: { existed: false }
1727
+ });
1728
+ }
1729
+ return out;
1730
+ }
1731
+ function mergePointerRecords(descriptors, previousRecords, result, beforeSnapshot) {
1732
+ const out = previousRecords.map((r) => ({ ...r }));
1733
+ const index = new Map(out.map((r, idx) => [r.pointer, idx]));
1734
+ for (const a of result.applied) {
1735
+ const previousEntry = beforeSnapshot?.[a.pointer];
1736
+ const previous = previousEntry === void 0 ? { existed: false } : { existed: true, value: previousEntry };
1737
+ const next = {
1738
+ pointer: a.pointer,
1739
+ strategy: "value",
1740
+ scope: descriptors.find((d) => d.pointer === a.pointer)?.scope ?? "core",
1741
+ installedSha256: bytesHashString(stableStringify(a.value)),
1742
+ previous
1743
+ };
1744
+ if (index.has(a.pointer)) {
1745
+ const idx = index.get(a.pointer);
1746
+ out[idx] = {
1747
+ ...out[idx],
1748
+ scope: next.scope,
1749
+ installedSha256: next.installedSha256,
1750
+ // If the lock already had a record, keep its previous value
1751
+ // verbatim so uninstall restores the original pre-install
1752
+ // state even after multiple in-place updates.
1753
+ previous: out[idx].previous ?? next.previous
1754
+ };
1755
+ } else {
1756
+ out.push(next);
1757
+ }
1758
+ }
1759
+ for (const s of result.skipped) {
1760
+ if (s.reason !== "already equal") continue;
1761
+ if (index.has(s.pointer)) continue;
1762
+ const existing = beforeSnapshot?.[s.pointer];
1763
+ const previous = existing === void 0 ? { existed: false } : { existed: true, value: existing };
1764
+ out.push({
1765
+ pointer: s.pointer,
1766
+ strategy: "value",
1767
+ scope: descriptors.find((d) => d.pointer === s.pointer)?.scope ?? "core",
1768
+ installedSha256: bytesHashString(stableStringify(existing ?? null)),
1769
+ previous
1770
+ });
1771
+ }
1772
+ return out;
1773
+ }
1774
+
1775
+ // src/installer/planner.js
1149
1776
  async function readBytes(path) {
1150
- if (!existsSync6(path)) return null;
1777
+ if (!existsSync7(path)) return null;
1151
1778
  const buf = await readFile3(path);
1152
1779
  const fileStat = await stat(path);
1153
1780
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")), mode: fileStat.mode & 511 };
1154
1781
  }
1155
1782
  async function readDesiredBytes(source) {
1156
- if (!source || !existsSync6(source)) return null;
1783
+ if (!source || !existsSync7(source)) return null;
1157
1784
  const buf = await readFile3(source);
1158
1785
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
1159
1786
  }
@@ -1233,6 +1860,34 @@ async function planFileInstall({ repoRoot, lock, allowUnowned = false, catalog =
1233
1860
  }
1234
1861
  return plan;
1235
1862
  }
1863
+ async function planStaleFileRemoval({ repoRoot, lock, staleCatalog }) {
1864
+ if (!Array.isArray(staleCatalog) || staleCatalog.length === 0) return [];
1865
+ const out = [];
1866
+ for (const entry of staleCatalog) {
1867
+ const targetPath = `${repoRoot}/${entry.path}`;
1868
+ const current = await readBytes(targetPath);
1869
+ if (!current) continue;
1870
+ const locked = lookupLockedFile(lock, entry.path);
1871
+ if (current.hash !== (locked?.sha256 ?? null)) {
1872
+ out.push({
1873
+ kind: "conflict",
1874
+ op: "file",
1875
+ target: targetPath,
1876
+ relPath: entry.path,
1877
+ reason: "stale profile asset is locally modified; refusing to remove"
1878
+ });
1879
+ continue;
1880
+ }
1881
+ out.push({
1882
+ kind: "delete",
1883
+ op: "file",
1884
+ target: targetPath,
1885
+ relPath: entry.path,
1886
+ reason: "remove stale profile asset on profile transition"
1887
+ });
1888
+ }
1889
+ return out;
1890
+ }
1236
1891
  async function planMigrationCleanup({ repoRoot, lock, migrationReport, allowUnowned = false }) {
1237
1892
  const action = migrationReport?.actions?.find((entry) => entry.kind === "candidate-remove-legacy-plugin-path");
1238
1893
  if (!action) return [];
@@ -1263,6 +1918,7 @@ async function planMigrationCleanup({ repoRoot, lock, migrationReport, allowUnow
1263
1918
  async function planUninstall({ repoRoot, lock }) {
1264
1919
  if (!lock) return [];
1265
1920
  const plan = [];
1921
+ const activeProfile = lock?.manager?.profile ?? "core";
1266
1922
  for (const entry of lock.files ?? []) {
1267
1923
  const targetPath = `${repoRoot}/${entry.path}`;
1268
1924
  const current = await readBytes(targetPath);
@@ -1279,9 +1935,41 @@ async function planUninstall({ repoRoot, lock }) {
1279
1935
  }
1280
1936
  plan.push({ kind: "delete", op: "file", target: targetPath, relPath: entry.path });
1281
1937
  }
1938
+ const rootPlan = await planRootReconciliation({
1939
+ repoRoot,
1940
+ profile: activeProfile,
1941
+ mode: "uninstall",
1942
+ previousRecords: (lock?.manager?.rootDocuments ?? []).flatMap((d) => d.pointers ?? [])
1943
+ });
1944
+ if (rootPlan?.kind === "conflict") {
1945
+ plan.push({
1946
+ op: "root-config",
1947
+ kind: "conflict",
1948
+ target: rootPlan.target,
1949
+ relPath: rootPlan.relPath,
1950
+ reason: rootPlan.reason
1951
+ });
1952
+ } else if (rootPlan && rootPlan.kind && rootPlan.kind !== "noop" && rootPlan.bytes) {
1953
+ plan.push({
1954
+ op: "file",
1955
+ kind: "update",
1956
+ target: rootPlan.target,
1957
+ relPath: rootPlan.relPath,
1958
+ bytes: rootPlan.bytes,
1959
+ mode: 420,
1960
+ reason: rootPlan.reason
1961
+ });
1962
+ }
1963
+ plan.push({
1964
+ kind: "delete",
1965
+ op: "file",
1966
+ target: `${repoRoot}/.opencode/ship.lock.json`,
1967
+ relPath: ".opencode/ship.lock.json",
1968
+ reason: "remove the install lock inside the transaction"
1969
+ });
1282
1970
  return plan;
1283
1971
  }
1284
- async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite, migrationSeed = null }) {
1972
+ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite, migrationSeed = null, models = null }) {
1285
1973
  const existing = await loadConfig(repoRoot);
1286
1974
  if (existing?.ok && !forceOverwrite) {
1287
1975
  return {
@@ -1295,7 +1983,28 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite,
1295
1983
  reason: "user config already present"
1296
1984
  };
1297
1985
  }
1298
- const desiredValue = migrationSeed ?? renderDefaultConfig(detection);
1986
+ let desiredValue = migrationSeed ?? renderDefaultConfig(detection);
1987
+ if (models && (models.planner || models.builder || models.finalReviewer)) {
1988
+ const merged = {
1989
+ ...desiredValue,
1990
+ schemaVersion: 2,
1991
+ profile: "engineering",
1992
+ workflow: {
1993
+ ...desiredValue.workflow ?? {},
1994
+ models: {
1995
+ planner: models.planner ?? desiredValue?.workflow?.models?.planner,
1996
+ builder: models.builder ?? desiredValue?.workflow?.models?.builder,
1997
+ finalReviewer: models.finalReviewer ?? desiredValue?.workflow?.models?.finalReviewer
1998
+ },
1999
+ approval: {
2000
+ mirrorToIssue: true,
2001
+ maxFailedRounds: 3,
2002
+ ...desiredValue?.workflow?.approval ?? {}
2003
+ }
2004
+ }
2005
+ };
2006
+ desiredValue = merged;
2007
+ }
1299
2008
  const desiredJson = JSON.stringify(desiredValue, null, 2) + "\n";
1300
2009
  const desiredSha = bytesHashString(desiredJson);
1301
2010
  const kind = existing?.ok && forceOverwrite ? "update" : "create";
@@ -1313,184 +2022,27 @@ async function planConfigSynthesis({ repoRoot, detection, lock, forceOverwrite,
1313
2022
  };
1314
2023
  }
1315
2024
  async function planRootConfigApply({ repoRoot, lock, forceRepair, planMode = null }) {
1316
- const detected = findRootConfig(repoRoot);
1317
- const target = detected.path ?? defaultRootConfigPath(repoRoot);
1318
- const previous = lock?.manager?.rootDocuments?.find((d) => d.path === detected.relative);
1319
- const fileMissing = !existsSync6(target);
1320
- if (fileMissing && !forceRepair) {
1321
- const seededRecords = previous?.pointers && previous.pointers.length > 0 ? previous.pointers : POINTER_ENTRIES.map((entry) => ({
1322
- pointer: entry.pointer,
1323
- strategy: entry.strategy,
1324
- installedSha256: bytesHashString(stableStringify(entry.value)),
1325
- previous: { existed: false }
1326
- }));
1327
- if (planMode) {
1328
- seededRecords.push({
1329
- pointer: planMode.id,
1330
- strategy: "value",
1331
- installedSha256: bytesHashString(stableStringify(planMode.block)),
1332
- previous: { existed: false }
1333
- });
1334
- }
1335
- return {
1336
- kind: "noop",
1337
- op: "root-config",
1338
- target,
1339
- relPath: detected.relative,
1340
- reason: "no root opencode.json present",
1341
- edits: [],
1342
- pointerRecords: seededRecords
1343
- };
1344
- }
1345
- if (fileMissing && forceRepair) {
1346
- const { synthesizeDefaultRootConfig: synthesizeDefaultRootConfig2, formatRootConfig: formatRootConfig3, applyPlanModeOwnership: applyPlanModeOwnership2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
1347
- let doc = synthesizeDefaultRootConfig2();
1348
- if (planMode) {
1349
- const applied = applyPlanModeOwnership2(doc, { block: planMode.block, pointer: planMode.id });
1350
- doc = applied.doc;
1351
- }
1352
- const bytes2 = Buffer.from(formatRootConfig3(doc), "utf8");
1353
- const newSha2 = bytesHashString(stableStringify(doc));
1354
- const installedPointers2 = POINTER_ENTRIES.map((entry) => ({
1355
- pointer: entry.pointer,
1356
- strategy: entry.strategy,
1357
- installedSha256: bytesHashString(stableStringify(entry.value)),
1358
- previous: { existed: false }
1359
- }));
1360
- if (planMode) {
1361
- installedPointers2.push({
1362
- pointer: planMode.id,
1363
- strategy: "value",
1364
- installedSha256: bytesHashString(stableStringify(planMode.block)),
1365
- previous: { existed: false }
1366
- });
1367
- }
1368
- return {
1369
- kind: "create",
1370
- op: "root-config",
1371
- target,
1372
- relPath: detected.relative,
1373
- bytes: bytes2,
1374
- desiredSha: newSha2,
1375
- currentSha: null,
1376
- edits: installedPointers2.map((p) => ({ kind: "create", pointer: p.pointer, value: "(synthesized)" })),
1377
- pointerRecords: installedPointers2,
1378
- format: "json",
1379
- document: doc,
1380
- reason: "creating root opencode.json with installer-owned Build permissions"
1381
- };
1382
- }
1383
- const docResult = readRootConfig(target);
1384
- if (!docResult.ok) {
1385
- return {
1386
- kind: "noop",
1387
- op: "root-config",
1388
- target,
1389
- relPath: detected.relative,
1390
- reason: `root config ${docResult.error.kind}`,
1391
- edits: [],
1392
- pointerRecords: previous?.pointers ?? []
1393
- };
1394
- }
1395
- const previousPointers = previous?.pointers ?? [];
1396
- const result = applyOwnedPointers(docResult.value, { allowEqualValues: true });
1397
- if (result.applied.length === 0 && result.skipped.every((s) => s.reason === "already equal")) {
1398
- const ownedPointers = previousPointers.length > 0 ? previousPointers : POINTER_ENTRIES.map((entry) => {
1399
- const existing = getPointer(docResult.value, entry.pointer);
1400
- return {
1401
- pointer: entry.pointer,
1402
- strategy: entry.strategy,
1403
- installedSha256: bytesHashString(stableStringify(existing ?? entry.value)),
1404
- previous: existing === void 0 ? { existed: false } : { existed: true, value: existing }
1405
- };
1406
- });
1407
- return {
1408
- kind: "noop",
1409
- op: "root-config",
1410
- target,
1411
- relPath: detected.relative,
1412
- reason: "no installer-owned entries missing",
1413
- edits: [],
1414
- pointerRecords: ownedPointers,
1415
- format: docResult.format,
1416
- document: docResult.value,
1417
- currentSha: docResult.sha256 ?? null,
1418
- desiredSha: docResult.sha256 ?? null
1419
- };
1420
- }
1421
- const edits = result.applied.map((a) => ({
1422
- kind: "create",
1423
- pointer: a.pointer,
1424
- value: a.value
1425
- }));
1426
- for (const s of result.skipped) {
1427
- if (s.reason === "already equal") continue;
1428
- edits.push({ kind: "conflict", pointer: s.pointer, reason: s.reason, existing: s.existing, desired: s.desired });
1429
- }
1430
- const { formatRootConfigPreserving: formatRootConfigPreserving2, formatRootConfig: formatRootConfig2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
1431
- const { parseRootConfigPreservingOrder: parseRootConfigPreservingOrder2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
1432
- const { setPointer: setPointer2 } = await Promise.resolve().then(() => (init_json_pointer(), json_pointer_exports));
1433
- let bytes;
1434
- let docForWrite = result.doc;
1435
- try {
1436
- const { value: sourceValue, format: sourceFormat } = parseRootConfigPreservingOrder2(docResult.raw ?? "");
1437
- docForWrite = sourceValue && typeof sourceValue === "object" ? sourceValue : result.doc;
1438
- for (const entry of result.applied) {
1439
- docForWrite = setPointer2(docForWrite, entry.pointer, entry.value);
1440
- }
1441
- bytes = Buffer.from(formatRootConfigPreserving2(docForWrite), "utf8");
1442
- void sourceFormat;
1443
- } catch {
1444
- bytes = Buffer.from(formatRootConfig2(result.doc), "utf8");
1445
- }
1446
- const newSha = bytesHashString(stableStringify(docForWrite));
1447
- const installedPointers = [...previousPointers];
1448
- for (const e of result.applied) {
1449
- const idx = installedPointers.findIndex((p) => p.pointer === e.pointer);
1450
- const previousEntry = docResult.before?.[e.pointer];
1451
- const next = {
1452
- pointer: e.pointer,
1453
- strategy: "value",
1454
- installedSha256: bytesHashString(stableStringify(e.value)),
1455
- previous: previousEntry == null ? { existed: false } : { existed: true, value: previousEntry }
1456
- };
1457
- if (idx >= 0) installedPointers[idx] = next;
1458
- else installedPointers.push(next);
1459
- }
1460
- for (const e of result.skipped) {
1461
- if (e.reason !== "already equal") continue;
1462
- const idx = installedPointers.findIndex((p) => p.pointer === e.pointer);
1463
- if (idx >= 0) continue;
1464
- installedPointers.push({
1465
- pointer: e.pointer,
1466
- strategy: "value",
1467
- installedSha256: bytesHashString(stableStringify(e.existing ?? null)),
1468
- previous: { existed: true, value: e.existing }
1469
- });
1470
- }
1471
- return {
1472
- kind: edits.some((e) => e.kind === "conflict") ? "conflict" : edits.length ? "update" : "noop",
1473
- op: "root-config",
1474
- target,
1475
- relPath: detected.relative,
1476
- bytes,
1477
- desiredSha: newSha,
1478
- currentSha: docResult.sha256 ?? null,
1479
- edits,
1480
- pointerRecords: installedPointers,
1481
- format: docResult.format,
1482
- document: docForWrite,
1483
- reason: edits.length === 0 ? "no installer-owned entries missing" : `apply ${result.applied.length} / skip ${result.skipped.length}`
1484
- };
2025
+ const previous = (lock?.manager?.rootDocuments ?? []).flatMap((d) => d.pointers ?? []);
2026
+ const previousProfile = lock?.manager?.profile ?? null;
2027
+ const desiredProfile = planMode && planMode.scope === "engineering" ? "engineering" : "core";
2028
+ const isTransition = previousProfile !== null && previousProfile !== desiredProfile;
2029
+ const mode = previous.length === 0 ? "install" : isTransition ? "profile-transition" : "install";
2030
+ return planRootReconciliation({
2031
+ repoRoot,
2032
+ profile: desiredProfile,
2033
+ mode,
2034
+ previousRecords: previous,
2035
+ forceRepair: Boolean(forceRepair)
2036
+ });
1485
2037
  }
1486
2038
 
1487
2039
  // src/installer/lock.js
1488
2040
  init_hash();
1489
2041
  init_json_pointer();
1490
2042
  import { readFile as readFile4, writeFile as writeFile2, rename as rename2, mkdir as mkdir2 } from "node:fs/promises";
1491
- import { existsSync as existsSync7 } from "node:fs";
2043
+ import { existsSync as existsSync8 } from "node:fs";
1492
2044
  import { dirname as dirname4, resolve as resolve6 } from "node:path";
1493
- var CURRENT_LOCK_SCHEMA = 2;
2045
+ var CURRENT_LOCK_SCHEMA = 3;
1494
2046
  function lockPath(repoRoot) {
1495
2047
  return resolve6(repoRoot, ".opencode", "ship.lock.json");
1496
2048
  }
@@ -1510,8 +2062,8 @@ function validateLock(rawLock) {
1510
2062
  }
1511
2063
  const issues = [];
1512
2064
  let kind = "ok";
1513
- if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1) {
1514
- issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
2065
+ if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1 && rawLock.contractVersion !== 2) {
2066
+ issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 2, or 1)`);
1515
2067
  kind = "schema";
1516
2068
  }
1517
2069
  const manager = rawLock.manager;
@@ -1521,13 +2073,13 @@ function validateLock(rawLock) {
1521
2073
  } else if (typeof manager !== "object" || manager === null) {
1522
2074
  issues.push("manager section must be an object");
1523
2075
  kind = kind === "ok" ? "shape" : kind;
1524
- } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 1) {
1525
- issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
2076
+ } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 2 && manager.schemaVersion !== 1) {
2077
+ issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA}, 2, or 1)`);
1526
2078
  kind = "schema";
1527
2079
  } else if (manager.name !== "opencode-ship") {
1528
2080
  issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
1529
2081
  kind = "shape";
1530
- } else if (rawLock.contractVersion === CURRENT_LOCK_SCHEMA && manager.schemaVersion === CURRENT_LOCK_SCHEMA && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
2082
+ } else if (rawLock.contractVersion >= 2 && manager.schemaVersion >= 2 && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
1531
2083
  issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: core, engineering)`);
1532
2084
  kind = "shape";
1533
2085
  }
@@ -1549,7 +2101,7 @@ function validateLock(rawLock) {
1549
2101
  }
1550
2102
  async function readValidatedLock(repoRoot) {
1551
2103
  const path = lockPath(repoRoot);
1552
- if (!existsSync7(path)) {
2104
+ if (!existsSync8(path)) {
1553
2105
  return { kind: "missing", lock: null, issues: [] };
1554
2106
  }
1555
2107
  let raw;
@@ -1573,8 +2125,8 @@ init_json_pointer();
1573
2125
 
1574
2126
  // src/installer/detection/project.js
1575
2127
  import { spawnSync } from "node:child_process";
1576
- import { existsSync as existsSync8, readFileSync as readFileSync4 } from "node:fs";
1577
- import { resolve as resolve7, join } from "node:path";
2128
+ import { existsSync as existsSync9, readFileSync as readFileSync4 } from "node:fs";
2129
+ import { resolve as resolve7, join as join2 } from "node:path";
1578
2130
  function runGit(cwd, args) {
1579
2131
  const r = spawnSync("git", ["-C", cwd, ...args], {
1580
2132
  stdio: ["ignore", "pipe", "pipe"],
@@ -1583,15 +2135,15 @@ function runGit(cwd, args) {
1583
2135
  return { status: r.status ?? -1, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
1584
2136
  }
1585
2137
  function detectPackageManager(repoRoot) {
1586
- if (existsSync8(join(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
1587
- if (existsSync8(join(repoRoot, "yarn.lock"))) return "yarn";
1588
- if (existsSync8(join(repoRoot, "bun.lockb"))) return "bun";
1589
- if (existsSync8(join(repoRoot, "package-lock.json"))) return "npm";
2138
+ if (existsSync9(join2(repoRoot, "pnpm-lock.yaml"))) return "pnpm";
2139
+ if (existsSync9(join2(repoRoot, "yarn.lock"))) return "yarn";
2140
+ if (existsSync9(join2(repoRoot, "bun.lockb"))) return "bun";
2141
+ if (existsSync9(join2(repoRoot, "package-lock.json"))) return "npm";
1590
2142
  return null;
1591
2143
  }
1592
2144
  function readPackageJson(repoRoot) {
1593
- const path = join(repoRoot, "package.json");
1594
- if (!existsSync8(path)) return null;
2145
+ const path = join2(repoRoot, "package.json");
2146
+ if (!existsSync9(path)) return null;
1595
2147
  try {
1596
2148
  return JSON.parse(readFileSync4(path, "utf8"));
1597
2149
  } catch {
@@ -1719,34 +2271,130 @@ init_root_config();
1719
2271
 
1720
2272
  // src/installer/transaction.js
1721
2273
  import {
1722
- writeFile as writeFile3,
1723
- rename as rename3,
1724
- readFile as readFile5,
1725
- unlink,
1726
- mkdir as mkdir3,
1727
- stat as stat2,
2274
+ writeFile as writeFile4,
2275
+ rename as rename4,
2276
+ readFile as readFile6,
2277
+ unlink as unlink2,
2278
+ mkdir as mkdir4,
2279
+ stat as stat3,
1728
2280
  open
1729
2281
  } from "node:fs/promises";
1730
- import { existsSync as existsSync9 } from "node:fs";
1731
- import { dirname as dirname5, resolve as resolve8 } from "node:path";
1732
- import { spawnSync as spawnSync2 } from "node:child_process";
2282
+ import { existsSync as existsSync11 } from "node:fs";
2283
+ import { dirname as dirname6, resolve as resolve10, join as join5 } from "node:path";
1733
2284
  init_hash();
1734
- function gitDir(repoRoot) {
1735
- const r = spawnSync2("git", ["-C", repoRoot, "rev-parse", "--path-format=absolute", "--git-dir"], {
1736
- encoding: "utf8"
2285
+
2286
+ // src/state/git-common-dir.js
2287
+ import { spawn } from "node:child_process";
2288
+ import { resolve as resolve8, join as join3 } from "node:path";
2289
+ var STATE_DIRNAME = "opencode-ship";
2290
+ async function resolveGitCommonDir(repoRoot) {
2291
+ if (typeof repoRoot !== "string" || repoRoot.length === 0) {
2292
+ throw new Error("resolveGitCommonDir: repoRoot must be a non-empty string");
2293
+ }
2294
+ return new Promise((resolveP, reject) => {
2295
+ const proc = spawn(
2296
+ "git",
2297
+ ["-C", repoRoot, "rev-parse", "--path-format=absolute", "--git-common-dir"],
2298
+ { stdio: ["ignore", "pipe", "pipe"], shell: false }
2299
+ );
2300
+ let out = "";
2301
+ let err = "";
2302
+ proc.stdout.on("data", (d) => {
2303
+ out += d.toString("utf8");
2304
+ });
2305
+ proc.stderr.on("data", (d) => {
2306
+ err += d.toString("utf8");
2307
+ });
2308
+ proc.on("error", reject);
2309
+ proc.on("close", (code) => {
2310
+ if (code !== 0) {
2311
+ reject(new Error(
2312
+ `git rev-parse --git-common-dir failed (exit ${code}): ${err.trim() || "unknown error"}`
2313
+ ));
2314
+ return;
2315
+ }
2316
+ const trimmed = out.trim();
2317
+ if (!trimmed) {
2318
+ reject(new Error("git rev-parse --git-common-dir returned an empty path"));
2319
+ return;
2320
+ }
2321
+ resolveP(resolve8(repoRoot, trimmed));
2322
+ });
1737
2323
  });
1738
- if (r.status !== 0) return null;
1739
- const trimmed = r.stdout.trim();
1740
- return trimmed ? resolve8(repoRoot, trimmed) : null;
1741
2324
  }
1742
- function lockDir(repoRoot) {
1743
- return resolve8(gitDir(repoRoot) ?? repoRoot, "opencode-ship");
2325
+ function opencodeShipStateDir(commonDir, ...segments) {
2326
+ if (typeof commonDir !== "string" || commonDir.length === 0) {
2327
+ throw new Error("opencodeShipStateDir: commonDir must be a non-empty string");
2328
+ }
2329
+ return join3(commonDir, STATE_DIRNAME, ...segments);
1744
2330
  }
1745
- function transactionLockPath(repoRoot) {
1746
- return resolve8(lockDir(repoRoot), ".txn.lock");
2331
+
2332
+ // src/state/durable-store.js
2333
+ import {
2334
+ open as fsOpen,
2335
+ writeFile as writeFile3,
2336
+ readFile as readFile5,
2337
+ rename as rename3,
2338
+ link,
2339
+ mkdir as mkdir3,
2340
+ readdir,
2341
+ unlink,
2342
+ stat as stat2
2343
+ } from "node:fs/promises";
2344
+ import { existsSync as existsSync10 } from "node:fs";
2345
+ import { dirname as dirname5, join as join4, resolve as resolve9 } from "node:path";
2346
+ import { createHash as createHash2, randomBytes } from "node:crypto";
2347
+ import { hostname as osHostname } from "node:os";
2348
+ var STALE_LOCK_MS = 120 * 1e3;
2349
+ function randomToken() {
2350
+ return randomBytes(8).toString("hex");
1747
2351
  }
1748
- function journalPath(repoRoot, txnId) {
1749
- return resolve8(lockDir(repoRoot), `.txn-${txnId}.journal`);
2352
+ function ensureString(value) {
2353
+ return JSON.stringify(value, null, 2) + "\n";
2354
+ }
2355
+ async function fsyncDir(path) {
2356
+ try {
2357
+ const handle = await fsOpen(path, "r");
2358
+ try {
2359
+ await handle.sync();
2360
+ } finally {
2361
+ await handle.close();
2362
+ }
2363
+ } catch {
2364
+ }
2365
+ }
2366
+ async function atomicReplaceJson(path, value) {
2367
+ if (typeof path !== "string" || path.length === 0) {
2368
+ throw new Error("atomicReplaceJson: path must be a non-empty string");
2369
+ }
2370
+ const target = resolve9(path);
2371
+ const parent = dirname5(target);
2372
+ await mkdir3(parent, { recursive: true });
2373
+ const tmp = `${target}.${randomToken()}.tmp`;
2374
+ const handle = await fsOpen(tmp, "w", 384);
2375
+ try {
2376
+ await handle.writeFile(ensureString(value), "utf8");
2377
+ await handle.sync();
2378
+ } finally {
2379
+ await handle.close();
2380
+ }
2381
+ await rename3(tmp, target);
2382
+ await fsyncDir(parent);
2383
+ }
2384
+
2385
+ // src/installer/transaction.js
2386
+ function lockDirFromCommonDir(commonDir) {
2387
+ return opencodeShipStateDir(commonDir);
2388
+ }
2389
+ async function lockDirForRepo(repoRoot) {
2390
+ const commonDir = await resolveGitCommonDir(repoRoot);
2391
+ return lockDirFromCommonDir(commonDir);
2392
+ }
2393
+ function transactionLockPath(lockDir) {
2394
+ return resolve10(lockDir, ".txn.lock");
2395
+ }
2396
+ function journalPath(lockDir, txnId) {
2397
+ return resolve10(lockDir, `.txn-${txnId}.journal`);
1750
2398
  }
1751
2399
  function backupPath(target, token) {
1752
2400
  return `${target}.txn-${token}-backup`;
@@ -1755,46 +2403,35 @@ function stagedPath(target, token) {
1755
2403
  return `${target}.txn-${token}-staged`;
1756
2404
  }
1757
2405
  async function mkdirp(path) {
1758
- await mkdir3(path, { recursive: true });
2406
+ await mkdir4(path, { recursive: true });
1759
2407
  }
1760
- async function acquireLock(repoRoot, txnId) {
1761
- const dir = lockDir(repoRoot);
1762
- const path = transactionLockPath(repoRoot);
1763
- await mkdirp(dir);
1764
- let created = false;
2408
+ async function acquireLock(lockDir, txnId) {
2409
+ await mkdirp(lockDir);
2410
+ const path = transactionLockPath(lockDir);
2411
+ const handle = await open(path, "wx", 384);
1765
2412
  try {
1766
- const handle = await open(path, "wx", 384);
1767
- created = true;
1768
- try {
1769
- await handle.writeFile(JSON.stringify({
1770
- pid: process.pid,
1771
- txnId,
1772
- startedAt: (/* @__PURE__ */ new Date()).toISOString()
1773
- }));
1774
- await handle.sync();
1775
- } finally {
1776
- await handle.close();
1777
- }
1778
- await fsyncDir(dir);
1779
- } catch (error) {
1780
- if (created) {
1781
- await unlink(path).catch(() => null);
1782
- await fsyncDir(dir);
1783
- }
1784
- throw error;
2413
+ await handle.writeFile(JSON.stringify({
2414
+ pid: process.pid,
2415
+ txnId,
2416
+ startedAt: (/* @__PURE__ */ new Date()).toISOString()
2417
+ }));
2418
+ await handle.sync();
2419
+ } finally {
2420
+ await handle.close();
1785
2421
  }
2422
+ await fsyncDir2(lockDir);
1786
2423
  }
1787
- async function releaseLock(repoRoot) {
2424
+ async function releaseLock(lockDir) {
1788
2425
  try {
1789
- await unlink(transactionLockPath(repoRoot));
2426
+ await unlink2(transactionLockPath(lockDir));
1790
2427
  } catch {
1791
2428
  }
1792
2429
  }
1793
- async function liveLockOwner(repoRoot) {
1794
- const path = transactionLockPath(repoRoot);
1795
- if (!existsSync9(path)) return false;
2430
+ async function liveLockOwner(lockDir) {
2431
+ const path = transactionLockPath(lockDir);
2432
+ if (!existsSync11(path)) return false;
1796
2433
  try {
1797
- const lock = JSON.parse(await readFile5(path, "utf8"));
2434
+ const lock = JSON.parse(await readFile6(path, "utf8"));
1798
2435
  if (!Number.isInteger(lock?.pid) || lock.pid <= 0) return false;
1799
2436
  process.kill(lock.pid, 0);
1800
2437
  return true;
@@ -1802,7 +2439,7 @@ async function liveLockOwner(repoRoot) {
1802
2439
  return error?.code === "EPERM";
1803
2440
  }
1804
2441
  }
1805
- async function fsyncDir(path) {
2442
+ async function fsyncDir2(path) {
1806
2443
  try {
1807
2444
  const handle = await open(path, "r");
1808
2445
  await handle.sync();
@@ -1810,13 +2447,13 @@ async function fsyncDir(path) {
1810
2447
  } catch {
1811
2448
  }
1812
2449
  }
1813
- function randomToken() {
2450
+ function randomToken2() {
1814
2451
  return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
1815
2452
  }
1816
2453
  function asMode(mode) {
1817
2454
  return typeof mode === "number" ? mode : 420;
1818
2455
  }
1819
- async function writeJournal(repoRoot, txnId, journal) {
2456
+ async function writeJournal(lockDir, txnId, journal) {
1820
2457
  const { entries, ...header } = journal;
1821
2458
  const json = { ...header, ledger: entries.map((entry) => ({
1822
2459
  op: entry.op,
@@ -1828,62 +2465,63 @@ async function writeJournal(repoRoot, txnId, journal) {
1828
2465
  installedSha256: entry.installedSha256 ?? null,
1829
2466
  mode: entry.mode ?? null
1830
2467
  })) };
1831
- const target = journalPath(repoRoot, txnId);
1832
- const staged = `${target}.tmp`;
1833
- const handle = await open(staged, "w", 384);
1834
- try {
1835
- await handle.writeFile(JSON.stringify(json, null, 2), "utf8");
1836
- await handle.sync();
1837
- } finally {
1838
- await handle.close();
1839
- }
1840
- await rename3(staged, target);
1841
- await fsyncDir(dirname5(target));
2468
+ await atomicReplaceJson(journalPath(lockDir, txnId), json);
1842
2469
  }
1843
- async function clearJournal(repoRoot, txnId) {
2470
+ async function clearJournal(lockDir, txnId) {
1844
2471
  if (!txnId) return;
1845
2472
  try {
1846
- await unlink(journalPath(repoRoot, txnId));
2473
+ await unlink2(journalPath(lockDir, txnId));
1847
2474
  } catch {
1848
2475
  }
1849
2476
  }
1850
- async function readJournal(repoRoot, name) {
1851
- const path = resolve8(lockDir(repoRoot), name);
2477
+ async function readJournal(lockDir, name) {
2478
+ const path = resolve10(lockDir, name);
2479
+ let raw;
1852
2480
  try {
1853
- const raw = await readFile5(path, "utf8");
1854
- return JSON.parse(raw);
1855
- } catch {
1856
- return null;
2481
+ raw = await readFile6(path, "utf8");
2482
+ } catch (err) {
2483
+ throw new Error(`transaction journal unreadable: ${path}: ${err?.message ?? err}`);
2484
+ }
2485
+ let parsed;
2486
+ try {
2487
+ parsed = JSON.parse(raw);
2488
+ } catch (err) {
2489
+ throw new Error(`transaction journal malformed JSON: ${path}: ${err?.message ?? err}`);
1857
2490
  }
2491
+ if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ledger)) {
2492
+ throw new Error(`transaction journal missing ledger: ${path}`);
2493
+ }
2494
+ return parsed;
1858
2495
  }
1859
2496
  async function isCommitted(journal) {
1860
2497
  if (journal.committed) return true;
1861
2498
  const marker = journal.ledger?.find((entry) => entry.commitMarker);
1862
- if (!marker?.target || !marker.installedSha256 || !existsSync9(marker.target)) return false;
2499
+ if (!marker?.target || !marker.installedSha256 || !existsSync11(marker.target)) return false;
1863
2500
  try {
1864
- return bytesHashString(await readFile5(marker.target, "utf8")) === marker.installedSha256;
2501
+ return bytesHashString(await readFile6(marker.target, "utf8")) === marker.installedSha256;
1865
2502
  } catch {
1866
2503
  return false;
1867
2504
  }
1868
2505
  }
1869
2506
  async function restoreEntry(entry) {
1870
2507
  if (entry.op === "write") {
1871
- if (entry.backup && existsSync9(entry.backup)) {
1872
- await rename3(entry.backup, entry.target);
1873
- } else if (entry.hadOriginal === false && existsSync9(entry.target)) {
1874
- await unlink(entry.target);
2508
+ if (entry.backup && existsSync11(entry.backup)) {
2509
+ await rename4(entry.backup, entry.target);
2510
+ } else if (entry.hadOriginal === false && existsSync11(entry.target)) {
2511
+ await unlink2(entry.target);
1875
2512
  }
1876
- if (entry.staged && existsSync9(entry.staged)) await unlink(entry.staged);
1877
- } else if (entry.op === "delete" && entry.backup && existsSync9(entry.backup)) {
1878
- await rename3(entry.backup, entry.target);
2513
+ if (entry.staged && existsSync11(entry.staged)) await unlink2(entry.staged);
2514
+ } else if (entry.op === "delete" && entry.backup && existsSync11(entry.backup)) {
2515
+ await rename4(entry.backup, entry.target);
1879
2516
  }
1880
- await fsyncDir(dirname5(entry.target));
2517
+ await fsyncDir2(dirname6(entry.target));
1881
2518
  }
1882
- async function recoverJournal(repoRoot, dir, name) {
1883
- const journal = await readJournal(repoRoot, name);
1884
- if (!journal) {
1885
- await unlink(resolve8(dir, name)).catch(() => null);
1886
- return true;
2519
+ async function recoverJournal(lockDir, name) {
2520
+ let journal;
2521
+ try {
2522
+ journal = await readJournal(lockDir, name);
2523
+ } catch (err) {
2524
+ return { ok: false, error: err };
1887
2525
  }
1888
2526
  const committed = await isCommitted(journal);
1889
2527
  const entries = committed ? journal.ledger ?? [] : [...journal.ledger ?? []].reverse();
@@ -1896,72 +2534,79 @@ async function recoverJournal(repoRoot, dir, name) {
1896
2534
  complete = false;
1897
2535
  }
1898
2536
  }
1899
- if (complete) await unlink(resolve8(dir, name)).catch(() => null);
1900
- return complete;
2537
+ if (complete) await unlink2(resolve10(lockDir, name)).catch(() => null);
2538
+ return { ok: complete };
1901
2539
  }
1902
- async function recover(repoRoot) {
1903
- const dir = lockDir(repoRoot);
1904
- if (!existsSync9(dir)) return { recovered: false, recoveredCount: 0 };
1905
- if (await liveLockOwner(repoRoot)) {
2540
+ async function recover(repoRoot, lockDir) {
2541
+ if (!existsSync11(lockDir)) return { recovered: false, recoveredCount: 0 };
2542
+ if (await liveLockOwner(lockDir)) {
1906
2543
  return { recovered: false, recoveredCount: 0, blocked: true };
1907
2544
  }
1908
- const { readdir } = await import("node:fs/promises");
1909
- const names = await readdir(dir).catch(() => []);
2545
+ const { readdir: readdir2 } = await import("node:fs/promises");
2546
+ const names = await readdir2(lockDir).catch(() => []);
1910
2547
  const journals = names.filter((n) => n.startsWith(".txn-") && n.endsWith(".journal"));
1911
2548
  if (!journals.length) {
1912
- await releaseLock(repoRoot);
2549
+ await releaseLock(lockDir);
1913
2550
  return { recovered: false, recoveredCount: 0 };
1914
2551
  }
1915
2552
  let totalRecovered = 0;
1916
2553
  let recoveryFailed = false;
2554
+ let recoveryError = null;
1917
2555
  for (const name of journals) {
1918
- if (await recoverJournal(repoRoot, dir, name)) totalRecovered += 1;
1919
- else recoveryFailed = true;
2556
+ const result = await recoverJournal(lockDir, name);
2557
+ if (!result.ok) {
2558
+ recoveryFailed = true;
2559
+ if (result.error) recoveryError = result.error.message ?? String(result.error);
2560
+ } else {
2561
+ totalRecovered += 1;
2562
+ }
1920
2563
  }
1921
- await releaseLock(repoRoot);
2564
+ await releaseLock(lockDir);
1922
2565
  return {
1923
2566
  recovered: totalRecovered > 0,
1924
2567
  recoveredCount: totalRecovered,
1925
2568
  blocked: recoveryFailed,
1926
- reason: recoveryFailed ? "recovery-failed" : null
2569
+ reason: recoveryFailed ? "recovery-failed" : null,
2570
+ recoveryError
1927
2571
  };
1928
2572
  }
1929
2573
  async function commitEntry(entry) {
1930
2574
  let changed = false;
1931
- if (entry.backup && existsSync9(entry.backup)) {
1932
- await unlink(entry.backup);
2575
+ if (entry.backup && existsSync11(entry.backup)) {
2576
+ await unlink2(entry.backup);
1933
2577
  changed = true;
1934
2578
  }
1935
- if (entry.staged && existsSync9(entry.staged)) {
1936
- await unlink(entry.staged);
2579
+ if (entry.staged && existsSync11(entry.staged)) {
2580
+ await unlink2(entry.staged);
1937
2581
  changed = true;
1938
2582
  }
1939
- if (changed) await fsyncDir(dirname5(entry.target));
2583
+ if (changed) await fsyncDir2(dirname6(entry.target));
1940
2584
  }
1941
2585
  async function executePlan({ repoRoot, plan, newLockBuilder }) {
1942
- const recovered = await recover(repoRoot);
2586
+ const lockDir = await lockDirForRepo(repoRoot);
2587
+ const recovered = await recover(repoRoot, lockDir);
1943
2588
  if (recovered.blocked) {
1944
2589
  const kind = recovered.reason ?? "lock-held";
1945
- const message = kind === "lock-held" ? "another opencode-ship transaction is active" : "a previous opencode-ship transaction could not be recovered";
2590
+ const message = kind === "lock-held" ? "another opencode-ship transaction is active" : `a previous opencode-ship transaction could not be recovered: ${recovered.recoveryError ?? "unknown error"}`;
1946
2591
  return { ok: false, error: { kind, message } };
1947
2592
  }
1948
2593
  const txnId = `txn-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
1949
2594
  try {
1950
- await acquireLock(repoRoot, txnId);
2595
+ await acquireLock(lockDir, txnId);
1951
2596
  } catch (e) {
1952
2597
  return { ok: false, error: { kind: "lock-held", message: e?.message ?? String(e) } };
1953
2598
  }
1954
2599
  const journal = { repoRoot, txnId, startedAt: (/* @__PURE__ */ new Date()).toISOString(), entries: [] };
1955
2600
  try {
1956
- await writeJournal(repoRoot, txnId, journal);
2601
+ await writeJournal(lockDir, txnId, journal);
1957
2602
  for (const op of plan) {
1958
2603
  if (op.op !== "file") continue;
1959
2604
  if (op.kind === "conflict" || op.kind === "noop" || op.kind === "converge") continue;
1960
- const token = randomToken();
2605
+ const token = randomToken2();
1961
2606
  const backup = backupPath(op.target, token);
1962
2607
  const staged = stagedPath(op.target, token);
1963
2608
  if (op.kind === "delete") {
1964
- if (!existsSync9(op.target)) continue;
2609
+ if (!existsSync11(op.target)) continue;
1965
2610
  journal.entries.push({
1966
2611
  op: "delete",
1967
2612
  target: op.target,
@@ -1970,11 +2615,11 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
1970
2615
  hadOriginal: true,
1971
2616
  mode: asMode(op.mode)
1972
2617
  });
1973
- await writeJournal(repoRoot, txnId, journal);
1974
- await rename3(op.target, backup);
2618
+ await writeJournal(lockDir, txnId, journal);
2619
+ await rename4(op.target, backup);
1975
2620
  } else {
1976
- await mkdirp(dirname5(op.target));
1977
- const hadOriginal = existsSync9(op.target);
2621
+ await mkdirp(dirname6(op.target));
2622
+ const hadOriginal = existsSync11(op.target);
1978
2623
  journal.entries.push({
1979
2624
  op: "write",
1980
2625
  target: op.target,
@@ -1983,24 +2628,24 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
1983
2628
  hadOriginal,
1984
2629
  mode: asMode(op.mode)
1985
2630
  });
1986
- await writeJournal(repoRoot, txnId, journal);
1987
- await writeFile3(staged, op.bytes ?? Buffer.alloc(0), { mode: asMode(op.mode) });
2631
+ await writeJournal(lockDir, txnId, journal);
2632
+ await writeFile4(staged, op.bytes ?? Buffer.alloc(0), { mode: asMode(op.mode) });
1988
2633
  const h = await open(staged, "r+");
1989
2634
  await h.sync();
1990
2635
  await h.close();
1991
- if (hadOriginal) await rename3(op.target, backup);
1992
- await rename3(staged, op.target);
2636
+ if (hadOriginal) await rename4(op.target, backup);
2637
+ await rename4(staged, op.target);
1993
2638
  }
1994
- await fsyncDir(dirname5(op.target));
2639
+ await fsyncDir2(dirname6(op.target));
1995
2640
  }
1996
2641
  if (newLockBuilder) {
1997
2642
  const lockValue = await newLockBuilder();
1998
2643
  const lockPathTarget = `${repoRoot}/.opencode/ship.lock.json`;
1999
- await mkdirp(dirname5(lockPathTarget));
2000
- const token = randomToken();
2644
+ await mkdirp(dirname6(lockPathTarget));
2645
+ const token = randomToken2();
2001
2646
  const backup = backupPath(lockPathTarget, token);
2002
2647
  const staged = stagedPath(lockPathTarget, token);
2003
- const hadOriginal = existsSync9(lockPathTarget);
2648
+ const hadOriginal = existsSync11(lockPathTarget);
2004
2649
  const finalLock = { ...lockValue, integrity: computeIntegrity(lockValue) };
2005
2650
  const lockBytes = JSON.stringify(finalLock, null, 2) + "\n";
2006
2651
  journal.entries.push({
@@ -2013,17 +2658,17 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
2013
2658
  installedSha256: bytesHashString(lockBytes),
2014
2659
  mode: 420
2015
2660
  });
2016
- await writeJournal(repoRoot, txnId, journal);
2017
- await writeFile3(staged, lockBytes, { mode: 420 });
2661
+ await writeJournal(lockDir, txnId, journal);
2662
+ await writeFile4(staged, lockBytes, { mode: 420 });
2018
2663
  const handle = await open(staged, "r+");
2019
2664
  await handle.sync();
2020
2665
  await handle.close();
2021
- if (hadOriginal) await rename3(lockPathTarget, backup);
2022
- await rename3(staged, lockPathTarget);
2023
- await fsyncDir(dirname5(lockPathTarget));
2666
+ if (hadOriginal) await rename4(lockPathTarget, backup);
2667
+ await rename4(staged, lockPathTarget);
2668
+ await fsyncDir2(dirname6(lockPathTarget));
2024
2669
  }
2025
2670
  journal.committed = true;
2026
- await writeJournal(repoRoot, txnId, journal);
2671
+ await writeJournal(lockDir, txnId, journal);
2027
2672
  let cleanupComplete = true;
2028
2673
  for (const entry of journal.entries) {
2029
2674
  try {
@@ -2032,7 +2677,7 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
2032
2677
  cleanupComplete = false;
2033
2678
  }
2034
2679
  }
2035
- if (cleanupComplete) await clearJournal(repoRoot, txnId);
2680
+ if (cleanupComplete) await clearJournal(lockDir, txnId);
2036
2681
  return {
2037
2682
  ok: true,
2038
2683
  txnId,
@@ -2041,13 +2686,13 @@ async function executePlan({ repoRoot, plan, newLockBuilder }) {
2041
2686
  cleanupPending: !cleanupComplete
2042
2687
  };
2043
2688
  } catch (e) {
2044
- await rollback(repoRoot, journal);
2689
+ await rollback(lockDir, journal);
2045
2690
  return { ok: false, error: { kind: "transaction-failed", message: e?.message ?? String(e) } };
2046
2691
  } finally {
2047
- await releaseLock(repoRoot);
2692
+ await releaseLock(lockDir);
2048
2693
  }
2049
2694
  }
2050
- async function rollback(repoRoot, journal) {
2695
+ async function rollback(lockDir, journal) {
2051
2696
  const entries = journal.entries ?? journal.ledger ?? [];
2052
2697
  let complete = true;
2053
2698
  for (const entry of [...entries].reverse()) {
@@ -2057,21 +2702,21 @@ async function rollback(repoRoot, journal) {
2057
2702
  complete = false;
2058
2703
  }
2059
2704
  }
2060
- if (complete && journal.txnId) await clearJournal(repoRoot, journal.txnId);
2705
+ if (complete && journal.txnId) await clearJournal(lockDir, journal.txnId);
2061
2706
  }
2062
2707
 
2063
2708
  // src/installer/migration.js
2064
- import { readFile as readFile6 } from "node:fs/promises";
2065
- import { existsSync as existsSync10 } from "node:fs";
2066
- import { resolve as resolve9 } from "node:path";
2709
+ import { readFile as readFile7 } from "node:fs/promises";
2710
+ import { existsSync as existsSync12 } from "node:fs";
2711
+ import { resolve as resolve11 } from "node:path";
2067
2712
  function legacyAdapterPath(repoRoot) {
2068
- return resolve9(repoRoot, ".opencode", "delivery.json");
2713
+ return resolve11(repoRoot, ".opencode", "delivery.json");
2069
2714
  }
2070
2715
  function legacyLockPath(repoRoot) {
2071
- return resolve9(repoRoot, ".opencode", "delivery.lock.json");
2716
+ return resolve11(repoRoot, ".opencode", "delivery.lock.json");
2072
2717
  }
2073
2718
  function legacyPluginPath(repoRoot) {
2074
- return resolve9(repoRoot, ".opencode", "plugin", "delivery.ts");
2719
+ return resolve11(repoRoot, ".opencode", "plugin", "delivery.ts");
2075
2720
  }
2076
2721
  async function detectLegacyShapes(repoRoot) {
2077
2722
  const out = {
@@ -2082,19 +2727,19 @@ async function detectLegacyShapes(repoRoot) {
2082
2727
  reviewer: false,
2083
2728
  verifier: false
2084
2729
  };
2085
- if (existsSync10(legacyAdapterPath(repoRoot))) out.adapter = true;
2086
- if (existsSync10(legacyLockPath(repoRoot))) out.legacyLock = true;
2087
- if (existsSync10(legacyPluginPath(repoRoot))) out.plugin = true;
2088
- if (existsSync10(resolve9(repoRoot, ".opencode/plugin/opencode-ship.js"))) out.pluginOld = true;
2089
- if (existsSync10(resolve9(repoRoot, ".opencode/agents/delivery-reviewer.md"))) out.reviewer = true;
2090
- if (existsSync10(resolve9(repoRoot, ".opencode/agents/delivery-verifier.md"))) out.verifier = true;
2730
+ if (existsSync12(legacyAdapterPath(repoRoot))) out.adapter = true;
2731
+ if (existsSync12(legacyLockPath(repoRoot))) out.legacyLock = true;
2732
+ if (existsSync12(legacyPluginPath(repoRoot))) out.plugin = true;
2733
+ if (existsSync12(resolve11(repoRoot, ".opencode/plugin/opencode-ship.js"))) out.pluginOld = true;
2734
+ if (existsSync12(resolve11(repoRoot, ".opencode/agents/delivery-reviewer.md"))) out.reviewer = true;
2735
+ if (existsSync12(resolve11(repoRoot, ".opencode/agents/delivery-verifier.md"))) out.verifier = true;
2091
2736
  return out;
2092
2737
  }
2093
2738
  async function readLegacyAdapter(repoRoot) {
2094
2739
  const path = legacyAdapterPath(repoRoot);
2095
- if (!existsSync10(path)) return null;
2740
+ if (!existsSync12(path)) return null;
2096
2741
  try {
2097
- const raw = await readFile6(path, "utf8");
2742
+ const raw = await readFile7(path, "utf8");
2098
2743
  return { path, raw, value: JSON.parse(raw) };
2099
2744
  } catch {
2100
2745
  return null;
@@ -2113,13 +2758,13 @@ async function migration({ repoRoot, lock, forceRepair, detection = null }) {
2113
2758
  if (legacy && shapes.legacyLock && !lock?.manager) {
2114
2759
  actions.push({ kind: "kept-legacy-lock", path: legacyLockPath(repoRoot) });
2115
2760
  }
2116
- if (shapes.plugin && existsSync10(resolve9(repoRoot, ".opencode/plugins/opencode-ship.js"))) {
2761
+ if (shapes.plugin && existsSync12(resolve11(repoRoot, ".opencode/plugins/opencode-ship.js"))) {
2117
2762
  if (!forceRepair) {
2118
2763
  actions.push({ kind: "candidate-remove-legacy-plugin", path: legacyPluginPath(repoRoot) });
2119
2764
  }
2120
2765
  }
2121
2766
  if (shapes.pluginOld) {
2122
- actions.push({ kind: "candidate-remove-legacy-plugin-path", path: resolve9(repoRoot, ".opencode/plugin/opencode-ship.js") });
2767
+ actions.push({ kind: "candidate-remove-legacy-plugin-path", path: resolve11(repoRoot, ".opencode/plugin/opencode-ship.js") });
2123
2768
  }
2124
2769
  return { shapes, actions, legacyPresent: Boolean(legacy), proposedConfigSeed };
2125
2770
  }
@@ -2163,11 +2808,11 @@ function legacyToShipConfig(legacy, detection = null) {
2163
2808
  // src/installer/executor.js
2164
2809
  init_plan_mode_permissions();
2165
2810
  async function readCurrentBytes(targetPath) {
2166
- if (!existsSync11(targetPath)) return null;
2167
- const buf = await readFile7(targetPath);
2811
+ if (!existsSync13(targetPath)) return null;
2812
+ const buf = await readFile8(targetPath);
2168
2813
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
2169
2814
  }
2170
- async function previewInstall({ rootPath, profile = null, replaceManaged, forceConfig, forceRootConfig }) {
2815
+ async function previewInstall({ rootPath, profile = null, replaceManaged, forceConfig, forceRootConfig, models = null }) {
2171
2816
  const detection = detectProject(rootPath ?? process.cwd());
2172
2817
  if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
2173
2818
  return { ok: false, error: { kind: "invalid-project", errors: detection.errors } };
@@ -2189,7 +2834,33 @@ async function previewInstall({ rootPath, profile = null, replaceManaged, forceC
2189
2834
  config: configValue,
2190
2835
  lock
2191
2836
  });
2837
+ if (resolved.profile === "engineering") {
2838
+ const candidate = await planConfigSynthesis({
2839
+ repoRoot,
2840
+ detection,
2841
+ lock,
2842
+ forceOverwrite: Boolean(forceConfig),
2843
+ migrationSeed: migrationReport?.proposedConfigSeed ?? null,
2844
+ models
2845
+ });
2846
+ if (candidate.kind === "create" || candidate.kind === "update") {
2847
+ const configValue2 = candidate.configValue;
2848
+ if (!configValue2.workflow || !configValue2.workflow.models) {
2849
+ return { ok: false, error: { kind: "engineering-models-required", message: "engineering profile requires workflow.models.{planner,builder,finalReviewer}" } };
2850
+ }
2851
+ const { planner, builder, finalReviewer } = configValue2.workflow.models;
2852
+ if (!planner || !builder || !finalReviewer) {
2853
+ return { ok: false, error: { kind: "engineering-models-required", message: "all three workflow.models.* are required" } };
2854
+ }
2855
+ if (!configValue2.workflow.approval || !configValue2.workflow.approval.mirrorToIssue || configValue2.workflow.approval.maxFailedRounds !== 3) {
2856
+ return { ok: false, error: { kind: "engineering-approval-required", message: "engineering profile requires workflow.approval.{mirrorToIssue:true, maxFailedRounds:3}" } };
2857
+ }
2858
+ }
2859
+ }
2860
+ const previousProfile = lock?.manager?.profile ?? null;
2861
+ const isProfileTransition = previousProfile && previousProfile !== resolved.profile;
2192
2862
  const activeCatalog = filterCatalogByProfile(CATALOG, resolved.profile);
2863
+ const staleCatalog = isProfileTransition ? filterCatalogByProfile(CATALOG, previousProfile).filter((e) => !activeCatalog.includes(e)) : [];
2193
2864
  const configPlan = await planConfigSynthesis({
2194
2865
  repoRoot,
2195
2866
  detection,
@@ -2198,15 +2869,16 @@ async function previewInstall({ rootPath, profile = null, replaceManaged, forceC
2198
2869
  migrationSeed: migrationReport?.proposedConfigSeed ?? null
2199
2870
  });
2200
2871
  const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged), catalog: activeCatalog });
2872
+ const staleFilePlan = await planStaleFileRemoval({ repoRoot, lock, staleCatalog });
2201
2873
  const migrationPlan = await planMigrationCleanup({
2202
2874
  repoRoot,
2203
2875
  lock,
2204
2876
  migrationReport,
2205
2877
  allowUnowned: Boolean(replaceManaged)
2206
2878
  });
2207
- const planMode = resolved.profile === "engineering" ? { id: "/agent/plan/permission", block: planModePermissions().build } : null;
2879
+ const planMode = resolved.profile === "engineering" ? { id: "/agent/plan/permission", block: planModePermissions().build, scope: "engineering" } : null;
2208
2880
  const rootPlan = await planRootConfigApply({ repoRoot, lock, forceRepair: Boolean(forceRootConfig), planMode });
2209
- const plan = [...filePlan ?? [], ...migrationPlan, configPlan, rootPlan];
2881
+ const plan = [...filePlan ?? [], ...staleFilePlan, ...migrationPlan, configPlan, rootPlan];
2210
2882
  const conflicts = plan.filter((p) => p && p.kind === "conflict");
2211
2883
  const summary = summarise(plan);
2212
2884
  return {
@@ -2215,6 +2887,8 @@ async function previewInstall({ rootPath, profile = null, replaceManaged, forceC
2215
2887
  detection,
2216
2888
  lock,
2217
2889
  profile: resolved,
2890
+ previousProfile,
2891
+ isProfileTransition,
2218
2892
  plan,
2219
2893
  conflicts,
2220
2894
  summary,
@@ -2286,7 +2960,7 @@ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan, profil
2286
2960
  manager: {
2287
2961
  schemaVersion: CURRENT_LOCK_SCHEMA,
2288
2962
  name: "opencode-ship",
2289
- version: "0.9.0",
2963
+ version: "0.10.0-rc.18",
2290
2964
  templateSet: TEMPLATE_SET_ID,
2291
2965
  // Newly written locks always carry the resolved profile so
2292
2966
  // future CLI invocations without --profile still resolve to
@@ -2434,10 +3108,10 @@ function relativeTemplate(source) {
2434
3108
  }
2435
3109
 
2436
3110
  // src/installer/commands/doctor.js
2437
- import { existsSync as existsSync12, readFileSync as readFileSync5 } from "node:fs";
2438
- import { spawnSync as spawnSync3 } from "node:child_process";
3111
+ import { existsSync as existsSync14, readFileSync as readFileSync5 } from "node:fs";
3112
+ import { spawnSync as spawnSync2 } from "node:child_process";
2439
3113
  init_hash();
2440
- import { resolve as resolve11 } from "node:path";
3114
+ import { resolve as resolve13 } from "node:path";
2441
3115
  init_root_config();
2442
3116
 
2443
3117
  // src/installer/report.js
@@ -2492,11 +3166,11 @@ function checkNode() {
2492
3166
  return { name: "node>=22.6.0", ok: /^v2[2-9]/.test(process.version), detail: process.version };
2493
3167
  }
2494
3168
  function checkGit() {
2495
- const r = spawnSync3("git", ["--version"], { encoding: "utf8" });
3169
+ const r = spawnSync2("git", ["--version"], { encoding: "utf8" });
2496
3170
  return { name: "git installed", ok: r.status === 0, detail: r.status === 0 ? r.stdout.trim() : "git not on PATH" };
2497
3171
  }
2498
3172
  function checkGh() {
2499
- const r = spawnSync3("gh", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
3173
+ const r = spawnSync2("gh", ["--version"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
2500
3174
  return { name: "gh installed", ok: r.status === 0, detail: r.status === 0 ? r.stdout.trim() : "gh CLI not on PATH" };
2501
3175
  }
2502
3176
  function checkGhAuth() {
@@ -2504,7 +3178,7 @@ function checkGhAuth() {
2504
3178
  if (!envToken) {
2505
3179
  return { name: "gh auth status", ok: false, detail: "no GH_TOKEN / GITHUB_TOKEN in environment; gh auth skipped" };
2506
3180
  }
2507
- const r = spawnSync3("gh", ["auth", "status"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
3181
+ const r = spawnSync2("gh", ["auth", "status"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
2508
3182
  return {
2509
3183
  name: "gh auth status",
2510
3184
  ok: r.status === 0,
@@ -2526,7 +3200,7 @@ function checkPackageIntegrity() {
2526
3200
  function buildSourceHashIndex() {
2527
3201
  const idx = /* @__PURE__ */ new Map();
2528
3202
  for (const entry of CATALOG) {
2529
- if (!existsSync12(entry.source)) continue;
3203
+ if (!existsSync14(entry.source)) continue;
2530
3204
  try {
2531
3205
  const buf = readFileSync5(entry.source, "utf8");
2532
3206
  idx.set(entry.source, bytesHashString(buf));
@@ -2535,11 +3209,12 @@ function buildSourceHashIndex() {
2535
3209
  }
2536
3210
  return idx;
2537
3211
  }
2538
- function checkCatalogInstall(repoRoot, sourceHashes) {
3212
+ function checkCatalogInstall(repoRoot, sourceHashes, profile) {
2539
3213
  const rows = [];
2540
- for (const entry of CATALOG) {
2541
- const target = resolve11(repoRoot, entry.path);
2542
- if (!existsSync12(target)) {
3214
+ const scoped = profile ? filterCatalogByProfile(CATALOG, profile) : CATALOG;
3215
+ for (const entry of scoped) {
3216
+ const target = resolve13(repoRoot, entry.path);
3217
+ if (!existsSync14(target)) {
2543
3218
  rows.push(`${entry.id}: missing`);
2544
3219
  continue;
2545
3220
  }
@@ -2558,7 +3233,7 @@ function checkCatalogInstall(repoRoot, sourceHashes) {
2558
3233
  }
2559
3234
  const allOk = rows.length === 0 || rows.every((r) => r.endsWith("ok"));
2560
3235
  return {
2561
- name: "catalog assets present",
3236
+ name: `catalog assets present (${profile ?? "core"})`,
2562
3237
  ok: allOk,
2563
3238
  detail: rows.join(",")
2564
3239
  };
@@ -2598,8 +3273,8 @@ async function checkManagedHashes(repoRoot, validatedLock) {
2598
3273
  }
2599
3274
  const drift = [];
2600
3275
  for (const entry of validatedLock.lock.files ?? []) {
2601
- const p = resolve11(repoRoot, entry.path);
2602
- if (!existsSync12(p)) {
3276
+ const p = resolve13(repoRoot, entry.path);
3277
+ if (!existsSync14(p)) {
2603
3278
  drift.push(`missing:${entry.path}`);
2604
3279
  continue;
2605
3280
  }
@@ -2683,7 +3358,7 @@ async function runDoctor({ rootPath, profile, json, writeOutput = true }) {
2683
3358
  checkGh(),
2684
3359
  checkGhAuth(),
2685
3360
  packageIntegrity,
2686
- checkCatalogInstall(repoRoot, sourceHashes),
3361
+ checkCatalogInstall(repoRoot, sourceHashes, resolved.profile),
2687
3362
  await checkLock(repoRoot),
2688
3363
  await checkConfig(repoRoot),
2689
3364
  await checkManagedHashes(repoRoot, validatedLock),
@@ -2723,12 +3398,19 @@ async function runInit(options) {
2723
3398
  profile: options.profile ?? null,
2724
3399
  replaceManaged: false,
2725
3400
  forceConfig: Boolean(options.forceConfig),
2726
- forceRootConfig: Boolean(options.forceRootConfig)
3401
+ forceRootConfig: Boolean(options.forceRootConfig),
3402
+ models: options.models ?? null
2727
3403
  });
2728
3404
  if (!preview.ok) {
2729
3405
  if (preview.error?.kind === "unsupported-lock-schema") {
2730
3406
  return emitFailure(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json, "init");
2731
3407
  }
3408
+ if (preview.error?.kind === "engineering-models-required") {
3409
+ return emitFailure(2, preview.error.message, options.json, "init");
3410
+ }
3411
+ if (preview.error?.kind === "engineering-approval-required") {
3412
+ return emitFailure(2, preview.error.message, options.json, "init");
3413
+ }
2732
3414
  if (preview.error?.kind === "lock-invalid") {
2733
3415
  return emitFailure(3, `lock invalid: ${(preview.error.issues ?? []).join("; ")}`, options.json, "init");
2734
3416
  }
@@ -2895,7 +3577,11 @@ async function runDiff(options) {
2895
3577
  const head = "# opencode-ship diff";
2896
3578
  const lines = [head, "", "## Plan"];
2897
3579
  for (const op of plan.filter(Boolean)) {
2898
- const bytesHint = op.bytes ? `${op.bytes.length ?? 0}b` : "";
3580
+ const bytesHint = (
3581
+ /** @type {any} */
3582
+ op.bytes ? `${/** @type {any} */
3583
+ op.bytes.length ?? 0}b` : ""
3584
+ );
2899
3585
  lines.push(` - ${op.kind.padEnd(9)} ${op.op} ${op.relPath ?? op.target}${bytesHint ? ` (${bytesHint})` : ""}${op.reason ? ` \u2014 ${op.reason}` : ""}`);
2900
3586
  }
2901
3587
  if (conflicts.length) {
@@ -2979,11 +3665,9 @@ function emitFailure2(code, message, json, command) {
2979
3665
  }
2980
3666
 
2981
3667
  // src/installer/commands/uninstall.js
2982
- import { unlink as unlink3 } from "node:fs/promises";
2983
- import { existsSync as existsSync13 } from "node:fs";
2984
- import { resolve as resolve12 } from "node:path";
3668
+ import { unlink as unlink4 } from "node:fs/promises";
2985
3669
  async function runUninstall(options) {
2986
- const preview = await previewUninstall({ rootPath: options.rootPath, profile: options.profile ?? null });
3670
+ const preview = await previewUninstall({ rootPath: options.rootPath });
2987
3671
  if (!preview.ok) {
2988
3672
  if (preview.error?.kind === "unsupported-lock-schema") {
2989
3673
  return emitFailure3(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json);
@@ -2994,6 +3678,16 @@ async function runUninstall(options) {
2994
3678
  return emitFailure3(2, preview.error?.kind ?? "invalid-project", options.json);
2995
3679
  }
2996
3680
  const { repoRoot, plan, conflicts, summary } = preview;
3681
+ if (options.purgeConfig) {
3682
+ const cfg = configPath(repoRoot);
3683
+ plan.push({
3684
+ op: "file",
3685
+ kind: "delete",
3686
+ target: cfg,
3687
+ relPath: ".opencode/ship.config.json",
3688
+ reason: "purge user-owned ship.config.json"
3689
+ });
3690
+ }
2997
3691
  if (conflicts.length > 0) {
2998
3692
  return emitReport(plan, conflicts, summary, options.json, 3, ["modified managed files; refusing to delete"]);
2999
3693
  }
@@ -3001,11 +3695,6 @@ async function runUninstall(options) {
3001
3695
  if (!tx.ok) {
3002
3696
  return emitFailure3(4, tx.error?.message ?? "transaction failure", options.json);
3003
3697
  }
3004
- const lockPath2 = resolve12(repoRoot, ".opencode", "ship.lock.json");
3005
- if (existsSync13(lockPath2)) await unlink3(lockPath2).catch(() => null);
3006
- if (options.purgeConfig) {
3007
- await unlink3(configPath(repoRoot)).catch(() => null);
3008
- }
3009
3698
  return emitReport(plan, [], summary, options.json, 0, [tx.recovered ? "journal recovered before uninstall" : ""].filter(Boolean));
3010
3699
  }
3011
3700
  function emitFailure3(code, message, json) {
@@ -3083,15 +3772,37 @@ ${helpText()}`);
3083
3772
  const options = parsed.options ?? {};
3084
3773
  const opts = options;
3085
3774
  const profile = opts.profile ?? null;
3775
+ const models = {
3776
+ planner: opts.plannerModel ?? null,
3777
+ builder: opts.builderModel ?? null,
3778
+ finalReviewer: opts.finalReviewerModel ?? null
3779
+ };
3780
+ const hasModels = Boolean(models.planner || models.builder || models.finalReviewer);
3086
3781
  switch (parsed.command) {
3087
3782
  case "init":
3088
- await runInit({ json: !!opts.json, rootPath: opts.rootPath, profile, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
3783
+ await runInit({
3784
+ json: !!opts.json,
3785
+ rootPath: opts.rootPath,
3786
+ profile,
3787
+ forceConfig: !!opts.forceConfig,
3788
+ forceRootConfig: !!opts.forceRootConfig,
3789
+ strictDoctor: !!opts.strictDoctor,
3790
+ models: hasModels ? models : null
3791
+ });
3089
3792
  return;
3090
3793
  case "diff":
3091
3794
  await runDiff({ json: !!opts.json, rootPath: opts.rootPath, profile });
3092
3795
  return;
3093
3796
  case "update":
3094
- await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, profile, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
3797
+ await runUpdate({
3798
+ json: !!opts.json,
3799
+ rootPath: opts.rootPath,
3800
+ profile,
3801
+ replaceManaged: !!opts.replaceManaged,
3802
+ forceConfig: !!opts.forceConfig,
3803
+ forceRootConfig: !!opts.forceRootConfig,
3804
+ models: hasModels ? models : null
3805
+ });
3095
3806
  return;
3096
3807
  case "doctor":
3097
3808
  await runDoctor({ json: !!opts.json, rootPath: opts.rootPath, profile });