@inixiative/config 0.5.35 → 0.5.37

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
@@ -40,7 +40,19 @@ One version of `@inixiative/config` names one coherent ecosystem state:
40
40
  - `bun` — blessed runtime, written to `.bun-version` and `packageManager`
41
41
  - `toolchain` — exact pins for `@biomejs/biome`, `typescript`, `tsup`, `@types/bun`
42
42
  - `required` — toolchain packages every repo must carry
43
- - `ecosystem` — the blessed, mutually-verified `@inixiative/*` set
43
+ - `ecosystem` — the primitives lane's blessed, mutually-verified `@inixiative/*` set, plus this package
44
+ - `agentic` — the agentic lane's blessed set
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
+
47
+ ```jsonc
48
+ "consumers": {
49
+ "inixiative/template": { "lanes": ["primitives"] },
50
+ "inixiative/kingdom": { "lanes": ["primitives", "agentic"], "upstream": "inixiative/template" },
51
+ "inixiative/oracle": { "lanes": ["agentic"] }
52
+ }
53
+ ```
54
+
55
+ A lane is a release cascade with its own blessed set. `primitives` (json-rules, permissions, transitions, rules-builder, prisma-map, atlas, gloss, archive) ships first; `agentic` (agent-session → foundry-core → foundry) builds on the primitives' blessed set and never the reverse. `check` holds every repo to the union of all lanes, so a repo that mixes both answers to both.
44
56
 
45
57
  TypeScript 6.0 notes, discovered by this repo's fixture suite:
46
58
 
@@ -52,17 +64,23 @@ TypeScript 6.0 notes, discovered by this repo's fixture suite:
52
64
  ```
53
65
  bunx @inixiative/config check [dir] [--preset=base|node|react]
54
66
  bunx @inixiative/config sync [dir] [--preset=...] [--force] [--no-install]
55
- bunx @inixiative/config scan [root]
56
- bunx @inixiative/config train [root]
67
+ bunx @inixiative/config scan [root] [--lane=primitives|agentic]
68
+ bunx @inixiative/config train [root] [--lane=primitives|agentic] [--push]
57
69
  ```
58
70
 
59
- `check` is read-only and exits non-zero on drift — run it in CI after a frozen-lockfile install. It verifies toolchain pins, `"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 that the declared range admits the blessed version and the lockfile actually resolves to it (the stale-lockfile class).
71
+ `check` is read-only and exits non-zero on drift — run it in CI after a frozen-lockfile install. It verifies toolchain pins, `"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). 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.
60
72
 
61
73
  `sync` applies every fix, re-locks via `bun install`, and runs `bun update` on stale ecosystem entries. 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.
62
74
 
63
- `scan` runs `check` across every ecosystem checkout under a root directory. Targets are derived from the BOM's `ecosystem` keys and matched by package name, so directory names and local layout never need declaring. Origin is the truth, not the checkout: scan fetches per repo and fails on checkouts behind origin (their findings describe a stale tree), fails when a BOM entry has no checkout at all, warns when a checkout sits on a non-default branch (its findings describe the branch, not what ships), and surfaces unmerged `claude/*` session branches.
75
+ `scan` runs `check` across every ecosystem checkout under a root directory, for every lane or just the one `--lane` names. Targets are derived from the BOM: lane packages match by package name — the root package or any workspace member, so a monorepo such as foundry is found by the packages it publishes — and consumers match by their `origin` GitHub repo. Directory names and local layout never need declaring: scan looks at each child of the root and one level into a child without a `package.json` (a workspace folder such as `kingdom-workspace/`). Linked git worktrees are other sessions' branches and never targets; when several clones claim one package or repo, only those with an `origin` remote count, and if that still leaves more than one the target is reported ambiguous rather than guessed. Origin is the truth, not the checkout: scan fetches per repo and fails on checkouts behind origin (their findings describe a stale tree), fails when a BOM entry has no checkout at all, warns when a checkout sits on a non-default branch (its findings describe the branch, not what ships), and surfaces unmerged `claude/*` session branches.
76
+
77
+ `train` is the release cascade, run from this repo's checkout. 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.
64
78
 
65
- `train` is the release cascade, run from this repo's checkout. Origin is the truth: it refuses to run from a config checkout behind origin or with any BOM repo missing a checkout, 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. It walks the ecosystem in dependency order; per repo it: bumps ecosystem ranges to the BOM → re-locks → runs the repo's `check` → commits only its own changes (`package.json` + `bun.lock`) → publishes via `npm publish` when the local version is ahead of npm (OTP prompts pass through) and waits until the registry serves that version → records it in the BOM. When anything was published 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. It never pushes unless asked: pass `--push` to push every repo it committed to, otherwise it prints the `git push` for each.
79
+ 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.
80
+
81
+ 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.
82
+
83
+ 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. 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.
66
84
 
67
85
  Division of labor: this CLI owns the toolchain set, ecosystem coherence, stubs, and lockfile format. Renovate owns everything else plus bumping `@inixiative/config` itself, and must be fenced off the toolchain packages.
68
86
 
@@ -85,4 +103,9 @@ jobs:
85
103
 
86
104
  ## Release flow
87
105
 
