opencode-ship 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  All notable changes to `opencode-ship` are recorded here.
4
4
 
5
+ ## 0.4.0 — Profile-aware installer foundation
6
+
7
+ `opencode-ship@0.4.0` adds the profile-aware installer foundation that issue #18 requires. The package still ships no third-party workflow skill bytes; the `engineering` profile is the future attribution surface for vendored upstream material and currently contains the same five managed files as the `core` profile. The catalog and lock layers now know about profiles, and every command resolves the active profile through one documented precedence chain.
8
+
9
+ ### Verification
10
+
11
+ - `npm run verify` exits `0` with 226 tests across 34 suites on the v0.4 HEAD.
12
+
13
+ ### Added
14
+
15
+ - **Profile model.** `src/profile.js` declares `PROFILES = ["core", "engineering"]` and exports `resolveProfile({ cli, config, lock })` for the documented precedence (CLI > ship.config > lock > default). Unknown profiles throw a descriptive `Error` so the CLI can surface them as `exit 2`.
16
+ - **`--profile` CLI flag.** Every subcommand (`init`, `diff`, `update`, `doctor`, `uninstall`) accepts `--profile <name>`; parse errors emit to `stderr` and return `exit 2`.
17
+ - **Lock schema v2.** `CURRENT_LOCK_SCHEMA` is bumped to 2. Newly written locks always carry `manager.profile`; v1 locks (no profile field) still validate as legacy core so v0.3 consumers can upgrade without manual migration. The `ship-lock.schema.json` `enum` allows `[1, 2]` for both `contractVersion` and `manager.schemaVersion`.
18
+ - **`ship.config.json .profile`.** The user config schema accepts an optional `profile` enum (`core | engineering`). The profile is loaded by the same precedence chain as the lock.
19
+ - **Profile-aware catalog.** Every `CATALOG` entry declares a `profiles` array. `filterCatalogByProfile(catalog, profile)` returns the subset that ships under the active profile; `validateCatalog` rejects entries that reference unknown profiles.
20
+ - **Profile-aware doctor.** The new `profile footprint` check scopes asset presence to the active profile; `package integrity` continues to check the full catalog so the maintainer can still see drift in the other profile.
21
+ - **Error to `stderr`.** CLI argument-parsing errors are now written to `stderr` (was `stdout`) so consumers can detect parse failures by exit code alone.
22
+
5
23
  ## 0.3.0 — Installer hardening and release pipeline
6
24
 
7
25
  `opencode-ship@0.3.0` hardens the installer for the public registry. This is the v0.3 installer foundation with the `core` profile only; it carries no third-party workflow skill bytes. The package is now fully consumable from npm with provenance. The catalog installs the five managed files plus the two generated artifacts (`ship.config.json`, `ship.lock.json`), and adds tighter guards around every existing one. The plugin target is `.opencode/plugins/opencode-ship.js` so OpenCode auto-loads it from the plural directory; root-config pointer ownership is recorded for every installer-owned entry so the future v0.4 opt-in `engineering` profile can restore previous values on uninstall. v0.3 is the approved slice shipped by parent spec `Viktorxyz/opencode-ship#16` and plan revision `f85bae931d9eed7763e2f6f4dc68e5fad71bdd38c8a667fc9ffe78b5290200be`.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > npm-distributed OpenCode installer and delivery plugin: a single command materialises the lifecycle plugin, reviewer/verifier agents, and skills into any consumer repository, with a recoverable lock and never silently overwrites managed files.
4
4
  >
5
- > **Status:** v0.3.0 installer foundation. The installer is a `pnpm dlx opencode-ship@latest <cmd>` workflow. Five idempotent CLI commands manage a managed-file lock, a transactional promoter, and a compiled ESM plugin that registers the canonical nine `delivery_*` tools. Post-merge cleanup is immediate and recoverable. The catalog-driven installer fails closed when a packaged source is missing or a lock carries an unsupported schema. The plugin target is `.opencode/plugins/opencode-ship.js`; OpenCode auto-loads plugins from the plural directory. v0.3 records every installer-owned root pointer; v0.4 restores ownership on uninstall and ships the opt-in `engineering` profile that turns this distribution into the canonical workflow defined by approved plan `f85bae931d9eed7763e2f6f4dc68e5fad71bdd38c8a667fc9ffe78b5290200be` (`Viktorxyz/opencode-ship#16`).
5
+ > **Status:** v0.4.0 profile-aware installer foundation. The installer is a `pnpm dlx opencode-ship@latest <cmd>` workflow. Five idempotent CLI commands manage a managed-file lock, a transactional promoter, and a compiled ESM plugin that registers the canonical nine `delivery_*` tools. Every command now accepts `--profile <name>` (`core` default, `engineering` opt-in) and resolves the active profile through one documented precedence chain (CLI > ship.config > lock > default). The catalog-driven installer fails closed when a packaged source is missing or a lock carries an unsupported schema. The plugin target is `.opencode/plugins/opencode-ship.js`; OpenCode auto-loads plugins from the plural directory. v0.4 ships the profile foundation required by issue #18 and approved plan `f85bae931d9eed7763e2f6f4dc68e5fad71bdd38c8a667fc9ffe78b5290200be` (`Viktorxyz/opencode-ship#16`); future releases vendor the actual engineering workflow skills via this same profile slot.
6
6
 
