@inixiative/config 0.5.80 → 0.5.82

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/README.md CHANGED
@@ -43,6 +43,7 @@ One version of `@inixiative/config` names one coherent ecosystem state:
43
43
  - `ecosystem` — the primitives lane's blessed, mutually-verified `@inixiative/*` set, plus this package
44
44
  - `agentic` — the agentic lane's blessed set
45
45
  - `consumers` — private repos that follow the blessed sets but never publish, keyed by GitHub `owner/repo`: `lanes` names the lanes each consumes, `upstream` the repo it syncs from
46
+ - `candidates` — published lane versions newer than their blessed one, waiting for every dependent to adopt them (see `train`); absent when there are none
46
47
 
47
48
  ```jsonc
48
49
  "consumers": {
@@ -71,7 +72,7 @@ bunx @inixiative/config ports [project]
71
72
 
72
73
  `ports` prints each project's local ports from `ports.json` (also exported as `@inixiative/config/ports.json`). A project's port for a service is the service's usual default plus 100 times the project's block, so no two projects collide and none sits on a default: Archive (block 7) serves HTTP on 4700 with Postgres on 6132; Kingdom (block 2) has web 3200, API 8200, Postgres 5632, Redis 6579. Reserved ports (macOS AirPlay on 5000 and 7000) are never assigned. Give a new project the next free block, and a new kind of service its usual default; the tests fail on any collision.
73
74
 
74
- `check` is read-only and exits non-zero on drift — run it in CI after a frozen-lockfile install. It verifies toolchain pins — declared once, at the root, pinned exactly: a workspace member declaring one is an error, and `bun.lock` must resolve each only to its pin (no second copy) — toolchain peer ranges (they must admit the pin and no older major; `sync` writes `>=<pin major>.<pin minor>.0`), `"latest"` ranges, `packageManager`/`.bun-version`, legacy `bun.lockb`, a committed (tracked, un-ignored) `bun.lock`, required scripts (`check`/`typecheck`/`lint`/`test`), lefthook (dep + `lefthook.yml` extending the shared hooks + `prepare` script; git repos only), stub `extends`, presence of this package, and for every ecosystem dependency — in the root or any workspace member, of either lane — that the declared range admits the blessed version and the lockfile actually resolves to it (the stale-lockfile class). A range that does not admit the blessed version is rewritten to `^<blessed>`, except an exact pin, which stays exact and moves to the blessed version. An npm alias (`"@inixiative/session-archive": "npm:@inixiative/archive@^0.2.1"`) is held to its target's blessed version and rewritten as an alias; `workspace:`, `file:` and `link:` ranges are a repo linking itself and are left alone.
75
+ `check` is read-only and exits non-zero on drift — run it in CI after a frozen-lockfile install. It verifies toolchain pins — declared once, at the root, pinned exactly: a workspace member declaring one is an error, and `bun.lock` must resolve each only to its pin (no second copy) — toolchain peer ranges (they must admit the pin and no older major; `sync` writes `>=<pin major>.<pin minor>.0`), `"latest"` ranges, `packageManager`/`.bun-version`, legacy `bun.lockb`, a committed (tracked, un-ignored) `bun.lock`, required scripts (`check`/`typecheck`/`lint`/`test`), lefthook (dep + `lefthook.yml` extending the shared hooks + `prepare` script; git repos only), stub `extends`, presence of this package, and for every ecosystem dependency — in the root or any workspace member, of either lane — that the declared range admits the blessed version and the lockfile actually resolves to it (the stale-lockfile class). A package with a candidate also accepts the candidate and anything between: a range admitting either passes, and so does a lock from the blessed version up to the candidate. A range that does not admit the blessed version is rewritten to `^<blessed>`, except an exact pin, which stays exact and moves to the blessed version. An npm alias (`"@inixiative/session-archive": "npm:@inixiative/archive@^0.2.1"`) is held to its target's blessed version and rewritten as an alias; `workspace:`, `file:` and `link:` ranges are a repo linking itself and are left alone.
75
76
 
76
77
  `sync` applies every fix (deleting member toolchain entries and their stale member links), re-locks via `bun install` (`--force` when a second toolchain copy survives), and runs `bun update` on stale ecosystem entries in the directory that declares them. It refuses to run on a dirty working tree without `--force`. The react preset is inferred from a `react` dependency; override with `--preset`. Missing scripts are filled with defaults; existing script bodies are never touched.
77
78
 
@@ -79,11 +80,23 @@ bunx @inixiative/config ports [project]
79
80
 
80
81
  `train` is the release cascade, run from a git checkout of this repo; it refuses to train from a copy that isn't one (a bunx or npm cache). One train at a time per machine: it takes one machine-wide lock, `~/.inixiative/train.lock`, shared by every clone, worktree and lane, and refuses to start while another train holds it, naming the holder and the lock path. The lock is created atomically and records the holder's pid, hostname and process start time. A lock is stale, and taken over, when its process is gone, its pid now belongs to a different process, or the file can't be parsed. A lock from another host is never judged stale; if its train is truly gone, delete the file. The lock is released on exit and on SIGINT, SIGTERM or SIGHUP. `--dry-run` takes no lock. It walks every lane in order (primitives, then agentic), or only the lane `--lane` names. Origin is the truth: it refuses to run from a config checkout behind origin, and it fast-forwards clean checkouts to origin before working (dirty or diverged repos still skip). It only ships the default branch: a checkout on a feature branch is skipped whole — never re-locked, committed to, or published from.
81
82
 
82
- Lanes stand alone. A lane with a package missing a checkout, or claimed by ambiguous checkouts, does not run; a lane whose check, commit or publish fails stops there and leaves its BOM entries as they were. Neither holds back the other lane. Within a lane it walks checkouts in dependency order; per checkout it: bumps ecosystem ranges to the blessed sets → re-locks → runs the repo's `check` → commits only its own changes (`package.json` files + `bun.lock`) → publishes each lane package whose local version is ahead of npm, in dependency order, and waits until the registry serves it (up to 15 minutes, since npm holds new versions for processing) → records it in its lane's section of the BOM. A root package publishes with `npm publish`; a workspace member is packed with `bun pm pack`, which rewrites `workspace:` ranges to the versions being released, and the tarball is published with `npm publish`, so OTP prompts pass through either way. A package already on npm at its local version but not yet blessed — published by hand, or by a train whose lane later failed — is blessed on the next run. Likewise, an `@inixiative/config` version committed ahead of npm (a release whose publish failed) is published by the next run, even when it blesses nothing new.
83
+ Lanes stand alone. A lane with a package missing a checkout, or claimed by ambiguous checkouts, does not run; a lane whose own check (not one against candidates, below), commit or publish fails stops there: what it already published stays a candidate, and none of its candidates is blessed that run. Neither holds back the other lane. Within a lane it walks checkouts in dependency order; per checkout it: bumps ecosystem ranges to the blessed sets with their candidates in place → re-locks → runs the repo's `check` → commits only its own changes (`package.json` files + `bun.lock`) → publishes each lane package whose local version is ahead of npm, in dependency order, and waits until the registry serves it (up to 15 minutes, since npm holds new versions for processing) → records it as a candidate in the BOM.
83
84
 
84
- Consumers run once, after every selected lane, upstreams first, against the full blessed set: a consumer of both lanes ends coherent with both whichever lane ran. They get the same bump → re-lock → `check`, never a publish, and their changes go through review: the train commits them on a `train/ecosystem-sync-<date>` branch and leaves the checkout there until it is merged.
85
+ Blessing is two-phase, so a dependent that needs work never deadlocks a release:
85
86
 
86
- When anything was blessed it then ships this package last, so the BOM names the new state: bumps its own patch version if npm already has the current one, blesses that version in the BOM, brings the `test/fixtures` consumers onto the blessed set, runs `check`, commits, publishes. Then every checkout the train re-locked (lane packages on their default branch, consumers on their review branch) is re-locked onto that new `@inixiative/config` and committed, so none is left pinning the previous release. It never pushes unless asked: pass `--push` to push every repo it committed to and open a PR for each consumer branch; otherwise it prints the commands. `--dry-run` walks the same plan and prints each step it would take (range rewrites, re-locks and checks, commits, review branches, publishes, BOM blessings, pushes) without running any of them; it only fetches and reads git state and `npm view`.
87
+ 1. **Candidate.** A published version is recorded under `candidates`, and the config release that ends the train carries it, so `check` in any repo's CI accepts it from then on.
88
+ 2. **Adopt.** A repo (lane checkout or consumer) whose check fails against the candidates it was moved onto doesn't stop the train. Its checkout is put back as it was, and the train opens `train/adopt-<pkg>-<version>` in a worktree (with the repo's ignored `.env*` files copied in, at any depth): the same bump and re-lock, then the repo's `train:adopt` script if its package.json declares one (Foundry's re-records cassettes with `bun run test:live`), all committed, and re-locked onto the new config release so the PR's CI judges it against the candidates. A lane checkout that adopts publishes nothing this run. A consumer that passes against candidates gets the same branch name: its default branch is still on the previous versions until the branch merges. If the adoption's commit is refused (its hooks typecheck), the branch is deleted and the summary says to adopt by hand.
89
+ 3. **Promote.** At the end of each run a candidate is blessed (moved into its lane's section) unless something holds it: its lane stopped; a repo has an adoption branch for it that is not yet on its default branch (opened this run, unmerged, or committed but never pushed); or a repo that declares it was not walked to an outcome this run — another lane's checkout, a skipped or failed one, a consumer without a checkout. A later train finds a waiting adoption branch and opens nothing new; once it merges, the repo is on the candidate and the train promotes it. A candidate every dependent already accepts is blessed in the run that published it. An adoption PR closed without merging holds its candidates until its branch is deleted.
90
+
91
+ The summary lists what was blessed, every candidate with what holds it, and each pending adoption branch with its PR when `gh` can find one.
92
+
93
+ A root package publishes with `npm publish`; a workspace member is packed with `bun pm pack`, which rewrites `workspace:` ranges to the versions being released, and the tarball is published with `npm publish`, so OTP prompts pass through either way. A package already on npm at its local version but neither blessed nor a candidate — published by hand, or by a train whose lane later failed — becomes a candidate on the next run. Likewise, an `@inixiative/config` version committed ahead of npm (a release whose publish failed) is published by the next run, even when it blesses nothing new.
94
+
95
+ Consumers run once, after every selected lane, upstreams first, against the full blessed set: a consumer of both lanes ends coherent with both whichever lane ran. They get the same bump → re-lock → `check`, never a publish, and their changes go through review: the train commits them on a `train/ecosystem-sync-<date>` branch, in a worktree, and puts the checkout back on its default branch, clean.
96
+
97
+ A train never leaves a checkout dirty or off its default branch. It only works in a checkout it found clean, so a checkout that then fails is reset to HEAD (tracked and untracked files; ignored ones such as node_modules and `.env` stay) and re-installed from its lockfile. Every branch it makes is committed in a temporary worktree, removed when the train exits.
98
+
99
+ When anything was recorded or blessed it then ships this package last, so the BOM names the new state: bumps its own patch version if npm already has the current one, blesses that version in the BOM, brings the `test/fixtures` consumers onto the blessed set, runs `check`, commits, publishes. Then every checkout the train re-locked (lane packages on their default branch, consumer and adoption branches in their worktrees) is re-locked onto that new `@inixiative/config` and committed, so none is left pinning the previous release. It never pushes unless asked: pass `--push` to push every repo it committed to and open a PR for each consumer and adoption branch; otherwise it prints the commands. `--dry-run` walks the same plan and prints each step it would take (range rewrites, re-locks and checks, commits, review branches, publishes, BOM blessings, pushes) without running any of them; it only fetches and reads git state and `npm view`.
87
100
 
88
101
  Each command accepts only its own flags. An unknown or misplaced flag (`--dryrun`, `--push` on `sync`) is a usage error and exits 2 before anything runs.
89
102
 
package/dist/cli.js CHANGED
@@ -2,9 +2,17 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { spawnSync as spawnSync2 } from "child_process";
5
- import { existsSync as existsSync2, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
5
+ import {
6
+ existsSync as existsSync2,
7
+ mkdirSync as mkdirSync2,
8
+ mkdtempSync,
9
+ readdirSync as readdirSync2,
10
+ readFileSync as readFileSync2,
11
+ rmSync as rmSync2,
12
+ writeFileSync as writeFileSync2
13
+ } from "fs";
6
14
  import { hostname as hostname2, tmpdir } from "os";
7
- import { dirname as dirname2, join as join2, resolve as resolve2 } from "path";
15
+ import { basename, dirname as dirname2, join as join2, resolve as resolve2 } from "path";
8
16
 
9
17
  // src/args.ts
10
18
  import { parseArgs } from "util";
@@ -44,6 +52,33 @@ var blessedVersions = (manifest2) => ({
44
52
  ...manifest2.ecosystem,
45
53
  ...manifest2.agentic
46
54
  });
55
+ var laneOf = (manifest2, name) => LANES.find((lane2) => name in lanePackages(manifest2, lane2)) ?? null;
56
+ var adoptionTarget = (manifest2) => {
57
+ const candidates = manifest2.candidates ?? {};
58
+ const overlay = (section) => Object.fromEntries(
59
+ Object.entries(section).map(([name, version]) => [name, candidates[name] ?? version])
60
+ );
61
+ return {
62
+ ...manifest2,
63
+ ecosystem: overlay(manifest2.ecosystem),
64
+ agentic: overlay(manifest2.agentic ?? {})
65
+ };
66
+ };
67
+ var promoteCandidates = (manifest2, names) => {
68
+ const promoted = [];
69
+ for (const name of names) {
70
+ const version = manifest2.candidates?.[name];
71
+ const lane2 = laneOf(manifest2, name);
72
+ if (!version || !lane2 || !manifest2.candidates) continue;
73
+ laneSection(manifest2, lane2)[name] = version;
74
+ delete manifest2.candidates[name];
75
+ promoted.push(`${name}@${version}`);
76
+ }
77
+ if (manifest2.candidates && Object.keys(manifest2.candidates).length === 0) {
78
+ delete manifest2.candidates;
79
+ }
80
+ return promoted;
81
+ };
47
82
  var consumersFor = (manifest2, lanes2) => {
48
83
  const consumers = manifest2.consumers ?? {};
49
84
  const selected = Object.keys(consumers).filter((repo) => consumers[repo].lanes.some((lane2) => lanes2.includes(lane2))).sort();
@@ -296,6 +331,10 @@ var orderBy = (items, key, deps) => {
296
331
  }
297
332
  return order;
298
333
  };
334
+ var declaredDependencies = (dir2) => {
335
+ const pkg = readJson(join(dir2, "package.json"));
336
+ return pkg ? dependencyNames(dir2, ["package.json", ...workspacePackagePaths(dir2, pkg)]) : /* @__PURE__ */ new Set();
337
+ };
299
338
  var publishOrder = (checkout) => {
300
339
  const names = new Set(checkout.packages.map((entry) => entry.name));
301
340
  const deps = new Map(
@@ -681,6 +720,7 @@ function inspect(dir2, manifest2, presetOverride) {
681
720
  }
682
721
  }
683
722
  const blessedSet = blessedVersions(manifest2);
723
+ const candidateSet = manifest2.candidates ?? {};
684
724
  const staleKeys = /* @__PURE__ */ new Set();
685
725
  for (const target of rangeTargets) {
686
726
  for (const field of DEP_FIELDS) {
@@ -693,14 +733,19 @@ function inspect(dir2, manifest2, presetOverride) {
693
733
  const blessed = blessedSet[name];
694
734
  if (!blessed || name === target.pkg.name || !deps) continue;
695
735
  const rewrite = (next) => name === key ? next : `npm:${name}@${next}`;
696
- const ok = admits(range, blessed);
736
+ const candidate = candidateSet[name];
737
+ const okBlessed = admits(range, blessed);
738
+ const okCandidate = candidate ? admits(range, candidate) : false;
739
+ const ok = okBlessed === true || okCandidate === true ? true : okBlessed;
740
+ const accepted = candidate === blessed ? `candidate ${candidate}` : `blessed ${blessed}${candidate ? ` or candidate ${candidate}` : ""}`;
697
741
  if (ok === false) {
698
742
  const next = /^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(range) ? blessed : `^${blessed}`;
699
743
  findings.push({
700
744
  level: "error",
701
745
  kind: "ecosystem-range",
702
746
  name,
703
- message: `${target.label}${key} ${field} range ${spec} does not admit blessed ${blessed} \u2192 ${rewrite(next)}`,
747
+ target: name,
748
+ message: `${target.label}${key} ${field} range ${spec} does not admit ${accepted} \u2192 ${rewrite(next)}`,
704
749
  fix: () => {
705
750
  deps[key] = rewrite(next);
706
751
  target.mark();
@@ -710,28 +755,31 @@ function inspect(dir2, manifest2, presetOverride) {
710
755
  if (ok === null) {
711
756
  findings.push({
712
757
  level: "warn",
713
- message: `${target.label}${key} range ${spec} not understood; verify manually against ${blessed}`
758
+ message: `${target.label}${key} range ${spec} not understood; verify manually against ${accepted}`
714
759
  });
715
760
  }
716
761
  if (!lockText || field === "peerDependencies" || staleKeys.has(key)) continue;
717
762
  const locked = lockedVersion(lockText, key, name);
763
+ const lockAccepted = locked === blessed || !!locked && locked === candidate || !!locked && !!candidate && compare(locked, blessed) >= 0 && compare(locked, candidate) <= 0;
718
764
  if (locked && admits(range, locked) === false) {
719
765
  staleKeys.add(key);
720
766
  findings.push({
721
767
  level: "error",
722
768
  kind: "stale-lock",
723
769
  name: key,
770
+ target: name,
724
771
  path: target.path,
725
772
  message: `${target.label}${key} locked at ${locked}, violating declared ${spec} (stale lockfile) \u2192 re-lock`
726
773
  });
727
- } else if (locked && locked !== blessed) {
774
+ } else if (locked && !lockAccepted) {
728
775
  staleKeys.add(key);
729
776
  findings.push({
730
777
  level: "error",
731
778
  kind: "stale-lock",
732
779
  name: key,
780
+ target: name,
733
781
  path: target.path,
734
- message: `${target.label}${key} locked at ${locked}, blessed is ${blessed} \u2192 re-lock`
782
+ message: `${target.label}${key} locked at ${locked}, ${accepted.replace(/^(blessed|candidate) /, "$1 is ")} \u2192 re-lock`
735
783
  });
736
784
  }
737
785
  }
@@ -1119,6 +1167,9 @@ if (command === "scan") {
1119
1167
  const missing = [...plan.lanes.flatMap((lane2) => lane2.missing), ...plan.missingConsumers];
1120
1168
  const ambiguous = [...plan.lanes.flatMap((lane2) => lane2.ambiguous), ...plan.ambiguousConsumers];
1121
1169
  const drifted = [];
1170
+ for (const [name, version] of Object.entries(manifest.candidates ?? {})) {
1171
+ console.log(`candidate: ${name}@${version} (blessed ${blessedVersions(manifest)[name]})`);
1172
+ }
1122
1173
  for (const checkout of checkouts) {
1123
1174
  console.log(`
