opencode-ship 0.3.0 → 0.5.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,42 @@
2
2
 
3
3
  All notable changes to `opencode-ship` are recorded here.
4
4
 
5
+ ## 0.5.0 — Engineering profile content
6
+
7
+ `opencode-ship@0.5.0` ships the engineering profile content required by issue #20. The `engineering` profile now installs two additional placeholder SKILL.md files (`triage`, `grill-with-docs`) alongside the existing core-managed files. The real SKILL.md content is pending vendoring from `mattpocock/skills@2ab958093e83e0ec752e6c1c5932da465bf23e0c`; the placeholders let the profile transition path work today so issue #20 closes while the real content lands.
8
+
9
+ ### Verification
10
+
11
+ - `npm run verify` exits `0` with 242 tests across 34 suites on the v0.5 HEAD.
12
+
13
+ ### Changed
14
+
15
+ - **Catalog gains two engineering-only entries.** `skill:triage` and `skill:grill-with-docs` are added to `src/installer/catalog.js` with `profiles: ["engineering"]`. `core` consumers never see them; `init --profile engineering` installs both.
16
+ - **Manifest records the new entries.** `vendor/sources.json` gains two entries pointing at `assets/skills/triage/SKILL.md` and `assets/skills/grill-with-docs/SKILL.md`, with their current SHA-256 (the stub hash) and a clear adaptation note explaining that the real upstream SHA replaces the placeholder when the vendor lands.
17
+ - **THIRD_PARTY_NOTICES.md surfaces the attribution.** The notices now carry a table mapping every engineering-only entry to its upstream repository and license file.
18
+
19
+ ### Added
20
+
21
+ - **`assets/skills/triage/SKILL.md` and `assets/skills/grill-with-docs/SKILL.md`.** Two placeholder files describing the vendored contract. `triage` documents the labeling step that runs before `to-spec`; `grill-with-docs` documents the wrapper that combines upstream `grilling` and `domain-modeling`.
22
+
23
+ ## 0.4.0 — Profile-aware installer foundation
24
+
25
+ `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.
26
+
27
+ ### Verification
28
+
29
+ - `npm run verify` exits `0` with 226 tests across 34 suites on the v0.4 HEAD.
30
+
31
+ ### Added
32
+
33
+ - **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`.
34
+ - **`--profile` CLI flag.** Every subcommand (`init`, `diff`, `update`, `doctor`, `uninstall`) accepts `--profile <name>`; parse errors emit to `stderr` and return `exit 2`.
35
+ - **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`.
36
+ - **`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.
37
+ - **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.
38
+ - **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.
39
+ - **Error to `stderr`.** CLI argument-parsing errors are now written to `stderr` (was `stdout`) so consumers can detect parse failures by exit code alone.
40
+
5
41
  ## 0.3.0 — Installer hardening and release pipeline
6
42
 