7
7
  ---
8
8
 
@@ -111,7 +111,7 @@ Existing consumers of `opencode-delivery@0.1.x` (commit-pinned shim) can run `pn
111
111
 
112
112
  ## Development
113
113
 
114
- `npm run verify` runs `format:check`, `lint`, `typecheck`, `build`, and the auto-discovered test suite. The tests cover the installer CLI, the lock and root-config planners, the catalog validator, the schema validator, agents, the packed-artifact smoke check, the transaction-recovery contract, and the order-preserving root-config merge. v0.3 ships with 190 tests across 34 suites on the approved plan hash `f85bae931d9eed7763e2f6f4dc68e5fad71bdd38c8a667fc9ffe78b5290200be`.
114
+ `npm run verify` runs `format:check`, `lint`, `typecheck`, `build`, and the auto-discovered test suite. The tests cover the installer CLI, the lock and root-config planners, the catalog validator, the schema validator, agents, the packed-artifact smoke check, the transaction-recovery contract, the profile-resolution precedence chain, the catalog profile filter, and the order-preserving root-config merge. v0.4 ships with 226 tests across 34 suites on the approved plan hash `f85bae931d9eed7763e2f6f4dc68e5fad71bdd38c8a667fc9ffe78b5290200be`.
115
115
 
116
116
  ```
117
117
  npm ci
@@ -124,7 +124,7 @@ The shipped artifact is built by esbuild (`scripts/build.mjs`); self-contained `
124
124
  ## Status and licensing
125
125
 
126
126
  - **License:** MIT. See `LICENSE`.
127
- - **Versioning:** SemVer. v0.2.0 is the first npm-distributed release. v0.3.0 is the installer foundation with core-only defaults. v0.4.0 adds the opt-in `engineering` profile that completes the approved `engineering-workflow-v0.4` plan.
127
+ - **Versioning:** SemVer. v0.2.0 is the first npm-distributed release. v0.3.0 is the installer foundation with core-only defaults. v0.4.0 adds the profile-aware installer foundation (`--profile` flag, lock schema v2, profile precedence) that issue #18 requires.
128
128
  - **Compatibility:** the bundled plugin targets `@opencode-ai/plugin >= 1.15.5 < 2` and OpenCode `>= 1.15.5`.
129
129
 
130
130
  ## FAQ
@@ -1,10 +1,12 @@
1
1
  # Third-Party Notices
2
2
 
3
- `opencode-ship@0.3.0` does not bundle third-party skill content. This file is
4
- the attribution surface for the v0.4 `engineering` profile that vendors
5
- upstream material; that release must list the exact files, source commit,
6
- reuse mode, and license here.
3
+ `opencode-ship@0.4.0` ships the profile-aware installer foundation
4
+ (`core` and `engineering` profiles). The current package version does
5
+ not bundle third-party skill content. The `engineering` profile is
6
+ the attribution surface for vendored upstream material; future
7
+ releases that vendor skills must list the exact files, source
8
+ commit, reuse mode, and license here.
7
9
 
8
10
  ## Bundled skill content
9
11
 
10
- None in v0.3.0.
12
+ None in v0.4.0.
package/dist/cli.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // opencode-ship CLI v0.3.0
2
+ // opencode-ship CLI v0.4.0
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropNames = Object.getOwnPropertyNames;
5
5
  var __esm = (fn, res) => function __init() {
@@ -484,6 +484,46 @@ var init_root_config = __esm({
484
484
  }
485
485
  });
486
486
 
487
+ // src/profile.js
488
+ var PROFILES = Object.freeze(["core", "engineering"]);
489
+ var DEFAULT_PROFILE = "core";
490
+ function isValidProfile(name) {
491
+ return typeof name === "string" && PROFILES.includes(name);
492
+ }
493
+ function normalizeProfile(name) {
494
+ if (name === void 0 || name === null) return DEFAULT_PROFILE;
495
+ if (!isValidProfile(name)) return null;
496
+ return name;
497
+ }
498
+ function resolveProfile({ cli = null, config = null, lock = null } = {}) {
499
+ if (cli !== null && cli !== void 0) {
500
+ const v = normalizeProfile(cli);
501
+ if (v === null) {
502
+ throw new Error(`unknown CLI profile '${cli}' (expected one of: ${PROFILES.join(", ")})`);
503
+ }
504
+ return { profile: v, source: "cli" };
505
+ }
506
+ if (config && typeof config === "object" && config.profile !== void 0 && config.profile !== null) {
507
+ const v = normalizeProfile(config.profile);
508
+ if (v === null) {
509
+ throw new Error(
510
+ `unknown ship.config.json profile '${config.profile}' (expected one of: ${PROFILES.join(", ")})`
511
+ );
512
+ }
513
+ return { profile: v, source: "config" };
514
+ }
515
+ if (lock && typeof lock === "object" && lock.manager && lock.manager.profile !== void 0) {
516
+ const v = normalizeProfile(lock.manager.profile);
517
+ if (v === null) {
518
+ throw new Error(
519
+ `unknown lock manager.profile '${lock.manager.profile}' (expected one of: ${PROFILES.join(", ")})`
520
+ );
521
+ }
522
+ return { profile: v, source: "lock" };
523
+ }
524
+ return { profile: DEFAULT_PROFILE, source: "default" };
525
+ }
526
+
487
527
  // src/installer/cli-args.js
488
528
  var USAGE = `opencode-ship <command> [options]