1124
1175
  ${checkout.name} \u2014 ${checkout.dir} (${laneLabel(checkout)})`);
@@ -1152,6 +1203,8 @@ scanned ${checkouts.length} repos (${lanes.join(" + ")}): ${drifted.length === 0
1152
1203
  }
1153
1204
  var Abort = class extends Error {
1154
1205
  };
1206
+ var CheckFailed = class extends Abort {
1207
+ };
1155
1208
  if (command === "train") {
1156
1209
  const effect = (description, planned, run) => {
1157
1210
  if (!dryRun) return run();
@@ -1179,9 +1232,9 @@ if (command === "train") {
1179
1232
  checkout: packageRoot
1180
1233
  });
1181
1234
  if (!lock.ok) {
1182
- const { pid, hostname: host, startedAt, lanes: held, root, checkout } = lock.heldBy;
1235
+ const { pid, hostname: host, startedAt, lanes: held2, root, checkout } = lock.heldBy;
1183
1236
  console.error(
1184
- `\u2717 another train is running (pid ${pid} on ${host}, lanes ${held.join(" + ")}, root ${root}, from ${checkout}, since ${startedAt}) \u2014 wait for it to finish; one train at a time. If that train is truly gone, delete ${lock.path}`
1237
+ `\u2717 another train is running (pid ${pid} on ${host}, lanes ${held2.join(" + ")}, root ${root}, from ${checkout}, since ${startedAt}) \u2014 wait for it to finish; one train at a time. If that train is truly gone, delete ${lock.path}`
1185
1238
  );
1186
1239
  process.exit(1);
1187
1240
  }
@@ -1212,12 +1265,21 @@ if (command === "train") {
1212
1265
  console.error(`\u2717 no ecosystem repos found under ${dir}`);
1213
1266
  process.exit(1);
1214
1267
  }
1215
- const blessed = [];
1268
+ const recorded = [];
1216
1269
  const skipped = [];
1217
1270
  const failed = [];
1271
+ const failedLanes = /* @__PURE__ */ new Set();
1218
1272
  const committed = /* @__PURE__ */ new Set();
1219
1273
  const reviewBranches = [];
1220
- let bomDirty = false;
1274
+ const waiting = [];
1275
+ const walked = /* @__PURE__ */ new Set();
1276
+ const worktrees = [];
1277
+ process.on("exit", () => {
1278
+ for (const { repo, path } of worktrees.splice(0)) {
1279
+ git(repo, "worktree", "remove", "--force", path);
1280
+ }
1281
+ });
1282
+ const target = () => adoptionTarget(manifest);
1221
1283
  const ready = (checkout) => {
1222
1284
  const porcelain = git(checkout.dir, "status", "--porcelain");
1223
1285
  if (porcelain.status !== 0) {
@@ -1263,14 +1325,18 @@ if (command === "train") {
1263
1325
  return true;
1264
1326
  };
1265
1327
  const bumpRanges = (checkout) => {
1266
- const inspection = inspect(checkout.dir, manifest);
1328
+ const inspection = inspect(checkout.dir, target());
1267
1329
  const bumps = inspection.findings.filter((finding) => finding.kind === "ecosystem-range");
1268
1330
  for (const bump of bumps) console.log(` ${bump.message}`);
1269
1331
  if (bumps.length > 0) {
1270
- effect(`rewrite ${bumps.length} ecosystem range(s) to the blessed set`, void 0, () => {
1271
- for (const bump of bumps) bump.fix?.();
1272
- inspection.flush();
1273
- });
1332
+ effect(
1333
+ `rewrite ${bumps.length} ecosystem range(s) to the blessed set and its candidates`,
1334
+ void 0,
1335
+ () => {
1336
+ for (const bump of bumps) bump.fix?.();
1337
+ inspection.flush();
1338
+ }
1339
+ );
1274
1340
  }
1275
1341
  return {
1276
1342
  inspection,
@@ -1279,37 +1345,188 @@ if (command === "train") {
1279
1345
  };
1280
1346
  };
1281
1347
  const relockAndCheck = (checkout, check = true) => effect(`re-lock ${checkout.name}${check ? " and run its check" : ""}`, void 0, () => {
1282
- if (spawnSync2("bun", ["install"], { cwd: checkout.dir, stdio: "inherit" }).status !== 0) {
1348
+ const install = (...flags) => spawnSync2("bun", ["install", ...flags], { cwd: checkout.dir, stdio: "inherit" }).status === 0;
1349
+ if (!install() && !install("--no-cache")) {
1283
1350
  throw new Abort(`bun install failed in ${checkout.name}`);
1284
1351
  }
1285
1352
  for (const { cwd, names } of staleUpdates(
1286
1353
  checkout.dir,
1287
- inspect(checkout.dir, manifest).findings
1354
+ inspect(checkout.dir, target()).findings
1288
1355
  )) {
1289
1356
  spawnSync2("bun", ["update", ...names], { cwd, stdio: "inherit" });
1290
1357
  }
1291
1358
  if (check && spawnSync2("bun", ["run", "check"], { cwd: checkout.dir, stdio: "inherit" }).status !== 0) {
1292
- throw new Abort(`check failed in ${checkout.name}`);
1359
+ throw new CheckFailed(`check failed in ${checkout.name}`);
1293
1360
  }
1294
1361
  });
1295
1362
  const followers = [];
1296
- const commitOwned = (checkout, packagePaths, changes) => {
1363
+ const restore = (checkout) => effect(`restore ${checkout.name} to HEAD`, void 0, () => {
1364
+ if (git(checkout.dir, "status", "--porcelain").stdout.trim() === "") return;
1365
+ git(checkout.dir, "reset", "--quiet", "--hard", "HEAD");
1366
+ git(checkout.dir, "clean", "--quiet", "-fd");
1367
+ spawnSync2("bun", ["install", "--frozen-lockfile"], { cwd: checkout.dir, stdio: "inherit" });
1368
+ });
1369
+ const movesOf = (checkout) => {
1370
+ const candidates2 = manifest.candidates ?? {};
1371
+ const names = inspect(checkout.dir, target()).findings.flatMap(
1372
+ (finding) => (finding.kind === "ecosystem-range" || finding.kind === "stale-lock") && finding.target && finding.target in candidates2 ? [finding.target] : []
1373
+ );
1374
+ return [...new Set(names)].sort();
1375
+ };
1376
+ const adoptionBranch = (moving) => `train/adopt-${moving.map((name) => `${name.replace(/^@inixiative\//, "")}-${manifest.candidates?.[name]}`).join("-")}`;
1377
+ const ahead = (cwd, ref) => {
1378
+ if (git(cwd, "rev-parse", "--verify", "--quiet", ref).status !== 0) return null;
1379
+ return Number(git(cwd, "rev-list", "--count", `HEAD..${ref}`).stdout.trim());
1380
+ };
1381
+ const pendingAdoption = (checkout, moving) => {
1382
+ if (moving.length === 0) return false;
1383
+ const branch = adoptionBranch(moving);
1384
+ const remote = ahead(checkout.dir, `refs/remotes/origin/${branch}`);
1385
+ const local = ahead(checkout.dir, `refs/heads/${branch}`);
1386
+ if (!remote && !local) return false;
1387
+ const state = remote ? "unmerged" : "unpushed";
1388
+ console.log(
1389
+ `\u23F3 adopting ${moving.join(", ")} on ${branch} \u2014 ${state === "unmerged" ? "waiting for it to merge" : "never pushed: push it and open its PR"}`
1390
+ );
1391
+ waiting.push({ name: checkout.name, dir: checkout.dir, moving, state, branch });
1392
+ walked.add(checkout.dir);
1393
+ return true;
1394
+ };
1395
+ const worktree = (checkout, branch) => {
1396
+ const path = join2(mkdtempSync(join2(tmpdir(), "inixiative-train-")), basename(checkout.dir));
1397
+ if (git(checkout.dir, "worktree", "add", "--quiet", "-b", branch, path, "HEAD").status !== 0) {
1398
+ throw new Abort(`could not create ${branch} in ${checkout.name}`);
1399
+ }
1400
+ worktrees.push({ repo: checkout.dir, path });
1401
+ const envFiles = git(
1402
+ checkout.dir,
1403
+ "ls-files",
1404
+ "--others",
1405
+ "--ignored",
1406
+ "--exclude-standard",
1407
+ "--",
1408
+ ":(glob)**/.env*"
1409
+ ).stdout.split("\n").filter(
1410
+ (file) => /(^|\/)\.env[^/]*$/.test(file) && !/(^|\/)(node_modules|\.worktrees)\//.test(file)
1411
+ );
1412
+ for (const file of envFiles) {
1413
+ if (existsSync2(join2(path, file))) continue;
1414
+ mkdirSync2(dirname2(join2(path, file)), { recursive: true });
1415
+ writeFileSync2(join2(path, file), readFileSync2(join2(checkout.dir, file)));
1416
+ }
1417
+ return { ...checkout, dir: path };
1418
+ };
1419
+ const dropBranch = (checkout, branch) => {
1420
+ const entry = worktrees.find(
1421
+ (tree) => tree.repo === checkout.dir && currentBranch(tree.path) === branch
1422
+ );
1423
+ if (entry) {
1424
+ git(checkout.dir, "worktree", "remove", "--force", entry.path);
1425
+ worktrees.splice(worktrees.indexOf(entry), 1);
1426
+ }
1427
+ git(checkout.dir, "branch", "-D", branch);
1428
+ };
1429
+ const defaultBranch = (cwd) => offDefaultBranch(cwd)?.main ?? currentBranch(cwd);
1430
+ const reviewBranch = (checkout, packagePaths, moving) => {
1431
+ const branch = moving.length > 0 ? adoptionBranch(moving) : freeBranch(checkout.dir, `train/ecosystem-sync-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`);
1432
+ const base = defaultBranch(checkout.dir);
1433
+ const opened = effect(
1434
+ `create review branch ${branch} in ${checkout.name} (in a worktree; the checkout stays on ${base})`,
1435
+ true,
1436
+ () => {
1437
+ const branchCheckout = worktree(checkout, branch);
1438
+ for (const path of [...packagePaths, "bun.lock"]) {
1439
+ if (existsSync2(join2(checkout.dir, path))) {
1440
+ writeFileSync2(join2(branchCheckout.dir, path), readFileSync2(join2(checkout.dir, path)));
1441
+ }
1442
+ }
1443
+ restore(checkout);
1444
+ try {
1445
+ relockAndCheck(branchCheckout, false);
1446
+ const message = moving.length > 0 ? `chore: adopt ${moving.map((name) => `${name}@${manifest.candidates?.[name]}`).join(", ")}` : void 0;
1447
+ if (!commitOwned(branchCheckout, packagePaths, true, message)) {
1448
+ console.log(`\u2713 nothing to commit for ${checkout.name} after its re-lock`);
1449
+ dropBranch(checkout, branch);
1450
+ return false;
1451
+ }
1452
+ } catch (error) {
1453
+ dropBranch(checkout, branch);
1454
+ throw error;
1455
+ }
1456
+ followers.push({ checkout: branchCheckout, onBranch: true });
1457
+ console.log(`committed on ${branch} for review; ${checkout.name} stays on ${base}`);
1458
+ return true;
1459
+ }
1460
+ );
1461
+ if (!opened) return;
1462
+ reviewBranches.push({ dir: checkout.dir, branch, base });
1463
+ if (moving.length > 0) {
1464
+ waiting.push({ name: checkout.name, dir: checkout.dir, moving, state: "opened", branch });
1465
+ }
1466
+ };
1467
+ const adopt = (checkout, moving) => {
1468
+ const branch = adoptionBranch(moving);
1469
+ const base = defaultBranch(checkout.dir);
1470
+ const label = moving.map((name) => `${name}@${manifest.candidates?.[name]}`).join(", ");
1471
+ const script = checkout.pkg.scripts?.["train:adopt"];
1472
+ try {
1473
+ effect(
1474
+ `open ${branch} in ${checkout.name}: re-lock onto ${label}${script ? ", run train:adopt" : ""}, commit`,
1475
+ void 0,
1476
+ () => {
1477
+ const branchCheckout = worktree(checkout, branch);
1478
+ try {
1479
+ bumpRanges(branchCheckout);
1480
+ relockAndCheck(branchCheckout, false);
1481
+ if (script && spawnSync2("bun", ["run", "train:adopt"], {
1482
+ cwd: branchCheckout.dir,
1483
+ stdio: "inherit"
1484
+ }).status !== 0) {
1485
+ console.log(
1486
+ `\u26A0 train:adopt failed in ${checkout.name} \u2014 the PR needs work before it merges`
1487
+ );
1488
+ }
1489
+ git(branchCheckout.dir, "add", "-A");
1490
+ const commit = spawnSync2("git", ["commit", "-m", `chore: adopt ${label}`], {
1491
+ cwd: branchCheckout.dir,
1492
+ stdio: "inherit"
1493
+ });
1494
+ if (commit.status !== 0) {
1495
+ throw new Abort(
1496
+ `commit failed on ${branch} in ${checkout.name} (its hooks reject the adoption)`
1497
+ );
1498
+ }
1499
+ } catch (error) {
1500
+ dropBranch(checkout, branch);
1501
+ throw error;
1502
+ }
1503
+ followers.push({ checkout: branchCheckout, onBranch: true });
1504
+ }
1505
+ );
1506
+ reviewBranches.push({ dir: checkout.dir, branch, base });
1507
+ waiting.push({ name: checkout.name, dir: checkout.dir, moving, state: "opened", branch });
1508
+ } catch (error) {
1509
+ if (!(error instanceof Abort)) throw error;
1510
+ failed.push(checkout.name);
1511
+ waiting.push({ name: checkout.name, dir: checkout.dir, moving, state: "failed" });
1512
+ console.error(`\u2717 ${error.message} \u2014 adopt ${label} in ${checkout.name} by hand`);
1513
+ }
1514
+ };
1515
+ const commitOwned = (checkout, packagePaths, changes, message = "chore: sync ecosystem deps to blessed set") => {
1297
1516
  if (dryRun && !changes) return false;
1298
1517
  return effect(`commit its package.json files + bun.lock in ${checkout.name}`, true, () => {
1299
- const tracked = [...packagePaths, "bun.lock"];
1518
+ const tracked = [...packagePaths, "bun.lock"].filter(
1519
+ (path) => existsSync2(join2(checkout.dir, path))
1520
+ );
1300
1521
  const mutated = git(checkout.dir, "status", "--porcelain", "--", ...tracked);
1301
1522
  if (mutated.status !== 0 || mutated.stdout.trim().length === 0) return false;
1302
1523
  if (git(checkout.dir, "add", "--", ...tracked).status !== 0) {
1303
1524
  throw new Abort(`git add failed in ${checkout.name} \u2014 is bun.lock ignored?`);
1304
1525
  }
1305
- const commit = spawnSync2(
1306
- "git",
1307
- ["commit", "-m", "chore: sync ecosystem deps to blessed set"],
1308
- {
1309
- cwd: checkout.dir,
1310
- stdio: "inherit"
1311
- }
1312
- );
1526
+ const commit = spawnSync2("git", ["commit", "-m", message], {
1527
+ cwd: checkout.dir,
1528
+ stdio: "inherit"
1529
+ });
1313
1530
  if (commit.status !== 0) throw new Abort(`commit failed in ${checkout.name}`);
1314
1531
  return true;
1315
1532
  });
@@ -1350,8 +1567,7 @@ if (command === "train") {
1350
1567
  \u2550 ${lane2} lane: ${checkouts.map((checkout) => checkout.name).join(" \u2192 ") || "(none)"}`