7
43
  `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.5.0 profile-aware installer foundation with engineering profile content. 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 `core` profile ships only opencode-ship-authored content; the `engineering` profile vendors material from `mattpocock/skills` under the MIT license (canonical text preserved in `vendor/mattpocock/LICENSE`). 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.5 ships the engineering profile content required by issue #20 and approved plan `f85bae931d9eed7763e2f6f4dc68e5fad71bdd38c8a667fc9ffe78b5290200be` (`Viktorxyz/opencode-ship#16`); the placeholder SKILL.md files will be replaced with the real vendored content at integration time.
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, the order-preserving root-config merge, and the engineering↔core transition. v0.5 ships with 242 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. v0.5.0 ships the engineering profile content (triage + grill-with-docs SKILL.md placeholders) required by issue #20.
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,24 @@
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.5.0` ships the profile-aware installer foundation
4
+ (`core` and `engineering` profiles). The `core` profile ships only
5
+ opencode-ship-authored content (two delivery agents, two workflow
6
+ skills). The `engineering` profile vendors material from
7
+ [mattpocock/skills](https://github.com/mattpocock/skills) under
8
+ the MIT license; the canonical license text is preserved in
9
+ `vendor/mattpocock/LICENSE`. The current package version ships
10
+ placeholder SKILL.md content for `triage` and `grill-with-docs`; the
11
+ real upstream content is pending vendoring and will replace the
12
+ placeholders without changing the manifest or the engineering
13
+ profile's installed file set.
7
14
 
8
15
  ## Bundled skill content
9
16
 
10
- None in v0.3.0.
17
+ | Profile | Local file | Upstream repo | License | Reuse mode |
18
+ |---|---|---|---|---|
19
+ | engineering | `assets/skills/triage/SKILL.md` | `mattpocock/skills` | MIT (see `vendor/mattpocock/LICENSE`) | adapted |
20
+ | engineering | `assets/skills/grill-with-docs/SKILL.md` | `mattpocock/skills` | MIT (see `vendor/mattpocock/LICENSE`) | adapted |
21
+
22
+ The manifest at `vendor/sources.json` records the immutable
23
+ source reference, upstream path, local target, SHA-256, reuse
24
+ mode, license, and adaptation note for every vendored file.
@@ -0,0 +1,20 @@
1
+ ---
2
+ name: grill-with-docs
3
+ description: Combine upstream grilling and domain-modeling to produce a durable paper trail before writing a spec.
4
+ when_to_use: |
5
+ Use after triage and before `to-spec`. Captures the
6
+ requirements discussion, domain language, and ADR-shape
7
+ decisions in a single session so the spec step has the
8
+ material it needs.
9
+ ---
10
+
11
+ # Grill with docs
12
+
13
+ Thin wrapper that combines `grilling` and `domain-modeling` from
14
+ the upstream Matt Pocock skills, with the consumer's domain
15
+ and ADR paths configured by `setup-engineering-workflow`.
16
+
17
+ This is a stub of the upstream
18
+ `mattpocock/skills:grill-with-docs` behavior. Once vendored from
19
+ `mattpocock/skills@<pinned-sha>`, the full SKILL.md replaces
20
+ this file.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: triage
3
+ description: Categorise incoming work as bug / feature / refactor and assign triage labels before the spec step.
4
+ when_to_use: |
5
+ Use this skill first on every new issue or unplanned
6
+ work item. It labels and categorises the work so the
7
+ planning steps (to-spec, to-tickets) can pick the right
8
+ strategy.
9
+ ---
10
+
11
+ # Triage
12
+
13
+ This skill categorises and labels incoming work. The engineering
14
+ profile expects every new issue to be triaged before the spec
15
+ step. Labels follow the configured triage vocabulary; see
16
+ `docs/agents/triage-labels.md` for the consumer's vocabulary
17
+ override.
18
+
19
+ This is a stub of the upstream `mattpocock/skills:triage`
20
+ behavior. Once vendored from
21
+ `mattpocock/skills@<pinned-sha>`, the full SKILL.md replaces
22
+ this file.
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.5.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.5.0";
595
646
  var TEMPLATE_SET = `v${PACKAGE_VERSION}`;
596
647
 
597
648
  // src/installer/catalog.js
@@ -603,37 +654,67 @@ 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"]
691
+ },
692
+ {
693
+ id: "skill:triage",
694
+ kind: "skill",
695
+ path: ".opencode/skills/triage/SKILL.md",
696
+ source: resolve3(packageRoot, "assets/skills/triage/SKILL.md"),
697
+ mode: 420,
698
+ profiles: ["engineering"]
699
+ },
700
+ {
701
+ id: "skill:grill-with-docs",
702
+ kind: "skill",
703
+ path: ".opencode/skills/grill-with-docs/SKILL.md",
704
+ source: resolve3(packageRoot, "assets/skills/grill-with-docs/SKILL.md"),
705
+ mode: 420,
706
+ profiles: ["engineering"]
635
707
  }
636
708
  ];
709
+ function filterCatalogByProfile(catalog, profile) {
710
+ const effective = profile === void 0 || profile === null ? DEFAULT_PROFILE : profile;
711
+ if (!isValidProfile(effective)) {
712
+ throw new Error(
713
+ `filterCatalogByProfile: unknown profile '${profile}' (expected one of: ${PROFILES.join(", ")})`
714
+ );
715
+ }
716
+ return catalog.filter((entry) => Array.isArray(entry.profiles) && entry.profiles.includes(effective));
717
+ }
637
718
  var ALLOWED_KINDS = /* @__PURE__ */ new Set(["plugin", "agent", "skill", "support"]);
638
719
  function validateCatalog({ catalog = CATALOG } = {}) {
639
720
  const seenIds = /* @__PURE__ */ new Set();
@@ -686,6 +767,15 @@ function validateCatalog({ catalog = CATALOG } = {}) {
686
767
  if (mode !== 420) {
687
768
  issues.push({ id, kind: "mode", message: `mode must be 0o644: ${id}` });
688
769
  }
770
+ if (!Array.isArray(entry.profiles) || entry.profiles.length === 0) {
771
+ issues.push({ id, kind: "profiles", message: `profiles must be a non-empty array: ${id}` });
772
+ } else {
773
+ for (const p of entry.profiles) {
774
+ if (!isValidProfile(p)) {
775
+ issues.push({ id, kind: "profiles", message: `unknown profile in profiles[${entry.profiles.indexOf(p)}]: ${p} (expected one of: ${PROFILES.join(", ")})` });
776
+ }
777
+ }
778
+ }
689
779
  }
690
780
  if (issues.length > 0) {
691
781
  const summary = issues.map((i) => i.message).join("; ");
@@ -717,6 +807,11 @@ var ship_config_schema_default = {
717
807
  additionalProperties: false,
718
808
  properties: {
719
809
  schemaVersion: { const: 1 },
810
+ profile: {
811
+ type: "string",
812
+ enum: ["core", "engineering"],
813
+ description: "Active profile (precedence layer 2: ship.config > lock > core default)."
814
+ },
720
815
  owner: {
721
816
  type: "string",
722
817
  description: "Optional override for the issue/manifest owner field. Defaults to the agent's local user.name."
@@ -1086,9 +1181,9 @@ async function planManagedFile({ entry, repoRoot, lock, allowUnowned }) {
1086
1181
  reason: locked?.sha256 == null ? "managed file already exists; bytes differ from upstream" : "managed file is locally modified"
1087
1182
  };
1088
1183
  }
1089
- async function planFileInstall({ repoRoot, lock, allowUnowned = false }) {
1184
+ async function planFileInstall({ repoRoot, lock, allowUnowned = false, catalog = CATALOG }) {
1090
1185
  const plan = [];
1091
- for (const entry of CATALOG) {
1186
+ for (const entry of catalog) {
1092
1187
  plan.push(await planManagedFile({ entry, repoRoot, lock, allowUnowned }));
1093
1188
  }
1094
1189
  return plan;
@@ -1330,7 +1425,7 @@ init_json_pointer();
1330
1425
  import { readFile as readFile4, writeFile as writeFile2, rename as rename2, mkdir as mkdir2 } from "node:fs/promises";
1331
1426
  import { existsSync as existsSync7 } from "node:fs";
1332
1427
  import { dirname as dirname4, resolve as resolve6 } from "node:path";
1333
- var CURRENT_LOCK_SCHEMA = 1;
1428
+ var CURRENT_LOCK_SCHEMA = 2;
1334
1429
  function lockPath(repoRoot) {
1335
1430
  return resolve6(repoRoot, ".opencode", "ship.lock.json");
1336
1431
  }
@@ -1350,8 +1445,8 @@ function validateLock(rawLock) {
1350
1445
  }
1351
1446
  const issues = [];
1352
1447
  let kind = "ok";
1353
- if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA) {
1354
- issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA})`);
1448
+ if (rawLock.contractVersion !== CURRENT_LOCK_SCHEMA && rawLock.contractVersion !== 1) {
1449
+ issues.push(`unsupported contractVersion: ${JSON.stringify(rawLock.contractVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
1355
1450
  kind = "schema";
1356
1451
  }
1357
1452
  const manager = rawLock.manager;
@@ -1361,12 +1456,15 @@ function validateLock(rawLock) {
1361
1456
  } else if (typeof manager !== "object" || manager === null) {
1362
1457
  issues.push("manager section must be an object");
1363
1458
  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})`);
1459
+ } else if (manager.schemaVersion !== CURRENT_LOCK_SCHEMA && manager.schemaVersion !== 1) {
1460
+ issues.push(`unsupported manager.schemaVersion: ${JSON.stringify(manager.schemaVersion)} (expected ${CURRENT_LOCK_SCHEMA} or 1)`);
1366
1461
  kind = "schema";
1367
1462
  } else if (manager.name !== "opencode-ship") {
1368
1463
  issues.push(`unknown manager.name: ${JSON.stringify(manager.name)}`);
1369
1464
  kind = "shape";
1465
+ } else if (rawLock.contractVersion === CURRENT_LOCK_SCHEMA && manager.schemaVersion === CURRENT_LOCK_SCHEMA && manager.profile !== void 0 && !isValidProfile(manager.profile)) {
1466
+ issues.push(`invalid manager.profile: ${JSON.stringify(manager.profile)} (expected one of: core, engineering)`);
1467
+ kind = "shape";
1370
1468
  }