489
529
 
@@ -498,6 +538,7 @@ Commands:
498
538
 
499
539
  Options:
500
540
  --root <path> Project root (defaults to cwd).
541
+ --profile <name> Override active profile: ${PROFILES.join(", ")}.
501
542
  --force-config Rewrite the user config from detection (init only).
502
543
  --force-root-config Create opencode.json when absent (init only).
503
544
  --strict-doctor Fail init when doctor reports unhealthy checks.
@@ -508,6 +549,7 @@ Options:
508
549
  function parseFlags(argv) {
509
550
  const options = {
510
551
  rootPath: null,
552
+ profile: null,
511
553
  json: false,
512
554
  replaceManaged: false,
513
555
  purgeConfig: false,
@@ -524,7 +566,16 @@ function parseFlags(argv) {
524
566
  else if (arg === "--force-root-config") options.forceRootConfig = true;
525
567
  else if (arg === "--strict-doctor") options.strictDoctor = true;
526
568
  else if (arg === "--root") options.rootPath = argv[++i];
527
- else if (arg === "-h" || arg === "--help") return { help: true };
569
+ else if (arg === "--profile") {
570
+ const value = argv[++i];
571
+ if (value === void 0) {
572
+ return { error: "--profile requires a value" };
573
+ }
574
+ if (!isValidProfile(value)) {
575
+ return { error: `unknown profile '${value}' (expected one of: ${PROFILES.join(", ")})` };
576
+ }
577
+ options.profile = value;
578
+ } else if (arg === "-h" || arg === "--help") return { help: true };
528
579
  else if (arg === "-v" || arg === "--version") return { version: true };
529
580
  else return { error: `unknown flag ${arg}` };
530
581
  }
@@ -591,7 +642,7 @@ function resolvePackageRoot(startUrl) {
591
642
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "node:fs";
592
643
  import { dirname as dirname2, resolve as resolve2 } from "node:path";
593
644
  import { fileURLToPath as fileURLToPath2 } from "node:url";
594
- var PACKAGE_VERSION = "0.3.0";
645
+ var PACKAGE_VERSION = "0.4.0";
595
646
  var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
596
647
 
597
648
  // src/installer/catalog.js
@@ -603,37 +654,51 @@ var CATALOG = [
603
654
  kind: "plugin",
604
655
  path: ".opencode/plugins/opencode-ship.js",
605
656
  source: resolve3(packageRoot, "dist/plugin.js"),
606
- mode: 420
657
+ mode: 420,
658
+ profiles: ["core", "engineering"]
607
659
  },
608
660
  {
609
661
  id: "agent:delivery-reviewer",
610
662
  kind: "agent",
611
663
  path: ".opencode/agents/delivery-reviewer.md",
612
664
  source: resolve3(packageRoot, "assets/agents/delivery-reviewer.md"),
613
- mode: 420
665
+ mode: 420,
666
+ profiles: ["core", "engineering"]
614
667
  },
615
668
  {
616
669
  id: "agent:delivery-verifier",
617
670
  kind: "agent",
618
671
  path: ".opencode/agents/delivery-verifier.md",
619
672
  source: resolve3(packageRoot, "assets/agents/delivery-verifier.md"),
620
- mode: 420
673
+ mode: 420,
674
+ profiles: ["core", "engineering"]
621
675
  },
622
676
  {
623
677
  id: "skill:delivery-workflow",
624
678
  kind: "skill",
625
679
  path: ".opencode/skills/delivery-workflow/SKILL.md",
626
680
  source: resolve3(packageRoot, "assets/skills/delivery-workflow/SKILL.md"),
627
- mode: 420
681
+ mode: 420,
682
+ profiles: ["core", "engineering"]
628
683
  },
629
684
  {
630
685
  id: "skill:planning-research-checkpoint",
631
686
  kind: "skill",
632
687
  path: ".opencode/skills/planning-research-checkpoint/SKILL.md",
633
688
  source: resolve3(packageRoot, "assets/skills/planning-research-checkpoint/SKILL.md"),
634
- mode: 420
689
+ mode: 420,
690
+ profiles: ["core", "engineering"]
635
691
  }
636
692
  ];
693
+ function filterCatalogByProfile(catalog, profile) {
694
+ const effective = profile === void 0 || profile === null ? DEFAULT_PROFILE : profile;
695
+ if (!isValidProfile(effective)) {
696
+ throw new Error(
697
+ `filterCatalogByProfile: unknown profile '${profile}' (expected one of: ${PROFILES.join(", ")})`
698
+ );
699
+ }
700
+ return catalog.filter((entry) => Array.isArray(entry.profiles) && entry.profiles.includes(effective));
701
+ }
637
702
  var ALLOWED_KINDS = /* @__PURE__ */ new Set(["plugin", "agent", "skill", "support"]);
638
703
  function validateCatalog({ catalog = CATALOG } = {}) {
639
704
  const seenIds = /* @__PURE__ */ new Set();
@@ -686,6 +751,15 @@ function validateCatalog({ catalog = CATALOG } = {}) {
686
751
  if (mode !== 420) {
687
752
  issues.push({ id, kind: "mode", message: `mode must be 0o644: ${id}` });
688
753
  }
754
+ if (!Array.isArray(entry.profiles) || entry.profiles.length === 0) {
755
+ issues.push({ id, kind: "profiles", message: `profiles must be a non-empty array: ${id}` });
756
+ } else {
757
+ for (const p of entry.profiles) {
758
+ if (!isValidProfile(p)) {
759
+ issues.push({ id, kind: "profiles", message: `unknown profile in profiles[${entry.profiles.indexOf(p)}]: ${p} (expected one of: ${PROFILES.join(", ")})` });
760
+ }
761
+ }
762
+ }
689
763
  }
690
764
  if (issues.length > 0) {
691
765
  const summary = issues.map((i) => i.message).join("; ");
@@ -717,6 +791,11 @@ var ship_config_schema_default = {
717
791
  additionalProperties: false,
718
792
  properties: {
719
793
  schemaVersion: { const: 1 },
794
+ profile: {
795
+ type: "string",
796
+ enum: ["core", "engineering"],
797
+ description: "Active profile (precedence layer 2: ship.config > lock > core default)."
798
+ },
720
799
  owner: {
721
800
  type: "string",
722
801
  description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
@@ -1086,9 +1165,9 @@ async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
1086
1165
  reason: locked?.sha256 == null ? "managed file already exists; bytes differ from upstream" : "managed file is locally modified"
1087
1166
  };
1088
1167
  }
1089
- async function planFileInstall({ repoRoot, lock, allowUnowned = false }) {
1168
+ async function planFileInstall({ repoRoot, lock, allowUnowned = false, catalog = CATALOG }) {
1090
1169
  const plan = [];
1091
- for (const entry of CATALOG) {
1170
+ for (const entry of catalog) {
1092
1171
  plan.push(await planManagedFile({ entry, repoRoot, lock, allowUnowned }));
1093
1172
  }
1094
1173
  return plan;
@@ -1330,7 +1409,7 @@ init_json_pointer();
1330
1409
  import { readFile as readFile4, writeFile as writeFile2, rename as rename2, mkdir as mkdir2 } from "node:fs/promises";
1331
1410
  import { existsSync as existsSync7 } from "node:fs";
1332
1411
  import { dirname as dirname4, resolve as resolve6 } from "node:path";
1333
- var CURRENT_LOCK_SCHEMA = 1;
1412
+ var CURRENT_LOCK_SCHEMA = 2;
1334
1413
  function lockPath(repoRoot) {
1335
1414
  return resolve6(repoRoot, ".opencode", "ship.lock.json");
1336
1415
  }
@@ -1350,8 +1429,8 @@ function validateLock(rawLock) {
1350
1429
  }
1351
1430
  const issues = [];
1352
1431
  let kind = "ok";
1353
- if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA) {
1354
- issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA})`);
1432
+ if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1) {
1433
+ issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
1355
1434
  kind = "schema";
1356
1435
  }
1357
1436
  const manager = rawLock.manager;
@@ -1361,12 +1440,15 @@ function validateLock(rawLock) {
1361
1440
  } else if (typeof manager !== "object" || manager === null) {
1362
1441
  issues.push("manager section must be an object");
1363
1442
  kind = kind === "ok" ? "shape" : kind;
1364
- } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA) {
1365
- issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA})`);
1443
+ } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 1) {
1444
+ issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
1366
1445
  kind = "schema";
1367
1446
  } else if (manager.name !== "opencode-ship") {
1368
1447
  issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
1369
1448
  kind = "shape";
1449
+ } else if (rawLock.contractVersion === CURRENT_LOCK_SCHEMA && manager.schemaVersion === CURRENT_LOCK_SCHEMA && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
1450
+ issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: core, engineering)`);
1451
+ kind = "shape";
1370
1452
  }
1371
1453
  if (!rawLock.files || !Array.isArray(rawLock.files)) {
1372
1454
  issues.push("files must be an array");
@@ -2003,7 +2085,7 @@ async function readCurrentBytes(targetPath) {
2003
2085
  const buf = await readFile7(targetPath);
2004
2086
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
2005
2087
  }
2006
- async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRootConfig }) {
2088
+ async function previewInstall({ rootPath, profile = null, replaceManaged, forceConfig, forceRootConfig }) {
2007
2089
  const detection = detectProject(rootPath ?? process.cwd());
2008
2090
  if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
2009
2091
  return { ok: false, error: { kind: "invalid-project", errors: detection.errors } };
@@ -2018,6 +2100,14 @@ async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRoot
2018
2100
  }
2019
2101
  const lock = validatedLock.lock;
2020
2102
  const migrationReport = await migration({ repoRoot, lock, forceRepair: false, detection });
2103
+ const configResult = await loadConfig(repoRoot);
2104
+ const configValue = configResult?.ok ? configResult.value : null;
2105
+ const resolved = resolveProfile({
2106
+ cli: profile,
2107
+ config: configValue,
2108
+ lock
2109
+ });
2110
+ const activeCatalog = filterCatalogByProfile(CATALOG, resolved.profile);
2021
2111
  const configPlan = await planConfigSynthesis({
2022
2112
  repoRoot,
2023
2113
  detection,
@@ -2025,7 +2115,7 @@ async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRoot
2025
2115
  forceOverwrite: Boolean(forceConfig),
2026
2116
  migrationSeed: migrationReport?.proposedConfigSeed ?? null
2027
2117
  });
2028
- const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged) });
2118
+ const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged), catalog: activeCatalog });
2029
2119
  const migrationPlan = await planMigrationCleanup({
2030
2120
  repoRoot,
2031
2121
  lock,
@@ -2036,7 +2126,17 @@ async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRoot
2036
2126
  const plan = [...filePlan ?? [], ...migrationPlan, configPlan, rootPlan];
2037
2127
  const conflicts = plan.filter((p) => p && p.kind === "conflict");
2038
2128
  const summary = summarise(plan);
2039
- return { ok: true, repoRoot, detection, lock, plan, conflicts, summary, migrationReport };
2129
+ return {
2130
+ ok: true,
2131
+ repoRoot,
2132
+ detection,
2133
+ lock,
2134
+ profile: resolved,
2135
+ plan,
2136
+ conflicts,
2137
+ summary,
2138
+ migrationReport
2139
+ };
2040
2140
  }
2041
2141
  async function previewUninstall({ rootPath }) {
2042
2142
  const detection = detectProject(rootPath ?? process.cwd());
@@ -2070,7 +2170,7 @@ function summarise(plan) {
2070
2170
  }
2071
2171
  return counts;
2072
2172
  }
2073
- async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
2173
+ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan, profile = null }) {
2074
2174
  const files = [];
2075
2175
  const remain = lock?.files?.filter((f) => !plan.some((op) => op?.relPath === f.path)) ?? [];
2076
2176
  for (const op of plan) {
@@ -2099,12 +2199,16 @@ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
2099
2199
  const hasRootPlan = Boolean(rootPlan?.target || rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0);
2100
2200
  const hasRootDocuments = rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 || lock?.manager?.rootDocuments && lock.manager.rootDocuments.length > 0;
2101
2201
  return {
2102
- contractVersion: 1,
2202
+ contractVersion: CURRENT_LOCK_SCHEMA,
2103
2203
  manager: {
2104
- schemaVersion: 1,
2204
+ schemaVersion: CURRENT_LOCK_SCHEMA,
2105
2205
  name: "opencode-ship",
2106
- version: "0.3.0",
2206
+ version: "0.4.0",
2107
2207
  templateSet: TEMPLATE_SET_ID,
2208
+ // Newly written locks always carry the resolved profile so
2209
+ // future CLI invocations without --profile still resolve to
2210
+ // the same choice through the lock-precedence layer.
2211
+ profile: profile ?? lock?.manager?.profile,
2108
2212
  appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
2109
2213
  config: {
2110
2214
  path: ".opencode/ship.config.json",
@@ -2156,7 +2260,8 @@ async function commitInstall(preview, { json, command }) {
2156
2260
  plan: fileOnly,
2157
2261
  lock: preview.lock,
2158
2262
  configPlan,
2159
- rootPlan
2263
+ rootPlan,
2264
+ profile: preview.profile?.profile
2160
2265
  });
2161
2266
  const txPlan = await stageFiles(fileOnly, repoRoot);
2162
2267
  if (configPlan && (configPlan.kind === "create" || configPlan.kind === "update")) {
@@ -2421,6 +2526,24 @@ async function checkManagedHashes(repoRoot, validatedLock) {
2421
2526
  }
2422
2527
  return { name: "managed hashes", ok: drift.length === 0, detail: drift.length ? drift.join(",") : "match" };
2423
2528
  }
2529
+ async function checkActiveProfileFootprint(repoRoot, validatedLock, profile) {
2530
+ if (validatedLock.kind !== "ok" || !validatedLock.lock) {
2531
+ return { name: "profile footprint", ok: true, detail: "no lock; n/a" };
2532
+ }
2533
+ if (!profile) {
2534
+ return { name: "profile footprint", ok: true, detail: "no profile; n/a" };
2535
+ }
2536
+ const expectedPaths = new Set(
2537
+ filterCatalogByProfile(CATALOG, profile).map((e) => e.path)
2538
+ );
2539
+ const present = (validatedLock.lock.files ?? []).filter((f) => expectedPaths.has(f.path)).map((f) => f.path);
2540
+ const missing = [...expectedPaths].filter((p) => !present.includes(p));
2541
+ return {
2542
+ name: "profile footprint",
2543
+ ok: missing.length === 0,
2544
+ detail: missing.length ? `missing profile assets: ${missing.join(",")}` : `${present.length}/${expectedPaths.size} present`
2545
+ };
2546
+ }
2424
2547
  async function checkRootConfig(repoRoot) {
2425
2548
  const { findRootConfig: findRootConfig2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
2426
2549
  const candidate = findRootConfig2(repoRoot);
@@ -2443,7 +2566,7 @@ function writeEnvelope({ command, plan, summary, diagnostics, json, exitCode })
2443
2566
  process.stdout.write(renderHuman({ command, plan, conflicts, summary, diagnostics }) + "\n");
2444
2567
  }
2445
2568
  }
2446
- async function runDoctor({ rootPath, json, writeOutput = true }) {
2569
+ async function runDoctor({ rootPath, profile, json, writeOutput = true }) {
2447
2570
  const detection = detectProject(rootPath ?? process.cwd());
2448
2571
  if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
2449
2572
  const issues2 = ["not in a git repository"];
@@ -2464,6 +2587,13 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2464
2587
  const sourceHashes = buildSourceHashIndex();
2465
2588
  const validatedLock = await readValidatedLock(repoRoot);
2466
2589
  const packageIntegrity = checkPackageIntegrity();
2590
+ const configResult = await loadConfig(repoRoot);
2591
+ const configValue = configResult?.ok ? configResult.value : null;
2592
+ const resolved = resolveProfile({
2593
+ cli: profile,
2594
+ config: configValue,
2595
+ lock: validatedLock.lock
2596
+ });
2467
2597
  const checks = [
2468
2598
  checkNode(),
2469
2599
  checkGit(),
@@ -2474,6 +2604,7 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2474
2604
  await checkLock(repoRoot),
2475
2605
  await checkConfig(repoRoot),
2476
2606
  await checkManagedHashes(repoRoot, validatedLock),
2607
+ await checkActiveProfileFootprint(repoRoot, validatedLock, resolved.profile),
2477
2608
  await checkRootConfig(repoRoot)
2478
2609
  ];
2479
2610
  const issues = checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
@@ -2491,7 +2622,7 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2491
2622
  if (validatedLock.kind === "schema") exitCode = 5;
2492
2623
  if (writeOutput) writeEnvelope({ command: "doctor", plan, summary, diagnostics: issues, json, exitCode });
2493
2624
  process.exitCode = exitCode;
2494
- return { issues, exitCode, plan, checks };
2625
+ return { issues, exitCode, plan, checks, profile: resolved };
2495
2626
  }
2496
2627
 
2497
2628
  // src/installer/commands/init.js
@@ -2506,6 +2637,7 @@ async function runInit(options) {
2506
2637
  }
2507
2638
  const preview = await previewInstall({
2508
2639
  rootPath: options.rootPath ?? null,
2640
+ profile: options.profile ?? null,
2509
2641
  replaceManaged: false,
2510
2642
  forceConfig: Boolean(options.forceConfig),
2511
2643
  forceRootConfig: Boolean(options.forceRootConfig)
@@ -2529,6 +2661,7 @@ async function runInit(options) {
2529
2661
  }
2530
2662
  const doctor = await runDoctor({
2531
2663
  rootPath: options.rootPath ?? null,
2664
+ profile: options.profile ?? null,
2532
2665
  json: Boolean(options.json),
2533
2666
  writeOutput: false
2534
2667
  });
@@ -2634,6 +2767,7 @@ async function runDiff(options) {
2634
2767
  }
2635
2768
  const preview = await previewInstall({
2636
2769
  rootPath: options.rootPath ?? null,
2770
+ profile: options.profile ?? null,
2637
2771
  replaceManaged: false,
2638
2772
  forceConfig: false,
2639
2773
  forceRootConfig: false
@@ -2704,6 +2838,7 @@ async function runUpdate(options) {
2704
2838
  }
2705
2839
  const preview = await previewInstall({
2706
2840
  rootPath: options.rootPath,
2841
+ profile: options.profile ?? null,
2707
2842
  replaceManaged: options.replaceManaged,
2708
2843
  forceConfig: options.forceConfig,
2709
2844
  forceRootConfig: options.forceRootConfig
@@ -2765,7 +2900,7 @@ import { unlink as unlink3 } from "node:fs/promises";
2765
2900
  import { existsSync as existsSync13 } from "node:fs";
2766
2901
  import { resolve as resolve12 } from "node:path";
2767
2902
  async function runUninstall(options) {
2768
- const preview = await previewUninstall({ rootPath: options.rootPath });
2903
+ const preview = await previewUninstall({ rootPath: options.rootPath, profile: options.profile ?? null });
2769
2904
  if (!preview.ok) {
2770
2905
  if (preview.error?.kind === "unsupported-lock-schema") {
2771
2906
  return emitFailure3(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json);
@@ -2856,7 +2991,7 @@ async function main() {
2856
2991
  return;
2857
2992
  }
2858
2993
  if ("error" in parsed) {
2859
- process.stdout.write(`opencode-ship: ${parsed.error}
2994
+ process.stderr.write(`opencode-ship: ${parsed.error}
2860
2995
 
2861
2996
  ${helpText()}`);
2862
2997
  process.exitCode = 2;
@@ -2864,21 +2999,22 @@ ${helpText()}`);
2864
2999
  }
2865
3000
  const options = parsed.options ?? {};
2866
3001
  const opts = options;
3002
+ const profile = opts.profile ?? null;
2867
3003
  switch (parsed.command) {
2868
3004
  case "init":
2869
- await runInit({ json: !!opts.json, rootPath: opts.rootPath, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
3005
+ await runInit({ json: !!opts.json, rootPath: opts.rootPath, profile, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
2870
3006
  return;
2871
3007
  case "diff":
2872
- await runDiff({ json: !!opts.json, rootPath: opts.rootPath });
3008
+ await runDiff({ json: !!opts.json, rootPath: opts.rootPath, profile });
2873
3009
  return;
2874
3010
  case "update":
2875
- await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
3011
+ await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, profile, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
2876
3012
  return;
2877
3013
  case "doctor":
2878
- await runDoctor({ json: !!opts.json, rootPath: opts.rootPath });
3014
+ await runDoctor({ json: !!opts.json, rootPath: opts.rootPath, profile });
2879
3015
  return;
2880
3016
  case "uninstall":
2881
- await runUninstall({ json: !!opts.json, rootPath: opts.rootPath, purgeConfig: !!opts.purgeConfig });
3017
+ await runUninstall({ json: !!opts.json, rootPath: opts.rootPath, profile, purgeConfig: !!opts.purgeConfig });
2882
3018
  return;
2883
3019
  default:
2884
3020
  process.stdout.write(helpText() + "\n");
package/dist/core.js CHANGED
@@ -1,4 +1,4 @@
1
- // opencode-ship/core v0.3.0
1
+ // opencode-ship/core v0.4.0
2
2
 
3
3
  // src/adapter.js
4
4
  import { readFile, writeFile, mkdir, rename } from "node:fs/promises";
package/dist/plugin.js CHANGED
@@ -1,4 +1,4 @@
1
- // opencode-ship v0.3.0
1
+ // opencode-ship v0.4.0
2
2
  var __defProp = Object.defineProperty;
3
3
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
4
  var __esm = (fn, res) => function __init() {
@@ -40,6 +40,17 @@ var init_hash = __esm({
40
40
  }
41
41
  });
42
42
 
43
+ // src/profile.js
44
+ function isValidProfile(name) {
45
+ return typeof name === "string" && PROFILES.includes(name);
46
+ }
47
+ var PROFILES;
48
+ var init_profile = __esm({
49
+ "src/profile.js"() {
50
+ PROFILES = Object.freeze(["core", "engineering"]);
51
+ }
52
+ });
53
+
43
54
  // src/installer/lock.js
44
55
  var lock_exports = {};
45
56
  __export(lock_exports, {
@@ -106,8 +117,8 @@ function validateLock(rawLock) {
106
117
  }
107
118
  const issues = [];
108
119
  let kind = "ok";
109
- if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA) {
110
- issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA})`);
120
+ if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1) {
121
+ issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
111
122
  kind = "schema";
112
123
  }
113
124
  const manager = rawLock.manager;
@@ -117,12 +128,15 @@ function validateLock(rawLock) {
117
128
  } else if (typeof manager !== "object" || manager === null) {
118
129
  issues.push("manager section must be an object");
119
130
  kind = kind === "ok" ? "shape" : kind;
120
- } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA) {
121
- issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA})`);
131
+ } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 1) {
132
+ issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
122
133
  kind = "schema";
123
134
  } else if (manager.name !== "opencode-ship") {
124
135
  issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
125
136
  kind = "shape";
137
+ } else if (rawLock.contractVersion === CURRENT_LOCK_SCHEMA && manager.schemaVersion === CURRENT_LOCK_SCHEMA && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
138
+ issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: core, engineering)`);
139
+ kind = "shape";
126
140
  }