1351
1568
  );
1352
1569
  const section = laneSection(manifest, lane2);
1353
- const before = { ...section };
1354
- const laneBlessed = [];
1570
+ const laneRecorded = [];
1355
1571
  try {
1356
1572
  if (missing.length > 0) {
1357
1573
  throw new Abort(
@@ -1370,7 +1586,6 @@ if (command === "train") {
1370
1586
  skipped.push(checkout.name);
1371
1587
  continue;
1372
1588
  }
1373
- const { inspection, bumped, staleLock } = bumpRanges(checkout);
1374
1589
  const releases = checkout.packages.map((entry) => {
1375
1590
  const local = JSON.parse(readFileSync2(join2(checkout.dir, entry.path), "utf8")).version ?? "0.0.0";
1376
1591
  const remote = npmLatest(entry.name);
@@ -1386,47 +1601,50 @@ if (command === "train") {
1386
1601
  skipped.push(checkout.name);
1387
1602
  continue;
1388
1603
  }
1604
+ const moving = movesOf(checkout);
1605
+ if (pendingAdoption(checkout, moving)) continue;
1606
+ const { inspection, bumped, staleLock } = bumpRanges(checkout);
1389
1607
  const relocked = bumped || staleLock || releases.some((release) => release.ahead);
1390
- if (relocked) relockAndCheck(checkout);
1391
- if (commitOwned(checkout, inspection.packagePaths, relocked)) committed.add(checkout.dir);
1392
- followers.push(checkout);
1393
- for (const { entry, local, remote, ahead } of releases) {
1394
- if (ahead) {
1608
+ try {
1609
+ if (relocked) relockAndCheck(checkout);
1610
+ if (commitOwned(checkout, inspection.packagePaths, relocked)) committed.add(checkout.dir);
1611
+ } catch (error) {
1612
+ if (!(error instanceof Abort)) throw error;
1613
+ restore(checkout);
1614
+ if (!(error instanceof CheckFailed) || moving.length === 0) throw error;
1615
+ console.log(`\u2717 ${error.message} against the candidates \u2014 it adopts them on a branch`);
1616
+ walked.add(checkout.dir);
1617
+ adopt(checkout, moving);
1618
+ continue;
1619
+ }
1620
+ walked.add(checkout.dir);
1621
+ followers.push({ checkout, onBranch: false });
1622
+ for (const { entry, local, remote, ahead: ahead2 } of releases) {
1623
+ if (ahead2) {
1395
1624
  publish(checkout, entry, local, remote);
1396
1625
  } else if (section[entry.name] === local) {
1397
1626
  console.log(`\u2713 ${entry.name}@${local} already on npm and blessed`);
1398
1627
  continue;
1628
+ } else if (manifest.candidates?.[entry.name] === local) {
1629
+ console.log(`\u2713 ${entry.name}@${local} already on npm and a candidate`);
1630
+ continue;
1399
1631
  } else {
1400
- console.log(`\u2713 ${entry.name}@${local} already on npm \u2014 blessing it`);
1632
+ console.log(`\u2713 ${entry.name}@${local} already on npm \u2014 recording it as a candidate`);
1401
1633
  }
1402
- section[entry.name] = local;
1403
- laneBlessed.push(`${entry.name}@${local}`);
1634
+ manifest.candidates = { ...manifest.candidates, [entry.name]: local };
1635
+ laneRecorded.push(`${entry.name}@${local}`);
1404
1636
  }
1405
1637
  }
1406
- if (laneBlessed.length > 0) {
1407
- effect(
1408
- `bless ${laneBlessed.join(", ")} in versions.json`,
1409
- void 0,
1410
- () => writeManifest(manifest)
1411
- );
1412
- bomDirty = true;
1413
- blessed.push(...laneBlessed);
1414
- }
1415
1638
  console.log(
1416
- `${lane2} lane blessed: ${laneBlessed.length === 0 ? "nothing new" : laneBlessed.join(", ")}`
1639
+ `${lane2} lane candidates: ${laneRecorded.length === 0 ? "nothing new" : laneRecorded.join(", ")}`
1417
1640
  );
1418
1641
  } catch (error) {
1419
1642
  if (!(error instanceof Abort)) throw error;
1420
- for (const name of Object.keys(section)) delete section[name];
1421
- Object.assign(section, before);
1643
+ failedLanes.add(lane2);
1422
1644
  failed.push(`${lane2} lane`);
1423
- console.error(`\u2717 ${error.message} \u2014 ${lane2} lane stopped; its BOM entries are unchanged`);
1424
- if (laneBlessed.length > 0) {
1425
- console.error(
1426
- ` published but not blessed: ${laneBlessed.join(", ")} \u2014 the next train blesses them`
1427
- );
1428
- }
1645
+ console.error(`\u2717 ${error.message} \u2014 ${lane2} lane stopped; none of its candidates is blessed`);
1429
1646
  }
1647
+ recorded.push(...laneRecorded);
1430
1648
  }
1431
1649
  if (plan.consumers.length > 0 || plan.missingConsumers.length > 0) {
1432
1650
  console.log(
@@ -1449,39 +1667,97 @@ if (command === "train") {
1449
1667
  skipped.push(checkout.name);
1450
1668
  continue;
1451
1669
  }
1670
+ const moving = movesOf(checkout);
1671
+ if (pendingAdoption(checkout, moving)) continue;
1672
+ const { inspection, bumped, staleLock } = bumpRanges(checkout);
1673
+ if (!bumped && !staleLock) {
1674
+ walked.add(checkout.dir);
1675
+ console.log("\u2713 already on the blessed set");
1676
+ continue;
1677
+ }
1452
1678
  try {
1453
- const { inspection, bumped, staleLock } = bumpRanges(checkout);
1454
- if (!bumped && !staleLock) {
1455
- console.log("\u2713 already on the blessed set");
1456
- continue;
1457
- }
1458
1679
  relockAndCheck(checkout);
1459
- const base = offDefaultBranch(checkout.dir)?.main ?? currentBranch(checkout.dir);
1460
- const branch = freeBranch(
1461
- checkout.dir,
1462
- `train/ecosystem-sync-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`
1463
- );
1464
- const switched = effect(
1465
- `create review branch ${branch} in ${checkout.name}`,
1466
- true,
1467
- () => git(checkout.dir, "switch", "-c", branch).status === 0
1468
- );
1469
- if (!switched) throw new Abort(`could not create ${branch} in ${checkout.name}`);
1470
- followers.push(checkout);
1471
- if (commitOwned(checkout, inspection.packagePaths, true)) {
1472
- reviewBranches.push({ dir: checkout.dir, branch, base });
1473
- if (!dryRun) {
1474
- console.log(`committed on ${branch} for review; the checkout stays on it until merged`);
1475
- }
1476
- }
1680
+ reviewBranch(checkout, inspection.packagePaths, moving);
1681
+ walked.add(checkout.dir);
1477
1682
  } catch (error) {
1478
1683
  if (!(error instanceof Abort)) throw error;
1684
+ restore(checkout);
1685
+ if (error instanceof CheckFailed && moving.length > 0) {
1686
+ console.log(`\u2717 ${error.message} against the candidates \u2014 it adopts them on a branch`);
1687
+ walked.add(checkout.dir);
1688
+ adopt(checkout, moving);
1689
+ continue;
1690
+ }
1479
1691
  failed.push(checkout.name);
1480
- console.error(`\u2717 ${error.message} \u2014 its working tree is left as is for inspection`);
1692
+ console.error(`\u2717 ${error.message} \u2014 its checkout is back on its default branch`);
1693
+ }
1694
+ }
1695
+ const candidateNames = Object.keys(manifest.candidates ?? {});
1696
+ if (candidateNames.length > 0) {
1697
+ const everything = trainPlan(dir, manifest, LANES);
1698
+ for (const checkout of [
1699
+ ...everything.lanes.flatMap((entry) => entry.checkouts),
1700
+ ...everything.consumers
1701
+ ]) {
1702
+ if (walked.has(checkout.dir)) continue;
1703
+ const declared = declaredDependencies(checkout.dir);
1704
+ const moving = candidateNames.filter((name) => declared.has(name));
1705
+ if (moving.length > 0) {
1706
+ waiting.push({ name: checkout.name, dir: checkout.dir, moving, state: "unverified" });
1707
+ }
1708
+ }
1709
+ for (const repo of [
1710
+ ...everything.missingConsumers,
1711
+ ...everything.ambiguousConsumers.map((entry) => entry.key)
1712
+ ]) {
1713
+ const consumed = manifest.consumers?.[repo]?.lanes ?? [];
1714
+ const moving = candidateNames.filter((name) => {
1715
+ const lane2 = laneOf(manifest, name);
1716
+ return !!lane2 && consumed.includes(lane2);
1717
+ });
1718
+ if (moving.length > 0) waiting.push({ name: repo, dir, moving, state: "unverified" });
1481
1719
  }
1482
1720
  }
1721
+ const held = new Set(waiting.flatMap((entry) => entry.moving));
1722
+ const promotable = candidateNames.filter((name) => {
1723
+ const lane2 = laneOf(manifest, name);
1724
+ return !!lane2 && !failedLanes.has(lane2) && !held.has(name);
1725
+ });
1726
+ const promoted = promoteCandidates(manifest, promotable);
1727
+ const bomDirty = recorded.length > 0 || promoted.length > 0;
1728
+ if (bomDirty) {
1729
+ effect(
1730
+ "write the candidates and blessings to versions.json",
1731
+ void 0,
1732
+ () => writeManifest(manifest)
1733
+ );
1734
+ }
1735
+ const why = {
1736
+ opened: "opened this run",
1737
+ unmerged: "waiting for its PR to merge",
1738
+ unpushed: "committed but never pushed",
1739
+ failed: "the train could not commit it \u2014 adopt by hand",
1740
+ unverified: "not verified this run"
1741
+ };
1483
1742
  console.log(`
1484
- blessed: ${blessed.length === 0 ? "nothing" : blessed.join(", ")}`);
1743
+ blessed: ${promoted.length === 0 ? "nothing" : promoted.join(", ")}`);
1744
+ const candidates = Object.entries(manifest.candidates ?? {});
1745
+ if (candidates.length > 0) {
1746
+ console.log("candidates (check accepts them; not yet blessed):");
1747
+ for (const [name, version] of candidates) {
1748
+ const on = waiting.filter((entry) => entry.moving.includes(name)).map((entry) => `${entry.name} (${entry.branch ?? why[entry.state]})`);
1749
+ console.log(` ${name}@${version}${on.length === 0 ? "" : ` \u2014 waiting on ${on.join(", ")}`}`);
1750
+ }
1751
+ }
1752
+ if (waiting.length > 0) {
1753
+ console.log("pending (merge each adoption PR, then run the train again to bless):");
1754
+ for (const entry of waiting) {
1755
+ const url = entry.state === "unmerged" && !dryRun && entry.branch ? prUrl(entry.dir, entry.branch) : null;
1756
+ console.log(
1757
+ ` ${entry.name}: ${entry.branch ? `${entry.branch} \u2014 ` : ""}${why[entry.state]}${url ? ` ${url}` : ""}`
1758
+ );
1759
+ }
1760
+ }
1485
1761
  if (skipped.length > 0) console.log(`skipped: ${skipped.join(", ")}`);
1486
1762
  if (failed.length > 0) console.log(`failed: ${failed.join(", ")}`);
1487
1763
  const remoteVersion = npmLatest("@inixiative/config");
@@ -1520,26 +1796,25 @@ blessed: ${blessed.length === 0 ? "nothing" : blessed.join(", ")}`);
1520
1796
  process.exit(1);
1521
1797
  }
1522
1798
  });
1523
- effect(
1524
- `commit "chore: bless ${blessed.join(", ")} \u2014 ${version}" in @inixiative/config`,
1525
- void 0,
1526
- () => {
1527
- spawnSync2("git", ["add", "--", "package.json", "versions.json", "test/fixtures"], {
1528
- cwd: packageRoot
1529
- });
1530
- if (spawnSync2("git", ["diff", "--cached", "--quiet"], { cwd: packageRoot }).status === 0)
1531
- return;
1532
- const commit = spawnSync2(
1533
- "git",
1534
- ["commit", "-m", `chore: bless ${blessed.join(", ")} \u2014 ${version}`],
1535
- { cwd: packageRoot, stdio: "inherit" }
1536
- );
1537
- if (commit.status !== 0) {
1538
- console.error("\u2717 commit failed in @inixiative/config \u2014 aborting before publish");
1539
- process.exit(1);
1540
- }
1799
+ const message = `chore: ${[
1800
+ promoted.length > 0 ? `bless ${promoted.join(", ")}` : "",
1801
+ recorded.length > 0 ? `candidate ${recorded.join(", ")}` : ""
1802
+ ].filter(Boolean).join("; ") || "release"} \u2014 ${version}`;
1803
+ effect(`commit "${message}" in @inixiative/config`, void 0, () => {
1804
+ spawnSync2("git", ["add", "--", "package.json", "versions.json", "test/fixtures"], {
1805
+ cwd: packageRoot
1806
+ });
1807
+ if (spawnSync2("git", ["diff", "--cached", "--quiet"], { cwd: packageRoot }).status === 0)
1808
+ return;
1809
+ const commit = spawnSync2("git", ["commit", "-m", message], {
1810
+ cwd: packageRoot,
1811
+ stdio: "inherit"
1812
+ });
1813
+ if (commit.status !== 0) {
1814
+ console.error("\u2717 commit failed in @inixiative/config \u2014 aborting before publish");
1815
+ process.exit(1);
1541
1816
  }
1542
- );
1817
+ });
1543
1818
  effect(`publish @inixiative/config@${version} to npm`, void 0, () => {
1544
1819
  const publish2 = spawnSync2("npm", ["publish"], { cwd: packageRoot, stdio: "inherit" });
1545
1820
  if (publish2.status !== 0 || !servedByRegistry("@inixiative/config", version)) {
@@ -1551,12 +1826,12 @@ blessed: ${blessed.length === 0 ? "nothing" : blessed.join(", ")}`);
1551
1826
  committed.add(packageRoot);
1552
1827
  if (followers.length > 0) console.log(`
1553
1828
  \u2550 re-lock onto @inixiative/config@${version}`);
1554
- for (const checkout of followers) {
1829
+ for (const { checkout, onBranch } of followers) {
1555
1830
  try {
1556
1831
  const { inspection, bumped, staleLock } = bumpRanges(checkout);
1557
1832
  if (!bumped && !staleLock) continue;
1558
1833
  relockAndCheck(checkout, false);
1559
- if (commitOwned(checkout, inspection.packagePaths, true) && !checkout.consumer)
1834
+ if (commitOwned(checkout, inspection.packagePaths, true) && !onBranch)
1560
1835
  committed.add(checkout.dir);
1561
1836
  if (!dryRun) console.log(`\u2713 ${checkout.name} locked to @inixiative/config@${version}`);
1562
1837
  } catch (error) {
@@ -1603,6 +1878,13 @@ dry run complete \u2014 a real train would commit in ${committed.size} repo(s) a
1603
1878
  }
1604
1879
  process.exit(failed.length > 0 ? 1 : 0);
1605
1880
  }
1881
+ function prUrl(cwd, branch) {
1882
+ const view = spawnSync2("gh", ["pr", "view", branch, "--json", "url", "--jq", ".url"], {
1883
+ cwd,
1884
+ encoding: "utf8"
1885
+ });
1886
+ return view.status === 0 ? view.stdout.trim() || null : null;
1887
+ }
1606
1888
  function currentBranch(cwd) {
1607
1889
  return git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.trim();
1608
1890
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/config",
3
- "version": "0.5.80",
3
+ "version": "0.5.82",
4
4
  "description": "Shared toolchain for the inixiative ecosystem: tsconfig/biome/tsup presets, a version manifest (BOM), and a sync/check CLI",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/versions.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "@types/bun"
14
14
  ],
15
15
  "ecosystem": {
16
- "@inixiative/config": "0.5.80",
16
+ "@inixiative/config": "0.5.82",
17
17
  "@inixiative/json-rules": "3.5.4",
18
18
  "@inixiative/permissions": "0.4.0",
19
19
  "@inixiative/transitions": "0.5.0",
@@ -24,7 +24,7 @@
24
24
  },
25
25
  "agentic": {
26
26
  "@inixiative/signet": "0.4.0",
27
- "@inixiative/archive": "0.18.0",
27
+ "@inixiative/archive": "0.19.0",
28
28
  "@inixiative/agent-session": "0.6.3",
29
29
  "@inixiative/foundry-core": "0.5.1",
30
30
  "@inixiative/foundry": "0.5.3",
@@ -48,5 +48,8 @@
48
48
  "agentic"
49
49
  ]
50
50
  }
51
+ },
52
+ "candidates": {
53
+ "@inixiative/agent-session": "0.6.4"
51
54
  }
52
55
  }