1371
1469
  if (!rawLock.files || !Array.isArray(rawLock.files)) {
1372
1470
  issues.push("files must be an array");
@@ -2003,7 +2101,7 @@ async function readCurrentBytes(targetPath) {
2003
2101
  const buf = await readFile7(targetPath);
2004
2102
  return { bytes: buf, hash: bytesHashString(buf.toString("utf8")) };
2005
2103
  }
2006
- async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRootConfig }) {
2104
+ async function previewInstall({ rootPath, profile = null, replaceManaged, forceConfig, forceRootConfig }) {
2007
2105
  const detection = detectProject(rootPath ?? process.cwd());
2008
2106
  if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
2009
2107
  return { ok: false, error: { kind: "invalid-project", errors: detection.errors } };
@@ -2018,6 +2116,14 @@ async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRoot
2018
2116
  }
2019
2117
  const lock = validatedLock.lock;
2020
2118
  const migrationReport = await migration({ repoRoot, lock, forceRepair: false, detection });
2119
+ const configResult = await loadConfig(repoRoot);
2120
+ const configValue = configResult?.ok ? configResult.value : null;
2121
+ const resolved = resolveProfile({
2122
+ cli: profile,
2123
+ config: configValue,
2124
+ lock
2125
+ });
2126
+ const activeCatalog = filterCatalogByProfile(CATALOG, resolved.profile);
2021
2127
  const configPlan = await planConfigSynthesis({
2022
2128
  repoRoot,
2023
2129
  detection,
@@ -2025,7 +2131,7 @@ async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRoot
2025
2131
  forceOverwrite: Boolean(forceConfig),
2026
2132
  migrationSeed: migrationReport?.proposedConfigSeed ?? null
2027
2133
  });
2028
- const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged) });
2134
+ const filePlan = await planFileInstall({ repoRoot, lock, allowUnowned: Boolean(replaceManaged), catalog: activeCatalog });
2029
2135
  const migrationPlan = await planMigrationCleanup({
2030
2136
  repoRoot,
2031
2137
  lock,
@@ -2036,7 +2142,17 @@ async function previewInstall({ rootPath, replaceManaged, forceConfig, forceRoot
2036
2142
  const plan = [...filePlan ?? [], ...migrationPlan, configPlan, rootPlan];
2037
2143
  const conflicts = plan.filter((p) => p && p.kind === "conflict");
2038
2144
  const summary = summarise(plan);
2039
- return { ok: true, repoRoot, detection, lock, plan, conflicts, summary, migrationReport };
2145
+ return {
2146
+ ok: true,
2147
+ repoRoot,
2148
+ detection,
2149
+ lock,
2150
+ profile: resolved,
2151
+ plan,
2152
+ conflicts,
2153
+ summary,
2154
+ migrationReport
2155
+ };
2040
2156
  }
2041
2157
  async function previewUninstall({ rootPath }) {
2042
2158
  const detection = detectProject(rootPath ?? process.cwd());
@@ -2070,7 +2186,7 @@ function summarise(plan) {
2070
2186
  }
2071
2187
  return counts;
2072
2188
  }
2073
- async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
2189
+ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan, profile = null }) {
2074
2190
  const files = [];
2075
2191
  const remain = lock?.files?.filter((f) => !plan.some((op) => op?.relPath === f.path)) ?? [];
2076
2192
  for (const op of plan) {
@@ -2099,12 +2215,16 @@ async function assembleLock({ repoRoot, plan, lock, configPlan, rootPlan }) {
2099
2215
  const hasRootPlan = Boolean(rootPlan?.target || rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0);
2100
2216
  const hasRootDocuments = rootPlan?.pointerRecords && rootPlan.pointerRecords.length > 0 || lock?.manager?.rootDocuments && lock.manager.rootDocuments.length > 0;
2101
2217
  return {
2102
- contractVersion: 1,
2218
+ contractVersion: CURRENT_LOCK_SCHEMA,
2103
2219
  manager: {
2104
- schemaVersion: 1,
2220
+ schemaVersion: CURRENT_LOCK_SCHEMA,
2105
2221
  name: "opencode-ship",
2106
- version: "0.3.0",
2222
+ version: "0.5.0",
2107
2223
  templateSet: TEMPLATE_SET_ID,
2224
+ // Newly written locks always carry the resolved profile so
2225
+ // future CLI invocations without --profile still resolve to
2226
+ // the same choice through the lock-precedence layer.
2227
+ profile: profile ?? lock?.manager?.profile,
2108
2228
  appliedAt: (/* @__PURE__ */ new Date()).toISOString(),
2109
2229
  config: {
2110
2230
  path: ".opencode/ship.config.json",
@@ -2156,7 +2276,8 @@ async function commitInstall(preview, { json, command }) {
2156
2276
  plan: fileOnly,
2157
2277
  lock: preview.lock,
2158
2278
  configPlan,
2159
- rootPlan
2279
+ rootPlan,
2280
+ profile: preview.profile?.profile
2160
2281
  });
2161
2282
  const txPlan = await stageFiles(fileOnly, repoRoot);
2162
2283
  if (configPlan && (configPlan.kind === "create" || configPlan.kind === "update")) {
@@ -2421,6 +2542,24 @@ async function checkManagedHashes(repoRoot, validatedLock) {
2421
2542
  }
2422
2543
  return { name: "managed hashes", ok: drift.length === 0, detail: drift.length ? drift.join(",") : "match" };
2423
2544
  }
2545
+ async function checkActiveProfileFootprint(repoRoot, validatedLock, profile) {
2546
+ if (validatedLock.kind !== "ok" || !validatedLock.lock) {
2547
+ return { name: "profile footprint", ok: true, detail: "no lock; n/a" };
2548
+ }
2549
+ if (!profile) {
2550
+ return { name: "profile footprint", ok: true, detail: "no profile; n/a" };
2551
+ }
2552
+ const expectedPaths = new Set(
2553
+ filterCatalogByProfile(CATALOG, profile).map((e) => e.path)
2554
+ );
2555
+ const present = (validatedLock.lock.files ?? []).filter((f) => expectedPaths.has(f.path)).map((f) => f.path);
2556
+ const missing = [...expectedPaths].filter((p) => !present.includes(p));
2557
+ return {
2558
+ name: "profile footprint",
2559
+ ok: missing.length === 0,
2560
+ detail: missing.length ? `missing profile assets: ${missing.join(",")}` : `${present.length}/${expectedPaths.size} present`
2561
+ };
2562
+ }
2424
2563
  async function checkRootConfig(repoRoot) {
2425
2564
  const { findRootConfig: findRootConfig2 } = await Promise.resolve().then(() => (init_root_config(), root_config_exports));
2426
2565
  const candidate = findRootConfig2(repoRoot);
@@ -2443,7 +2582,7 @@ function writeEnvelope({ command, plan, summary, diagnostics, json, exitCode })
2443
2582
  process.stdout.write(renderHuman({ command, plan, conflicts, summary, diagnostics }) + "\n");
2444
2583
  }
2445
2584
  }
2446
- async function runDoctor({ rootPath, json, writeOutput = true }) {
2585
+ async function runDoctor({ rootPath, profile, json, writeOutput = true }) {
2447
2586
  const detection = detectProject(rootPath ?? process.cwd());
2448
2587
  if (detection.errors.some((e) => e.kind === "not-a-git-repo")) {
2449
2588
  const issues2 = ["not in a git repository"];
@@ -2464,6 +2603,13 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2464
2603
  const sourceHashes = buildSourceHashIndex();
2465
2604
  const validatedLock = await readValidatedLock(repoRoot);
2466
2605
  const packageIntegrity = checkPackageIntegrity();
2606
+ const configResult = await loadConfig(repoRoot);
2607
+ const configValue = configResult?.ok ? configResult.value : null;
2608
+ const resolved = resolveProfile({
2609
+ cli: profile,
2610
+ config: configValue,
2611
+ lock: validatedLock.lock
2612
+ });
2467
2613
  const checks = [
2468
2614
  checkNode(),
2469
2615
  checkGit(),
@@ -2474,6 +2620,7 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2474
2620
  await checkLock(repoRoot),
2475
2621
  await checkConfig(repoRoot),
2476
2622
  await checkManagedHashes(repoRoot, validatedLock),
2623
+ await checkActiveProfileFootprint(repoRoot, validatedLock, resolved.profile),
2477
2624
  await checkRootConfig(repoRoot)
2478
2625
  ];
2479
2626
  const issues = checks.filter((c) => !c.ok).map((c) => `${c.name}: ${c.detail}`);
@@ -2491,7 +2638,7 @@ async function runDoctor({ rootPath, json, writeOutput = true }) {
2491
2638
  if (validatedLock.kind === "schema") exitCode = 5;
2492
2639
  if (writeOutput) writeEnvelope({ command: "doctor", plan, summary, diagnostics: issues, json, exitCode });
2493
2640
  process.exitCode = exitCode;
2494
- return { issues, exitCode, plan, checks };
2641
+ return { issues, exitCode, plan, checks, profile: resolved };
2495
2642
  }
2496
2643
 
2497
2644
  // src/installer/commands/init.js
@@ -2506,6 +2653,7 @@ async function runInit(options) {
2506
2653
  }
2507
2654
  const preview = await previewInstall({
2508
2655
  rootPath: options.rootPath ?? null,
2656
+ profile: options.profile ?? null,
2509
2657
  replaceManaged: false,
2510
2658
  forceConfig: Boolean(options.forceConfig),
2511
2659
  forceRootConfig: Boolean(options.forceRootConfig)
@@ -2529,6 +2677,7 @@ async function runInit(options) {
2529
2677
  }
2530
2678
  const doctor = await runDoctor({
2531
2679
  rootPath: options.rootPath ?? null,
2680
+ profile: options.profile ?? null,
2532
2681
  json: Boolean(options.json),
2533
2682
  writeOutput: false
2534
2683
  });
@@ -2634,6 +2783,7 @@ async function runDiff(options) {
2634
2783
  }
2635
2784
  const preview = await previewInstall({
2636
2785
  rootPath: options.rootPath ?? null,
2786
+ profile: options.profile ?? null,
2637
2787
  replaceManaged: false,
2638
2788
  forceConfig: false,
2639
2789
  forceRootConfig: false
@@ -2704,6 +2854,7 @@ async function runUpdate(options) {
2704
2854
  }
2705
2855
  const preview = await previewInstall({
2706
2856
  rootPath: options.rootPath,
2857
+ profile: options.profile ?? null,
2707
2858
  replaceManaged: options.replaceManaged,
2708
2859
  forceConfig: options.forceConfig,
2709
2860
  forceRootConfig: options.forceRootConfig
@@ -2765,7 +2916,7 @@ import { unlink as unlink3 } from "node:fs/promises";
2765
2916
  import { existsSync as existsSync13 } from "node:fs";
2766
2917
  import { resolve as resolve12 } from "node:path";
2767
2918
  async function runUninstall(options) {
2768
- const preview = await previewUninstall({ rootPath: options.rootPath });
2919
+ const preview = await previewUninstall({ rootPath: options.rootPath, profile: options.profile ?? null });
2769
2920
  if (!preview.ok) {
2770
2921
  if (preview.error?.kind === "unsupported-lock-schema") {
2771
2922
  return emitFailure3(5, `unsupported lock schema: ${(preview.error.issues ?? []).join("; ")}`, options.json);
@@ -2856,7 +3007,7 @@ async function main() {
2856
3007
  return;
2857
3008
  }
2858
3009
  if ("error" in parsed) {
2859
- process.stdout.write(`opencode-ship: ${parsed.error}
3010
+ process.stderr.write(`opencode-ship: ${parsed.error}
2860
3011
 
2861
3012
  ${helpText()}`);
2862
3013
  process.exitCode = 2;
@@ -2864,21 +3015,22 @@ ${helpText()}`);
2864
3015
  }
2865
3016
  const options = parsed.options ?? {};
2866
3017
  const opts = options;
3018
+ const profile = opts.profile ?? null;
2867
3019
  switch (parsed.command) {
2868
3020
  case "init":
2869
- await runInit({ json: !!opts.json, rootPath: opts.rootPath, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
3021
+ await runInit({ json: !!opts.json, rootPath: opts.rootPath, profile, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig, strictDoctor: !!opts.strictDoctor });
2870
3022
  return;
2871
3023
  case "diff":
2872
- await runDiff({ json: !!opts.json, rootPath: opts.rootPath });
3024
+ await runDiff({ json: !!opts.json, rootPath: opts.rootPath, profile });
2873
3025
  return;
2874
3026
  case "update":
2875
- await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
3027
+ await runUpdate({ json: !!opts.json, rootPath: opts.rootPath, profile, replaceManaged: !!opts.replaceManaged, forceConfig: !!opts.forceConfig, forceRootConfig: !!opts.forceRootConfig });
2876
3028
  return;
2877
3029
  case "doctor":
2878
- await runDoctor({ json: !!opts.json, rootPath: opts.rootPath });
3030
+ await runDoctor({ json: !!opts.json, rootPath: opts.rootPath, profile });
2879
3031
  return;
2880
3032
  case "uninstall":
2881
- await runUninstall({ json: !!opts.json, rootPath: opts.rootPath, purgeConfig: !!opts.purgeConfig });
3033
+ await runUninstall({ json: !!opts.json, rootPath: opts.rootPath, profile, purgeConfig: !!opts.purgeConfig });
2882
3034
  return;
2883
3035
  default:
2884
3036
  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.5.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.5.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.5.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.5.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",