127
141
  if (!rawLock.files || !Array.isArray(rawLock.files)) {
128
142
  issues.push("files must be an array");
@@ -181,7 +195,8 @@ var init_lock = __esm({
181
195
  "src/installer/lock.js"() {
182
196
  init_hash();
183
197
  init_json_pointer();
184
- CURRENT_LOCK_SCHEMA = 1;
198
+ init_profile();
199
+ CURRENT_LOCK_SCHEMA = 2;
185
200
  }
186
201
  });
187
202
 
@@ -14282,6 +14297,11 @@ var ship_config_schema_default = {
14282
14297
  additionalProperties: false,
14283
14298
  properties: {
14284
14299
  schemaVersion: { const: 1 },
14300
+ profile: {
14301
+ type: "string",
14302
+ enum: ["core", "engineering"],
14303
+ description: "Active profile (precedence layer 2: ship.config > lock > core default)."
14304
+ },
14285
14305
  owner: {
14286
14306
  type: "string",
14287
14307
  description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
@@ -14877,7 +14897,7 @@ function flattenShipConfig(ship) {
14877
14897
  import { readFileSync as readFileSync2, existsSync as existsSync6 } from "node:fs";
14878
14898
  import { dirname as dirname5, resolve as resolve11 } from "node:path";
14879
14899
  import { fileURLToPath } from "node:url";
14880
- var PACKAGE_VERSION = "0.3.0";
14900
+ var PACKAGE_VERSION = "0.4.0";
14881
14901
  var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
14882
14902
 
14883
14903
  // src/plugin.js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-ship",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "description": "npm-distributed OpenCode installer that materializes the delivery plugin, reviewer/verifier agents, skills, ship config and lock into any consumer repository.",
6
6
  "license": "MIT",
@@ -7,6 +7,11 @@
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
9
  "schemaVersion": { "const": 1 },
10
+ "profile": {
11
+ "type": "string",
12
+ "enum": ["core", "engineering"],
13
+ "description": "Active profile (precedence layer 2: ship.config > lock > core default)."
14
+ },
10
15
  "owner": {
11
16
  "type": "string",
12
17
  "description": "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
@@ -6,16 +6,20 @@
6
6
  "required": ["contractVersion", "manager", "files", "integrity"],
7
7
  "additionalProperties": false,
8
8
  "properties": {
9
- "contractVersion": { "const": 1 },
9
+ "contractVersion": { "enum": [1, 2] },
10
10
  "manager": {
11
11
  "type": "object",
12
12
  "required": ["schemaVersion", "name", "version", "appliedAt", "config"],
13
13
  "additionalProperties": false,
14
14
  "properties": {
15
- "schemaVersion": { "const": 1 },
15
+ "schemaVersion": { "enum": [1, 2] },
16
16
  "name": { "type": "string", "pattern": "^opencode-ship$" },
17
17
  "version": { "type": "string", "minLength": 1 },
18
18
  "templateSet": { "type": "string", "minLength": 1 },
19
+ "profile": {
20
+ "enum": ["core", "engineering"],
21
+ "description": "Profile the lock was applied under. Required on locks written at schema 2; absent on legacy schema-1 locks (resolved to core by the profile precedence layer)."
22
+ },
19
23
  "appliedAt": { "type": "string", "format": "date-time" },
20
24
  "config": {
21
25
  "type": "object",