88
- Publishing moves in dependency order (for example, json-rules → permissions → transitions → rules-builder), with config last. Archive is included in the ecosystem BOM and release train as a standalone MIT-licensed package. `train` automates the whole walk, this package included. Downstream repos pick it up via `sync`.
106
+ Two lanes, each publishing in dependency order, with config last:
107
+
108
+ - **primitives** — for example json-rules → permissions → transitions → rules-builder, plus prisma-map, atlas, gloss and archive (a standalone MIT package). Consumers: template, kingdom.
109
+ - **agentic** — agent-session → foundry-core → foundry, built on the primitives' blessed set. foundry-core and foundry publish from the foundry monorepo's `packages/core` and `packages/foundry`. Consumers: oracle (a private leaf), kingdom.
110
+
111
+ A release is `train` (or `train --lane=agentic` to leave the primitives alone): bump the package's version on its default branch, then run the train. `train` automates the whole walk, this package and the consumer PRs included. Downstream repos pick up toolchain changes via `sync`.
package/dist/cli.js CHANGED
@@ -2,15 +2,55 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { spawnSync as spawnSync2 } from "child_process";
5
- import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
6
- import { join as join2, resolve } from "path";
5
+ import { existsSync as existsSync2, mkdtempSync, readdirSync as readdirSync2, readFileSync as readFileSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "fs";
6
+ import { tmpdir } from "os";
7
+ import { dirname as dirname2, join as join2, resolve } from "path";
7
8
 
8
9
  // src/lib.ts
9
10
  import { spawnSync } from "child_process";
10
- import { existsSync, globSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
11
+ import {
12
+ existsSync,
13
+ globSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ rmSync,
17
+ statSync,
18
+ writeFileSync
19
+ } from "fs";
11
20
  import { dirname, join, sep } from "path";
12
21
  import { fileURLToPath } from "url";
13
22
  var packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
23
+ var LANES = ["primitives", "agentic"];
24
+ var isLane = (value) => LANES.includes(value);
25
+ var lanePackages = (manifest2, lane) => {
26
+ const section = lane === "primitives" ? manifest2.ecosystem : manifest2.agentic ?? {};
27
+ const { "@inixiative/config": _config, ...packages } = section;
28
+ return packages;
29
+ };
30
+ var laneSection = (manifest2, lane) => {
31
+ if (lane === "primitives") return manifest2.ecosystem;
32
+ manifest2.agentic ??= {};
33
+ return manifest2.agentic;
34
+ };
35
+ var blessedVersions = (manifest2) => ({
36
+ ...manifest2.ecosystem,
37
+ ...manifest2.agentic
38
+ });
39
+ var consumersFor = (manifest2, lanes2) => {
40
+ const consumers = manifest2.consumers ?? {};
41
+ const selected = Object.keys(consumers).filter((repo) => consumers[repo].lanes.some((lane) => lanes2.includes(lane))).sort();
42
+ const order = [];
43
+ const visit = (repo, trail) => {
44
+ if (order.includes(repo)) return;
45
+ if (trail.includes(repo))
46
+ throw new Error(`consumer upstream cycle: ${[...trail, repo].join(" \u2192 ")}`);
47
+ const upstream = consumers[repo].upstream;
48
+ if (upstream && selected.includes(upstream)) visit(upstream, [...trail, repo]);
49
+ order.push(repo);
50
+ };
51
+ for (const repo of selected) visit(repo, []);
52
+ return order;
53
+ };
14
54
  var REQUIRED_SCRIPTS = {
15
55
  typecheck: "tsc --noEmit",
16
56
  lint: "biome check .",
@@ -107,65 +147,184 @@ var admits = (range, version) => {
107
147
  }
108
148
  return vMajor === major && vMinor === minor;
109
149
  };
110
- var lockedVersion = (lockText, name) => {
150
+ var lockedVersion = (lockText, key, target = key) => {
111
151
  const lock = tryParseJsonc(lockText);
112
152
  const packages = lock?.packages;
113
153
  if (!packages || typeof packages !== "object") return null;
114
- const entry = packages[name];
154
+ const entry = packages[key];
115
155
  const spec = Array.isArray(entry) ? entry[0] : null;
116
- if (typeof spec !== "string" || !spec.startsWith(`${name}@`)) return null;
117
- const version = spec.slice(name.length + 1);
156
+ if (typeof spec !== "string" || !spec.startsWith(`${target}@`)) return null;
157
+ const version = spec.slice(target.length + 1);
118
158
  return /^\d/.test(version) ? version : null;
119
159
  };
120
- var topoOrder = (root, manifest2) => {
121
- const repos = discoverRepos(root, manifest2).map((repo) => ({
122
- ...repo,
123
- pkg: JSON.parse(readFileSync(join(repo.dir, "package.json"), "utf8"))
124
- }));
125
- const names = new Set(repos.map((repo) => repo.name));
126
- const dependsOn = /* @__PURE__ */ new Map();
127
- for (const repo of repos) {
128
- const edges = /* @__PURE__ */ new Set();
160
+ var npmAlias = (spec) => {
161
+ const match = spec.match(/^npm:((?:@[^/@]+\/)?[^@]+)@(.+)$/);
162
+ return match ? { name: match[1], range: match[2] } : null;
163
+ };
164
+ var readJson = (path) => {
165
+ try {
166
+ return tryParseJsonc(readFileSync(path, "utf8"));
167
+ } catch {
168
+ return null;
169
+ }
170
+ };
171
+ var originRepo = (dir2) => {
172
+ const url = spawnSync("git", ["remote", "get-url", "origin"], { cwd: dir2, encoding: "utf8" });
173
+ if (url.status !== 0) return null;
174
+ const match = url.stdout.trim().match(/github\.com[:/]([^/]+\/[^/]+?)(?:\.git)?\/?$/);
175
+ return match ? match[1] : null;
176
+ };
177
+ var isLinkedWorktree = (dir2) => {
178
+ const dotGit = join(dir2, ".git");
179
+ return existsSync(dotGit) && !statSync(dotGit).isDirectory();
180
+ };
181
+ var childDirs = (dir2) => readdirSync(dir2, { withFileTypes: true }).filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")).filter((entry) => entry.name !== "node_modules").map((entry) => join(dir2, entry.name));
182
+ var candidateDirs = (root) => {
183
+ const dirs = [];
184
+ for (const child of childDirs(root)) {
185
+ const nested = existsSync(join(child, "package.json")) ? [child] : childDirs(child).filter((dir2) => existsSync(join(dir2, "package.json")));
186
+ dirs.push(...nested.filter((dir2) => !isLinkedWorktree(dir2)));
187
+ }
188
+ return dirs;
189
+ };
190
+ var discover = (root, manifest2, lanes2 = LANES) => {
191
+ const wanted = /* @__PURE__ */ new Map();
192
+ for (const lane of lanes2) {
193
+ for (const name of Object.keys(lanePackages(manifest2, lane))) wanted.set(name, lane);
194
+ }
195
+ const consumerRepos = new Set(consumersFor(manifest2, lanes2));
196
+ const claims = /* @__PURE__ */ new Map();
197
+ const claim = (key, checkout) => claims.set(key, [...claims.get(key) ?? [], checkout]);
198
+ for (const dir2 of candidateDirs(root)) {
199
+ const pkg = readJson(join(dir2, "package.json"));
200
+ if (!pkg) continue;
201
+ const name = typeof pkg.name === "string" ? pkg.name : dir2;
202
+ const provided = [
203
+ { name: pkg.name, path: "package.json" },
204
+ ...workspacePackagePaths(dir2, pkg).map((path) => ({
205
+ name: readJson(join(dir2, path))?.name,
206
+ path
207
+ }))
208
+ ].filter(
209
+ (entry) => typeof entry.name === "string" && wanted.has(entry.name)
210
+ );
211
+ if (provided.length > 0) {
212
+ const checkout = {
213
+ dir: dir2,
214
+ name,
215
+ pkg,
216
+ lane: wanted.get(provided[0].name),
217
+ packages: provided
218
+ };
219
+ for (const entry of provided) claim(entry.name, checkout);
220
+ continue;
221
+ }
222
+ if (consumerRepos.size === 0 || !existsSync(join(dir2, ".git"))) continue;
223
+ const repo = originRepo(dir2);
224
+ if (repo && consumerRepos.has(repo)) {
225
+ const consumer = manifest2.consumers[repo];
226
+ claim(repo, { dir: dir2, name, pkg, lane: null, packages: [], consumer: { repo, ...consumer } });
227
+ }
228
+ }
229
+ const chosen = /* @__PURE__ */ new Map();
230
+ const ambiguous = [];
231
+ for (const [key, candidates] of claims) {
232
+ const pool = candidates.length > 1 ? candidates.filter((candidate) => originRepo(candidate.dir)) : candidates;
233
+ if (pool.length === 1) chosen.set(pool[0].dir, pool[0]);
234
+ else ambiguous.push({ key, dirs: candidates.map((candidate) => candidate.dir).sort() });
235
+ }
236
+ const ambiguousDirs = new Set(ambiguous.flatMap((entry) => entry.dirs));
237
+ const checkouts = [...chosen.values()].filter((checkout) => !ambiguousDirs.has(checkout.dir)).map((checkout) => ({ ...checkout, packages: publishOrder(checkout) })).sort((a, b) => a.name.localeCompare(b.name));
238
+ return { checkouts, ambiguous: ambiguous.sort((a, b) => a.key.localeCompare(b.key)) };
239
+ };
240
+ var dependencyNames = (dir2, paths) => {
241
+ const names = /* @__PURE__ */ new Set();
242
+ for (const path of paths) {
243
+ const pkg = readJson(join(dir2, path));
129
244
  for (const field of DEP_FIELDS) {
130
- for (const dep of Object.keys(repo.pkg[field] ?? {})) {
131
- if (names.has(dep) && dep !== repo.name) edges.add(dep);
245
+ for (const [key, spec] of Object.entries(pkg?.[field] ?? {})) {
246
+ names.add(key);
247
+ const alias = npmAlias(spec);
248
+ if (alias) names.add(alias.name);
132
249
  }
133
250
  }
134
- dependsOn.set(repo.name, edges);
135
251
  }
252
+ return names;
253
+ };
254
+ var orderBy = (items, key, deps) => {
136
255
  const order = [];
137
256
  const done = /* @__PURE__ */ new Set();
138
- while (done.size < repos.length) {
139
- const ready = repos.filter(
140
- (repo) => !done.has(repo.name) && [...dependsOn.get(repo.name)].every((dep) => done.has(dep))
141
- ).sort((a, b) => a.name.localeCompare(b.name));
257
+ while (done.size < items.length) {
258
+ const ready = items.filter((item) => !done.has(key(item)) && deps(item).every((dep) => done.has(dep))).sort((a, b) => key(a).localeCompare(key(b)));
142
259
  if (ready.length === 0) throw new Error("dependency cycle among ecosystem repos");
143
- for (const repo of ready) {
144
- order.push(repo);
145
- done.add(repo.name);
260
+ for (const item of ready) {
261
+ order.push(item);
262
+ done.add(key(item));
146
263
  }
147
264
  }
148
265
  return order;
149
266
  };
150
- var writeManifest = (manifest2) => writeFileSync(join(packageRoot, "versions.json"), `${JSON.stringify(manifest2, null, 2)}
151
- `);
152
- var discoverRepos = (root, manifest2) => {
153
- const repos = [];
154
- for (const entry of readdirSync(root)) {
155
- const pkgPath = join(root, entry, "package.json");
156
- if (!existsSync(pkgPath)) continue;
157
- const name = tryParseJsonc(readFileSync(pkgPath, "utf8"))?.name;
158
- if (typeof name === "string" && name in manifest2.ecosystem && name !== "@inixiative/config")
159
- repos.push({ dir: join(root, entry), name });
160
- }
161
- return repos.sort((a, b) => a.name.localeCompare(b.name));
267
+ var publishOrder = (checkout) => {
268
+ const names = new Set(checkout.packages.map((entry) => entry.name));
269
+ const deps = new Map(
270
+ checkout.packages.map((entry) => [
271
+ entry.name,
272
+ [...dependencyNames(checkout.dir, [entry.path])].filter(
273
+ (dep) => names.has(dep) && dep !== entry.name
274
+ )
275
+ ])
276
+ );
277
+ return orderBy(
278
+ checkout.packages,
279
+ (entry) => entry.name,
280
+ (entry) => deps.get(entry.name) ?? []
281
+ );
162
282
  };
163
- var missingRepos = (repos, manifest2) => {
164
- const found = new Set(repos.map((repo) => repo.name));
165
- return Object.keys(manifest2.ecosystem).filter(
166
- (name) => name !== "@inixiative/config" && !found.has(name)
283
+ var topoOrder = (checkouts) => {
284
+ const publishing = checkouts.filter((checkout) => !checkout.consumer);
285
+ const owner = /* @__PURE__ */ new Map();
286
+ for (const checkout of publishing) {
287
+ for (const entry of checkout.packages) owner.set(entry.name, checkout.dir);
288
+ }
289
+ return orderBy(
290
+ publishing,
291
+ (checkout) => checkout.dir,
292
+ (checkout) => {
293
+ const paths = ["package.json", ...workspacePackagePaths(checkout.dir, checkout.pkg)];
294
+ return [...dependencyNames(checkout.dir, paths)].map((dep) => owner.get(dep)).filter((dir2) => !!dir2 && dir2 !== checkout.dir);
295
+ }
167
296
  );
168
297
  };
298
+ var trainPlan = (root, manifest2, lanes2 = LANES) => {
299
+ const { checkouts, ambiguous } = discover(root, manifest2, lanes2);
300
+ const consumerOrder = consumersFor(manifest2, lanes2);
301
+ const byRepo = new Map(
302
+ checkouts.flatMap(
303
+ (checkout) => checkout.consumer ? [[checkout.consumer.repo, checkout]] : []
304
+ )
305
+ );
306
+ return {
307
+ lanes: LANES.filter((lane) => lanes2.includes(lane)).map((lane) => {
308
+ const names = new Set(Object.keys(lanePackages(manifest2, lane)));
309
+ const own = checkouts.filter((checkout) => checkout.lane === lane);
310
+ return {
311
+ lane,
312
+ checkouts: topoOrder(own),
313
+ missing: [...names].filter(
314
+ (name) => !own.some((checkout) => checkout.packages.some((entry) => entry.name === name)) && !ambiguous.some((entry) => entry.key === name)
315
+ ),
316
+ ambiguous: ambiguous.filter((entry) => names.has(entry.key))
317
+ };
318
+ }),
319
+ consumers: consumerOrder.flatMap((repo) => byRepo.get(repo) ?? []),
320
+ missingConsumers: consumerOrder.filter(
321
+ (repo) => !byRepo.has(repo) && !ambiguous.some((entry) => entry.key === repo)
322
+ ),
323
+ ambiguousConsumers: ambiguous.filter((entry) => consumerOrder.includes(entry.key))
324
+ };
325
+ };
326
+ var writeManifest = (manifest2) => writeFileSync(join(packageRoot, "versions.json"), `${JSON.stringify(manifest2, null, 2)}
327
+ `);
169
328
  var workspacePackagePaths = (dir2, pkg) => {
170
329
  const raw = pkg.workspaces;
171
330
  const globs = Array.isArray(raw) ? raw : Array.isArray(raw?.packages) ? raw.packages : [];
@@ -407,22 +566,28 @@ function inspect(dir2, manifest2, presetOverride) {
407
566
  }
408
567
  }))
409
568
  ];
410
- for (const [name, blessed] of Object.entries(manifest2.ecosystem)) {
411
- for (const target of rangeTargets) {
412
- if (name === target.pkg.name) continue;
413
- for (const field of DEP_FIELDS) {
414
- const deps = target.pkg[field];
415
- const range = deps?.[name];
416
- if (!range || range.startsWith("file:") || range.startsWith("link:")) continue;
569
+ const blessedSet = blessedVersions(manifest2);
570
+ const staleKeys = /* @__PURE__ */ new Set();
571
+ for (const target of rangeTargets) {
572
+ for (const field of DEP_FIELDS) {
573
+ const deps = target.pkg[field];
574
+ for (const [key, spec] of Object.entries(deps ?? {})) {
575
+ if (/^(file|link|workspace):/.test(spec)) continue;
576
+ const alias = npmAlias(spec);
577
+ const name = alias && !(key in blessedSet) ? alias.name : key;
578
+ const range = alias && name === alias.name ? alias.range : spec;
579
+ const blessed = blessedSet[name];
580
+ if (!blessed || name === target.pkg.name || !deps) continue;
581
+ const rewrite = (next) => name === key ? next : `npm:${name}@${next}`;
417
582
  const ok = admits(range, blessed);
418
583
  if (ok === false) {
419
584
  findings.push({
420
585
  level: "error",
421
586
  kind: "ecosystem-range",
422
587
  name,
423
- message: `${target.label}${name} ${field} range ${range} does not admit blessed ${blessed} \u2192 ^${blessed}`,
588
+ message: `${target.label}${key} ${field} range ${spec} does not admit blessed ${blessed} \u2192 ${rewrite(`^${blessed}`)}`,
424
589
  fix: () => {
425
- deps[name] = `^${blessed}`;
590
+ deps[key] = rewrite(`^${blessed}`);
426
591
  target.mark();
427
592
  }
428
593
  });
@@ -430,28 +595,28 @@ function inspect(dir2, manifest2, presetOverride) {
430
595
  if (ok === null) {
431
596
  findings.push({
432
597
  level: "warn",
433
- message: `${target.label}${name} range ${range} not understood; verify manually against ${blessed}`
598
+ message: `${target.label}${key} range ${spec} not understood; verify manually against ${blessed}`
599
+ });
600
+ }
601
+ if (!lockText || field === "peerDependencies" || staleKeys.has(key)) continue;
602
+ const locked = lockedVersion(lockText, key, name);
603
+ if (locked && admits(range, locked) === false) {
604
+ staleKeys.add(key);
605
+ findings.push({
606
+ level: "error",
607
+ kind: "stale-lock",
608
+ name: key,
609
+ message: `${target.label}${key} locked at ${locked}, violating declared ${spec} (stale lockfile) \u2192 re-lock`
610
+ });
611
+ } else if (locked && locked !== blessed) {
612
+ staleKeys.add(key);
613
+ findings.push({
614
+ level: "error",
615
+ kind: "stale-lock",
616
+ name: key,
617
+ message: `${target.label}${key} locked at ${locked}, blessed is ${blessed} \u2192 re-lock`
434
618
  });
435
619
  }
436
- }
437
- }
438
- const installed = pkg.dependencies?.[name] ?? pkg.devDependencies?.[name];
439
- if (lockText && installed) {
440
- const locked = lockedVersion(lockText, name);
441
- if (locked && admits(installed, locked) === false) {
442
- findings.push({
443
- level: "error",
444
- kind: "stale-lock",
445
- name,
446
- message: `${name} locked at ${locked}, violating declared ${installed} (stale lockfile) \u2192 re-lock`
447
- });
448
- } else if (locked && locked !== blessed) {
449
- findings.push({
450
- level: "error",
451
- kind: "stale-lock",
452
- name,
453
- message: `${name} locked at ${locked}, blessed is ${blessed} \u2192 re-lock`
454
- });
455
620
  }
456
621
  }
457
622
  }
@@ -535,16 +700,19 @@ var args = process.argv.slice(2);
535
700
  var command = args[0];
536
701
  var flags = new Set(args.filter((arg) => arg.startsWith("--")).map((arg) => arg.split("=")[0]));
537
702
  var presetFlag = args.find((arg) => arg.startsWith("--preset="))?.split("=")[1];
703
+ var laneFlag = args.find((arg) => arg.startsWith("--lane="))?.split("=")[1];
538
704
  var positional = args.slice(1).filter((arg) => !arg.startsWith("--"));
539
705
  var dir = resolve(positional[0] ?? ".");
540
706
  var usage = () => {
541
707
  console.log(
542
- "Usage: inixiative-config <check|sync|scan|train> [dir] [--preset=base|node|react] [--force] [--no-install]"
708
+ "Usage: inixiative-config <check|sync|scan|train> [dir] [--preset=base|node|react] [--lane=primitives|agentic] [--force] [--no-install] [--push]"
543
709
  );
544
710
  process.exit(2);
545
711
  };
546
712
  if (!["check", "sync", "scan", "train"].includes(command)) usage();
547
713
  if (presetFlag && !["base", "node", "react"].includes(presetFlag)) usage();
714
+ if (laneFlag !== void 0 && !isLane(laneFlag)) usage();
715
+ var lanes = laneFlag && isLane(laneFlag) ? [laneFlag] : [...LANES];
548
716
  var manifest = loadManifest();
549
717
  var report = (findings) => {
550
718
  for (const finding of findings) {
@@ -600,18 +768,21 @@ if (command === "check") {
600
768
  const { findings } = inspect(dir, manifest, presetFlag);
601
769
  process.exit(report(findings) > 0 ? 1 : 0);
602
770
  }
771
+ var laneLabel = (checkout) => checkout.consumer ? `consumer of ${checkout.consumer.lanes.join(" + ")}` : `${checkout.lane} lane`;
603
772
  if (command === "scan") {
604
- const repos = discoverRepos(dir, manifest);
605
- if (repos.length === 0) {
773
+ const plan = trainPlan(dir, manifest, lanes);
774
+ const checkouts = [...plan.lanes.flatMap((lane) => lane.checkouts), ...plan.consumers];
775
+ if (checkouts.length === 0) {
606
776
  console.error(`\u2717 no ecosystem repos found under ${dir}`);
607
777
  process.exit(1);
608
778
  }
609
- const missing = missingRepos(repos, manifest);
779
+ const missing = [...plan.lanes.flatMap((lane) => lane.missing), ...plan.missingConsumers];
780
+ const ambiguous = [...plan.lanes.flatMap((lane) => lane.ambiguous), ...plan.ambiguousConsumers];
610
781
  const drifted = [];
611
- for (const repo of repos) {
782
+ for (const checkout of checkouts) {
612
783
  console.log(`
613
- ${repo.name} \u2014 ${repo.dir}`);
614
- const off = offDefaultBranch(repo.dir);
784
+ ${checkout.name} \u2014 ${checkout.dir} (${laneLabel(checkout)})`);
785
+ const off = offDefaultBranch(checkout.dir);
615
786
  const branchFindings = off ? [
616
787
  {
617
788
  level: "warn",
@@ -620,175 +791,280 @@ ${repo.name} \u2014 ${repo.dir}`);
620
791
  ] : [];
621
792
  const findings = [
622
793
  ...branchFindings,
623
- ...staleCheckoutFindings(repo.dir),
624
- ...inspect(repo.dir, manifest).findings
794
+ ...staleCheckoutFindings(checkout.dir),
795
+ ...inspect(checkout.dir, manifest).findings
625
796
  ];
626
- if (report(findings) > 0) drifted.push(repo.name);
627
- for (const branch of unmergedSessionBranches(repo.dir)) {
797
+ if (report(findings) > 0) drifted.push(checkout.name);
798
+ for (const branch of unmergedSessionBranches(checkout.dir)) {
628
799
  console.log(`\u26A0 unmerged session branch: ${branch}`);
629
800
  }
630
801
  }
631
802
  if (missing.length > 0) console.error(`
632
803
  \u2717 no checkout found for: ${missing.join(", ")}`);
804
+ for (const entry of ambiguous) {
805
+ console.error(`\u2717 ambiguous checkouts for ${entry.key}: ${entry.dirs.join(", ")}`);
806
+ }
633
807
  console.log(
634
808
  `
635
- scanned ${repos.length} repos: ${drifted.length === 0 ? "all in sync" : `${drifted.length} drifted (${drifted.join(", ")})`}${missing.length > 0 ? `, ${missing.length} missing` : ""}`
809
+ scanned ${checkouts.length} repos (${lanes.join(" + ")}): ${drifted.length === 0 ? "all in sync" : `${drifted.length} drifted (${drifted.join(", ")})`}${missing.length > 0 ? `, ${missing.length} missing` : ""}${ambiguous.length > 0 ? `, ${ambiguous.length} ambiguous` : ""}`
636
810
  );
637
- process.exit(drifted.length > 0 || missing.length > 0 ? 1 : 0);
811
+ process.exit(drifted.length > 0 || missing.length > 0 || ambiguous.length > 0 ? 1 : 0);
638
812
  }
813
+ var Abort = class extends Error {
814
+ };
639
815
  if (command === "train") {
640
- const order = topoOrder(dir, manifest);
641
- if (order.length === 0) {
642
- console.error(`\u2717 no ecosystem repos found under ${dir}`);
643
- process.exit(1);
644
- }
645
- const missing = missingRepos(order, manifest);
646
- if (missing.length > 0) {
647
- console.error(
648
- `\u2717 no checkout under ${dir} for: ${missing.join(", ")} \u2014 a partial train ships an incoherent set`
649
- );
650
- process.exit(1);
651
- }
652
816
  git(packageRoot, "fetch", "--quiet");
653
817
  if (behindOrigin(packageRoot) > 0) {
654
818
  console.error("\u2717 this config checkout is behind origin \u2014 its blessed set is stale; pull first");
655
819
  process.exit(1);
656
820
  }
657
- console.log(`train order: ${order.map((repo) => repo.name).join(" \u2192 ")}`);
658
- const published = [];
821
+ const plan = trainPlan(dir, manifest, lanes);
822
+ if (plan.lanes.every((lane) => lane.checkouts.length === 0) && plan.consumers.length === 0) {
823
+ console.error(`\u2717 no ecosystem repos found under ${dir}`);
824
+ process.exit(1);
825
+ }
826
+ const blessed = [];
659
827
  const skipped = [];
660
- const committed = [];
828
+ const failed = [];
829
+ const committed = /* @__PURE__ */ new Set();
830
+ const reviewBranches = [];
661
831
  let bomDirty = false;
662
- for (const repo of order) {
663
- console.log(`
664
- \u25B8 ${repo.name} \u2014 ${repo.dir}`);
665
- const porcelain = spawnSync2("git", ["status", "--porcelain"], {
666
- cwd: repo.dir,
667
- encoding: "utf8"
668
- });
832
+ const ready = (checkout) => {
833
+ const porcelain = git(checkout.dir, "status", "--porcelain");
669
834
  if (porcelain.status !== 0) {
670
835
  console.log("\u26A0 not a git checkout \u2014 skipping");
671
- skipped.push(repo.name);
672
- continue;
836
+ return false;
673
837
  }
674
- const off = offDefaultBranch(repo.dir);
838
+ const off = offDefaultBranch(checkout.dir);
675
839
  if (off) {
676
840
  console.log(
677
841
  `\u26A0 on ${off.branch}, not ${off.main} \u2014 the train only ships the default branch, skipping`
678
842
  );
679
- skipped.push(repo.name);
680
- continue;
843
+ return false;
681
844
  }
682
845
  if (porcelain.stdout.trim().length > 0) {
683
- console.log("\u26A0 dirty working tree \u2014 commit or stash your work first, skipping");
684
- skipped.push(repo.name);
685
- continue;
846
+ console.log("\u26A0 dirty working tree \u2014 commit your work first, skipping");
847
+ return false;
686
848
  }
687
- git(repo.dir, "fetch", "--quiet");
688
- for (const branch of unmergedSessionBranches(repo.dir)) {
849
+ git(checkout.dir, "fetch", "--quiet");
850
+ for (const branch of unmergedSessionBranches(checkout.dir)) {
689
851
  console.log(`\u26A0 unmerged session branch: ${branch} \u2014 a bump may be hiding there`);
690
852
  }
691
- const behind = behindOrigin(repo.dir);
853
+ const behind = behindOrigin(checkout.dir);
692
854
  if (behind > 0) {
693
- const localOnly = git(repo.dir, "rev-list", "--count", "@{upstream}..HEAD");
855
+ const localOnly = git(checkout.dir, "rev-list", "--count", "@{upstream}..HEAD");
694
856
  if (localOnly.status === 0 && Number(localOnly.stdout.trim()) > 0) {
695
857
  console.log("\u26A0 diverged from origin \u2014 reconcile first, skipping");
696
- skipped.push(repo.name);
697
- continue;
858
+ return false;
698
859
  }
699
- const ff = git(repo.dir, "merge", "--ff-only", "@{upstream}");
700
- if (ff.status !== 0) {
860
+ if (git(checkout.dir, "merge", "--ff-only", "@{upstream}").status !== 0) {
701
861
  console.log("\u26A0 fast-forward to origin failed \u2014 reconcile first, skipping");
702
- skipped.push(repo.name);
703
- continue;
862
+ return false;
704
863
  }
705
864
  console.log(`\u2193 fast-forwarded to origin (+${behind})`);
706
- repo.pkg = JSON.parse(readFileSync2(join2(repo.dir, "package.json"), "utf8"));
865
+ checkout.pkg = JSON.parse(readFileSync2(join2(checkout.dir, "package.json"), "utf8"));
707
866
  }
708
- const inspection = inspect(repo.dir, manifest);
867
+ return true;
868
+ };
869
+ const bumpRanges = (checkout) => {
870
+ const inspection = inspect(checkout.dir, manifest);
709
871
  const bumps = inspection.findings.filter((finding) => finding.kind === "ecosystem-range");
710
872
  for (const bump of bumps) {
711
873
  console.log(` ${bump.message}`);
712
874
  bump.fix?.();
713
875
  }
714
876
  inspection.flush();
715
- const bumped = bumps.length > 0;
716
- const staleLock = inspection.findings.some((finding) => finding.kind === "stale-lock");
717
- const remote = spawnSync2("npm", ["view", `${repo.name}@latest`, "version"], {
718
- encoding: "utf8"
877
+ return {
878
+ inspection,
879
+ bumped: bumps.length > 0,
880
+ staleLock: inspection.findings.some((finding) => finding.kind === "stale-lock")
881
+ };
882
+ };
883
+ const relockAndCheck = (checkout) => {
884
+ if (spawnSync2("bun", ["install"], { cwd: checkout.dir, stdio: "inherit" }).status !== 0) {
885
+ throw new Abort(`bun install failed in ${checkout.name}`);
886
+ }
887
+ const stale = inspect(checkout.dir, manifest).findings.filter((finding) => finding.kind === "stale-lock" && finding.name).map((finding) => finding.name);
888
+ if (stale.length > 0) {
889
+ spawnSync2("bun", ["update", ...new Set(stale)], { cwd: checkout.dir, stdio: "inherit" });
890
+ }
891
+ if (spawnSync2("bun", ["run", "check"], { cwd: checkout.dir, stdio: "inherit" }).status !== 0) {
892
+ throw new Abort(`check failed in ${checkout.name}`);
893
+ }
894
+ };
895
+ const commitOwned = (checkout, packagePaths) => {
896
+ const tracked = [...packagePaths, "bun.lock"];
897
+ const mutated = git(checkout.dir, "status", "--porcelain", "--", ...tracked);
898
+ if (mutated.status !== 0 || mutated.stdout.trim().length === 0) return false;
899
+ if (git(checkout.dir, "add", "--", ...tracked).status !== 0) {
900
+ throw new Abort(`git add failed in ${checkout.name} \u2014 is bun.lock ignored?`);
901
+ }
902
+ const commit = spawnSync2("git", ["commit", "-m", "chore: sync ecosystem deps to blessed set"], {
903
+ cwd: checkout.dir,
904
+ stdio: "inherit"
719
905
  });
720
- const remoteVersion = remote.status === 0 ? remote.stdout.trim() : null;
721
- const localVersion = repo.pkg.version ?? "0.0.0";
722
- const ahead = remoteVersion === null || compare(localVersion, remoteVersion) > 0;
723
- if (remoteVersion && compare(localVersion, remoteVersion) < 0) {
724
- console.log(`\u26A0 local ${localVersion} is behind npm ${remoteVersion} \u2014 pull first, skipping`);
725
- skipped.push(repo.name);
726
- continue;
906
+ if (commit.status !== 0) throw new Abort(`commit failed in ${checkout.name}`);
907
+ return true;
908
+ };
909
+ const publish = (checkout, entry) => {
910
+ if (entry.path === "package.json") {
911
+ return spawnSync2("npm", ["publish"], { cwd: checkout.dir, stdio: "inherit" }).status === 0;
727
912
  }
728
- if (bumped || ahead || staleLock) {
729
- const install = spawnSync2("bun", ["install"], { cwd: repo.dir, stdio: "inherit" });
730
- if (install.status !== 0) {
731
- console.error("\u2717 bun install failed \u2014 aborting train");
732
- process.exit(1);
913
+ const memberDir = join2(checkout.dir, dirname2(entry.path));
914
+ const out = mkdtempSync(join2(tmpdir(), "inixiative-train-"));
915
+ try {
916
+ const pack = spawnSync2("bun", ["pm", "pack", "--destination", out], {
917
+ cwd: memberDir,
918
+ stdio: "inherit"
919
+ });
920
+ const tarball = readdirSync2(out).find((name) => name.endsWith(".tgz"));
921
+ if (pack.status !== 0 || !tarball) return false;
922
+ return spawnSync2("npm", ["publish", join2(out, tarball)], { cwd: memberDir, stdio: "inherit" }).status === 0;
923
+ } finally {
924
+ rmSync2(out, { recursive: true, force: true });
925
+ }
926
+ };
927
+ const npmLatest = (name) => {
928
+ const remote = spawnSync2("npm", ["view", `${name}@latest`, "version"], { encoding: "utf8" });
929
+ return remote.status === 0 ? remote.stdout.trim() : null;
930
+ };
931
+ for (const { lane, checkouts, missing, ambiguous } of plan.lanes) {
932
+ console.log(
933
+ `
934
+ \u2550 ${lane} lane: ${checkouts.map((checkout) => checkout.name).join(" \u2192 ") || "(none)"}`
935
+ );
936
+ const section = laneSection(manifest, lane);
937
+ const before = { ...section };
938
+ const laneBlessed = [];
939
+ try {
940
+ if (missing.length > 0) {
941
+ throw new Abort(
942
+ `no checkout under ${dir} for: ${missing.join(", ")} \u2014 a partial lane ships an incoherent set`
943
+ );
733
944
  }
734
- const stale = inspect(repo.dir, manifest).findings.filter((finding) => finding.kind === "stale-lock" && finding.name).map((finding) => finding.name);
735
- if (stale.length > 0) {
736
- spawnSync2("bun", ["update", ...new Set(stale)], { cwd: repo.dir, stdio: "inherit" });
945
+ if (ambiguous.length > 0) {
946
+ throw new Abort(
947
+ ambiguous.map((entry) => `ambiguous checkouts for ${entry.key}: ${entry.dirs.join(", ")}`).join("; ")
948
+ );
737
949
  }
738
- const check = spawnSync2("bun", ["run", "check"], { cwd: repo.dir, stdio: "inherit" });
739
- if (check.status !== 0) {
740
- console.error(`\u2717 check failed in ${repo.name} \u2014 aborting train`);
741
- process.exit(1);
950
+ for (const checkout of checkouts) {
951
+ console.log(`
952
+ \u25B8 ${checkout.name} \u2014 ${checkout.dir}`);
953
+ if (!ready(checkout)) {
954
+ skipped.push(checkout.name);
955
+ continue;
956
+ }
957
+ const { inspection, bumped, staleLock } = bumpRanges(checkout);
958
+ const releases = checkout.packages.map((entry) => {
959
+ const local = JSON.parse(readFileSync2(join2(checkout.dir, entry.path), "utf8")).version ?? "0.0.0";
960
+ const remote = npmLatest(entry.name);
961
+ return { entry, local, remote, ahead: remote === null || compare(local, remote) > 0 };
962
+ });
963
+ const behind = releases.find(
964
+ (release) => release.remote && compare(release.local, release.remote) < 0
965
+ );
966
+ if (behind) {
967
+ console.log(
968
+ `\u26A0 ${behind.entry.name} local ${behind.local} is behind npm ${behind.remote} \u2014 pull first, skipping`
969
+ );
970
+ skipped.push(checkout.name);
971
+ continue;
972
+ }
973
+ if (bumped || staleLock || releases.some((release) => release.ahead))
974
+ relockAndCheck(checkout);
975
+ if (commitOwned(checkout, inspection.packagePaths)) committed.add(checkout.dir);
976
+ for (const { entry, local, remote, ahead } of releases) {
977
+ if (ahead) {
978
+ console.log(`publishing ${entry.name}@${local} (npm has ${remote ?? "none"})`);
979
+ if (!publish(checkout, entry)) throw new Abort(`publish failed for ${entry.name}`);
980
+ if (!servedByRegistry(entry.name, local)) {
981
+ throw new Abort(
982
+ `npm accepted ${entry.name}@${local} but the registry does not serve it`
983
+ );
984
+ }
985
+ } else if (section[entry.name] === local) {
986
+ console.log(`\u2713 ${entry.name}@${local} already on npm and blessed`);
987
+ continue;
988
+ } else {
989
+ console.log(`\u2713 ${entry.name}@${local} already on npm \u2014 blessing it`);
990
+ }
991
+ section[entry.name] = local;
992
+ laneBlessed.push(`${entry.name}@${local}`);
993
+ }
742
994
  }
743
- }
744
- const tracked = [...inspection.packagePaths, "bun.lock"];
745
- const mutated = spawnSync2("git", ["status", "--porcelain", "--", ...tracked], {
746
- cwd: repo.dir,
747
- encoding: "utf8"
748
- });
749
- if (mutated.status === 0 && mutated.stdout.trim().length > 0) {
750
- spawnSync2("git", ["add", "--", ...tracked], { cwd: repo.dir });
751
- const commit = spawnSync2(
752
- "git",
753
- ["commit", "-m", "chore: sync ecosystem deps to blessed set"],
754
- { cwd: repo.dir, stdio: "inherit" }
995
+ if (laneBlessed.length > 0) {
996
+ writeManifest(manifest);
997
+ bomDirty = true;
998
+ blessed.push(...laneBlessed);
999
+ }
1000
+ console.log(
1001
+ `${lane} lane blessed: ${laneBlessed.length === 0 ? "nothing new" : laneBlessed.join(", ")}`
755
1002
  );
756
- if (commit.status !== 0) {
757
- console.error(`\u2717 commit failed in ${repo.name} \u2014 aborting train`);
758
- process.exit(1);
1003
+ } catch (error) {
1004
+ if (!(error instanceof Abort)) throw error;
1005
+ for (const name of Object.keys(section)) delete section[name];
1006
+ Object.assign(section, before);
1007
+ failed.push(`${lane} lane`);
1008
+ console.error(`\u2717 ${error.message} \u2014 ${lane} lane stopped; its BOM entries are unchanged`);
1009
+ if (laneBlessed.length > 0) {
1010
+ console.error(
1011
+ ` published but not blessed: ${laneBlessed.join(", ")} \u2014 the next train blesses them`
1012
+ );
759
1013
  }
760
- committed.push(repo.dir);
761
1014
  }
762
- if (ahead) {
763
- console.log(`publishing ${repo.name}@${localVersion} (npm has ${remoteVersion ?? "none"})`);
764
- const publish = spawnSync2("npm", ["publish"], { cwd: repo.dir, stdio: "inherit" });
765
- if (publish.status !== 0) {
766
- console.error(`\u2717 publish failed for ${repo.name} \u2014 aborting train`);
767
- process.exit(1);
1015
+ }
1016
+ if (plan.consumers.length > 0 || plan.missingConsumers.length > 0) {
1017
+ console.log(
1018
+ `
1019
+ \u2550 consumers: ${plan.consumers.map((checkout) => checkout.name).join(" \u2192 ") || "(none)"}`
1020
+ );
1021
+ }
1022
+ for (const repo of plan.missingConsumers) {
1023
+ console.error(`\u2717 no checkout under ${dir} for consumer ${repo}`);
1024
+ failed.push(repo);
1025
+ }
1026
+ for (const entry of plan.ambiguousConsumers) {
1027
+ console.error(`\u2717 ambiguous checkouts for consumer ${entry.key}: ${entry.dirs.join(", ")}`);
1028
+ failed.push(entry.key);
1029
+ }
1030
+ for (const checkout of plan.consumers) {
1031
+ console.log(`
1032
+ \u25B8 ${checkout.name} \u2014 ${checkout.dir} (${laneLabel(checkout)})`);
1033
+ if (!ready(checkout)) {
1034
+ skipped.push(checkout.name);
1035
+ continue;
1036
+ }
1037
+ try {
1038
+ const { inspection, bumped, staleLock } = bumpRanges(checkout);
1039
+ if (!bumped && !staleLock) {
1040
+ console.log("\u2713 already on the blessed set");
1041
+ continue;
768
1042
  }
769
- if (!servedByRegistry(repo.name, localVersion)) {
770
- console.error(
771
- `\u2717 npm accepted ${repo.name}@${localVersion} but the registry does not serve it \u2014 aborting train`
772
- );
773
- process.exit(1);
1043
+ relockAndCheck(checkout);
1044
+ const base = offDefaultBranch(checkout.dir)?.main ?? currentBranch(checkout.dir);
1045
+ const branch = freeBranch(
1046
+ checkout.dir,
1047
+ `train/ecosystem-sync-${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`
1048
+ );
1049
+ if (git(checkout.dir, "switch", "-c", branch).status !== 0) {
1050
+ throw new Abort(`could not create ${branch} in ${checkout.name}`);
774
1051
  }
775
- committed.push(repo.dir);
776
- manifest.ecosystem[repo.name] = localVersion;
777
- writeManifest(manifest);
778
- bomDirty = true;
779
- published.push(`${repo.name}@${localVersion}`);
780
- } else {
781
- console.log(`\u2713 ${repo.name}@${localVersion} already on npm`);
1052
+ if (commitOwned(checkout, inspection.packagePaths)) {
1053
+ reviewBranches.push({ dir: checkout.dir, branch, base });
1054
+ console.log(`committed on ${branch} for review; the checkout stays on it until merged`);
1055
+ }
1056
+ } catch (error) {
1057
+ if (!(error instanceof Abort)) throw error;
1058
+ failed.push(checkout.name);
1059
+ console.error(`\u2717 ${error.message} \u2014 its working tree is left as is for inspection`);
782
1060
  }
783
1061
  }
784
1062
  console.log(`
785
- published: ${published.length === 0 ? "nothing" : published.join(", ")}`);
1063
+ blessed: ${blessed.length === 0 ? "nothing" : blessed.join(", ")}`);
786
1064
  if (skipped.length > 0) console.log(`skipped: ${skipped.join(", ")}`);
1065
+ if (failed.length > 0) console.log(`failed: ${failed.join(", ")}`);
787
1066
  if (bomDirty) {
788
- const remote = spawnSync2("npm", ["view", "@inixiative/config@latest", "version"], {
789
- encoding: "utf8"
790
- });
791
- const remoteVersion = remote.status === 0 ? remote.stdout.trim() : null;
1067
+ const remoteVersion = npmLatest("@inixiative/config");
792
1068
  let version = ownVersion();
793
1069
  if (remoteVersion !== null && compare(version, remoteVersion) <= 0) {
794
1070
  version = bumpPatch(remoteVersion);
@@ -816,46 +1092,70 @@ published: ${published.length === 0 ? "nothing" : published.join(", ")}`);
816
1092
  });
817
1093
  const commit = spawnSync2(
818
1094
  "git",
819
- ["commit", "-m", `chore: bless ${published.join(", ")} \u2014 ${version}`],
820
- {
821
- cwd: packageRoot,
822
- stdio: "inherit"
823
- }
1095
+ ["commit", "-m", `chore: bless ${blessed.join(", ")} \u2014 ${version}`],
1096
+ { cwd: packageRoot, stdio: "inherit" }
824
1097
  );
825
1098
  if (commit.status !== 0) {
826
1099
  console.error("\u2717 commit failed in @inixiative/config \u2014 aborting before publish");
827
1100
  process.exit(1);
828
1101
  }
829
- const publish = spawnSync2("npm", ["publish"], { cwd: packageRoot, stdio: "inherit" });
830
- if (publish.status !== 0 || !servedByRegistry("@inixiative/config", version)) {
1102
+ const publish2 = spawnSync2("npm", ["publish"], { cwd: packageRoot, stdio: "inherit" });
1103
+ if (publish2.status !== 0 || !servedByRegistry("@inixiative/config", version)) {
831
1104
  console.error("\u2717 publish failed for @inixiative/config");
832
1105
  process.exit(1);
833
1106
  }
834
- committed.push(packageRoot);
1107
+ committed.add(packageRoot);
835
1108
  console.log(`published: @inixiative/config@${version} \u2014 the BOM names the new state`);
836
1109
  }
837
1110
  if (flags.has("--push")) {
838
1111
  for (const repoDir of committed) {
839
- const push = spawnSync2("git", ["push"], { cwd: repoDir, stdio: "inherit" });
840
- if (push.status !== 0) {
1112
+ if (spawnSync2("git", ["push"], { cwd: repoDir, stdio: "inherit" }).status !== 0) {
841
1113
  console.error(`\u2717 push failed in ${repoDir}`);
842
1114
  process.exit(1);
843
1115
  }
844
1116
  }
845
- console.log(`pushed: ${committed.length} repo${committed.length === 1 ? "" : "s"}`);
846
- } else if (committed.length > 0) {
1117
+ for (const { dir: repoDir, branch, base } of reviewBranches) {
1118
+ const push = spawnSync2("git", ["push", "-u", "origin", branch], {
1119
+ cwd: repoDir,
1120
+ stdio: "inherit"
1121
+ });
1122
+ const pr = push.status === 0 && spawnSync2("gh", ["pr", "create", "--fill", "--base", base, "--head", branch], {
1123
+ cwd: repoDir,
1124
+ stdio: "inherit"
1125
+ }).status === 0;
1126
+ if (!pr) console.error(`\u2717 could not push ${branch} and open its PR in ${repoDir}`);
1127
+ }
1128
+ console.log(`pushed: ${committed.size + reviewBranches.length} repos`);
1129
+ } else if (committed.size > 0 || reviewBranches.length > 0) {
847
1130
  console.log("train does not push without --push \u2014 review its commits, then push:");
848
1131
  for (const repoDir of committed) console.log(` git -C ${repoDir} push`);
1132
+ for (const { dir: repoDir, branch, base } of reviewBranches) {
1133
+ console.log(
1134
+ ` git -C ${repoDir} push -u origin ${branch} && (cd ${repoDir} && gh pr create --fill --base ${base})`
1135
+ );
1136
+ }
849
1137
  }
850
- process.exit(0);
1138
+ process.exit(failed.length > 0 ? 1 : 0);
1139
+ }
1140
+ function currentBranch(cwd) {
1141
+ return git(cwd, "rev-parse", "--abbrev-ref", "HEAD").stdout.trim();
1142
+ }
1143
+ function freeBranch(cwd, name) {
1144
+ const taken = (candidate2) => git(cwd, "rev-parse", "--verify", "--quiet", `refs/heads/${candidate2}`).status === 0;
1145
+ let candidate = name;
1146
+ for (let n = 2; taken(candidate); n++) candidate = `${name}-${n}`;
1147
+ return candidate;
851
1148
  }
852
1149
  function servedByRegistry(name, version) {
853
- for (let attempt = 0; attempt < 10; attempt++) {
854
- const view = spawnSync2("npm", ["view", `${name}@${version}`, "version"], { encoding: "utf8" });
1150
+ const deadline = Date.now() + 15 * 6e4;
1151
+ while (true) {
1152
+ const view = spawnSync2("npm", ["view", `${name}@${version}`, "version", "--prefer-online"], {
1153
+ encoding: "utf8"
1154
+ });
855
1155
  if (view.status === 0 && view.stdout.trim() === version) return true;
856
- Bun.sleepSync(3e3);
1156
+ if (Date.now() >= deadline) return false;
1157
+ Bun.sleepSync(1e4);
857
1158
  }
858
- return false;
859
1159
  }
860
1160
  function bumpPatch(version) {
861
1161
  const [major, minor, patch] = version.split(".").map(Number);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/config",
3
- "version": "0.5.35",
3
+ "version": "0.5.37",
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.35",
16
+ "@inixiative/config": "0.5.37",
17
17
  "@inixiative/json-rules": "2.25.0",
18
18
  "@inixiative/permissions": "0.3.2",
19
19
  "@inixiative/transitions": "0.1.0",
@@ -21,6 +21,30 @@
21
21
  "@inixiative/prisma-map": "0.2.0",
22
22
  "@inixiative/atlas": "0.1.1",
23
23
  "@inixiative/gloss": "0.0.4",
24
- "@inixiative/archive": "0.2.1"
24
+ "@inixiative/archive": "0.3.0"
25
+ },
26
+ "agentic": {
27
+ "@inixiative/agent-session": "0.2.0",
28
+ "@inixiative/foundry-core": "0.2.0",
29
+ "@inixiative/foundry": "0.2.0"
30
+ },
31
+ "consumers": {
32
+ "inixiative/template": {
33
+ "lanes": [
34
+ "primitives"
35
+ ]
36
+ },
37
+ "inixiative/kingdom": {
38
+ "lanes": [
39
+ "primitives",
40
+ "agentic"
41
+ ],
42
+ "upstream": "inixiative/template"
43
+ },
44
+ "inixiative/oracle": {
45
+ "lanes": [
46
+ "agentic"
47
+ ]
48
+ }
25
49
  }
26
50
  }