@mutmutco/cli 3.136.0 → 3.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/main.cjs +354 -80
  2. package/package.json +1 -1
package/dist/main.cjs CHANGED
@@ -5849,6 +5849,21 @@ async function sweepDeferredWorktrees(store, deps, removalContext) {
5849
5849
  continue;
5850
5850
  }
5851
5851
  const owner = removalContext ? lookupWorktreeOwner(removalContext.primaryRoot, entry.path) : void 0;
5852
+ if (!current && deps.pathExists?.(entry.path) === false) {
5853
+ removed.push(entry.path);
5854
+ if (removalContext) {
5855
+ recordWorktreeRemoval(removalContext.primaryRoot, {
5856
+ action: "removed",
5857
+ command: removalContext.command,
5858
+ target: entry.path,
5859
+ branch: entry.branch,
5860
+ actor: removalContext.actor,
5861
+ owner,
5862
+ reason: "deferred worktree already absent from Git registration and filesystem"
5863
+ });
5864
+ }
5865
+ continue;
5866
+ }
5852
5867
  if (removalContext) {
5853
5868
  const activeRoot = removalContext.activeWorkspaceRoot ?? resolveActiveWorkspaceRoot();
5854
5869
  const activeGuard = decideActiveWorkspaceGuard(entry.path, activeRoot, process.platform, {
@@ -8398,6 +8413,12 @@ function loadBootstrapSeeds(manifestJson) {
8398
8413
  if (s.ownership !== "org" && s.ownership !== "repo") {
8399
8414
  throw new Error(`invalid seed ownership '${s.ownership}' for ${s.target} (must be 'org' or 'repo')`);
8400
8415
  }
8416
+ if (s.managedBlock) {
8417
+ const block = s.managedBlock;
8418
+ if (!block.source || !block.begin || !block.end || block.begin === block.end || /[\r\n]/.test(block.begin + block.end)) {
8419
+ throw new Error(`invalid managedBlock for ${s.target} (needs source and distinct single-line begin/end markers)`);
8420
+ }
8421
+ }
8401
8422
  }
8402
8423
  return {
8403
8424
  seeds,
@@ -8413,6 +8434,68 @@ function missingPlaceholders(rendered) {
8413
8434
  for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
8414
8435
  return [...out];
8415
8436
  }
8437
+ function isPropagatableSeed(seed) {
8438
+ return seed.ownership === "org" && seed.source === "self" || seed.managedBlock != null;
8439
+ }
8440
+ function sourceLines(content) {
8441
+ const lines = [];
8442
+ const newline = /\r\n|\n|\r/g;
8443
+ let start = 0;
8444
+ for (const match of content.matchAll(newline)) {
8445
+ const index = match.index;
8446
+ lines.push({ start, textEnd: index, end: index + match[0].length, text: content.slice(start, index) });
8447
+ start = index + match[0].length;
8448
+ }
8449
+ lines.push({ start, textEnd: content.length, end: content.length, text: content.slice(start) });
8450
+ return lines;
8451
+ }
8452
+ function normalizeEol(content) {
8453
+ return content.replace(/\r\n|\r|\n/g, "\n");
8454
+ }
8455
+ function upsertManagedSeedBlock(current, desired, begin, end) {
8456
+ const desiredLines = sourceLines(desired);
8457
+ const desiredBegins = desiredLines.filter((line) => line.text === begin);
8458
+ const desiredEnds = desiredLines.filter((line) => line.text === end);
8459
+ if (desiredBegins.length !== 1 || desiredEnds.length !== 1 || desiredEnds[0].start <= desiredBegins[0].start) {
8460
+ return { ok: false, reason: "managed block source must contain exactly one well-ordered marker pair" };
8461
+ }
8462
+ const desiredBegin = desiredBegins[0];
8463
+ const desiredEnd = desiredEnds[0];
8464
+ if (desired.slice(0, desiredBegin.start).trim() || desired.slice(desiredEnd.end).trim()) {
8465
+ return { ok: false, reason: "managed block source must contain only the bounded block" };
8466
+ }
8467
+ const canonical = desired.slice(desiredBegin.start, desiredEnd.textEnd);
8468
+ const currentLines = sourceLines(current);
8469
+ const begins = currentLines.filter((line) => line.text === begin);
8470
+ const ends = currentLines.filter((line) => line.text === end);
8471
+ if (begins.length === 0 && ends.length === 0) {
8472
+ const eol2 = current.match(/\r\n|\n|\r/)?.[0] ?? "\n";
8473
+ const block2 = normalizeEol(canonical).replace(/\n/g, eol2);
8474
+ if (current.length === 0) return { ok: true, content: `${block2}${eol2}`, changed: true, action: "insert" };
8475
+ const separator = /(?:\r\n|\n|\r)$/.test(current) ? eol2 : `${eol2}${eol2}`;
8476
+ return { ok: true, content: `${current}${separator}${block2}${eol2}`, changed: true, action: "insert" };
8477
+ }
8478
+ if (begins.length !== 1 || ends.length !== 1) {
8479
+ return { ok: false, reason: `malformed managed block markers (begin=${begins.length}, end=${ends.length})` };
8480
+ }
8481
+ const currentBegin = begins[0];
8482
+ const currentEnd = ends[0];
8483
+ if (currentEnd.start <= currentBegin.start) {
8484
+ return { ok: false, reason: "malformed managed block markers (end precedes begin)" };
8485
+ }
8486
+ const existing = current.slice(currentBegin.start, currentEnd.textEnd);
8487
+ if (normalizeEol(existing) === normalizeEol(canonical)) {
8488
+ return { ok: true, content: current, changed: false, action: "current" };
8489
+ }
8490
+ const eol = current.match(/\r\n|\n|\r/)?.[0] ?? "\n";
8491
+ const block = normalizeEol(canonical).replace(/\n/g, eol);
8492
+ return {
8493
+ ok: true,
8494
+ content: `${current.slice(0, currentBegin.start)}${block}${current.slice(currentEnd.textEnd)}`,
8495
+ changed: true,
8496
+ action: "replace"
8497
+ };
8498
+ }
8416
8499
  async function resolveHubSeedSource(execGit, defaultBranch = "development") {
8417
8500
  let status;
8418
8501
  try {
@@ -9477,6 +9560,9 @@ function seedMatchesProjectType(seed, projectType) {
9477
9560
  return projectType != null && seed.projectTypes.includes(projectType);
9478
9561
  }
9479
9562
  function planSeedAction(seed, exists) {
9563
+ if (seed.managedBlock) {
9564
+ return exists ? { target: seed.target, action: "update", ownership: "repo", reason: "repo-owned file; Hub-managed block reconciled in place" } : { target: seed.target, action: "create", ownership: "repo", reason: "repo-owned, missing; full template created with Hub-managed block" };
9565
+ }
9480
9566
  if (seed.source === "managed-block") {
9481
9567
  return exists ? { target: seed.target, action: "update", ownership: "org", reason: "org-managed block merged in-place (repo-owned lines preserved)" } : { target: seed.target, action: "create", ownership: "org", reason: "org-managed block; .gitignore absent, created" };
9482
9568
  }
@@ -9535,6 +9621,18 @@ function resolveSeedContent(seed, vars, readFile9) {
9535
9621
  }
9536
9622
  return null;
9537
9623
  }
9624
+ function resolveSeedWriteContent(seed, vars, readFile9, remoteContent) {
9625
+ if (!seed.managedBlock) {
9626
+ return { ok: true, content: resolveSeedContent(seed, vars, readFile9), managed: seed.source === "managed-block" };
9627
+ }
9628
+ const base = remoteContent ?? resolveSeedContent(seed, vars, readFile9);
9629
+ if (base == null) return { ok: true, content: null, managed: true };
9630
+ const blockSeed = { ...seed, source: seed.managedBlock.source, managedBlock: void 0 };
9631
+ const desired = resolveSeedContent(blockSeed, vars, readFile9);
9632
+ if (desired == null) return { ok: true, content: null, managed: true };
9633
+ const result = upsertManagedSeedBlock(base, desired, seed.managedBlock.begin, seed.managedBlock.end);
9634
+ return result.ok ? { ok: true, content: result.content, managed: true } : { ok: false, reason: result.reason, managed: true };
9635
+ }
9538
9636
  function buildRegisterPayload(repo, cls, vars, options = {}) {
9539
9637
  const parsedRepo = parseOwnerRepo(repo);
9540
9638
  const slug = parsedRepo.slug;
@@ -14122,6 +14220,11 @@ function semverRelation(a, b) {
14122
14220
  function classifyRow(row) {
14123
14221
  const findings = [];
14124
14222
  const reasons = /* @__PURE__ */ new Set();
14223
+ if (row.onboarding.status === "drift") {
14224
+ findings.push(`canonical onboarding link drift: ${row.onboarding.detail}`);
14225
+ } else if (row.onboarding.status === "unknown") {
14226
+ reasons.add(`canonical onboarding link evidence unknown: ${row.onboarding.detail}`);
14227
+ }
14125
14228
  const releaseRequired = row.track !== "trunk" && !row.classifications.includes("no-deploy-surface");
14126
14229
  const tagIsVersion = Boolean(row.release.version) && row.unknowns.some((u) => u.startsWith(NO_VERSION_LOCK_PREFIX));
14127
14230
  for (const unknown of row.unknowns) {
@@ -14384,10 +14487,10 @@ var rollout_plan_default = {
14384
14487
  note: "The v4.0.0 stamp happens at cut time (D6e #4463); until then the candidate is the origin/development head artifacts (built cli/dist + npm pack), identity proven by dist content hash (D6a)."
14385
14488
  },
14386
14489
  baseline: {
14387
- version: "3.136.0",
14388
- tag: "v3.136.0",
14389
- commit: "acba68c21013",
14390
- npm: "@mutmutco/cli@3.136.0"
14490
+ version: "3.137.0",
14491
+ tag: "v3.137.0",
14492
+ commit: "48d8161cd121",
14493
+ npm: "@mutmutco/cli@3.137.0"
14391
14494
  },
14392
14495
  exitCriterion: "fleet-n-of-n",
14393
14496
  hubOnlyShortcut: "forbidden",
@@ -14404,14 +14507,14 @@ var rollout_plan_default = {
14404
14507
  repo: "mutmutco/mmi-hub",
14405
14508
  role: "canary",
14406
14509
  schedule: "train",
14407
- v3Target: "v3.136.0"
14510
+ v3Target: "v3.137.0"
14408
14511
  }
14409
14512
  ],
14410
14513
  rollbackTrigger: "Any red inside the post-cut soak window: `devops train gate` FAIL attributable to the v4 doors, Hub endpoint health probe failure, a v3 client refused while the compat window must still admit it (SUPPORTED_MINOR_WINDOW=2, MIN_CLIENT_VERSION 0.0.0 \u2014 D6a), or npm consumer install/doctor failure on the v4 dist.",
14411
14514
  rollback: {
14412
14515
  independent: true,
14413
- mechanism: "npm dist-tag latest -> 3.136.0 and redeploy the Hub Lambda from tag v3.136.0 (acba68c21013); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
14414
- v3Target: "v3.136.0 (@mutmutco/cli@3.136.0, tag commit acba68c21013 \u2014 the preserved latest-v3 distribution, D6b)"
14516
+ mechanism: "npm dist-tag latest -> 3.137.0 and redeploy the Hub Lambda from tag v3.137.0 (48d8161cd121); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
14517
+ v3Target: "v3.137.0 (@mutmutco/cli@3.137.0, tag commit 48d8161cd121 \u2014 the preserved latest-v3 distribution, D6b)"
14415
14518
  }
14416
14519
  },
14417
14520
  {
@@ -15619,6 +15722,7 @@ function registerQueryCommands(program3) {
15619
15722
 
15620
15723
  // src/fleet-track-inventory.ts
15621
15724
  var NO_DEPLOY_SURFACE_MODELS = /* @__PURE__ */ new Set(["none", "content"]);
15725
+ var CANONICAL_ONBOARDING_URL = "https://github.com/mutmutco/MMI-Hub/blob/main/docs/Architecture/agentic-dev-environment.md";
15622
15726
  function ghErrorText(e) {
15623
15727
  const err = e;
15624
15728
  return String(err?.stderr || err?.message || e).trim().replace(/\s+/g, " ");
@@ -15649,6 +15753,23 @@ async function readFleetPluginProbe(deps, repo, branch) {
15649
15753
  return isGhNotFound2(e) ? { repo, state: "absent" } : { repo, state: "unknown", unknown: `plugin surface read failed: ${ghErrorText(e)}` };
15650
15754
  }
15651
15755
  }
15756
+ async function readFleetOnboardingProbe(deps, repo, branch) {
15757
+ const [owner, name] = repo.split("/");
15758
+ const ref = branch ? `?ref=${encodeURIComponent(branch)}` : "";
15759
+ try {
15760
+ const raw = await deps.ghJson([
15761
+ "api",
15762
+ `repos/${encodeURIComponent(owner ?? "")}/${encodeURIComponent(name ?? "")}/contents/README.md${ref}`
15763
+ ]);
15764
+ if (raw.encoding !== "base64" || typeof raw.content !== "string") {
15765
+ return { repo, status: "unknown", detail: "README contents read returned no base64 content" };
15766
+ }
15767
+ const readme = Buffer.from(raw.content.replace(/\s/g, ""), "base64").toString("utf8");
15768
+ return readme.includes(CANONICAL_ONBOARDING_URL) ? { repo, status: "canonical", detail: CANONICAL_ONBOARDING_URL } : { repo, status: "drift", detail: `README.md does not link ${CANONICAL_ONBOARDING_URL}` };
15769
+ } catch (e) {
15770
+ return isGhNotFound2(e) ? { repo, status: "drift", detail: `README.md is absent; expected ${CANONICAL_ONBOARDING_URL}` } : { repo, status: "unknown", detail: `onboarding README read failed: ${ghErrorText(e)}` };
15771
+ }
15772
+ }
15652
15773
  async function mapBounded2(values, limit, read) {
15653
15774
  const results = new Array(values.length);
15654
15775
  let next = 0;
@@ -15679,9 +15800,10 @@ function classificationsOf(surfaces) {
15679
15800
  if (surfaces.deploy === "absent") classes.push("no-deploy-surface");
15680
15801
  return classes;
15681
15802
  }
15682
- function buildFleetTrackInventory(projects, releaseProbes, pluginProbes, generatedAt) {
15803
+ function buildFleetTrackInventory(projects, releaseProbes, pluginProbes, generatedAt, onboardingProbes = []) {
15683
15804
  const releaseByRepo = new Map(releaseProbes.map((p) => [p.repo.toLowerCase(), p]));
15684
15805
  const pluginByRepo = new Map(pluginProbes.map((p) => [p.repo.toLowerCase(), p]));
15806
+ const onboardingByRepo = new Map(onboardingProbes.map((p) => [p.repo.toLowerCase(), p]));
15685
15807
  const projectByRepo = /* @__PURE__ */ new Map();
15686
15808
  const anomalies = [];
15687
15809
  for (const project2 of projects) {
@@ -15703,11 +15825,13 @@ function buildFleetTrackInventory(projects, releaseProbes, pluginProbes, generat
15703
15825
  const project2 = projectByRepo.get(repo.toLowerCase());
15704
15826
  const release = releaseByRepo.get(repo.toLowerCase());
15705
15827
  const plugin = pluginByRepo.get(repo.toLowerCase());
15828
+ const onboarding = onboardingByRepo.get(repo.toLowerCase()) ?? { repo, status: "unknown", detail: "onboarding README was not probed" };
15706
15829
  const surfaces = classifySurfaces(project2, plugin);
15707
15830
  const unknowns = [...release?.unknowns ?? []];
15708
15831
  if (!release) unknowns.push("GitHub release evidence was not collected");
15709
15832
  if (plugin?.unknown) unknowns.push(plugin.unknown);
15710
15833
  if (!plugin) unknowns.push("plugin surface was not probed");
15834
+ if (onboarding.status === "unknown") unknowns.push(onboarding.detail);
15711
15835
  return {
15712
15836
  repo,
15713
15837
  slug: String(project2?.slug ?? repo.split("/")[1] ?? repo).toLowerCase(),
@@ -15719,6 +15843,7 @@ function buildFleetTrackInventory(projects, releaseProbes, pluginProbes, generat
15719
15843
  surfaces,
15720
15844
  classifications: classificationsOf(surfaces),
15721
15845
  pluginVersion: plugin?.version ?? null,
15846
+ onboarding,
15722
15847
  release: {
15723
15848
  tag: release?.releaseTag ?? null,
15724
15849
  version: release?.releasedVersion ?? null,
@@ -15731,7 +15856,7 @@ function buildFleetTrackInventory(projects, releaseProbes, pluginProbes, generat
15731
15856
  requiredChecks: Array.isArray(project2?.requiredChecks) ? project2.requiredChecks : null,
15732
15857
  exemptReason: typeof project2?.ciExemptReason === "string" ? project2.ciExemptReason : null
15733
15858
  },
15734
- source: { registry: "hub-registry-live", release: "github-releases-live", plugin: "github-contents-live" },
15859
+ source: { registry: "hub-registry-live", release: "github-releases-live", plugin: "github-contents-live", onboarding: "github-readme-live" },
15735
15860
  freshness: { readAt: generatedAt, releasedAt: release?.releasedAt ?? null },
15736
15861
  unknowns
15737
15862
  };
@@ -15744,6 +15869,7 @@ function buildFleetTrackInventory(projects, releaseProbes, pluginProbes, generat
15744
15869
  roster: "hub-registry-live",
15745
15870
  releases: "github-releases-live",
15746
15871
  plugin: "github-contents-live",
15872
+ onboarding: "github-readme-live",
15747
15873
  committedInventory: "never"
15748
15874
  },
15749
15875
  counts: {
@@ -15754,6 +15880,7 @@ function buildFleetTrackInventory(projects, releaseProbes, pluginProbes, generat
15754
15880
  noVault: repos.filter((r) => r.classifications.includes("no-vault")).length,
15755
15881
  noPlugin: repos.filter((r) => r.classifications.includes("no-plugin")).length,
15756
15882
  noDeploySurface: repos.filter((r) => r.classifications.includes("no-deploy-surface")).length,
15883
+ onboardingDrift: repos.filter((r) => r.onboarding.status === "drift").length,
15757
15884
  unknownRows: repos.filter((r) => r.unknowns.length > 0).length,
15758
15885
  anomalies: anomalies.length
15759
15886
  },
@@ -15785,11 +15912,12 @@ async function readFleetTrackInventory(deps = defaultFleetTrackInventoryDeps())
15785
15912
  }
15786
15913
  }
15787
15914
  }
15788
- const [releaseProbes, pluginProbes] = await Promise.all([
15915
+ const [releaseProbes, pluginProbes, onboardingProbes] = await Promise.all([
15789
15916
  mapBounded2(repos, 6, (repo) => readOrgVersionProbe(deps.query, repo)),
15790
- mapBounded2(repos, 6, (repo) => readFleetPluginProbe(deps.query, repo, branchByRepo.get(repo.toLowerCase())))
15917
+ mapBounded2(repos, 6, (repo) => readFleetPluginProbe(deps.query, repo, branchByRepo.get(repo.toLowerCase()))),
15918
+ mapBounded2(repos, 6, (repo) => readFleetOnboardingProbe(deps.query, repo, branchByRepo.get(repo.toLowerCase())))
15791
15919
  ]);
15792
- return buildFleetTrackInventory(projects, releaseProbes, pluginProbes, deps.now().toISOString());
15920
+ return buildFleetTrackInventory(projects, releaseProbes, pluginProbes, deps.now().toISOString(), onboardingProbes);
15793
15921
  }
15794
15922
  function formatFleetTrackInventory(report) {
15795
15923
  const { counts } = report;
@@ -15797,13 +15925,13 @@ function formatFleetTrackInventory(report) {
15797
15925
  `fleet version-track inventory \u2014 ${counts.registeredRepos} registered repositories (${counts.projects} registry projects)`,
15798
15926
  `source: live Hub registry + live GitHub releases/contents \xB7 read ${report.generatedAt} \xB7 committed inventory: never`,
15799
15927
  `tracks: ${counts.tracks.full} full \xB7 ${counts.tracks.direct} direct \xB7 ${counts.tracks.trunk} trunk`,
15800
- `classified: ${counts.noBoard} no-board \xB7 ${counts.noVault} no-vault \xB7 ${counts.noPlugin} no-plugin \xB7 ${counts.noDeploySurface} no-deploy-surface \xB7 ${counts.unknownRows} rows with unknowns`,
15928
+ `classified: ${counts.noBoard} no-board \xB7 ${counts.noVault} no-vault \xB7 ${counts.noPlugin} no-plugin \xB7 ${counts.noDeploySurface} no-deploy-surface \xB7 ${counts.onboardingDrift} onboarding-link drift \xB7 ${counts.unknownRows} rows with unknowns`,
15801
15929
  "",
15802
- "repository | track | deploy model | released | plugin | classifications"
15930
+ "repository | track | deploy model | released | plugin | onboarding | classifications"
15803
15931
  ];
15804
15932
  for (const row of report.repos) {
15805
15933
  lines.push(
15806
- `${row.repo} | ${row.track}${row.declaredTrack ? "" : " (derived)"} | ${row.deployModel ?? "UNDECLARED"} | ${row.release.version ?? "UNKNOWN"} | ${row.pluginVersion ?? (row.surfaces.plugin === "present" ? "present" : row.surfaces.plugin)} | ${row.classifications.join(", ") || "none"}`
15934
+ `${row.repo} | ${row.track}${row.declaredTrack ? "" : " (derived)"} | ${row.deployModel ?? "UNDECLARED"} | ${row.release.version ?? "UNKNOWN"} | ${row.pluginVersion ?? (row.surfaces.plugin === "present" ? "present" : row.surfaces.plugin)} | ${row.onboarding.status} | ${row.classifications.join(", ") || "none"}`
15807
15935
  );
15808
15936
  for (const unknown of row.unknowns) lines.push(` ? ${unknown}`);
15809
15937
  }
@@ -15818,7 +15946,7 @@ function formatFleetTrackInventory(report) {
15818
15946
  function registerFleetTrackInventory(program3) {
15819
15947
  const train = program3.commands.find((c) => c.name() === "train");
15820
15948
  if (!train) throw new Error("train inventory registration requires the train command group");
15821
- train.command("inventory").description("live fleet version-track inventory (#4451) \u2014 every registered repo's track, deploy model, CI contract, release evidence, and explicit no-board/no-vault/no-plugin/no-deploy-surface classification").option("--json", "machine-readable output (the typed D2b input shape)").action(async (o) => {
15949
+ train.command("inventory").description("live fleet version-track inventory (#4451) \u2014 every registered repo's track, release evidence, surface classification, and canonical Hub onboarding-link evidence").option("--json", "machine-readable output (the typed D2b input shape)").action(async (o) => {
15822
15950
  try {
15823
15951
  const report = await readFleetTrackInventory();
15824
15952
  console.log(o.json ? JSON.stringify(report, null, 2) : formatFleetTrackInventory(report));
@@ -20069,7 +20197,7 @@ var import_node_net = require("node:net");
20069
20197
  var import_node_util5 = require("node:util");
20070
20198
 
20071
20199
  // src/rules-sync.ts
20072
- function normalizeEol(s) {
20200
+ function normalizeEol2(s) {
20073
20201
  return s.replace(/\r\n/g, "\n");
20074
20202
  }
20075
20203
 
@@ -20122,8 +20250,8 @@ function envFileKeys(content) {
20122
20250
  return keys;
20123
20251
  }
20124
20252
  function detectStaleEnvFile(exampleContent, targetContent, mtimes) {
20125
- const example = normalizeEol(exampleContent);
20126
- const target = normalizeEol(targetContent);
20253
+ const example = normalizeEol2(exampleContent);
20254
+ const target = normalizeEol2(targetContent);
20127
20255
  const exampleKeys = envFileKeys(example);
20128
20256
  const targetKeys = envFileKeys(target);
20129
20257
  for (const key of exampleKeys) {
@@ -25448,7 +25576,7 @@ function listCandidatePaths(cwd, exec = import_node_child_process12.execFileSync
25448
25576
  return [];
25449
25577
  }
25450
25578
  }
25451
- function rebuildRepoIndex(cwd, repoSlug2) {
25579
+ function rebuildRepoIndex(cwd, repoSlug3) {
25452
25580
  const candidates = listCandidatePaths(cwd).filter(isIndexablePath);
25453
25581
  const ignored = defaultIsIgnored(cwd, candidates);
25454
25582
  const readmeHints = loadReadmeHints(cwd, candidates.filter((p) => !ignored.has(p)));
@@ -25480,7 +25608,7 @@ function rebuildRepoIndex(cwd, repoSlug2) {
25480
25608
  entries.sort((a, b) => a.path.localeCompare(b.path));
25481
25609
  const projection = {
25482
25610
  schema: REPO_INDEX_SCHEMA,
25483
- repo: repoSlug2,
25611
+ repo: repoSlug3,
25484
25612
  builtAt: (/* @__PURE__ */ new Date()).toISOString(),
25485
25613
  entries
25486
25614
  };
@@ -28756,6 +28884,71 @@ function renderPropagationReport(plan) {
28756
28884
  return lines.join("\n");
28757
28885
  }
28758
28886
 
28887
+ // src/bootstrap-propagation-identity.ts
28888
+ var import_node_crypto8 = require("node:crypto");
28889
+ var PROPAGATION_BRANCH_PREFIX = "seed-propagate-";
28890
+ var TARGET_MARKER_NAME = "mmi-bootstrap-propagation-target";
28891
+ function safeBranchPart(value, maxLength, fallback) {
28892
+ const safe = value.normalize("NFKD").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, maxLength).replace(/-+$/g, "");
28893
+ return safe || fallback;
28894
+ }
28895
+ function repoSlug2(repo) {
28896
+ return repo.trim().split("/").pop()?.toLowerCase() || "repo";
28897
+ }
28898
+ function propagationBranch(repo, target) {
28899
+ const repoPart = safeBranchPart(repoSlug2(repo), 32, "repo");
28900
+ const targetPart = safeBranchPart(target, 48, "target");
28901
+ const hash = (0, import_node_crypto8.createHash)("sha256").update(repo.trim().toLowerCase()).update("\0").update(target).digest("hex").slice(0, 12);
28902
+ return `${PROPAGATION_BRANCH_PREFIX}${repoPart}-${targetPart}-${hash}`;
28903
+ }
28904
+ function legacyPropagationBranch(repo) {
28905
+ return `${PROPAGATION_BRANCH_PREFIX}${repoSlug2(repo)}`;
28906
+ }
28907
+ function renderPropagationTargetMarker(target) {
28908
+ return `<!-- ${TARGET_MARKER_NAME}: ${JSON.stringify(target)} -->`;
28909
+ }
28910
+ function targetMarkers(body) {
28911
+ const present = body.includes(`${TARGET_MARKER_NAME}:`);
28912
+ const targets = [];
28913
+ const marker = new RegExp(`<!--\\s*${TARGET_MARKER_NAME}:\\s*(.*?)\\s*-->`, "g");
28914
+ for (const match of body.matchAll(marker)) {
28915
+ try {
28916
+ const parsed = JSON.parse(match[1]);
28917
+ if (typeof parsed === "string") targets.push(parsed);
28918
+ } catch {
28919
+ }
28920
+ }
28921
+ return { present, targets };
28922
+ }
28923
+ function canonicalProseTargets(body) {
28924
+ const targets = [];
28925
+ const command = /bootstrap propagate --target (.+?) --execute`/g;
28926
+ const prose = /(?:org-owned copy of|declared marker-bounded block inside repo-owned) `([^`]+)` — the file this PR carries and nothing else/g;
28927
+ for (const match of body.matchAll(command)) targets.push(match[1]);
28928
+ for (const match of body.matchAll(prose)) targets.push(match[1]);
28929
+ return targets;
28930
+ }
28931
+ function propagationPrMatchesTarget(pr2, target, identity) {
28932
+ const body = pr2.body ?? "";
28933
+ const markers = targetMarkers(body);
28934
+ if (markers.present) {
28935
+ if (markers.targets.length !== 1 || markers.targets[0] !== target) return false;
28936
+ return !pr2.files?.length || pr2.files.length === 1 && pr2.files[0] === target;
28937
+ }
28938
+ if (identity === "target-specific") return false;
28939
+ const proseTargets = canonicalProseTargets(body);
28940
+ if (proseTargets.some((candidate) => candidate !== target)) return false;
28941
+ if (pr2.files?.length) {
28942
+ return pr2.files.length === 1 && pr2.files[0] === target;
28943
+ }
28944
+ return proseTargets.length > 0;
28945
+ }
28946
+ function propagationPrCandidatesForTarget(target, targetSpecificPrs, legacyPrs) {
28947
+ const targetSpecific = targetSpecificPrs.filter((pr2) => propagationPrMatchesTarget(pr2, target, "target-specific"));
28948
+ if (targetSpecific.length) return targetSpecific;
28949
+ return legacyPrs.filter((pr2) => propagationPrMatchesTarget(pr2, target, "legacy"));
28950
+ }
28951
+
28759
28952
  // src/bootstrap-rollback.ts
28760
28953
  function resolveRollbackRecord(records, repo, target) {
28761
28954
  const candidates = records.filter((r) => r.repo === repo && r.target === target && r.mergeSha);
@@ -29609,6 +29802,7 @@ function registerBootstrapCommands(program3) {
29609
29802
  return fail(`bootstrap apply: --only '${onlyTarget}' names no seed in ${manifestPath}. Declared targets:
29610
29803
  ${known}`);
29611
29804
  }
29805
+ const onlyManagedBlock = onlyTarget ? seedsToApply[0]?.managedBlock != null : false;
29612
29806
  const gh = async (args) => execFileP2("gh", args, { timeout: 2e4 });
29613
29807
  const readFile9 = (p) => (0, import_node_fs38.existsSync)(p) ? (0, import_node_fs38.readFileSync)(p, "utf8") : null;
29614
29808
  const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
@@ -29716,9 +29910,22 @@ function registerBootstrapCommands(program3) {
29716
29910
  exists = false;
29717
29911
  }
29718
29912
  const planned = planSeedAction(resolved, exists);
29719
- const isBlock = resolved.source === "managed-block";
29720
- const content = planned.action === "create" || planned.action === "update" ? isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile9) : null;
29721
- const action = reconcileSeedAction(planned, content, isBlock, remoteContent);
29913
+ const isLegacyBlock = resolved.source === "managed-block";
29914
+ let content = null;
29915
+ let isManaged = isLegacyBlock || resolved.managedBlock != null;
29916
+ if (planned.action === "create" || planned.action === "update") {
29917
+ if (isLegacyBlock) {
29918
+ content = upsertManagedGitignoreBlock(remoteContent).content;
29919
+ } else {
29920
+ const writeContent = resolveSeedWriteContent(resolved, vars, readFile9, remoteContent);
29921
+ if (!writeContent.ok) {
29922
+ return fail(`bootstrap apply: ${resolved.target}: ${writeContent.reason} \u2014 refusing to overwrite repo-owned content`);
29923
+ }
29924
+ content = writeContent.content;
29925
+ isManaged = writeContent.managed;
29926
+ }
29927
+ }
29928
+ const action = reconcileSeedAction(planned, content, isManaged, remoteContent);
29722
29929
  actions.push(action);
29723
29930
  const docBody = content ?? remoteContent;
29724
29931
  if (resolved.target.startsWith("docs/") && resolved.target.endsWith(".md") && docBody !== null) {
@@ -29771,11 +29978,11 @@ function registerBootstrapCommands(program3) {
29771
29978
  "--head",
29772
29979
  seedPlan.branch,
29773
29980
  "--title",
29774
- onlyTarget ? `chore: propagate org-owned ${onlyTarget} from MMI-Hub` : `bootstrap: seed ${parsedRepo.name} (${baseBranch} is protected)`,
29981
+ onlyTarget ? `chore: propagate ${onlyManagedBlock ? "Hub-managed block in" : "org-owned"} ${onlyTarget} from MMI-Hub` : `bootstrap: seed ${parsedRepo.name} (${baseBranch} is protected)`,
29775
29982
  "--body",
29776
29983
  onlyTarget ? `Auto-opened by \`mmi-cli devops bootstrap apply ${repo} --only ${onlyTarget} --execute\` (#3818).
29777
29984
 
29778
- \`${onlyTarget}\` is an org-owned seed declared in MMI-Hub's \`skills/bootstrap/seeds/manifest.json\` at MMI-Hub@${seedSource.sha}; this PR brings this repo's copy to the Hub's. It carries that file and nothing else \u2014 no labels, ruleset, merge settings or registry META were touched.
29985
+ ${onlyManagedBlock ? `Only the marker-bounded Hub-managed block inside repo-owned \`${onlyTarget}\`` : `The org-owned \`${onlyTarget}\` seed`} is declared by MMI-Hub's \`skills/bootstrap/seeds/manifest.json\` at MMI-Hub@${seedSource.sha}; this PR reconciles that declared scope and preserves everything outside it. It carries that file and nothing else \u2014 no labels, ruleset, merge settings or registry META were touched.
29779
29986
 
29780
29987
  \`${baseBranch}\` is protected (${seedPlan.reason}), so delivery goes via this branch + PR \u2014 a direct contents PUT 409s on a protected base.` : `Auto-opened by \`mmi-cli devops bootstrap apply --execute ${repo}\` (#2286): \`${baseBranch}\` is protected (${seedPlan.reason}), so the org-managed seed files are delivered via this branch + PR \u2014 a direct contents PUT 409s on a protected base ("N of N required status checks are expected"). Seeds are from MMI-Hub@${seedSource.sha}.`
29781
29988
  ]);
@@ -29881,23 +30088,24 @@ LIVE apply to ${repo}:
29881
30088
  ${applied.join("\n ")}`);
29882
30089
  }
29883
30090
  });
29884
- bootstrap.command("propagate").description("#4238: re-entrant canary\u2192wave tick \u2014 plan (or, with --execute, open) per-repo PRs fanning an org-owned seed out to the fleet").option("--target <path>", "the manifest target to propagate (an ownership:org + source:self seed, e.g. .github/workflows/agent-pr.yml)").option("--execute", "LIVE tick via gh (master-gated) \u2014 opens/reuses per-repo seed-propagate PRs; dry-run prints the plan only").option("--json", "machine-readable output").action(async () => {
30091
+ bootstrap.command("propagate").description("#4238: re-entrant canary\u2192wave tick \u2014 plan (or, with --execute, open) per-repo PRs fanning an org-owned seed out to the fleet").option("--target <path>", "the manifest target to propagate (an org-owned whole file or declared Hub-managed block)").option("--execute", "LIVE tick via gh (master-gated) \u2014 opens/reuses per-repo seed-propagate PRs; dry-run prints the plan only").option("--json", "machine-readable output").action(async () => {
29885
30092
  const o = { target: rawValue("--target", ""), execute: rawFlag("--execute"), json: rawFlag("--json") };
29886
30093
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
29887
30094
  if (!(0, import_node_fs38.existsSync)(manifestPath)) return fail(`bootstrap propagate: ${manifestPath} not found; run from the MMI-Hub repo root \u2014 the Hub's copies ARE the desired state this tick propagates`);
29888
30095
  const seedSource = await resolveHubSeedSource(execGitForSeedSource);
29889
30096
  if (!seedSource.ok) return fail(`bootstrap propagate: ${seedSource.reason}`);
29890
30097
  const manifest = loadBootstrapSeeds((0, import_node_fs38.readFileSync)(manifestPath, "utf8"));
29891
- const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
30098
+ const propagatable = manifest.seeds.filter(isPropagatableSeed);
29892
30099
  if (!o.target) {
29893
30100
  return fail(`bootstrap propagate: --target <path> is required \u2014 one of:
29894
30101
  ${propagatable.map((s) => s.target).join("\n ")}`);
29895
30102
  }
29896
30103
  const seed = propagatable.find((s) => s.target === o.target);
29897
- if (!seed) return fail(`bootstrap propagate: --target '${o.target}' names no ownership:org + source:self seed in ${manifestPath}. Propagatable targets:
30104
+ if (!seed) return fail(`bootstrap propagate: --target '${o.target}' names no centrally propagatable seed in ${manifestPath}. Propagatable targets:
29898
30105
  ${propagatable.map((s) => s.target).join("\n ")}`);
29899
- if (!(0, import_node_fs38.existsSync)(seed.target)) return fail(`bootstrap propagate: the Hub's own copy of '${seed.target}' is missing \u2014 nothing to propagate`);
29900
- const hubContent = (0, import_node_fs38.readFileSync)(seed.target, "utf8");
30106
+ if (!seed.managedBlock && !(0, import_node_fs38.existsSync)(seed.target)) return fail(`bootstrap propagate: the Hub's own copy of '${seed.target}' is missing \u2014 nothing to propagate`);
30107
+ const hubContent = seed.managedBlock ? null : (0, import_node_fs38.readFileSync)(seed.target, "utf8");
30108
+ const readSeedFile = (path2) => (0, import_node_fs38.existsSync)(path2) ? (0, import_node_fs38.readFileSync)(path2, "utf8") : null;
29901
30109
  const isWorkflowSeed = seed.target.startsWith(".github/workflows/");
29902
30110
  const cfg = await loadConfig();
29903
30111
  const projects = await fetchProjectsList(registryClientDeps(cfg));
@@ -29953,11 +30161,12 @@ LIVE apply to ${repo}:
29953
30161
  });
29954
30162
  const gh = async (args) => execFileP2("gh", args, { timeout: 2e4 });
29955
30163
  const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
29956
- const branchPrefix = "seed-propagate";
29957
30164
  const reads = [];
30165
+ const desiredByRepo = /* @__PURE__ */ new Map();
29958
30166
  for (const r of repos) {
29959
30167
  if (r.waiver) continue;
29960
- const baseBranch = classOf(r.repo) === "content" ? "main" : "development";
30168
+ const repoClass = classOf(r.repo);
30169
+ const baseBranch = repoClass === "content" ? "main" : "development";
29961
30170
  let content = null;
29962
30171
  try {
29963
30172
  const resp = await gh(["api", `repos/${r.repo}/contents/${enc(seed.target)}?ref=${baseBranch}`]);
@@ -29966,13 +30175,35 @@ LIVE apply to ${repo}:
29966
30175
  } catch {
29967
30176
  content = null;
29968
30177
  }
29969
- const drift = compareSeedBytes(hubContent, content);
30178
+ let desired;
30179
+ if (seed.managedBlock) {
30180
+ const vars = withDerivedRepoVars({}, parseOwnerRepo(r.repo), repoClass);
30181
+ const resolved = resolveSeedWriteContent(seed, vars, readSeedFile, content);
30182
+ if (!resolved.ok) {
30183
+ return fail(`bootstrap propagate: ${r.repo} ${seed.target}: ${resolved.reason} \u2014 refusing to overwrite repo-owned content`);
30184
+ }
30185
+ if (resolved.content == null) {
30186
+ return fail(`bootstrap propagate: cannot resolve the full template or managed block source for '${seed.target}' \u2014 refusing a partial fleet plan`);
30187
+ }
30188
+ desired = resolved.content;
30189
+ } else {
30190
+ desired = hubContent;
30191
+ }
30192
+ desiredByRepo.set(r.repo, desired);
30193
+ const drift = compareSeedBytes(desired, content);
29970
30194
  let pr2;
29971
30195
  try {
29972
- const branch = `${branchPrefix}-${r.slug}`;
29973
- const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit,body", "--limit", "1"]);
29974
- const arr = JSON.parse(listed.stdout || "[]");
29975
- const p = arr[0];
30196
+ const listBranchPrs = async (branch) => {
30197
+ const listed = await gh(["pr", "list", "--repo", r.repo, "--head", branch, "--base", baseBranch, "--state", "all", "--json", "number,url,state,statusCheckRollup,mergeCommit,body,files", "--limit", "100"]);
30198
+ return JSON.parse(listed.stdout || "[]");
30199
+ };
30200
+ const targetSpecific = (await listBranchPrs(propagationBranch(r.repo, seed.target))).map((p2) => ({ ...p2, files: p2.files?.map((f) => f.path) }));
30201
+ let identified = propagationPrCandidatesForTarget(seed.target, targetSpecific, []);
30202
+ if (!identified.length) {
30203
+ const legacy = (await listBranchPrs(legacyPropagationBranch(r.repo))).map((p2) => ({ ...p2, files: p2.files?.map((f) => f.path) }));
30204
+ identified = propagationPrCandidatesForTarget(seed.target, [], legacy);
30205
+ }
30206
+ const p = identified[0];
29976
30207
  if (p) {
29977
30208
  const rollup = p.statusCheckRollup ?? [];
29978
30209
  const checks = rollup.length === 0 ? "none" : rollup.some((c) => c.conclusion === "FAILURE" || c.state === "FAILURE") ? "red" : rollup.every((c) => c.conclusion === "SUCCESS" || c.state === "SUCCESS") ? "success" : "pending";
@@ -30005,9 +30236,8 @@ LIVE apply to ${repo}:
30005
30236
  const headSha = seedSource.sha;
30006
30237
  for (const rec of plan.records) {
30007
30238
  if (rec.action !== "open-pr") continue;
30008
- const repoEntry = repos.find((r) => r.repo === rec.repo);
30009
30239
  const baseBranch = classOf(rec.repo) === "content" ? "main" : "development";
30010
- const branch = `${branchPrefix}-${repoEntry.slug}`;
30240
+ const branch = propagationBranch(rec.repo, seed.target);
30011
30241
  const baseRef = await gh(["api", `repos/${rec.repo}/git/ref/heads/${baseBranch}`, "--jq", ".object.sha"]);
30012
30242
  const baseSha = baseRef.stdout.trim();
30013
30243
  let branchExists = true;
@@ -30025,7 +30255,9 @@ LIVE apply to ${repo}:
30025
30255
  existingSha = void 0;
30026
30256
  }
30027
30257
  const tmp = (0, import_node_path36.join)((0, import_node_os16.tmpdir)(), `mmi-propagate-${process.pid}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.json`);
30028
- (0, import_node_fs38.writeFileSync)(tmp, JSON.stringify(contentPutBody(seed.target, hubContent, branch, existingSha)), "utf8");
30258
+ const desiredContent = desiredByRepo.get(rec.repo);
30259
+ if (desiredContent == null) return fail(`bootstrap propagate: no resolved content for ${rec.repo} ${seed.target} \u2014 refusing to write`);
30260
+ (0, import_node_fs38.writeFileSync)(tmp, JSON.stringify(contentPutBody(seed.target, desiredContent, branch, existingSha)), "utf8");
30029
30261
  try {
30030
30262
  await gh(contentPutInputArgs(rec.repo, seed.target, tmp));
30031
30263
  } finally {
@@ -30034,8 +30266,14 @@ LIVE apply to ${repo}:
30034
30266
  } catch {
30035
30267
  }
30036
30268
  }
30037
- const openPrs = await gh(["pr", "list", "--repo", rec.repo, "--head", branch, "--base", baseBranch, "--state", "open", "--json", "number,url"]);
30038
- const prDecision = decideSeedPrAction(JSON.parse(openPrs.stdout || "[]"));
30269
+ const openPrs = await gh(["pr", "list", "--repo", rec.repo, "--head", branch, "--base", baseBranch, "--state", "open", "--json", "number,url,body,files"]);
30270
+ const listedOpenPrs = JSON.parse(openPrs.stdout || "[]");
30271
+ const identifiedOpenPrs = propagationPrCandidatesForTarget(
30272
+ seed.target,
30273
+ listedOpenPrs.map((p) => ({ ...p, files: p.files?.map((f) => f.path) })),
30274
+ []
30275
+ );
30276
+ const prDecision = decideSeedPrAction(identifiedOpenPrs);
30039
30277
  let prUrl;
30040
30278
  if (prDecision.action === "reuse") {
30041
30279
  prUrl = prDecision.url;
@@ -30050,11 +30288,12 @@ LIVE apply to ${repo}:
30050
30288
  "--head",
30051
30289
  branch,
30052
30290
  "--title",
30053
- `chore: propagate org-owned ${seed.target} from MMI-Hub (wave ${rec.wave})`,
30291
+ `chore: propagate ${seed.managedBlock ? "Hub-managed block in" : "org-owned"} ${seed.target} from MMI-Hub (wave ${rec.wave})`,
30054
30292
  "--body",
30055
30293
  `Auto-opened by \`mmi-cli devops bootstrap propagate --target ${seed.target} --execute\` (#4238), wave ${rec.wave}.
30056
30294
 
30057
- Propagates MMI-Hub@${headSha} 's copy of \`${seed.target}\` to this repo \u2014 the file this PR carries and nothing else.
30295
+ Propagates MMI-Hub@${headSha}'s ${seed.managedBlock ? "declared marker-bounded block inside repo-owned" : "org-owned copy of"} \`${seed.target}\` \u2014 the file this PR carries and nothing else; content outside a managed block remains untouched.
30296
+ ${renderPropagationTargetMarker(seed.target)}
30058
30297
 
30059
30298
  Rollback: \`mmi-cli devops bootstrap rollback ${rec.repo} --target ${seed.target} --execute\` (#4240) reverts this PR's merge commit; never re-run propagate with old bytes.`
30060
30299
  ]);
@@ -30092,18 +30331,18 @@ Rollback: \`mmi-cli devops bootstrap rollback ${rec.repo} --target ${seed.target
30092
30331
  const seedSource = await resolveHubSeedSource(execGitForSeedSource);
30093
30332
  if (!seedSource.ok) return fail(`bootstrap rollback: ${seedSource.reason}`);
30094
30333
  const manifest = loadBootstrapSeeds((0, import_node_fs38.readFileSync)(manifestPath, "utf8"));
30095
- const propagatable = manifest.seeds.filter((s) => s.ownership === "org" && s.source === "self");
30334
+ const propagatable = manifest.seeds.filter(isPropagatableSeed);
30096
30335
  if (!o.target) {
30097
30336
  return fail(`bootstrap rollback: --target <path> is required \u2014 one of:
30098
30337
  ${propagatable.map((s) => s.target).join("\n ")}`);
30099
30338
  }
30100
30339
  const seed = propagatable.find((s) => s.target === o.target);
30101
- if (!seed) return fail(`bootstrap rollback: --target '${o.target}' names no ownership:org + source:self seed in ${manifestPath}. Propagatable (hence rollback-able) targets:
30340
+ if (!seed) return fail(`bootstrap rollback: --target '${o.target}' names no centrally propagatable seed in ${manifestPath}. Propagatable (hence rollback-able) targets:
30102
30341
  ${propagatable.map((s) => s.target).join("\n ")}`);
30103
30342
  const slug = parsedRepo.slug;
30104
30343
  const baseBranch = o.class === "content" ? "main" : "development";
30105
- const branchPrefix = "seed-propagate";
30106
- const propagateBranch = `${branchPrefix}-${slug}`;
30344
+ const targetPropagateBranch = propagationBranch(repo, seed.target);
30345
+ const legacyPropagateBranch = legacyPropagationBranch(repo);
30107
30346
  const gh = async (args) => execFileP2("gh", args, { timeout: 2e4 });
30108
30347
  const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
30109
30348
  let candidates;
@@ -30126,9 +30365,17 @@ Rollback: \`mmi-cli devops bootstrap rollback ${rec.repo} --target ${seed.target
30126
30365
  } else {
30127
30366
  candidates = [];
30128
30367
  try {
30129
- const listed = await gh(["pr", "list", "--repo", repo, "--head", propagateBranch, "--base", baseBranch, "--state", "merged", "--json", "number,url,mergedAt,mergeCommit,files", "--limit", "20"]);
30130
- const arr = JSON.parse(listed.stdout || "[]");
30131
- for (const p of arr) {
30368
+ const listMergedBranchPrs = async (branch) => {
30369
+ const listed = await gh(["pr", "list", "--repo", repo, "--head", branch, "--base", baseBranch, "--state", "merged", "--json", "number,url,mergedAt,mergeCommit,body,files", "--limit", "100"]);
30370
+ return JSON.parse(listed.stdout || "[]");
30371
+ };
30372
+ const targetSpecific = (await listMergedBranchPrs(targetPropagateBranch)).map((p) => ({ ...p, files: p.files?.map((f) => f.path) }));
30373
+ let identified = propagationPrCandidatesForTarget(seed.target, targetSpecific, []);
30374
+ if (!identified.length) {
30375
+ const legacy = (await listMergedBranchPrs(legacyPropagateBranch)).map((p) => ({ ...p, files: p.files?.map((f) => f.path) }));
30376
+ identified = propagationPrCandidatesForTarget(seed.target, [], legacy);
30377
+ }
30378
+ for (const p of identified) {
30132
30379
  if (!p.mergeCommit?.oid) continue;
30133
30380
  candidates.push({
30134
30381
  repo,
@@ -30137,11 +30384,11 @@ Rollback: \`mmi-cli devops bootstrap rollback ${rec.repo} --target ${seed.target
30137
30384
  url: p.url,
30138
30385
  mergeSha: p.mergeCommit.oid,
30139
30386
  mergedAt: p.mergedAt ?? "",
30140
- files: (p.files ?? []).map((f) => f.path)
30387
+ files: p.files ?? []
30141
30388
  });
30142
30389
  }
30143
30390
  } catch (e) {
30144
- return fail(`bootstrap rollback: could not read ${repo}'s merged ${propagateBranch} PR history: ${e.message}`);
30391
+ return fail(`bootstrap rollback: could not read ${repo}'s merged ${targetPropagateBranch} or positively matching legacy PR history: ${e.message}`);
30145
30392
  }
30146
30393
  }
30147
30394
  const plan = planRollback(repo, seed.target, slug, candidates);
@@ -32136,7 +32383,7 @@ var import_node_os18 = require("node:os");
32136
32383
  var import_node_path42 = require("node:path");
32137
32384
 
32138
32385
  // src/worktree-install-cache.ts
32139
- var import_node_crypto8 = require("node:crypto");
32386
+ var import_node_crypto9 = require("node:crypto");
32140
32387
  var import_node_fs42 = require("node:fs");
32141
32388
  var import_node_path41 = require("node:path");
32142
32389
  var CACHE_DIR = "worktree-install-cache";
@@ -32164,7 +32411,7 @@ var realWorktreeInstallCacheFs = {
32164
32411
  }
32165
32412
  };
32166
32413
  function hashLockfileBytes(contents) {
32167
- return (0, import_node_crypto8.createHash)("sha256").update(contents).digest("hex");
32414
+ return (0, import_node_crypto9.createHash)("sha256").update(contents).digest("hex");
32168
32415
  }
32169
32416
  function resolveWorktreeInstallLockfile(packageDir, fs2 = realWorktreeInstallCacheFs) {
32170
32417
  for (const name of LOCKFILE_NAMES) {
@@ -33190,7 +33437,7 @@ ${err.stderr ?? ""}`;
33190
33437
 
33191
33438
  // src/issue-commands.ts
33192
33439
  var import_node_fs44 = require("node:fs");
33193
- var import_node_crypto9 = require("node:crypto");
33440
+ var import_node_crypto10 = require("node:crypto");
33194
33441
  var ghRunner = async (args, timeoutMs) => (await execFileP2("gh", args, { timeout: timeoutMs })).stdout;
33195
33442
  var ReparentConflictError = class extends Error {
33196
33443
  constructor(message, payload) {
@@ -33481,7 +33728,7 @@ function rowIdempotencyKey(batchKey, spec) {
33481
33728
  const identity = `${spec.type}
33482
33729
  ${spec.title.trim()}
33483
33730
  ${spec.body ?? ""}`;
33484
- const hash = (0, import_node_crypto9.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
33731
+ const hash = (0, import_node_crypto10.createHash)("sha256").update(identity).digest("hex").slice(0, 16);
33485
33732
  return `${batchKey}:${hash}`;
33486
33733
  }
33487
33734
  var BATCH_SPEC_KEYS = /* @__PURE__ */ new Set(["type", "title", "body", "priority", "labels", "label", "parent", "repo", "surface"]);
@@ -34160,7 +34407,7 @@ function onboardPluginGate(deps) {
34160
34407
  declared,
34161
34408
  settingsDeclared: readSettingsAutoUpdate(deps.readSettings(), MMI_MARKETPLACE_NAME)
34162
34409
  }).effective;
34163
- return autoUpdate ? { ok: false, detail: "background auto-update is ON \u2014 duplicate writer; run `mmi-updater reconcile` outside Claude Code to disable it" } : { ok: true, detail: "background auto-update off; mmi-updater is the single release-gated writer" };
34410
+ return autoUpdate ? { ok: false, detail: "background auto-update is ON \u2014 duplicate writer; run `mmi-hub update` outside Claude Code to disable it" } : { ok: true, detail: "background auto-update off; mmi-hub is the single release-gated writer" };
34164
34411
  }
34165
34412
  async function collectOnboardStatus(opts = {}) {
34166
34413
  const cfg = await loadConfig();
@@ -36460,13 +36707,13 @@ var surfaces_default = {
36460
36707
  publishVisibility: "public"
36461
36708
  },
36462
36709
  {
36463
- id: "mmi-updater",
36710
+ id: "mmi-hub",
36464
36711
  classification: "capability",
36465
36712
  kind: "cli",
36466
36713
  ownerPath: "updater/package.json",
36467
36714
  deliveryPath: "updater/package.json",
36468
36715
  delivery: "npm",
36469
- applicability: "editor-agnostic agent surfaces",
36716
+ applicability: "editor-agnostic MMI installation and maintenance; npm package @mutmutco/hub exposing mmi-hub",
36470
36717
  versionCoordinated: true,
36471
36718
  versionPaths: [
36472
36719
  {
@@ -36483,6 +36730,34 @@ var surfaces_default = {
36483
36730
  },
36484
36731
  publishVisibility: "public"
36485
36732
  },
36733
+ {
36734
+ id: "mmi-updater-compat",
36735
+ classification: "packaging",
36736
+ kind: "cli",
36737
+ ownerPath: "packages/updater-compat/package.json",
36738
+ deliveryPath: "packages/updater-compat/package.json",
36739
+ delivery: "npm",
36740
+ applicability: "one-window deprecated @mutmutco/updater / mmi-updater wrapper delegating to the exact coordinated @mutmutco/hub version",
36741
+ versionCoordinated: true,
36742
+ versionPaths: [
36743
+ {
36744
+ path: "packages/updater-compat/package.json",
36745
+ pointer: "version"
36746
+ },
36747
+ {
36748
+ path: "packages/updater-compat/package.json",
36749
+ pointer: "dependencies.@mutmutco/hub"
36750
+ }
36751
+ ],
36752
+ additionalPaths: [
36753
+ "packages/updater-compat/dist"
36754
+ ],
36755
+ artifactIdentity: {
36756
+ kind: "npm-pack",
36757
+ packagePath: "packages/updater-compat"
36758
+ },
36759
+ publishVisibility: "public"
36760
+ },
36486
36761
  {
36487
36762
  id: "mmi-cli-lock",
36488
36763
  classification: "packaging",
@@ -36944,7 +37219,7 @@ function checkCliVersion(input, releasedNote) {
36944
37219
  warn: true,
36945
37220
  label: "mmi-cli",
36946
37221
  detail: `${report.currentVersion} \u2014 freshness UNKNOWN, the published version could not be read`,
36947
- fix: "check it directly: `mmi-cli --version` against `npm view @mutmutco/cli version`; update with `npm install -g @mutmutco/cli@<released>`",
37222
+ fix: "check actual maintenance state with `mmi-hub status`; converge with `mmi-hub update`",
36948
37223
  verbose: evidence
36949
37224
  };
36950
37225
  }
@@ -36954,7 +37229,7 @@ function checkCliVersion(input, releasedNote) {
36954
37229
  ok: false,
36955
37230
  label: "mmi-cli",
36956
37231
  detail: `${report.currentVersion} \u2192 ${report.releasedVersion}`,
36957
- fix: report.fix,
37232
+ fix: "run `mmi-hub update` to converge the CLI and every present host surface",
36958
37233
  verbose: evidence
36959
37234
  };
36960
37235
  }
@@ -36967,8 +37242,8 @@ function checkFleetDrift(probe) {
36967
37242
  id,
36968
37243
  ok: true,
36969
37244
  label,
36970
- detail: "no updater journal \u2014 no reconciler scheduled on this machine",
36971
- verbose: [`journal: ${probe.journalPath} (absent)`, "schedule one with `mmi-updater install` (@mutmutco/updater)"]
37245
+ detail: "no MMI Hub maintenance journal on this machine",
37246
+ verbose: [`journal: ${probe.journalPath} (absent)`, "bootstrap with `npm install -g @mutmutco/hub` then `mmi-hub install`"]
36972
37247
  };
36973
37248
  }
36974
37249
  if (probe.unreadable) {
@@ -36979,14 +37254,14 @@ function checkFleetDrift(probe) {
36979
37254
  warn: true,
36980
37255
  label,
36981
37256
  detail: `journal could not be read \u2014 ${probe.unreadable}`,
36982
- fix: `read ${probe.journalPath} directly, or re-register the reconciler with \`mmi-updater install\``,
37257
+ fix: `read ${probe.journalPath} directly, or repair maintenance with \`mmi-hub install\``,
36983
37258
  verbose: [`journal: ${probe.journalPath}`, `read failed: ${probe.unreadable}`]
36984
37259
  };
36985
37260
  }
36986
37261
  const evidence = [
36987
37262
  `journal: ${probe.journalPath}${probe.lastEventAt ? ` (last event ${probe.lastEventAt})` : ""}${probe.truncated ? ` \u2014 newest ${JOURNAL_TAIL_BYTES / 1024} KiB read` : ""}`,
36988
37263
  `expected: ${probe.expected ?? "(no gated release recorded yet)"}`,
36989
- ...probe.surfaces.map((s) => `${s.surface}: installed ${s.installed ?? "unknown"} \u2014 ${s.verdict}${s.detail ? ` (${s.detail})` : ""}${s.compat ? ` [compat ${s.compat}]` : ""}`),
37264
+ ...probe.surfaces.map((s) => `${s.surface}: last recorded ${s.installed ?? "unknown"} \u2014 ${s.verdict}${s.detail ? ` (${s.detail})` : ""}${s.compat ? ` [compat ${s.compat}]` : ""}`),
36990
37265
  ...probe.absent?.length ? [`absent since their last arm, skipped by the reconciler: ${probe.absent.join(", ")}`] : []
36991
37266
  ];
36992
37267
  if (!probe.expected) {
@@ -37031,7 +37306,7 @@ function checkFleetDrift(probe) {
37031
37306
  warn: true,
37032
37307
  label,
37033
37308
  detail: `${probe.expected} \u2014 ${probe.surfaces.length - unverified.length} converged, ${unverifiedNote}: no installed version recorded`,
37034
- fix: `read why in ${probe.journalPath} (each arm records its own reason) \u2014 \`mmi-updater reconcile\` retries now; doctor never installs`,
37309
+ fix: `read why in ${probe.journalPath} (each arm records its own reason) \u2014 \`mmi-hub update\` retries now; doctor never installs`,
37035
37310
  verbose: evidence
37036
37311
  };
37037
37312
  }
@@ -37042,7 +37317,7 @@ function checkFleetDrift(probe) {
37042
37317
  label,
37043
37318
  ...unverified.length ? { verified: false } : {},
37044
37319
  detail: `${drifted.length} of ${probe.surfaces.length} surface(s) behind ${probe.expected}: ${drifted.map((s) => `${s.surface} ${s.installed}`).join(", ")}${unverifiedNote ? `; ${unverifiedNote}` : ""}`,
37045
- fix: `the scheduled updater converges these on its next tick \u2014 \`mmi-updater reconcile\` runs one now; doctor never installs${unverified.length ? `. The unverified surface(s) recorded no version \u2014 read their reason in ${probe.journalPath}` : ""}`,
37320
+ fix: `hourly Hub maintenance converges these on its next tick \u2014 \`mmi-hub update\` runs one now; doctor never installs${unverified.length ? `. The unverified surface(s) recorded no version \u2014 read their reason in ${probe.journalPath}` : ""}`,
37046
37321
  verbose: evidence
37047
37322
  };
37048
37323
  }
@@ -37335,7 +37610,7 @@ function gcReapable(plan) {
37335
37610
  async function runDoctorClean(opts, io, deps) {
37336
37611
  const full = !opts.fast && !opts.banner && !opts.preflight;
37337
37612
  const applyEnv = full;
37338
- const applyRepo = full && opts.repoWrites !== false;
37613
+ const applyRepo = full && opts.repoWrites === true;
37339
37614
  const lane = {
37340
37615
  banner: Boolean(opts.banner),
37341
37616
  fast: Boolean(opts.fast),
@@ -37450,7 +37725,7 @@ async function runDoctorClean(opts, io, deps) {
37450
37725
  ...cli,
37451
37726
  ok: false,
37452
37727
  detail: cli.ok ? `missing commands: ${missing.join(", ")}` : cli.detail,
37453
- fix: `run \`${cliUpdateCommand(released)}\` \u2014 the CLI lacks ${missing.join(", ")} (or wait for the scheduled updater's next tick)`,
37728
+ fix: `run \`mmi-hub update\` \u2014 the CLI lacks ${missing.join(", ")} (or wait for hourly Hub maintenance)`,
37454
37729
  verbose: [...cli.verbose ?? [], `missing commands: ${missing.join(", ")}`]
37455
37730
  });
37456
37731
  }
@@ -37483,7 +37758,7 @@ async function runDoctorClean(opts, io, deps) {
37483
37758
  emitNow({ ok: true, id: "gitignore-block", label: "gitignore block", detail: "rewrote managed block", verbose: giEvidence });
37484
37759
  restartPending = true;
37485
37760
  } else {
37486
- emitNow({ ok: false, id: "gitignore-block", label: "gitignore block", fix: "run `mmi-cli doctor` (without --no-repo-writes) to write the org-managed .gitignore block", verbose: giEvidence });
37761
+ emitNow({ ok: false, id: "gitignore-block", label: "gitignore block", fix: "run `mmi-cli doctor --apply` to write the org-managed .gitignore block", verbose: giEvidence });
37487
37762
  }
37488
37763
  }
37489
37764
  async function runPluginCacheRow() {
@@ -37906,7 +38181,7 @@ async function runDoctorClean(opts, io, deps) {
37906
38181
  // #3574: `${n} stale` was prepended to `fix` because the ✗ branch would not render `detail` —
37907
38182
  // the same fact this row already puts in `detail` on its ✓ path four lines up. One convention now.
37908
38183
  detail: `${n} stale`,
37909
- fix: "run `mmi-cli doctor` (without --no-repo-writes) to reap merged branches, stale refs, and dead worktrees",
38184
+ fix: "run `mmi-cli doctor --apply` to reap merged branches, stale refs, and dead worktrees",
37910
38185
  verbose: gcEvidence
37911
38186
  });
37912
38187
  }
@@ -37924,7 +38199,7 @@ async function runDoctorClean(opts, io, deps) {
37924
38199
  restartPending = true;
37925
38200
  }
37926
38201
  } else {
37927
- emitNow(scratch.plan.safeAuto.length === 0 ? { id: "scratch", ok: true, label: "scratch", verbose: scratchEvidence } : { id: "scratch", ok: false, label: "scratch", detail: `${scratch.plan.safeAuto.length} aged item(s)`, fix: "run `mmi-cli doctor` (without --no-repo-writes)", verbose: scratchEvidence });
38202
+ emitNow(scratch.plan.safeAuto.length === 0 ? { id: "scratch", ok: true, label: "scratch", verbose: scratchEvidence } : { id: "scratch", ok: false, label: "scratch", detail: `${scratch.plan.safeAuto.length} aged item(s)`, fix: "run `mmi-cli doctor --apply`", verbose: scratchEvidence });
37928
38203
  }
37929
38204
  }
37930
38205
  const prefix = [
@@ -38386,9 +38661,8 @@ function mmiDoctorDeps(opts = {}) {
38386
38661
  pluginTrustState: () => codexHookTrustState(),
38387
38662
  releasedVersion: throttled ? throttled.read : fetchNpmReleasedVersion,
38388
38663
  releasedVersionNote: throttled ? throttled.note : void 0,
38389
- // #4954: the fleet reconciler's journal, read. `@mutmutco/updater` owns every npm install now, so
38390
- // doctor's own CLI self-update (#3272) is gone and this read is what took its place — the same
38391
- // journal `mmi-updater stamp` reports from, parsed here into one row.
38664
+ // #4954/#5023: the Hub maintenance journal is read-only last-run evidence. `@mutmutco/hub`
38665
+ // owns every version install; `mmi-hub status` separately probes actual installed state.
38392
38666
  fleetDrift: () => readFleetDrift(process.env),
38393
38667
  // #3282: the --apply self-heal for a stale/unresolved Claude plugin — the same marketplace reinstall
38394
38668
  // `mmi-cli plugin heal` drives. Takes effect on the next Claude reload, so the row asks for a restart.
@@ -42001,14 +42275,14 @@ access.command("audit").description("audit collaborator roles + train-branch pus
42001
42275
  });
42002
42276
  access.command("capabilities").description("enumerate your effective vault reach \u2014 every credential NAME + tier + scope you can read/use across project + org/master tiers (names only, no values) (#1615)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action((o) => withSecrets((d) => secretsCapabilities(d, o)));
42003
42277
  var isWin2 = process.platform === "win32";
42004
- program2.command("doctor").description("heal plugin wiring and clean up repo cruft \u2014 repairs run by default (#3975); CLI/plugin updates belong to `mmi-updater` and are only REPORTED here (#4954); use --verbose for the full audit checklist").option("--banner", "one-line resume summary; silent when all gates pass").option("--fast", "offline-safe fast lane; read-only, skips slow checks and network probes").option("--preflight", "read-only fast gate for automation: measures and reports with the shared exit code, zero writes (#4199, canon); heal env drift with a plain run or --no-repo-writes").option("--verbose", "print the evidence behind every check \u2014 probes, resolved paths, versions compared, and the names behind each count (#2977)").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output (an output format \u2014 repairs still run by lane)").option("--apply", "deprecated no-op: repairs run by default now (#3975); kept so older instructions still parse").option("--no-repo-writes", "env/plugin repairs only \u2014 never mutate the repo working tree; report pending managed .gitignore repairs with the follow-up command (for train preflights)").option("--self", "verify CLI/plugin version parity and gh auth reach; suggests plugin-heal on a hard gap (reads the published version, so not offline-safe) (#2689)").addHelpText("after", "\nExit codes:\n 0 no hard gaps remain; advisory gaps may exist\n 1 one or more included hard checks failed\n\nA plain run heals the plugin install and cleans repo cruft (gitignore block, merged branches, dead\nworktrees, aged scratch) automatically (#3975). CLI and plugin UPDATES belong to the scheduled\n`mmi-updater` (#4954); doctor reports the lag and the fleet-drift row and never installs.\n--no-repo-writes keeps the working tree untouched for train preflights; --banner/--fast/--self are\nread-only lanes.\n\n--banner keeps the legacy SessionStart contract and returns 0 unless the process crashes.\n").action(async (opts) => {
42278
+ program2.command("doctor").description("safely heal active plugin wiring and report hygiene; repository cleanup requires explicit --apply (#5023), while version convergence belongs to `mmi-hub update`").option("--banner", "one-line resume summary; silent when all gates pass").option("--fast", "offline-safe fast lane; read-only, skips slow checks and network probes").option("--preflight", "read-only fast gate for automation: measures and reports with the shared exit code, zero writes").option("--verbose", "print the evidence behind every check \u2014 probes, resolved paths, versions compared, and the names behind each count (#2977)").option("--guide", "print the canonical MMI Agentic Onboarding URL").option("--json", "machine-readable output (an output format \u2014 repairs still run by lane)").option("--apply", "advanced explicit lane: also apply guarded repository cleanup (gitignore, docs index, board mechanics, merged branches, dead worktrees, aged scratch)").option("--no-repo-writes", "compatibility spelling for the safe default: env/plugin repairs only, never mutate the repository").option("--self", "verify CLI/plugin version parity and gh auth reach; suggests plugin-heal on a hard gap (reads the published version, so not offline-safe) (#2689)").addHelpText("after", "\nExit codes:\n 0 no hard gaps remain; advisory gaps may exist\n 1 one or more included hard checks failed\n\nA plain run may heal safe machine-global plugin wiring but never mutates the repository. Run\n`mmi-cli doctor --apply` only when you explicitly want the guarded full repository cleanup lane.\nCLI and host version convergence belongs to `mmi-hub update`; doctor reports lag and last-run evidence.\n--banner/--fast/--self/--preflight are read-only lanes.\n").action(async (opts) => {
42005
42279
  if (opts.guide) {
42006
- consoleIo.log("MMI Agentic Onboarding: docs/Architecture/agentic-dev-environment.md");
42280
+ consoleIo.log(`MMI Agentic Onboarding: ${CANONICAL_ONBOARDING_URL}`);
42007
42281
  return;
42008
42282
  }
42009
42283
  process.exitCode = await runDoctorClean(
42010
42284
  {
42011
- repoWrites: opts.repoWrites,
42285
+ repoWrites: opts.apply === true && opts.repoWrites !== false,
42012
42286
  banner: opts.banner,
42013
42287
  preflight: opts.preflight,
42014
42288
  fast: opts.fast || opts.self,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "3.136.0",
3
+ "version": "3.137.0",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",