@mutmutco/cli 2.12.0 → 2.13.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 (3) hide show
  1. package/README.md +9 -3
  2. package/dist/index.cjs +790 -139
  3. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -3393,8 +3393,8 @@ var program = new Command();
3393
3393
 
3394
3394
  // src/index.ts
3395
3395
  var import_promises2 = require("node:fs/promises");
3396
- var import_node_fs5 = require("node:fs");
3397
- var import_node_crypto2 = require("node:crypto");
3396
+ var import_node_fs6 = require("node:fs");
3397
+ var import_node_crypto3 = require("node:crypto");
3398
3398
 
3399
3399
  // src/rules-sync.ts
3400
3400
  function normalizeEol(s) {
@@ -3501,8 +3501,8 @@ function parseHookInput(stdin) {
3501
3501
  // src/index.ts
3502
3502
  var import_node_child_process6 = require("node:child_process");
3503
3503
  var import_node_util6 = require("node:util");
3504
- var import_node_path6 = require("node:path");
3505
- var import_node_os2 = require("node:os");
3504
+ var import_node_path7 = require("node:path");
3505
+ var import_node_os3 = require("node:os");
3506
3506
 
3507
3507
  // src/saga-head-maintainer.ts
3508
3508
  var import_node_child_process2 = require("node:child_process");
@@ -3827,10 +3827,10 @@ function defaultHubUrl() {
3827
3827
  function buildHealth(i) {
3828
3828
  const problems = [];
3829
3829
  if (!i.sagaApiUrl) problems.push("Hub API URL not configured");
3830
- if (!i.identity) problems.push("no GitHub identity (gh auth token / GH_TOKEN)");
3830
+ if (!i.identity) problems.push("no Hub session identity (run `gh auth login`, then retry)");
3831
3831
  if (!i.reachable) problems.push("saga backend unreachable");
3832
3832
  if (i.reachable && i.livenessStatus === 403 && i.livenessMessage === "Forbidden") {
3833
- problems.push("saga API route-level 403 from GitHubAuthorizer cache/policy");
3833
+ problems.push("saga API route-level 403 from HubSessionAuthorizer/session policy");
3834
3834
  }
3835
3835
  if (i.reachable && i.authorized === false) problems.push("saga backend rejected authenticated state access");
3836
3836
  if (!i.key.sessionId || i.key.sessionId === "-") problems.push("unsafe session id");
@@ -3887,7 +3887,7 @@ async function fetchWithRetry(fetchImpl, url, init, opts = {}) {
3887
3887
 
3888
3888
  // src/saga-note.ts
3889
3889
  var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
3890
- var ROUTE_LEVEL_403 = "saga API route-level 403 from GitHubAuthorizer cache/policy";
3890
+ var ROUTE_LEVEL_403 = "saga API route-level 403 from HubSessionAuthorizer/session policy";
3891
3891
  function agentSurface(env = process.env) {
3892
3892
  const surface = env.MMI_AGENT_SURFACE?.trim() || (env.CODEX_THREAD_ID?.trim() && !env.CLAUDE_SESSION_ID?.trim() ? "codex" : "claude");
3893
3893
  if (AGENT_SURFACE_TOKENS.includes(surface)) return surface;
@@ -4064,6 +4064,72 @@ Related work discovered by mmi-cli:
4064
4064
  ${lines.join("\n")}`;
4065
4065
  }
4066
4066
 
4067
+ // src/sub-issue.ts
4068
+ function parseIssueRef(ref) {
4069
+ const trimmed = ref.trim();
4070
+ const url = trimmed.match(/^https:\/\/github\.com\/([^/]+\/[^/]+)\/issues\/(\d+)$/i);
4071
+ if (url) return { repo: url[1], number: Number(url[2]) };
4072
+ const qualified = trimmed.match(/^([^/\s#]+\/[^/\s#]+)#(\d+)$/);
4073
+ if (qualified) return { repo: qualified[1], number: Number(qualified[2]) };
4074
+ const bare = trimmed.match(/^#?(\d+)$/);
4075
+ if (bare) return { number: Number(bare[1]) };
4076
+ throw new Error(`invalid issue reference "${ref}" \u2014 expected #123, 123, owner/repo#123, or an issue URL`);
4077
+ }
4078
+ function buildResolveIdArgs(ref) {
4079
+ const args = ["issue", "view", String(ref.number), "--json", "id", "--jq", ".id"];
4080
+ if (ref.repo) args.push("--repo", ref.repo);
4081
+ return args;
4082
+ }
4083
+ function buildAddSubIssueArgs(parentId, subIssueId) {
4084
+ if (!parentId) throw new Error("addSubIssue: parentId is required");
4085
+ if (!subIssueId) throw new Error("addSubIssue: subIssueId is required");
4086
+ return [
4087
+ "api",
4088
+ "graphql",
4089
+ "-f",
4090
+ "query=mutation($p:ID!,$c:ID!){addSubIssue(input:{issueId:$p,subIssueId:$c}){issue{number subIssues{totalCount}} subIssue{number}}}",
4091
+ "-f",
4092
+ `p=${parentId}`,
4093
+ "-f",
4094
+ `c=${subIssueId}`
4095
+ ];
4096
+ }
4097
+ function parseAddSubIssueResult(stdout) {
4098
+ try {
4099
+ const issue2 = JSON.parse(stdout)?.data?.addSubIssue;
4100
+ const parentNumber = issue2?.issue?.number;
4101
+ const subIssueNumber = issue2?.subIssue?.number;
4102
+ const totalCount = issue2?.issue?.subIssues?.totalCount;
4103
+ if (typeof parentNumber !== "number" || typeof subIssueNumber !== "number") return void 0;
4104
+ return { parentNumber, subIssueNumber, totalCount: typeof totalCount === "number" ? totalCount : 0 };
4105
+ } catch {
4106
+ return void 0;
4107
+ }
4108
+ }
4109
+ var RESOLVE_ID_TIMEOUT_MS = 1e4;
4110
+ async function resolveIssueNodeId(runGh, ref, fallbackRepo) {
4111
+ const resolved = ref.repo ? ref : { ...ref, repo: fallbackRepo };
4112
+ const id = (await runGh(buildResolveIdArgs(resolved), RESOLVE_ID_TIMEOUT_MS)).trim();
4113
+ if (!id) throw new Error(`could not resolve node id for issue #${ref.number}${resolved.repo ? ` in ${resolved.repo}` : ""}`);
4114
+ return id;
4115
+ }
4116
+ async function linkSubIssue(runGh, parentRef, childRef, defaultRepo) {
4117
+ const parent = parseIssueRef(parentRef);
4118
+ const child = parseIssueRef(childRef);
4119
+ const parentId = await resolveIssueNodeId(runGh, parent, defaultRepo);
4120
+ const subIssueId = await resolveIssueNodeId(runGh, child, defaultRepo);
4121
+ const stdout = await runGh(buildAddSubIssueArgs(parentId, subIssueId), GH_MUTATION_TIMEOUT_MS);
4122
+ const result = parseAddSubIssueResult(stdout);
4123
+ if (!result) throw new Error(`addSubIssue returned an unexpected response:
4124
+ ${stdout.trim() || "(empty)"}`);
4125
+ return result;
4126
+ }
4127
+ function parentLinkFields(result, error) {
4128
+ if (result) return { parent: result };
4129
+ if (error) return { parentLinkError: error };
4130
+ return {};
4131
+ }
4132
+
4067
4133
  // src/report.ts
4068
4134
  var HUB_REPO = "mutmutco/MMI-Hub";
4069
4135
  var REPORT_LABEL = "report";
@@ -4997,6 +5063,99 @@ function parseWorktreePorcelain(stdout) {
4997
5063
  function samePath(a, b) {
4998
5064
  return a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
4999
5065
  }
5066
+ function normPath(p) {
5067
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
5068
+ }
5069
+ function parseComposeLs(stdout) {
5070
+ const text = stdout.trim();
5071
+ if (!text) return [];
5072
+ const rows = [];
5073
+ const pushIfObject = (v) => {
5074
+ if (v && typeof v === "object" && !Array.isArray(v)) rows.push(v);
5075
+ };
5076
+ try {
5077
+ const parsed = JSON.parse(text);
5078
+ if (Array.isArray(parsed)) parsed.forEach(pushIfObject);
5079
+ else pushIfObject(parsed);
5080
+ } catch {
5081
+ for (const line of text.split(/\r?\n/)) {
5082
+ const trimmed = line.trim();
5083
+ if (!trimmed) continue;
5084
+ try {
5085
+ pushIfObject(JSON.parse(trimmed));
5086
+ } catch {
5087
+ }
5088
+ }
5089
+ }
5090
+ return rows.map((row) => {
5091
+ const name = typeof row.Name === "string" ? row.Name.trim() : "";
5092
+ if (!name) return null;
5093
+ const raw = typeof row.ConfigFiles === "string" ? row.ConfigFiles : "";
5094
+ const configFiles = raw.split(",").map((f) => f.trim()).filter(Boolean);
5095
+ return { name, configFiles };
5096
+ }).filter((p) => Boolean(p));
5097
+ }
5098
+ function selectWorktreeComposeProjects(worktreePath, projects) {
5099
+ const root = normPath(worktreePath);
5100
+ if (!root) return [];
5101
+ const names = [];
5102
+ for (const project2 of projects) {
5103
+ const inside = project2.configFiles.some((file) => {
5104
+ const f = normPath(file);
5105
+ return f === root || f.startsWith(`${root}/`);
5106
+ });
5107
+ if (inside && !names.includes(project2.name)) names.push(project2.name);
5108
+ }
5109
+ return names;
5110
+ }
5111
+ function deriveComposeProjectName(worktreePath) {
5112
+ const norm = normPath(worktreePath);
5113
+ if (!norm) return void 0;
5114
+ const base = norm.slice(norm.lastIndexOf("/") + 1);
5115
+ const name = base.replace(/[^a-z0-9_-]+/g, "_").replace(/^[^a-z0-9]+/, "");
5116
+ return name || void 0;
5117
+ }
5118
+ function planWorktreeComposeTeardown(worktreePath, discovered, hasStageState) {
5119
+ const names = [...discovered];
5120
+ if (!hasStageState && discovered.length === 0) return names;
5121
+ const recovery = deriveComposeProjectName(worktreePath);
5122
+ if (recovery && !names.includes(recovery)) names.push(recovery);
5123
+ return names;
5124
+ }
5125
+ async function runWorktreeStageTeardown(worktreePath, deps) {
5126
+ const hasStageState = deps.hasStageState(worktreePath);
5127
+ const stoppedPid = hasStageState ? await deps.stopRecordedStage(worktreePath).catch(() => void 0) : void 0;
5128
+ let discovered;
5129
+ try {
5130
+ discovered = selectWorktreeComposeProjects(worktreePath, await deps.listComposeProjects());
5131
+ } catch {
5132
+ return { status: stoppedPid != null ? "torn-down" : "none", stoppedPid };
5133
+ }
5134
+ const projects = planWorktreeComposeTeardown(worktreePath, discovered, hasStageState);
5135
+ const brought = [];
5136
+ const failed = [];
5137
+ const errors = [];
5138
+ for (const project2 of projects) {
5139
+ try {
5140
+ await deps.composeDown(project2);
5141
+ brought.push(project2);
5142
+ } catch (e) {
5143
+ failed.push(project2);
5144
+ errors.push(`docker compose -p ${project2} down -v: ${errorMessage(e)}`);
5145
+ }
5146
+ }
5147
+ if (failed.length) {
5148
+ return {
5149
+ status: "failed",
5150
+ stoppedPid,
5151
+ composeProjects: brought.length ? brought : void 0,
5152
+ failedProjects: failed,
5153
+ error: errors.join("; ")
5154
+ };
5155
+ }
5156
+ if (stoppedPid == null && brought.length === 0) return { status: "none" };
5157
+ return { status: "torn-down", stoppedPid, composeProjects: brought.length ? brought : void 0 };
5158
+ }
5000
5159
  function selectPrMergeCleanupWorktree(branch, before, after, startingPath) {
5001
5160
  if (!branch) return void 0;
5002
5161
  const current = after.find((w) => w.branch === branch)?.path;
@@ -5042,15 +5201,24 @@ async function cleanupPrMergeLocalBranch(branch, options) {
5042
5201
  const safeCwd = selectSafeWorktreeCwd([...afterWorktrees, ...beforeWorktrees], wtPath);
5043
5202
  const git = (args) => safeCwd ? options.execGit(["-C", safeCwd, ...args]) : options.execGit(args);
5044
5203
  if (wtPath) {
5204
+ let stageTeardown;
5205
+ if (options.teardownWorktreeStage) {
5206
+ try {
5207
+ stageTeardown = await options.teardownWorktreeStage(wtPath);
5208
+ } catch (e) {
5209
+ stageTeardown = { status: "failed", error: errorMessage(e) };
5210
+ }
5211
+ }
5045
5212
  try {
5046
5213
  await git(["worktree", "remove", "--force", wtPath]);
5047
- report.worktree = { path: wtPath, status: "removed" };
5214
+ report.worktree = { path: wtPath, status: "removed", stageTeardown };
5048
5215
  } catch (e) {
5049
5216
  report.worktree = {
5050
5217
  path: wtPath,
5051
5218
  status: "failed",
5052
5219
  error: errorMessage(e),
5053
- safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath)
5220
+ safeCleanupCommand: safeWorktreeRemoveCommand(safeCwd, wtPath),
5221
+ stageTeardown
5054
5222
  };
5055
5223
  report.localBranch = { name: branch, status: "not-attempted", reason: "worktree-removal-failed" };
5056
5224
  return report;
@@ -5100,14 +5268,28 @@ function formatGcPlan(plan2, apply) {
5100
5268
  }
5101
5269
 
5102
5270
  // src/command-plans.ts
5103
- function stagePlan(stage2 = {}) {
5271
+ function stagePlan(stage2 = {}, stops = true) {
5104
5272
  return [
5105
- { label: "force-kill previous local stage", command: "mmi-cli stage stop --apply" },
5273
+ ...stops ? [{ label: "force-kill previous local stage", command: "mmi-cli stage stop --apply" }] : [],
5106
5274
  { label: "run local build", command: stage2.build || "(no stage.build configured)" },
5107
5275
  { label: "start local stage", command: stage2.up || "(no stage.up configured)" },
5108
5276
  { label: "check health", command: stage2.healthUrl ? `curl --fail ${stage2.healthUrl}` : "(no stage.healthUrl configured)" }
5109
5277
  ];
5110
5278
  }
5279
+ function derivedStagePlan(derived, shell2, stops = true) {
5280
+ const { port } = derived;
5281
+ const envOrder = ["MMI_STAGE", "MMI_PORT", "PORT", "COMPOSE_PROFILES"];
5282
+ const resolved = { MMI_STAGE: "development", MMI_PORT: String(port), PORT: String(port), COMPOSE_PROFILES: "local" };
5283
+ const ensureEnv = shell2 === "powershell" ? `if (-not (Test-Path -LiteralPath '.env')) { Copy-Item -LiteralPath '.env.example' -Destination '.env' }` : `[ -f .env ] || cp .env.example .env`;
5284
+ const up = shell2 === "powershell" ? `${envOrder.map((k) => `$env:${k}='${resolved[k]}'`).join("; ")}; docker compose up -d --build` : `${envOrder.map((k) => `${k}=${resolved[k]}`).join(" ")} docker compose up -d --build`;
5285
+ const health = shell2 === "powershell" ? `curl.exe --fail ${derived.url}` : `curl --fail ${derived.url}`;
5286
+ return [
5287
+ ...stops ? [{ label: "force-kill previous local stage", command: "mmi-cli stage stop --apply" }] : [],
5288
+ { label: "bootstrap .env from .env.example", command: ensureEnv },
5289
+ { label: `start local stage on port ${port} (registry portRange)`, command: up },
5290
+ { label: `check health at ${derived.url}`, command: health }
5291
+ ];
5292
+ }
5111
5293
  function stageLivePlan() {
5112
5294
  return [
5113
5295
  { label: "stage-live is not an org command; /stage is local only", command: "mmi-cli stage run --apply" },
@@ -5123,7 +5305,8 @@ function trainPlan(command) {
5123
5305
  { label: "preflight required rc secret names", command: "mmi-cli secrets preflight --stage rc --repo <owner/repo>", gated: true },
5124
5306
  { label: "for Hub distribution changes, verify the release bump is already landed before touching rc", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
5125
5307
  { label: "merge development to rc", gated: true },
5126
- { label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml ...; hub-serverless: no manual dispatch, deploy.yml auto-fires on rc push", gated: true }
5308
+ { label: "trigger the deploy path for this repo model, returning the Hub Actions run id/url (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml auto-fires on rc push", gated: true },
5309
+ { label: "after a failed deploy, retry the existing rc ref (no re-tag/merge)", command: "mmi-cli tenant redeploy <owner/repo> rc --watch", gated: true }
5127
5310
  ];
5128
5311
  }
5129
5312
  if (command === "release") {
@@ -5135,7 +5318,7 @@ function trainPlan(command) {
5135
5318
  { label: "merge rc to main", gated: true },
5136
5319
  { label: "for Hub distribution changes, verify the promoted SHA carries the release bump", command: "node scripts/release-distribution.mjs verify <release-tag> --skip-npm-view", gated: true },
5137
5320
  { label: "tag release and publish GitHub Release", gated: true },
5138
- { label: "trigger the deploy path for this repo model", command: "tenant-container: gh workflow run tenant-deploy.yml ...; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release", gated: true },
5321
+ { label: "trigger the deploy path for this repo model, returning the Hub Actions run id/url (and, with --watch, its outcome)", command: "tenant-container: gh workflow run tenant-deploy.yml ... then gh run list/watch; hub-serverless: no manual dispatch, deploy.yml + publish.yml auto-fire on the release", gated: true },
5139
5322
  { label: "roll development forward", gated: true }
5140
5323
  ];
5141
5324
  }
@@ -5160,6 +5343,114 @@ function bootstrapPlan(repo, repoClass) {
5160
5343
  ];
5161
5344
  }
5162
5345
 
5346
+ // src/stage-default.ts
5347
+ function shellFor(platform = process.platform) {
5348
+ return platform === "win32" ? "powershell" : "bash";
5349
+ }
5350
+ function deriveStageGap(inputs) {
5351
+ const missing = [];
5352
+ if (inputs.deployModel !== "tenant-container") {
5353
+ return `local stage default applies to tenant-container repos only (registry deployModel = ${inputs.deployModel ?? "unset"})`;
5354
+ }
5355
+ if (!inputs.hasCompose) missing.push("docker-compose.yml");
5356
+ if (!inputs.hasEnvExample) missing.push(".env.example");
5357
+ if (!inputs.portRange) missing.push("Hub registry portRange");
5358
+ return missing.length ? `cannot derive a default local stage \u2014 missing: ${missing.join(", ")}` : null;
5359
+ }
5360
+ function deriveStage(inputs) {
5361
+ if (deriveStageGap(inputs) || !inputs.portRange) return null;
5362
+ const port = inputs.portRange[0];
5363
+ const config = {
5364
+ up: "docker compose up -d --build",
5365
+ healthUrl: "http://127.0.0.1:$STAGE_PORT/",
5366
+ // A generic compose app's landing route is unknown (e.g. MM-Website serves /tr/), so "the server
5367
+ // answered" — any HTTP status — is the health signal, not a 2xx on the bare root.
5368
+ healthAnyStatus: true,
5369
+ portRange: inputs.portRange,
5370
+ ensureEnv: { example: ".env.example", target: ".env" },
5371
+ env: {
5372
+ MMI_STAGE: "development",
5373
+ MMI_PORT: "$STAGE_PORT",
5374
+ PORT: "$STAGE_PORT",
5375
+ COMPOSE_PROFILES: "local"
5376
+ }
5377
+ };
5378
+ return { config, port, url: stageUrlForPort(port) };
5379
+ }
5380
+ function stageUrlForPort(port) {
5381
+ return `http://127.0.0.1:${port}/`;
5382
+ }
5383
+ var POSIX_ONLY = [
5384
+ /(^|[^&|])&&([^&]|$)/,
5385
+ // && chaining (cmd.exe uses it too, but PowerShell <7 / the typical agent shell does not)
5386
+ /\|\|/,
5387
+ // || chaining
5388
+ // `VAR=value cmd` LEADING env prefix only — anchored to a command boundary (start-of-string or after a
5389
+ // ; & | separator) so it never fires on a flag value mid-command (`-e DEBUG=true app`, `--env X=y svc`),
5390
+ // which are valid cross-shell. The value excludes separators so it can't span past the command boundary.
5391
+ /(?:^|[;&|])\s*[A-Za-z_][A-Za-z0-9_]*=[^\s;&|]+\s+\S/,
5392
+ /(^|[\s;&|])(cp|rm|mv|ln|cat|touch|export)\s/
5393
+ // bare POSIX file/env builtins
5394
+ ];
5395
+ function looksPosixOnly(command) {
5396
+ if (!command?.trim()) return false;
5397
+ return POSIX_ONLY.some((re) => re.test(command));
5398
+ }
5399
+ function stalePosixFields(config, shell2) {
5400
+ if (shell2 !== "powershell") return [];
5401
+ const fields = [];
5402
+ if (looksPosixOnly(config?.build)) fields.push("build");
5403
+ if (looksPosixOnly(config?.up)) fields.push("up");
5404
+ return fields;
5405
+ }
5406
+ function sanitizeLocalStage(local, stale) {
5407
+ if (!stale.length) return local;
5408
+ const clean2 = { ...local };
5409
+ for (const field of stale) delete clean2[field];
5410
+ return clean2;
5411
+ }
5412
+ function staleNote(staleFields, outcome) {
5413
+ const list = staleFields.join(", ");
5414
+ const plural = staleFields.length > 1 ? "fields" : "field";
5415
+ return `local .mmi stage ${plural} ${list} ${staleFields.length > 1 ? "are" : "is"} POSIX-only and unusable on PowerShell \u2014 ${outcome}`;
5416
+ }
5417
+ function decideStage(inputs) {
5418
+ const { local, shell: shell2, registry: registry2, hasCompose, hasEnvExample } = inputs;
5419
+ const staleFields = stalePosixFields(local, shell2);
5420
+ const stale = staleFields.length > 0;
5421
+ const upStale = staleFields.includes("up");
5422
+ if (local?.up?.trim() && !upStale) {
5423
+ if (!stale) return { source: "local", config: local };
5424
+ return {
5425
+ source: "local",
5426
+ config: sanitizeLocalStage(local, staleFields),
5427
+ staleIgnored: true,
5428
+ staleFields,
5429
+ gap: staleNote(staleFields, `kept the cross-shell parts of the local recipe and ignored ${staleFields.join(", ")}`)
5430
+ };
5431
+ }
5432
+ const deriveInputs = {
5433
+ portRange: registry2.portRange,
5434
+ deployModel: registry2.deployModel,
5435
+ hasCompose,
5436
+ hasEnvExample
5437
+ };
5438
+ const derived = deriveStage(deriveInputs);
5439
+ if (derived) {
5440
+ return {
5441
+ source: "derived",
5442
+ derived,
5443
+ registryError: registry2.error,
5444
+ staleIgnored: stale || void 0,
5445
+ staleFields: stale ? staleFields : void 0
5446
+ };
5447
+ }
5448
+ const registryGap = registry2.error ? `Hub registry read failed (${registry2.error}) \u2014 cannot derive a default local stage` : null;
5449
+ const baseGap = registryGap ?? deriveStageGap(deriveInputs);
5450
+ const gap = stale ? `local .mmi stage recipe is POSIX-only (${staleFields.join(", ")}) and unusable on PowerShell; ${baseGap ?? "no registry-derived default available"}` : baseGap ?? "no stage.up configured and no registry-derived default available";
5451
+ return { source: "none", gap, staleIgnored: stale || void 0, staleFields: stale ? staleFields : void 0, registryError: registry2.error };
5452
+ }
5453
+
5163
5454
  // src/bootstrap-seeds.ts
5164
5455
  var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
5165
5456
  function loadBootstrapSeeds(manifestJson) {
@@ -5204,6 +5495,7 @@ var MANAGED_GITIGNORE_LINES = [
5204
5495
  ".claude/worktrees/",
5205
5496
  ".mmi/.session",
5206
5497
  ".mmi/head-ts/",
5498
+ ".aws-sam/",
5207
5499
  "/*.png"
5208
5500
  ];
5209
5501
  function renderManagedGitignoreBlock() {
@@ -5237,6 +5529,23 @@ ${block}
5237
5529
  }
5238
5530
  return { content: next, changed: src !== next };
5239
5531
  }
5532
+ function diffManagedGitignoreBlock(current) {
5533
+ const lines = (current ?? "").replace(/\r\n/g, "\n").split("\n");
5534
+ const beginAt = lines.findIndex((l) => l === GITIGNORE_MANAGED_BEGIN);
5535
+ const endAt = beginAt === -1 ? -1 : lines.findIndex((l, i) => i > beginAt && l === GITIGNORE_MANAGED_END);
5536
+ const hasMarker = beginAt !== -1 || lines.some((l) => l === GITIGNORE_MANAGED_END);
5537
+ if (!hasMarker) return { added: [], removed: [], seeded: true };
5538
+ const wellOrdered = beginAt !== -1 && endAt !== -1;
5539
+ if (!wellOrdered) return { added: [], removed: [], seeded: false };
5540
+ const isRule = (l) => l.trim() !== "" && !l.trim().startsWith("#");
5541
+ const oldBody = lines.slice(beginAt + 1, endAt).filter(isRule);
5542
+ const newBody = MANAGED_GITIGNORE_LINES.filter(isRule);
5543
+ return {
5544
+ added: newBody.filter((l) => !oldBody.includes(l)),
5545
+ removed: oldBody.filter((l) => !newBody.includes(l)),
5546
+ seeded: false
5547
+ };
5548
+ }
5240
5549
 
5241
5550
  // src/doctor.ts
5242
5551
  var GH_PROJECT_LOGIN_FIX = 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"';
@@ -5267,7 +5576,7 @@ function buildAwsCrossAccountCheck(input) {
5267
5576
  var MMI_PLUGIN_ID = "mmi@mmi";
5268
5577
  var PLUGIN_LABEL = "plugin install record (mmi@mmi for this project)";
5269
5578
  function pluginInstallManualFix(projectPath, surface = "claude-cli") {
5270
- const register = surface === "codex" ? `\`codex plugin add ${MMI_PLUGIN_ID}\`` : surface === "shell" ? `enable the MMI plugin in your client` : surface === "claude-vscode" ? `\`claude plugin enable ${MMI_PLUGIN_ID}\`` : `\`/plugin install ${MMI_PLUGIN_ID}\``;
5579
+ const register = surface === "codex" ? `\`codex plugin add ${MMI_PLUGIN_ID}\`` : surface === "cursor" ? `import the MMI Team Marketplace in Cursor Dashboard \u2192 Settings \u2192 Plugins (or enable the MMI plugin from the marketplace panel)` : surface === "shell" ? `enable the MMI plugin in your client` : surface === "claude-vscode" ? `\`claude plugin enable ${MMI_PLUGIN_ID}\`` : `\`/plugin install ${MMI_PLUGIN_ID}\``;
5271
5580
  return `run ${register} then ${reloadAction(surface)} to register the install record for ${projectPath}`;
5272
5581
  }
5273
5582
  function isMmiPluginEnabled(settings) {
@@ -5322,6 +5631,9 @@ function bestRecord(records) {
5322
5631
  });
5323
5632
  }
5324
5633
  function pluginConfigDriftFix(pluginId, surface = "claude-cli") {
5634
+ if (surface === "cursor") {
5635
+ return `\`${pluginId}\` is registered through Cursor's Team Marketplace \u2014 refresh it in Dashboard \u2192 Settings \u2192 Plugins (Cursor manages its own plugin records; mmi-cli cannot rewrite them), then ${reloadAction(surface)}`;
5636
+ }
5325
5637
  const file = surface === "codex" ? "~/.codex/plugins/installed_plugins.json" : "~/.claude/plugins/installed_plugins.json";
5326
5638
  return `\`${pluginId}\` has duplicate install rows or stale gitCommitSha in ${file} \u2014 run \`mmi-cli doctor\` interactively to collapse them to one user-scope row (a .bak backup is written first), then ${reloadAction(surface)}`;
5327
5639
  }
@@ -5363,7 +5675,8 @@ function buildGitignoreManagedBlockCheck(input) {
5363
5675
  if (!input.isOrgRepo) return base;
5364
5676
  const { content, changed } = upsertManagedGitignoreBlock(input.content);
5365
5677
  if (!changed) return base;
5366
- return { ...base, ok: false, contentToWrite: content };
5678
+ const { added, removed, seeded } = diffManagedGitignoreBlock(input.content);
5679
+ return { ...base, ok: false, contentToWrite: content, added, removed, seeded };
5367
5680
  }
5368
5681
  var MMI_PLUGIN_CACHE_CLEANUP_LABEL = "stale MMI plugin cache dirs (Claude/Codex)";
5369
5682
  var MMI_PLUGIN_CACHE_CLEANUP_FIX = "run `mmi-cli doctor` to quarantine stale MMI-only plugin cache dirs, then reload the affected agent surface";
@@ -5413,6 +5726,9 @@ function detectSurface(env) {
5413
5726
  if (env.MMI_AGENT_SURFACE === "codex" || has("CODEX_HOME") || (env.CLAUDE_PLUGIN_ROOT ?? "").includes(".codex")) {
5414
5727
  return "codex";
5415
5728
  }
5729
+ if (env.MMI_AGENT_SURFACE === "cursor" || has("CURSOR_TRACE_ID") || has("CURSOR_USER") || has("CURSOR_SESSION_ID")) {
5730
+ return "cursor";
5731
+ }
5416
5732
  const isClaude = has("CLAUDECODE") || has("CLAUDE_CODE_ENTRYPOINT") || has("CLAUDE_PLUGIN_ROOT") || env.MMI_AGENT_SURFACE === "claude";
5417
5733
  const isVscode = env.TERM_PROGRAM === "vscode" || has("VSCODE_PID") || has("VSCODE_GIT_ASKPASS_NODE");
5418
5734
  if (isClaude && isVscode) return "claude-vscode";
@@ -5425,6 +5741,8 @@ function reloadAction(surface) {
5425
5741
  return "restart VS Code";
5426
5742
  case "codex":
5427
5743
  return "restart Codex";
5744
+ case "cursor":
5745
+ return "restart Cursor (or refresh the Team Marketplace from Dashboard \u2192 Settings \u2192 Plugins)";
5428
5746
  case "claude-cli":
5429
5747
  case "shell":
5430
5748
  default:
@@ -5439,6 +5757,8 @@ function pluginRecoveryFix(surface) {
5439
5757
  return `${claude} # then ${reloadAction(surface)} to reload MMI commands`;
5440
5758
  case "codex":
5441
5759
  return "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi # then restart Codex";
5760
+ case "cursor":
5761
+ return `in Cursor Dashboard \u2192 Settings \u2192 Plugins, click Update next to the MMI Team Marketplace; then ${reloadAction(surface)} to reload MMI skills + hooks`;
5442
5762
  case "shell":
5443
5763
  default:
5444
5764
  return "npm install -g @mutmutco/cli@latest";
@@ -5482,13 +5802,33 @@ var execFileP3 = (0, import_node_util5.promisify)(import_node_child_process5.exe
5482
5802
  function stageStatePath(cwd = process.cwd()) {
5483
5803
  return (0, import_node_path4.join)(cwd, "tmp", "stage", "state.json");
5484
5804
  }
5805
+ var POSIX_ONLY_VERBS = ["cp", "mv", "rm", "ln", "cat", "touch", "chmod", "export"];
5806
+ function posixOnlyShellProblems(command, field, platform = process.platform) {
5807
+ if (platform !== "win32" || !command?.trim()) return [];
5808
+ const problems = [];
5809
+ if (/(^|&&|\||;)\s*[A-Za-z_][A-Za-z0-9_]*=\S/.test(command)) {
5810
+ problems.push(
5811
+ `stage.${field} uses POSIX inline env assignment (VAR=value command) which fails in cmd.exe on Windows; use 'set VAR=value && command' or a cross-platform launcher`
5812
+ );
5813
+ }
5814
+ for (const verb of POSIX_ONLY_VERBS) {
5815
+ if (new RegExp(`(^|&&|\\||;|\\()\\s*${verb}\\b`).test(command)) {
5816
+ problems.push(
5817
+ `stage.${field} calls POSIX '${verb}' which does not exist in cmd.exe on Windows; use the cmd/PowerShell equivalent or a cross-platform script`
5818
+ );
5819
+ }
5820
+ }
5821
+ return problems;
5822
+ }
5485
5823
  function validateStageConfig(config = {}, action) {
5486
5824
  const problems = [];
5487
- if (action === "run" && !config.build?.trim()) problems.push("stage.build is required for stage run");
5825
+ if (action === "run" && !config.build?.trim() && !config.ensureEnv) problems.push("stage.build is required for stage run");
5488
5826
  if (!config.up?.trim()) problems.push("stage.up is required to start the local stage");
5489
5827
  if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
5490
5828
  problems.push("stage.healthUrl must be an http(s) URL");
5491
5829
  }
5830
+ if (action === "run") problems.push(...posixOnlyShellProblems(config.build, "build"));
5831
+ problems.push(...posixOnlyShellProblems(config.up, "up"));
5492
5832
  if (config.portRange != null) {
5493
5833
  const r = config.portRange;
5494
5834
  const ok = Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n) && n >= 1024 && n <= 65535) && r[0] <= r[1];
@@ -5553,13 +5893,13 @@ async function killTree(pid) {
5553
5893
  }
5554
5894
  }
5555
5895
  }
5556
- async function waitForHealth(url, timeoutMs) {
5896
+ async function waitForHealth(url, timeoutMs, anyStatus = false) {
5557
5897
  const deadline = Date.now() + timeoutMs;
5558
5898
  let last = "";
5559
5899
  while (Date.now() < deadline) {
5560
5900
  try {
5561
5901
  const res = await fetch(url, { signal: AbortSignal.timeout(Math.min(5e3, timeoutMs)) });
5562
- if (res.ok) return;
5902
+ if (anyStatus || res.ok) return;
5563
5903
  last = `HTTP ${res.status}`;
5564
5904
  } catch (e) {
5565
5905
  last = e.message;
@@ -5594,6 +5934,13 @@ async function startStage(config = {}, opts = {}) {
5594
5934
  stagePort = pickStagePort(config.portRange, (p) => free.has(p));
5595
5935
  }
5596
5936
  const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
5937
+ if (config.ensureEnv) {
5938
+ const target = (0, import_node_path4.join)(cwd, config.ensureEnv.target);
5939
+ const example = (0, import_node_path4.join)(cwd, config.ensureEnv.example);
5940
+ if (!(0, import_node_fs3.existsSync)(target) && (0, import_node_fs3.existsSync)(example)) (0, import_node_fs3.copyFileSync)(example, target);
5941
+ }
5942
+ const extraEnv = {};
5943
+ for (const [k, v] of Object.entries(config.env ?? {})) extraEnv[k] = sub(v) ?? v;
5597
5944
  const up = sub(config.up.trim());
5598
5945
  const child = (0, import_node_child_process5.spawn)(up, {
5599
5946
  cwd,
@@ -5601,7 +5948,7 @@ async function startStage(config = {}, opts = {}) {
5601
5948
  detached: true,
5602
5949
  windowsHide: true,
5603
5950
  stdio: "ignore",
5604
- env: stagePort != null ? { ...process.env, STAGE_PORT: String(stagePort) } : process.env
5951
+ env: { ...process.env, ...stagePort != null ? { STAGE_PORT: String(stagePort) } : {}, ...extraEnv }
5605
5952
  });
5606
5953
  const state = {
5607
5954
  pid: child.pid ?? 0,
@@ -5613,13 +5960,13 @@ async function startStage(config = {}, opts = {}) {
5613
5960
  };
5614
5961
  (0, import_node_fs3.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
5615
5962
  try {
5616
- if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4);
5963
+ if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4, config.healthAnyStatus);
5617
5964
  } catch (e) {
5618
5965
  await killTree(state.pid);
5619
5966
  (0, import_node_fs3.rmSync)(statePath, { force: true });
5620
5967
  throw e;
5621
5968
  }
5622
- const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
5969
+ const result = { ok: true, action: "start", statePath, pid: state.pid, port: stagePort, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
5623
5970
  opts.onReady?.(result);
5624
5971
  child.unref();
5625
5972
  return result;
@@ -5630,7 +5977,7 @@ async function runStage(config = {}, opts = {}) {
5630
5977
  const cwd = opts.cwd ?? process.cwd();
5631
5978
  const timeoutMs = opts.timeoutMs ?? 6e4;
5632
5979
  await stopStage({ ...opts, cwd });
5633
- await shell(config.build.trim(), cwd, timeoutMs);
5980
+ if (config.build?.trim()) await shell(config.build.trim(), cwd, timeoutMs);
5634
5981
  const started = await startStage(config, { ...opts, cwd, timeoutMs });
5635
5982
  return { ...started, action: "run", message: `built and ${started.message}` };
5636
5983
  }
@@ -5726,14 +6073,56 @@ async function requireBranch(deps, branch) {
5726
6073
  const current = clean(await deps.run("git", ["rev-parse", "--abbrev-ref", "HEAD"]));
5727
6074
  if (current !== branch) throw new Error(`must run from ${branch}, currently on ${current || "(unknown)"}`);
5728
6075
  }
5729
- async function dispatchDeploy(deps, ctx, stage2, ref, model) {
6076
+ var HUB_REPO2 = "mutmutco/MMI-Hub";
6077
+ var CORRELATE_ATTEMPTS = 5;
6078
+ var CORRELATE_DELAY_MS = 1500;
6079
+ var CORRELATE_SKEW_SLACK_MS = 1e4;
6080
+ async function correlateTenantRun(deps, since) {
6081
+ const sleep = deps.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
6082
+ const threshold = since - CORRELATE_SKEW_SLACK_MS;
6083
+ for (let attempt = 0; attempt < CORRELATE_ATTEMPTS; attempt++) {
6084
+ if (attempt > 0) await sleep(CORRELATE_DELAY_MS);
6085
+ let rows;
6086
+ try {
6087
+ const out = await deps.run("gh", [
6088
+ "run",
6089
+ "list",
6090
+ "--repo",
6091
+ HUB_REPO2,
6092
+ "--workflow",
6093
+ "tenant-deploy.yml",
6094
+ "--limit",
6095
+ "10",
6096
+ "--json",
6097
+ "databaseId,url,event,createdAt"
6098
+ ]);
6099
+ rows = JSON.parse(out);
6100
+ } catch {
6101
+ continue;
6102
+ }
6103
+ const match = rows.filter((r) => r.event === "workflow_dispatch" && typeof r.databaseId === "number").map((r) => ({ row: r, created: Date.parse(r.createdAt ?? "") })).filter((c) => Number.isFinite(c.created) && c.created >= threshold).sort((a, b) => b.created - a.created)[0];
6104
+ if (match) return { runId: match.row.databaseId, runUrl: match.row.url };
6105
+ }
6106
+ return {};
6107
+ }
6108
+ async function watchTenantRun(deps, runId) {
6109
+ if (runId == null) return "pending";
6110
+ try {
6111
+ await deps.run("gh", ["run", "watch", String(runId), "--repo", HUB_REPO2, "--exit-status"]);
6112
+ return "success";
6113
+ } catch {
6114
+ return "failure";
6115
+ }
6116
+ }
6117
+ async function dispatchDeploy(deps, ctx, stage2, ref, model, watch) {
5730
6118
  if (model === "tenant-container") {
6119
+ const since = (deps.now ?? Date.now)();
5731
6120
  await deps.run("gh", [
5732
6121
  "workflow",
5733
6122
  "run",
5734
6123
  "tenant-deploy.yml",
5735
6124
  "--repo",
5736
- "mutmutco/MMI-Hub",
6125
+ HUB_REPO2,
5737
6126
  "-f",
5738
6127
  `slug=${ctx.slug}`,
5739
6128
  "-f",
@@ -5743,12 +6132,17 @@ async function dispatchDeploy(deps, ctx, stage2, ref, model) {
5743
6132
  "-f",
5744
6133
  `stage=${stage2}`
5745
6134
  ]);
5746
- return `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`;
6135
+ const { runId, runUrl } = await correlateTenantRun(deps, since);
6136
+ const deployStatus = watch ? await watchTenantRun(deps, runId) : "pending";
6137
+ return { note: `dispatched tenant-deploy.yml (slug=${ctx.slug}, ref=${ref}, stage=${stage2})`, runId, runUrl, deployStatus };
5747
6138
  }
5748
6139
  if (model === "hub-serverless") {
5749
- return ref === "rc" ? "no manual dispatch: deploy.yml auto-fires on the rc push (rc stage)" : "no manual dispatch: deploy.yml + publish.yml auto-fire on the published Release (prod)";
6140
+ return {
6141
+ note: ref === "rc" ? "no manual dispatch: deploy.yml auto-fires on the rc push (rc stage)" : "no manual dispatch: deploy.yml + publish.yml auto-fire on the published Release (prod)",
6142
+ deployStatus: "pending"
6143
+ };
5750
6144
  }
5751
- return `no manual dispatch: ${model} repo deploys via its own push-triggered workflow`;
6145
+ return { note: `no manual dispatch: ${model} repo deploys via its own push-triggered workflow`, deployStatus: "pending" };
5752
6146
  }
5753
6147
  async function preflight(deps, ctx, stage2) {
5754
6148
  let meta = null;
@@ -5767,7 +6161,8 @@ async function preflight(deps, ctx, stage2) {
5767
6161
  await deps.runSelf(["secrets", "preflight", "--stage", stage2, "--repo", ctx.repo]);
5768
6162
  return model;
5769
6163
  }
5770
- async function runTrainApply(command, deps) {
6164
+ async function runTrainApply(command, deps, options = {}) {
6165
+ const watch = options.watch ?? false;
5771
6166
  const ctx = await buildTrainApplyContext(deps);
5772
6167
  await requireCleanTree(deps);
5773
6168
  await deps.run("git", ["fetch", "origin"]);
@@ -5787,8 +6182,8 @@ async function runTrainApply(command, deps) {
5787
6182
  await deps.run("git", ["tag", tag2]);
5788
6183
  await deps.run("git", ["push", "origin", "rc"]);
5789
6184
  await deps.run("git", ["push", "origin", tag2]);
5790
- const dispatch2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2);
5791
- return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, dispatch: dispatch2 };
6185
+ const d2 = await dispatchDeploy(deps, ctx, "rc", "rc", deployModel2, watch);
6186
+ return { ...ctx, command, stage: "rc", ref: "rc", tag: tag2, deployModel: deployModel2, promoted: true, dispatch: d2.note, runId: d2.runId, runUrl: d2.runUrl, deployStatus: d2.deployStatus };
5792
6187
  }
5793
6188
  await requireBranch(deps, "rc");
5794
6189
  ensurePositiveCount(
@@ -5805,12 +6200,37 @@ async function runTrainApply(command, deps) {
5805
6200
  await deps.run("git", ["push", "origin", "main"]);
5806
6201
  await deps.run("git", ["push", "origin", tag]);
5807
6202
  await deps.run("gh", ["release", "create", tag, "--generate-notes", "--latest", "--repo", ctx.repo]);
5808
- const dispatch = await dispatchDeploy(deps, ctx, "main", "main", deployModel);
6203
+ const d = await dispatchDeploy(deps, ctx, "main", "main", deployModel, watch);
5809
6204
  await deps.run("git", ["checkout", "development"]);
5810
6205
  await deps.run("git", ["pull", "--ff-only", "origin", "development"]);
5811
6206
  await deps.run("git", ["merge", "main", "--no-edit"]);
5812
6207
  await deps.run("git", ["push", "origin", "development"]);
5813
- return { ...ctx, command, stage: "main", ref: "main", tag, deployModel, dispatch };
6208
+ return { ...ctx, command, stage: "main", ref: "main", tag, deployModel, promoted: true, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
6209
+ }
6210
+ async function runTenantRedeploy(deps, options) {
6211
+ const { stage: stage2 } = options;
6212
+ const ref = options.ref ?? stage2;
6213
+ const watch = options.watch ?? false;
6214
+ const repo = options.repo;
6215
+ const [owner, name] = repo.split("/");
6216
+ if (!owner || !name) throw new Error(`repo must be owner/name, got ${repo}`);
6217
+ const login = requireValue(clean(await deps.run("gh", ["api", "user", "--jq", ".login"])), "GitHub login");
6218
+ const verdict = await deps.trainAuthority(repo);
6219
+ if (!verdict.ok) throw new Error(`${commandAuthorityLabel(owner)}: train authority could not be verified (${verdict.error})`);
6220
+ if (!verdict.train) throw new Error(`${commandAuthorityLabel(owner)}: @${login} is ${verdict.role} \u2014 no train authority on ${repo}`);
6221
+ const ctx = { repo, owner, slug: name.toLowerCase(), login };
6222
+ let meta = null;
6223
+ try {
6224
+ meta = JSON.parse(await deps.runSelf(["project", "get", repo]));
6225
+ } catch {
6226
+ meta = null;
6227
+ }
6228
+ const deployModel = resolveDeployModel2(meta, repo);
6229
+ if (deployModel !== "tenant-container") {
6230
+ throw new Error(`${repo} is ${deployModel}, not tenant-container \u2014 there is no central tenant-deploy run to retry (its deploy fires from its own workflow)`);
6231
+ }
6232
+ const d = await dispatchDeploy(deps, ctx, stage2, ref, deployModel, watch);
6233
+ return { ...ctx, command: "tenant-redeploy", stage: stage2, ref, deployModel, dispatch: d.note, runId: d.runId, runUrl: d.runUrl, deployStatus: d.deployStatus };
5814
6234
  }
5815
6235
 
5816
6236
  // src/port-registry.ts
@@ -6212,6 +6632,12 @@ async function rulesetDetails(deps, repo, list) {
6212
6632
  }
6213
6633
  return details;
6214
6634
  }
6635
+ function repoRefsMatch(a, b) {
6636
+ return a.toLowerCase() === b.toLowerCase();
6637
+ }
6638
+ function projectRegistryIncludesRepo(projects, repo) {
6639
+ return projects.some((p) => (p.repos ?? []).some((r) => repoRefsMatch(r, repo)));
6640
+ }
6215
6641
  function localRegistryCheck(deps, path2, predicate) {
6216
6642
  const text = deps.readLocalFile?.(path2);
6217
6643
  if (text == null) return null;
@@ -6361,7 +6787,7 @@ async function verifyBootstrap(repo, repoClass, deps) {
6361
6787
  }
6362
6788
  const fanout = repo === "mutmutco/MMI-Hub" ? true : localRegistryCheck(deps, ".github/fanout-targets.json", (json) => Array.isArray(json?.repos) && json.repos.some((r) => r.repo === repo.split("/")[1] && r.branch === baseBranch));
6363
6789
  if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
6364
- const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
6790
+ const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && projectRegistryIncludesRepo(json.projects, repo));
6365
6791
  if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
6366
6792
  const rulesetList = await restJson2(deps, `repos/${repo}/rulesets?includes_parents=true`, []);
6367
6793
  const rulesets = await rulesetDetails(deps, repo, rulesetList);
@@ -6421,6 +6847,94 @@ function renderBootstrapVerifyReport(report) {
6421
6847
  return lines.join("\n");
6422
6848
  }
6423
6849
 
6850
+ // src/hub-auth.ts
6851
+ var import_node_crypto2 = require("node:crypto");
6852
+ var import_node_fs5 = require("node:fs");
6853
+ var import_node_path5 = require("node:path");
6854
+ var import_node_os2 = require("node:os");
6855
+ var REFRESH_WINDOW_MS = 10 * 60 * 1e3;
6856
+ var EXCHANGE_TIMEOUT_MS = 8e3;
6857
+ var EXCHANGE_ATTEMPTS = 2;
6858
+ function normalizeBaseUrl(baseUrl) {
6859
+ return baseUrl.replace(/\/$/, "");
6860
+ }
6861
+ function tokenFingerprint(token) {
6862
+ return (0, import_node_crypto2.createHash)("sha256").update(token).digest("hex");
6863
+ }
6864
+ function defaultHubSessionCachePath(env = process.env) {
6865
+ if (env.MMI_HUB_SESSION_CACHE) return env.MMI_HUB_SESSION_CACHE;
6866
+ if (process.platform === "win32") {
6867
+ const base2 = env.LOCALAPPDATA || (0, import_node_path5.join)((0, import_node_os2.homedir)(), "AppData", "Local");
6868
+ return (0, import_node_path5.join)(base2, "MMI Future", "mmi-cli", "hub-session.json");
6869
+ }
6870
+ const base = env.XDG_STATE_HOME || (0, import_node_path5.join)((0, import_node_os2.homedir)(), ".mmi");
6871
+ return (0, import_node_path5.join)(base, "mmi-cli", "hub-session.json");
6872
+ }
6873
+ function readCache(path2, apiUrl, now, githubTokenFingerprint) {
6874
+ try {
6875
+ const session = JSON.parse((0, import_node_fs5.readFileSync)(path2, "utf8"));
6876
+ if (!session.token || !session.expiresAt || session.apiUrl !== apiUrl) return null;
6877
+ if (session.githubTokenFingerprint !== githubTokenFingerprint) return null;
6878
+ if (new Date(session.expiresAt).getTime() <= now.getTime() + REFRESH_WINDOW_MS) return null;
6879
+ return session;
6880
+ } catch {
6881
+ return null;
6882
+ }
6883
+ }
6884
+ function writeCache(path2, session) {
6885
+ (0, import_node_fs5.mkdirSync)((0, import_node_path5.dirname)(path2), { recursive: true });
6886
+ const tmp = `${path2}.${process.pid}.${Date.now()}.tmp`;
6887
+ (0, import_node_fs5.writeFileSync)(tmp, JSON.stringify(session, null, 2) + "\n", { encoding: "utf8", mode: 384 });
6888
+ try {
6889
+ (0, import_node_fs5.chmodSync)(tmp, 384);
6890
+ } catch {
6891
+ }
6892
+ (0, import_node_fs5.renameSync)(tmp, path2);
6893
+ try {
6894
+ (0, import_node_fs5.chmodSync)(path2, 384);
6895
+ } catch {
6896
+ }
6897
+ }
6898
+ async function hubAuthSession(deps) {
6899
+ if (!deps.baseUrl) return void 0;
6900
+ const apiUrl = normalizeBaseUrl(deps.baseUrl);
6901
+ const now = deps.now?.() ?? /* @__PURE__ */ new Date();
6902
+ const cachePath = deps.cachePath ?? defaultHubSessionCachePath();
6903
+ const ghToken = await deps.githubToken();
6904
+ if (!ghToken) return void 0;
6905
+ const githubTokenFingerprint = tokenFingerprint(ghToken);
6906
+ const cached = readCache(cachePath, apiUrl, now, githubTokenFingerprint);
6907
+ if (cached) return cached;
6908
+ try {
6909
+ const res = await fetchWithRetry(
6910
+ deps.fetch ?? fetch,
6911
+ `${apiUrl}/auth/session`,
6912
+ { method: "POST", headers: { Authorization: `Bearer ${ghToken}` } },
6913
+ { attempts: EXCHANGE_ATTEMPTS, timeoutMs: EXCHANGE_TIMEOUT_MS }
6914
+ );
6915
+ if (!res.ok) return void 0;
6916
+ const body = await res.json();
6917
+ if (!body.token || !body.expiresAt) return void 0;
6918
+ const session = {
6919
+ token: body.token,
6920
+ expiresAt: body.expiresAt,
6921
+ login: typeof body.login === "string" ? body.login : void 0,
6922
+ apiUrl,
6923
+ githubTokenFingerprint
6924
+ };
6925
+ try {
6926
+ writeCache(cachePath, session);
6927
+ } catch {
6928
+ }
6929
+ return session;
6930
+ } catch {
6931
+ return void 0;
6932
+ }
6933
+ }
6934
+ async function hubAuthToken(deps) {
6935
+ return (await hubAuthSession(deps))?.token;
6936
+ }
6937
+
6424
6938
  // src/bootstrap-apply.ts
6425
6939
  function parseOwnerRepo(repo) {
6426
6940
  const trimmed = repo.trim();
@@ -6560,7 +7074,7 @@ function retriedFetch(deps, url, init) {
6560
7074
  async function fetchTrainAuthority(repo, deps) {
6561
7075
  if (!deps.baseUrl) return { ok: false, error: "Hub API URL not configured" };
6562
7076
  const token = await deps.token();
6563
- if (!token) return { ok: false, error: "no GitHub token (gh auth login)" };
7077
+ if (!token) return { ok: false, error: "no Hub session token (run `gh auth login`)" };
6564
7078
  try {
6565
7079
  const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/train-authority?repo=${encodeURIComponent(repo)}`, {
6566
7080
  method: "GET",
@@ -6599,7 +7113,7 @@ async function fetchProjectBySlugChecked(slug, deps) {
6599
7113
  if (!deps.baseUrl) return { ok: false, error: "no Hub API URL (set MMI_HUB_URL or use a current MMI CLI/plugin build)" };
6600
7114
  if (!slug) return { ok: false, error: "no slug" };
6601
7115
  const token = await deps.token();
6602
- if (!token) return { ok: false, error: "no GitHub token (run `gh auth login`)" };
7116
+ if (!token) return { ok: false, error: "no Hub session token (run `gh auth login`)" };
6603
7117
  try {
6604
7118
  const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}`, {
6605
7119
  method: "GET",
@@ -6651,7 +7165,7 @@ async function fetchOrgConfig(deps) {
6651
7165
  async function postJson(pathSuffix, payload, deps, method = "POST") {
6652
7166
  if (!deps.baseUrl) return { ok: false, status: 0, body: null, error: "no Hub API URL (set MMI_HUB_URL or use a current MMI CLI/plugin build)" };
6653
7167
  const token = await deps.token();
6654
- if (!token) return { ok: false, status: 0, body: null, error: "no GitHub token (run `gh auth login`)" };
7168
+ if (!token) return { ok: false, status: 0, body: null, error: "no Hub session token (run `gh auth login`)" };
6655
7169
  try {
6656
7170
  const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}${pathSuffix}`, {
6657
7171
  method,
@@ -7087,7 +7601,7 @@ function parseKbTree(stdout, prefix) {
7087
7601
  }
7088
7602
 
7089
7603
  // src/plan.ts
7090
- var import_node_path5 = require("node:path");
7604
+ var import_node_path6 = require("node:path");
7091
7605
 
7092
7606
  // src/frontmatter.ts
7093
7607
  function splitFrontmatter(content) {
@@ -7170,8 +7684,8 @@ function rankPlansByRelevance(plans, signals, opts = {}) {
7170
7684
 
7171
7685
  // src/plan.ts
7172
7686
  var PLANS_DIR = "plans";
7173
- var META_FILE = (0, import_node_path5.join)(PLANS_DIR, ".plan-meta.json");
7174
- var planPath = (slug) => (0, import_node_path5.join)(PLANS_DIR, `${slug}.md`);
7687
+ var META_FILE = (0, import_node_path6.join)(PLANS_DIR, ".plan-meta.json");
7688
+ var planPath = (slug) => (0, import_node_path6.join)(PLANS_DIR, `${slug}.md`);
7175
7689
  var metaKey = (project2, slug) => `${project2}/${slug}`;
7176
7690
  function parseMeta(raw) {
7177
7691
  if (!raw) return {};
@@ -7787,8 +8301,9 @@ async function awsCallerArn() {
7787
8301
  return void 0;
7788
8302
  }
7789
8303
  }
7790
- async function sagaHeaders(extra = {}) {
7791
- const t = await githubToken();
8304
+ async function hubHeaders(extra = {}) {
8305
+ const cfg = await loadConfig();
8306
+ const t = await hubAuthToken({ baseUrl: cfg.sagaApiUrl ?? defaultHubUrl(), githubToken });
7792
8307
  return t ? { ...extra, Authorization: `Bearer ${t}` } : extra;
7793
8308
  }
7794
8309
  async function loadConfig() {
@@ -7852,21 +8367,21 @@ function sessionDeps() {
7852
8367
  env: process.env,
7853
8368
  readPersisted: () => {
7854
8369
  try {
7855
- return (0, import_node_fs5.readFileSync)(SESSION_FILE, "utf8");
8370
+ return (0, import_node_fs6.readFileSync)(SESSION_FILE, "utf8");
7856
8371
  } catch {
7857
8372
  return null;
7858
8373
  }
7859
8374
  },
7860
8375
  writePersisted: (id) => persistSession(id),
7861
8376
  now: () => /* @__PURE__ */ new Date(),
7862
- rand: () => (0, import_node_crypto2.randomBytes)(4).toString("hex")
8377
+ rand: () => (0, import_node_crypto3.randomBytes)(4).toString("hex")
7863
8378
  };
7864
8379
  }
7865
8380
  var resolveSessionId = () => resolveSession(sessionDeps());
7866
8381
  function persistSession(id) {
7867
8382
  try {
7868
- (0, import_node_fs5.mkdirSync)(".mmi", { recursive: true });
7869
- (0, import_node_fs5.writeFileSync)(SESSION_FILE, id, "utf8");
8383
+ (0, import_node_fs6.mkdirSync)(".mmi", { recursive: true });
8384
+ (0, import_node_fs6.writeFileSync)(SESSION_FILE, id, "utf8");
7870
8385
  } catch {
7871
8386
  }
7872
8387
  }
@@ -7884,7 +8399,7 @@ async function postCapture(capture, quiet = false) {
7884
8399
  }
7885
8400
  const res = await fetchWithRetry(fetch, `${cfg.sagaApiUrl}/saga/capture`, {
7886
8401
  method: "POST",
7887
- headers: await sagaHeaders({ "content-type": "application/json" }),
8402
+ headers: await hubHeaders({ "content-type": "application/json" }),
7888
8403
  body: JSON.stringify({ ...capture, ...await sagaKey(cfg) })
7889
8404
  }, { attempts: 2, timeoutMs: 2e4, retryOn: () => false });
7890
8405
  let message = "";
@@ -7987,12 +8502,12 @@ async function applyGcPlan(plan2, remote) {
7987
8502
  }
7988
8503
  function resolveVersion() {
7989
8504
  try {
7990
- const manifest = (0, import_node_path6.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
7991
- return JSON.parse((0, import_node_fs5.readFileSync)(manifest, "utf8")).version || "0.0.0";
8505
+ const manifest = (0, import_node_path7.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
8506
+ return JSON.parse((0, import_node_fs6.readFileSync)(manifest, "utf8")).version || "0.0.0";
7992
8507
  } catch {
7993
8508
  try {
7994
- const pkg = (0, import_node_path6.join)(__dirname, "..", "package.json");
7995
- return JSON.parse((0, import_node_fs5.readFileSync)(pkg, "utf8")).version || "0.0.0";
8509
+ const pkg = (0, import_node_path7.join)(__dirname, "..", "package.json");
8510
+ return JSON.parse((0, import_node_fs6.readFileSync)(pkg, "utf8")).version || "0.0.0";
7996
8511
  } catch {
7997
8512
  return "0.0.0";
7998
8513
  }
@@ -8000,7 +8515,7 @@ function resolveVersion() {
8000
8515
  }
8001
8516
  function readRepoVersion() {
8002
8517
  try {
8003
- return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path6.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
8518
+ return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
8004
8519
  } catch {
8005
8520
  return void 0;
8006
8521
  }
@@ -8097,10 +8612,10 @@ async function runRulesSync(opts, io = consoleIo) {
8097
8612
  for (const entry of fetched) {
8098
8613
  if ("error" in entry) continue;
8099
8614
  const { file, source } = entry;
8100
- const current = (0, import_node_fs5.existsSync)(file) ? await (0, import_promises2.readFile)(file, "utf8") : null;
8615
+ const current = (0, import_node_fs6.existsSync)(file) ? await (0, import_promises2.readFile)(file, "utf8") : null;
8101
8616
  if (needsUpdate(source, current)) {
8102
8617
  const slash = file.lastIndexOf("/");
8103
- if (slash > 0) (0, import_node_fs5.mkdirSync)(file.slice(0, slash), { recursive: true });
8618
+ if (slash > 0) (0, import_node_fs6.mkdirSync)(file.slice(0, slash), { recursive: true });
8104
8619
  await (0, import_promises2.writeFile)(file, normalizeEol(source), "utf8");
8105
8620
  changed++;
8106
8621
  if (!opts.quiet) io.log(`mmi-cli rules: updated ${file}`);
@@ -8126,7 +8641,7 @@ async function runDocsSync(opts, io = consoleIo) {
8126
8641
  return null;
8127
8642
  }
8128
8643
  },
8129
- localContent: async (f) => (0, import_node_fs5.existsSync)(f) ? await (0, import_promises2.readFile)(f, "utf8") : null,
8644
+ localContent: async (f) => (0, import_node_fs6.existsSync)(f) ? await (0, import_promises2.readFile)(f, "utf8") : null,
8130
8645
  writeDoc: async (f, c) => {
8131
8646
  await (0, import_promises2.writeFile)(f, c, "utf8");
8132
8647
  }
@@ -8140,7 +8655,7 @@ docs.command("sync").option("--quiet", "stay silent unless something changed or
8140
8655
  var saga = program2.command("saga").description("per-session continuity");
8141
8656
  async function runNote(summary, o) {
8142
8657
  const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
8143
- const capture = buildNoteCapture(summary, o, (0, import_node_crypto2.randomUUID)(), { sha: sha || void 0, branch: key.branch });
8658
+ const capture = buildNoteCapture(summary, o, (0, import_node_crypto3.randomUUID)(), { sha: sha || void 0, branch: key.branch });
8144
8659
  await postCapture(capture);
8145
8660
  }
8146
8661
  saga.command("note <summary>").description("record a one-line structured note into your saga (the per-turn capture)").option("--next <text>", 'set "where I left off" (NEXT)').option("--decision <text>", "append a verbatim decision").option("--queue-add <text>", "add a worklist item").option("--queue-done <n>", "mark worklist item N done").option("--verified", "mark this claim as checked against source (state: verified, else asserted)").option("--diagnostic", "isolate a probe write (state: diagnostic, source: probe) \u2014 never resume/LAST 5").option("--supersedes <key>", "retire prior decisions matching an evidence key (pr:N | file:path)").option("--anchor <intent>", "set the sprint North-Star (write-protected; needs --anchor-force to change)").option("--anchor-force", "overwrite an existing anchor").action((summary, o) => runNote(summary, o));
@@ -8154,7 +8669,7 @@ async function runSagaShow(opts, io = consoleIo) {
8154
8669
  try {
8155
8670
  const key = await sagaKey(cfg);
8156
8671
  const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
8157
- const res = await fetchWithRetry(fetch, `${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders() }, { attempts: 2, timeoutMs: 3e3 });
8672
+ const res = await fetchWithRetry(fetch, `${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await hubHeaders() }, { attempts: 2, timeoutMs: 3e3 });
8158
8673
  if (res.ok) {
8159
8674
  io.log(resumeCue());
8160
8675
  return io.log(await res.text());
@@ -8171,7 +8686,7 @@ saga.command("show").option("--quiet", "no-op silently when unconfigured/unreach
8171
8686
  saga.command("capture").option("--quiet", "capture silently (for the Stop hook)").description("per-turn deterministic capture (Stop hook): turn boundary + current sha").action(async (opts) => {
8172
8687
  const hook = parseHookInput(await readStdin());
8173
8688
  if (hook.session_id) persistSession(hook.session_id);
8174
- await postCapture({ event: "stop", id: (0, import_node_crypto2.randomUUID)(), source: "hook", sha: await gitOut(["rev-parse", "--short", "HEAD"]), surface: agentSurface() }, opts.quiet ?? false);
8689
+ await postCapture({ event: "stop", id: (0, import_node_crypto3.randomUUID)(), source: "hook", sha: await gitOut(["rev-parse", "--short", "HEAD"]), surface: agentSurface() }, opts.quiet ?? false);
8175
8690
  });
8176
8691
  saga.command("session").option("--quiet", "silent (for the SessionStart hook)").description("persist the harness session id for this repo (SessionStart hook)").action(async () => {
8177
8692
  const hook = parseHookInput(await readStdin());
@@ -8198,7 +8713,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
8198
8713
  if (!cfg.sagaApiUrl) return;
8199
8714
  const key = await sagaKey(cfg);
8200
8715
  const qs = new URLSearchParams(key).toString();
8201
- const res = await fetch(`${cfg.sagaApiUrl}/saga/state?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
8716
+ const res = await fetch(`${cfg.sagaApiUrl}/saga/state?${qs}`, { headers: await hubHeaders(), signal: AbortSignal.timeout(8e3) });
8202
8717
  if (!res.ok) return;
8203
8718
  const state = await res.json();
8204
8719
  if (!state.actionLog?.length) return;
@@ -8206,7 +8721,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
8206
8721
  if (!update) return;
8207
8722
  await fetch(`${cfg.sagaApiUrl}/saga/head`, {
8208
8723
  method: "POST",
8209
- headers: await sagaHeaders({ "content-type": "application/json" }),
8724
+ headers: await hubHeaders({ "content-type": "application/json" }),
8210
8725
  body: JSON.stringify({ ...update, ...key }),
8211
8726
  signal: AbortSignal.timeout(2e4)
8212
8727
  });
@@ -8223,7 +8738,7 @@ saga.command("key").option("--json", "machine-readable output").description("pri
8223
8738
  });
8224
8739
  async function probeBackend(url) {
8225
8740
  try {
8226
- const res = await fetchWithRetry(fetch, `${url}/saga/head`, { headers: await sagaHeaders() }, { attempts: 3, timeoutMs: 4e3 });
8741
+ const res = await fetchWithRetry(fetch, `${url}/saga/head`, { headers: await hubHeaders() }, { attempts: 3, timeoutMs: 4e3 });
8227
8742
  let message = "";
8228
8743
  try {
8229
8744
  const body = await res.clone().json();
@@ -8238,7 +8753,7 @@ async function probeBackend(url) {
8238
8753
  async function probeSagaAccess(url, key) {
8239
8754
  try {
8240
8755
  const qs = new URLSearchParams(key).toString();
8241
- const res = await fetch(`${url}/saga/state?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
8756
+ const res = await fetch(`${url}/saga/state?${qs}`, { headers: await hubHeaders(), signal: AbortSignal.timeout(8e3) });
8242
8757
  return res.ok;
8243
8758
  } catch {
8244
8759
  return false;
@@ -8250,7 +8765,7 @@ async function runSagaHealth(o, io = consoleIo) {
8250
8765
  const key = await sagaKey(cfg, session);
8251
8766
  const source = session.source;
8252
8767
  const [identity, liveness] = await Promise.all([
8253
- githubLogin(),
8768
+ hubAuthSession({ baseUrl: cfg.sagaApiUrl ?? defaultHubUrl(), githubToken }).then((s) => s?.login),
8254
8769
  cfg.sagaApiUrl ? probeBackend(cfg.sagaApiUrl) : Promise.resolve({ reachable: false })
8255
8770
  ]);
8256
8771
  const authorized = cfg.sagaApiUrl && liveness.reachable ? await probeSagaAccess(cfg.sagaApiUrl, key) : void 0;
@@ -8387,6 +8902,7 @@ async function attachToProject(issueNumber, repo, priority) {
8387
8902
  return void 0;
8388
8903
  }
8389
8904
  }
8905
+ var ghRunner = async (args, timeoutMs) => (await execFileP4("gh", args, { timeout: timeoutMs })).stdout;
8390
8906
  function scheduleRelatedDiscovery(o) {
8391
8907
  try {
8392
8908
  const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
@@ -8401,39 +8917,39 @@ function scheduleRelatedDiscovery(o) {
8401
8917
  }
8402
8918
  }
8403
8919
  function makePlanDeps(cfg, io = consoleIo) {
8404
- const ensureDir = () => (0, import_node_fs5.mkdirSync)(PLANS_DIR, { recursive: true });
8920
+ const ensureDir = () => (0, import_node_fs6.mkdirSync)(PLANS_DIR, { recursive: true });
8405
8921
  return {
8406
8922
  apiUrl: cfg.sagaApiUrl,
8407
8923
  fetch: (url, init = {}) => fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(1e4) }),
8408
- headers: (extra) => sagaHeaders(extra),
8924
+ headers: (extra) => hubHeaders(extra),
8409
8925
  project: async () => (await sagaKey(cfg)).project,
8410
8926
  readLocal: (slug) => {
8411
8927
  try {
8412
- return (0, import_node_fs5.readFileSync)(planPath(slug), "utf8");
8928
+ return (0, import_node_fs6.readFileSync)(planPath(slug), "utf8");
8413
8929
  } catch {
8414
8930
  return null;
8415
8931
  }
8416
8932
  },
8417
8933
  writeLocal: (slug, content) => {
8418
8934
  ensureDir();
8419
- (0, import_node_fs5.writeFileSync)(planPath(slug), content, "utf8");
8935
+ (0, import_node_fs6.writeFileSync)(planPath(slug), content, "utf8");
8420
8936
  },
8421
8937
  removeLocal: (slug) => {
8422
8938
  try {
8423
- (0, import_node_fs5.rmSync)(planPath(slug));
8939
+ (0, import_node_fs6.rmSync)(planPath(slug));
8424
8940
  } catch {
8425
8941
  }
8426
8942
  },
8427
8943
  readMetaRaw: () => {
8428
8944
  try {
8429
- return (0, import_node_fs5.readFileSync)(META_FILE, "utf8");
8945
+ return (0, import_node_fs6.readFileSync)(META_FILE, "utf8");
8430
8946
  } catch {
8431
8947
  return null;
8432
8948
  }
8433
8949
  },
8434
8950
  writeMetaRaw: (raw) => {
8435
8951
  ensureDir();
8436
- (0, import_node_fs5.writeFileSync)(META_FILE, raw, "utf8");
8952
+ (0, import_node_fs6.writeFileSync)(META_FILE, raw, "utf8");
8437
8953
  },
8438
8954
  log: (m) => io.log(m),
8439
8955
  err: (m) => io.err(m),
@@ -8528,7 +9044,7 @@ function makeSecretsDeps(cfg) {
8528
9044
  return {
8529
9045
  apiUrl: cfg.sagaApiUrl,
8530
9046
  fetch: (url, init = {}) => fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(1e4) }),
8531
- headers: (extra) => sagaHeaders(extra),
9047
+ headers: (extra) => hubHeaders(extra),
8532
9048
  // Vault paths are lowercase kebab (AGENTS.md naming); sagaKey carries the repo name's original
8533
9049
  // casing, which leaked mixed-case into `secrets where` output (#681).
8534
9050
  slug: async () => (await sagaKey(cfg)).project.toLowerCase(),
@@ -8581,7 +9097,7 @@ secrets.command("use <key>").description("print guidance on consuming a secret w
8581
9097
  secrets.command("grant <repo> <login> <key>").description("MASTER-ONLY: grant a project-admin standing access to a specific org-tier secret").action((repo, login, key) => withSecrets((d) => secretsGrant(d, repo, login, key, {})));
8582
9098
  secrets.command("revoke <repo> <login> <key>").description("MASTER-ONLY: withdraw a previously granted org-tier secret access").action((repo, login, key) => withSecrets((d) => secretsRevoke(d, repo, login, key, {})));
8583
9099
  function registryClientDeps(cfg) {
8584
- return { baseUrl: cfg.sagaApiUrl, token: githubToken };
9100
+ return { baseUrl: cfg.sagaApiUrl, token: () => hubAuthToken({ baseUrl: cfg.sagaApiUrl, githubToken }) };
8585
9101
  }
8586
9102
  function slugOf(repoOrSlug) {
8587
9103
  return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
@@ -8601,6 +9117,15 @@ tenant.command("control <owner/repo> <stage> <action>").description("run bounded
8601
9117
  const res = await tenantControl({ repo, stage: stage2, action }, registryClientDeps(cfg));
8602
9118
  reportWrite("tenant control", res);
8603
9119
  });
9120
+ tenant.command("redeploy <owner/repo> <stage>").description("re-dispatch the central tenant-deploy.yml for an already-promoted ref (no re-tag/merge); train-authority gated").option("--ref <ref>", "ref to deploy (defaults to the stage branch rc/main \u2014 the promoted ref)").option("--watch", "block on the dispatched run and report its outcome (gh run watch --exit-status)").option("--json", "machine-readable output").action(async (repo, stage2, o) => {
9121
+ if (stage2 !== "rc" && stage2 !== "main") return fail("tenant redeploy: <stage> must be rc or main");
9122
+ try {
9123
+ const result = await runTenantRedeploy(trainApplyDeps(), { repo, stage: stage2, ref: o.ref, watch: o.watch });
9124
+ return printLine(o.json ? JSON.stringify(result, null, 2) : renderTenantRedeploy(result));
9125
+ } catch (e) {
9126
+ return fail(`tenant redeploy: ${e.message}`);
9127
+ }
9128
+ });
8604
9129
  async function v2ReadinessDeps(cfg) {
8605
9130
  const reg = registryClientDeps(cfg);
8606
9131
  return {
@@ -8620,7 +9145,7 @@ async function v2ReadinessDeps(cfg) {
8620
9145
  const qs = new URLSearchParams({ repo: targetRepo2 }).toString();
8621
9146
  const res = await fetchWithRetry(fetch, `${apiUrl.replace(/\/$/, "")}/secrets/list?${qs}`, {
8622
9147
  method: "GET",
8623
- headers: await sagaHeaders()
9148
+ headers: await hubHeaders()
8624
9149
  }, { attempts: 2, timeoutMs: 5e3 });
8625
9150
  if (!res.ok) throw new Error(`secrets list failed for ${targetRepo2}: HTTP ${res.status}`);
8626
9151
  const body = await res.json();
@@ -8680,9 +9205,14 @@ project.command("get [owner/repo]").description("a project's META (board ids + p
8680
9205
  } catch (e) {
8681
9206
  return fail(e.message);
8682
9207
  }
8683
- const meta = await fetchProjectBySlug(slugOf(target), registryClientDeps(cfg));
8684
- if (!meta) return fail(`project get: no registry META for ${target} (unknown, unbootstrapped, or Hub unreachable)`);
8685
- console.log(JSON.stringify(meta));
9208
+ const read = await fetchProjectBySlugChecked(slugOf(target), registryClientDeps(cfg));
9209
+ if (!read.ok) {
9210
+ return fail(`project get: Hub registry read failed (${read.error}) \u2014 likely transient (cold start, network, or auth blip); retry shortly`);
9211
+ }
9212
+ if (!read.project) {
9213
+ return fail(`project get: no registry META for ${target} (unknown or unbootstrapped)`);
9214
+ }
9215
+ console.log(JSON.stringify(read.project));
8686
9216
  });
8687
9217
  project.command("resolve <owner/repo>").description("deploy coords for a stage \u2014 for diagnosis. NOTE: /deploy-coords is OIDC-gated (a deploy job\u2019s id-token), so a gh-token CLI cannot read it from a dev machine").option("--stage <main|rc>", "deploy stage", "main").option("--json", "machine-readable output").action((_repoOrRepo, o) => {
8688
9218
  const msg = "project resolve: deploy coords are served only to a deploy workflow (GitHub OIDC id-token, repo-scoped). A gh-token CLI on a dev machine cannot read /deploy-coords; inspect the DEPLOY# item via the AWS console / a master DDB read instead.";
@@ -8854,7 +9384,7 @@ oauth.command("verify").description("probe Google authorize with an arbitrary po
8854
9384
  if (mismatch) process.exitCode = 1;
8855
9385
  });
8856
9386
  var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
8857
- issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").requiredOption("--type <type>", "bug | feature | task (sets the matching label)").requiredOption("--title <title>", "issue title").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file, or from stdin with -").requiredOption("--priority <priority>", "urgent | high | medium | low (label + board Priority field when configured)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--no-related", "skip the auto related-issues comment").action(async (o) => {
9387
+ issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").requiredOption("--type <type>", "bug | feature | task (sets the matching label)").requiredOption("--title <title>", "issue title").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file, or from stdin with -").requiredOption("--priority <priority>", "urgent | high | medium | low (label + board Priority field when configured)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--parent <ref>", "file as a native sub-issue of this parent (#123, owner/repo#123, or URL)").option("--no-related", "skip the auto related-issues comment").action(async (o) => {
8858
9388
  let args;
8859
9389
  let priority;
8860
9390
  let body;
@@ -8862,6 +9392,7 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
8862
9392
  body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises2.readFile, readStdin });
8863
9393
  priority = normalizePriority(o.priority);
8864
9394
  args = buildIssueArgs({ type: o.type, title: o.title, body, priority, repo: o.repo, labels: o.label });
9395
+ if (o.parent !== void 0) parseIssueRef(o.parent);
8865
9396
  } catch (e) {
8866
9397
  return fail(`issue create: ${e.message}`);
8867
9398
  }
@@ -8875,8 +9406,20 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
8875
9406
  }
8876
9407
  const created = await ghCreate(args);
8877
9408
  const projectItemId = await attachToProject(created.number, o.repo, priority);
9409
+ let parent;
9410
+ let parentLinkError;
9411
+ if (o.parent !== void 0) {
9412
+ try {
9413
+ parent = await linkSubIssue(ghRunner, o.parent, created.url, o.repo);
9414
+ } catch (e) {
9415
+ const err = e;
9416
+ parentLinkError = (err.stderr || err.message || String(e)).trim();
9417
+ process.stderr.write(`warning: issue #${created.number} created but NOT linked under ${o.parent}: ${parentLinkError}
9418
+ `);
9419
+ }
9420
+ }
8878
9421
  if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body });
8879
- console.log(JSON.stringify({ ...created, label: o.type, priority, projectItemId }));
9422
+ console.log(JSON.stringify({ ...created, label: o.type, priority, projectItemId, ...parentLinkFields(parent, parentLinkError) }));
8880
9423
  });
8881
9424
  issue.command("discover-related").description("find related issues for an existing issue and post only high-confidence links").requiredOption("--number <number>", "created issue number").requiredOption("--title <title>", "created issue title").requiredOption("--body <body>", "created issue body").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "print candidates instead of posting").action(async (o) => {
8882
9425
  const number = Number(o.number);
@@ -8913,6 +9456,17 @@ issue.command("discover-related").description("find related issues for an existi
8913
9456
  } catch {
8914
9457
  }
8915
9458
  });
9459
+ issue.command("link-child <parent> <child>").description("link an existing issue as a native sub-issue of a parent and print {parentNumber,subIssueNumber,totalCount} JSON").option("--repo <owner/repo>", "repo for bare refs on either side (defaults to the current repo)").action(async (parentRef, childRef, o) => {
9460
+ const defaultRepo = await resolveRepo(o.repo);
9461
+ try {
9462
+ const result = await linkSubIssue(ghRunner, parentRef, childRef, defaultRepo);
9463
+ console.log(JSON.stringify(result));
9464
+ } catch (e) {
9465
+ const err = e;
9466
+ const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
9467
+ return fail(`issue link-child: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
9468
+ }
9469
+ });
8916
9470
  program2.command("report").description("file a friction report on the Hub board (GitHub auth, dedups open reports) and print {number,url} JSON").requiredOption("--title <title>", "one-line friction summary").option("--body <body>", "report body (markdown)").option("--body-file <path|->", "read report body from a UTF-8 file, or from stdin with -").option("--type <type>", "bug | feature | task (sets the matching label)", "task").option("--priority <priority>", "urgent | high | medium | low (board Priority field when configured)", "medium").option("--repo <owner/repo>", `target repo (defaults to the org Hub: ${HUB_REPO})`).option("--force", "file a new issue even when an open report looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 report always prints JSON; #682)").action(async (o) => {
8917
9471
  let body;
8918
9472
  let priority;
@@ -8991,6 +9545,20 @@ async function remoteBranchExists(branch) {
8991
9545
  return void 0;
8992
9546
  }
8993
9547
  }
9548
+ var COMPOSE_TIMEOUT_MS = 12e4;
9549
+ function teardownWorktreeStage(worktreePath) {
9550
+ return runWorktreeStageTeardown(worktreePath, {
9551
+ hasStageState: (wt) => (0, import_node_fs6.existsSync)(stageStatePath(wt)),
9552
+ stopRecordedStage: async (wt) => (await stopStage({ cwd: wt })).pid,
9553
+ listComposeProjects: async () => {
9554
+ const { stdout } = await execFileP4("docker", ["compose", "ls", "--all", "--format", "json"], { timeout: GC_GH_TIMEOUT_MS });
9555
+ return parseComposeLs(stdout);
9556
+ },
9557
+ composeDown: async (project2) => {
9558
+ await execFileP4("docker", ["compose", "-p", project2, "down", "-v"], { timeout: COMPOSE_TIMEOUT_MS });
9559
+ }
9560
+ });
9561
+ }
8994
9562
  pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (number, o) => {
8995
9563
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
8996
9564
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
@@ -9027,7 +9595,8 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
9027
9595
  } : await cleanupPrMergeLocalBranch(headRef, {
9028
9596
  beforeWorktrees,
9029
9597
  startingPath,
9030
- execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
9598
+ execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout,
9599
+ teardownWorktreeStage
9031
9600
  });
9032
9601
  console.log(JSON.stringify({
9033
9602
  merged: number,
@@ -9143,16 +9712,50 @@ function rawValues(flag) {
9143
9712
  return out;
9144
9713
  }
9145
9714
  function printLine(value) {
9146
- (0, import_node_fs5.writeSync)(1, `${value}
9715
+ (0, import_node_fs6.writeSync)(1, `${value}
9147
9716
  `);
9148
9717
  }
9149
9718
  function stageKeepAlive() {
9150
9719
  return setTimeout(() => void 0, 5 * 60 * 1e3);
9151
9720
  }
9721
+ async function resolveStage() {
9722
+ const cfg = await loadConfig();
9723
+ const local = cfg.stage;
9724
+ const read = await fetchProjectBySlugChecked(await repoSlug(), registryClientDeps(cfg)).catch((e) => ({ ok: false, error: e.message }));
9725
+ const project2 = read.ok ? read.project : null;
9726
+ const portRangeMeta = project2?.portRange ?? void 0;
9727
+ const portRange = portRangeMeta && typeof portRangeMeta.start === "number" && typeof portRangeMeta.end === "number" ? [portRangeMeta.start, portRangeMeta.end] : void 0;
9728
+ return decideStage({
9729
+ local,
9730
+ shell: shellFor(),
9731
+ registry: { deployModel: project2?.deployModel, portRange, error: read.ok ? void 0 : read.error },
9732
+ hasCompose: (0, import_node_fs6.existsSync)((0, import_node_path7.join)(process.cwd(), "docker-compose.yml")),
9733
+ hasEnvExample: (0, import_node_fs6.existsSync)((0, import_node_path7.join)(process.cwd(), ".env.example"))
9734
+ });
9735
+ }
9736
+ function stageStepsFor(res, stops = true) {
9737
+ if (res.source === "derived" && res.derived) return derivedStagePlan(res.derived, shellFor(), stops);
9738
+ if (res.source === "local") return stagePlan(res.config ?? {}, stops);
9739
+ return [{ label: `no local stage to run \u2014 ${res.gap ?? "stage config gap"}` }];
9740
+ }
9741
+ function staleStageNote(res) {
9742
+ if (!res.staleIgnored) return null;
9743
+ const fields = res.staleFields ?? [];
9744
+ const list = fields.join(", ");
9745
+ const label = fields.length > 1 ? `fields ${list}` : `field ${list || "build/up"}`;
9746
+ if (res.source === "local") {
9747
+ return `note: POSIX-only .mmi stage ${label} ignored on PowerShell \u2014 kept the rest of the local recipe`;
9748
+ }
9749
+ return `note: stale POSIX-only .mmi stage ${label} ignored on PowerShell \u2014 using the registry-derived default`;
9750
+ }
9751
+ function reportedStageUrl(res, result) {
9752
+ if (!res.derived) return void 0;
9753
+ return result.port != null ? stageUrlForPort(result.port) : res.derived.url;
9754
+ }
9152
9755
  program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block via the atomic ORG#config.portCursor allocator (committed-file fallback)").option("--json", "machine-readable output").action(async (repo, o) => {
9153
- const path2 = (0, import_node_path6.join)(process.cwd(), "infra", "port-ranges.json");
9756
+ const path2 = (0, import_node_path7.join)(process.cwd(), "infra", "port-ranges.json");
9154
9757
  const allocate = async (seed) => {
9155
- const { stdout } = await execFileP4("node", [(0, import_node_path6.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
9758
+ const { stdout } = await execFileP4("node", [(0, import_node_path7.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
9156
9759
  const parsed = JSON.parse(stdout);
9157
9760
  if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
9158
9761
  return parsed.range;
@@ -9161,20 +9764,27 @@ program2.command("port-range <repo>").description("assign (idempotently) + print
9161
9764
  printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
9162
9765
  });
9163
9766
  var stage = program2.command("stage").description("plan or run the repo local stage environment").option("--json", "machine-readable output").option("--apply", "run the full local stage: stop previous, build, start, health-check").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async (o) => {
9164
- const cfg = (await loadConfig()).stage;
9767
+ const res = await resolveStage();
9165
9768
  if (o.apply) {
9769
+ if (res.source === "none") return fail(`stage: ${res.gap}`);
9770
+ const cfg = res.config ?? res.derived.config;
9166
9771
  const hold = stageKeepAlive();
9167
9772
  try {
9168
9773
  const result = await runStage(cfg, { timeoutMs: Number(o.timeoutMs || 6e4) });
9169
- return printLine(o.json ? JSON.stringify(result) : `mmi-cli stage: ${result.message}`);
9774
+ const reportUrl = reportedStageUrl(res, result);
9775
+ const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
9776
+ return printLine(o.json ? JSON.stringify({ ...result, source: res.source, url: reportUrl }) : `mmi-cli stage: ${result.message}${url}`);
9170
9777
  } catch (e) {
9171
9778
  return fail(`stage: ${e.message}`);
9172
9779
  } finally {
9173
9780
  clearTimeout(hold);
9174
9781
  }
9175
9782
  }
9176
- const steps = stagePlan(cfg);
9177
- console.log(o.json ? JSON.stringify({ command: "stage", steps }, null, 2) : renderSteps("mmi-cli stage: dry-run plan", steps));
9783
+ const steps = stageStepsFor(res);
9784
+ if (o.json) return console.log(JSON.stringify({ command: "stage", source: res.source, url: res.derived?.url, staleIgnored: res.staleIgnored, staleFields: res.staleFields, registryError: res.registryError, steps }, null, 2));
9785
+ const note = staleStageNote(res);
9786
+ if (note) printLine(note);
9787
+ console.log(renderSteps("mmi-cli stage: dry-run plan", steps));
9178
9788
  });
9179
9789
  stage.command("stop").description("stop the previous local stage process recorded in tmp/stage/state.json").option("--json", "machine-readable output").option("--apply", "kill the recorded process tree and remove the state file").action(async () => {
9180
9790
  const o = { json: rawFlag("--json"), apply: rawFlag("--apply") };
@@ -9191,11 +9801,16 @@ stage.command("stop").description("stop the previous local stage process recorde
9191
9801
  });
9192
9802
  stage.command("start").description("start the configured local stage process and optionally wait for health").option("--json", "machine-readable output").option("--apply", "start the configured stage.up process").option("--timeout-ms <ms>", "bounded health timeout", "60000").action(async () => {
9193
9803
  const o = { json: rawFlag("--json"), apply: rawFlag("--apply"), timeoutMs: rawValue("--timeout-ms", "60000") };
9194
- const cfg = (await loadConfig()).stage;
9804
+ const res = await resolveStage();
9195
9805
  if (!o.apply) {
9196
- const steps = [{ label: "start local stage", command: cfg?.up || "(no stage.up configured)" }];
9197
- return printLine(o.json ? JSON.stringify({ command: "stage start", steps }, null, 2) : renderSteps("mmi-cli stage start: dry-run plan", steps));
9198
- }
9806
+ const steps = stageStepsFor(res, false);
9807
+ if (o.json) return printLine(JSON.stringify({ command: "stage start", source: res.source, url: res.derived?.url, staleIgnored: res.staleIgnored, staleFields: res.staleFields, registryError: res.registryError, steps }, null, 2));
9808
+ const note = staleStageNote(res);
9809
+ if (note) printLine(note);
9810
+ return printLine(renderSteps("mmi-cli stage start: dry-run plan", steps));
9811
+ }
9812
+ if (res.source === "none") return fail(`stage start: ${res.gap}`);
9813
+ const cfg = res.config ?? res.derived.config;
9199
9814
  try {
9200
9815
  const hold = stageKeepAlive();
9201
9816
  let printed = false;
@@ -9204,10 +9819,12 @@ stage.command("start").description("start the configured local stage process and
9204
9819
  timeoutMs: Number(o.timeoutMs || 6e4),
9205
9820
  onReady: (ready) => {
9206
9821
  printed = true;
9207
- printLine(o.json ? JSON.stringify(ready) : `mmi-cli stage start: ${ready.message}`);
9822
+ const reportUrl = reportedStageUrl(res, ready);
9823
+ const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
9824
+ printLine(o.json ? JSON.stringify({ ...ready, source: res.source, url: reportUrl }) : `mmi-cli stage start: ${ready.message}${url}`);
9208
9825
  }
9209
9826
  });
9210
- if (!printed) printLine(o.json ? JSON.stringify(result) : `mmi-cli stage start: ${result.message}`);
9827
+ if (!printed) printLine(o.json ? JSON.stringify({ ...result, source: res.source, url: reportedStageUrl(res, result) }) : `mmi-cli stage start: ${result.message}`);
9211
9828
  } finally {
9212
9829
  clearTimeout(hold);
9213
9830
  }
@@ -9217,11 +9834,16 @@ stage.command("start").description("start the configured local stage process and
9217
9834
  });
9218
9835
  stage.command("run").description("force-stop previous stage, build, start, and health-check").option("--json", "machine-readable output").option("--apply", "run the configured stage sequence").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async () => {
9219
9836
  const o = { json: rawFlag("--json"), apply: rawFlag("--apply"), timeoutMs: rawValue("--timeout-ms", "60000") };
9220
- const cfg = (await loadConfig()).stage;
9837
+ const res = await resolveStage();
9221
9838
  if (!o.apply) {
9222
- const steps = stagePlan(cfg);
9223
- return printLine(o.json ? JSON.stringify({ command: "stage run", steps }, null, 2) : renderSteps("mmi-cli stage run: dry-run plan", steps));
9224
- }
9839
+ const steps = stageStepsFor(res);
9840
+ if (o.json) return printLine(JSON.stringify({ command: "stage run", source: res.source, url: res.derived?.url, staleIgnored: res.staleIgnored, staleFields: res.staleFields, registryError: res.registryError, steps }, null, 2));
9841
+ const note = staleStageNote(res);
9842
+ if (note) printLine(note);
9843
+ return printLine(renderSteps("mmi-cli stage run: dry-run plan", steps));
9844
+ }
9845
+ if (res.source === "none") return fail(`stage run: ${res.gap}`);
9846
+ const cfg = res.config ?? res.derived.config;
9225
9847
  try {
9226
9848
  const hold = stageKeepAlive();
9227
9849
  let printed = false;
@@ -9229,12 +9851,14 @@ stage.command("run").description("force-stop previous stage, build, start, and h
9229
9851
  const result = await runStage(cfg, {
9230
9852
  timeoutMs: Number(o.timeoutMs || 6e4),
9231
9853
  onReady: (ready) => {
9232
- const runReady = { ...ready, action: "run", message: `built and ${ready.message}` };
9854
+ const reportUrl = reportedStageUrl(res, ready);
9855
+ const url = reportUrl ? ` \u2014 ${reportUrl}` : "";
9856
+ const runReady = { ...ready, action: "run", source: res.source, url: reportUrl, message: `built and ${ready.message}${url}` };
9233
9857
  printed = true;
9234
9858
  printLine(o.json ? JSON.stringify(runReady) : `mmi-cli stage run: ${runReady.message}`);
9235
9859
  }
9236
9860
  });
9237
- if (!printed) printLine(o.json ? JSON.stringify(result) : `mmi-cli stage run: ${result.message}`);
9861
+ if (!printed) printLine(o.json ? JSON.stringify({ ...result, source: res.source, url: reportedStageUrl(res, result) }) : `mmi-cli stage run: ${result.message}`);
9238
9862
  } finally {
9239
9863
  clearTimeout(hold);
9240
9864
  }
@@ -9247,8 +9871,37 @@ program2.command("stage-live").description("explain that remote rc/live environm
9247
9871
  const steps = stageLivePlan();
9248
9872
  console.log(o.json ? JSON.stringify({ command: "stage-live", steps }, null, 2) : renderSteps("mmi-cli stage-live: not an org command", steps));
9249
9873
  });
9874
+ var GH_TRAIN_TIMEOUT_MS = 3e4;
9875
+ var GH_RUN_WATCH_TIMEOUT_MS = 20 * 6e4;
9876
+ function trainApplyDeps() {
9877
+ return {
9878
+ run: async (file, args) => {
9879
+ const timeout = file !== "gh" ? GIT_TIMEOUT_MS : args[0] === "run" && args[1] === "watch" ? GH_RUN_WATCH_TIMEOUT_MS : GH_TRAIN_TIMEOUT_MS;
9880
+ return (await execFileP4(file, args, { timeout })).stdout;
9881
+ },
9882
+ runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
9883
+ trainAuthority: async (repo) => {
9884
+ const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
9885
+ return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
9886
+ }
9887
+ };
9888
+ }
9889
+ function renderDeployLine(d) {
9890
+ const parts = [d.dispatch];
9891
+ if (d.runUrl) parts.push(`run ${d.runUrl}`);
9892
+ if (d.deployStatus === "success") parts.push("deploy: SUCCEEDED");
9893
+ else if (d.deployStatus === "failure") parts.push("deploy: FAILED (promotion stands; retry the deploy, do not re-tag)");
9894
+ else if (d.runId != null) parts.push(`deploy: dispatched (watch: gh run watch ${d.runId} --repo mutmutco/MMI-Hub --exit-status)`);
9895
+ return parts.join("; ");
9896
+ }
9897
+ function renderTrainApply(commandName, r) {
9898
+ return `mmi-cli ${commandName}: promoted ${r.repo} \u2192 ${r.stage} at ${r.tag} [${r.deployModel}]; ${renderDeployLine(r)}`;
9899
+ }
9900
+ function renderTenantRedeploy(r) {
9901
+ return `mmi-cli tenant redeploy: ${r.repo} ${r.stage} (ref=${r.ref}) [${r.deployModel}]; ${renderDeployLine(r)}`;
9902
+ }
9250
9903
  for (const commandName of ["rcand", "release", "hotfix"]) {
9251
- program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
9904
+ program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit master-admin approval`).option("--json", "machine-readable output").option("--watch", "block on the dispatched tenant-deploy.yml run and report its outcome (tenant-container)").option("--apply", commandName === "hotfix" ? "reserved; hotfix uses the /hotfix skill PR path" : "execute the guarded master-only train after explicit approval").action(async (o) => {
9252
9905
  try {
9253
9906
  await requireFreshTrainCli(commandName);
9254
9907
  } catch (e) {
@@ -9257,16 +9910,8 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
9257
9910
  if (o.apply) {
9258
9911
  if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
9259
9912
  try {
9260
- const result = await runTrainApply(commandName, {
9261
- run: async (file, args) => (await execFileP4(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
9262
- runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
9263
- trainAuthority: async (repo) => {
9264
- const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
9265
- return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
9266
- }
9267
- });
9268
- const message = `mmi-cli ${commandName}: applied ${result.repo} ${result.stage} train at ${result.tag} [${result.deployModel}]; ${result.dispatch}`;
9269
- return printLine(o.json ? JSON.stringify(result, null, 2) : message);
9913
+ const result = await runTrainApply(commandName, trainApplyDeps(), { watch: o.watch });
9914
+ return printLine(o.json ? JSON.stringify(result, null, 2) : renderTrainApply(commandName, result));
9270
9915
  } catch (e) {
9271
9916
  return fail(`${commandName}: ${e.message}`);
9272
9917
  }
@@ -9286,13 +9931,14 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
9286
9931
  const o = { class: rawValue("--class", "deployable"), json: rawFlag("--json") };
9287
9932
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
9288
9933
  const cfg = await loadConfig();
9289
- const apiProjects = await fetchProjectsJson({ baseUrl: cfg.sagaApiUrl, token: githubToken });
9934
+ const reg = registryClientDeps(cfg);
9935
+ const apiProjects = await fetchProjectsJson(reg);
9290
9936
  const slug = (repo.includes("/") ? repo.split("/")[1] : repo).toLowerCase();
9291
- const meta = await fetchProjectBySlug(slug, { baseUrl: cfg.sagaApiUrl, token: githubToken });
9937
+ const meta = await fetchProjectBySlug(slug, reg);
9292
9938
  const report = await verifyBootstrap(repo, o.class, {
9293
9939
  client: defaultGitHubClient(),
9294
9940
  projectMeta: meta,
9295
- readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs5.existsSync)(path2) ? (0, import_node_fs5.readFileSync)(path2, "utf8") : null,
9941
+ readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs6.existsSync)(path2) ? (0, import_node_fs6.readFileSync)(path2, "utf8") : null,
9296
9942
  // requiredGcpApis is stored as an array by a JSON write, but `project set --var KEY=VALUE` stores a raw
9297
9943
  // comma-string — accept either so the seeded value verifies regardless of how it was written.
9298
9944
  requiredGcpApis: (() => {
@@ -9333,12 +9979,12 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
9333
9979
  return fail(`bootstrap apply: ${e.message}`);
9334
9980
  }
9335
9981
  const manifestPath = "skills/bootstrap/seeds/manifest.json";
9336
- if (!(0, import_node_fs5.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
9337
- const manifest = loadBootstrapSeeds((0, import_node_fs5.readFileSync)(manifestPath, "utf8"));
9982
+ if (!(0, import_node_fs6.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
9983
+ const manifest = loadBootstrapSeeds((0, import_node_fs6.readFileSync)(manifestPath, "utf8"));
9338
9984
  const baseBranch = o.class === "content" ? "main" : "development";
9339
9985
  const slug = parsedRepo.slug;
9340
9986
  const gh = async (args) => execFileP4("gh", args, { timeout: 2e4 });
9341
- const readFile2 = (p) => (0, import_node_fs5.existsSync)(p) ? (0, import_node_fs5.readFileSync)(p, "utf8") : null;
9987
+ const readFile2 = (p) => (0, import_node_fs6.existsSync)(p) ? (0, import_node_fs6.readFileSync)(p, "utf8") : null;
9342
9988
  const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
9343
9989
  const vars = {};
9344
9990
  for (const value of rawValues("--var")) {
@@ -9411,7 +10057,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
9411
10057
  }
9412
10058
  if (o.execute) {
9413
10059
  const cfg = await loadConfig();
9414
- const res = await registerProject(registerPayload, { baseUrl: cfg.sagaApiUrl, token: githubToken });
10060
+ const res = await registerProject(registerPayload, registryClientDeps(cfg));
9415
10061
  if (res.ok) {
9416
10062
  ddbWrites.push({ slug: registerPayload.slug, action: "register", record: registerPayload });
9417
10063
  applied.push(`ddb register ${registerPayload.slug}`);
@@ -9462,16 +10108,16 @@ access.command("audit").description("audit collaborator roles + train-branch pus
9462
10108
  if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
9463
10109
  targets = [{ repo: o.repo, class: o.class }];
9464
10110
  } else {
9465
- const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0, import_node_fs5.existsSync)("projects.json") ? (0, import_node_fs5.readFileSync)("projects.json", "utf8") : null;
10111
+ const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0, import_node_fs6.existsSync)("projects.json") ? (0, import_node_fs6.readFileSync)("projects.json", "utf8") : null;
9466
10112
  if (!projectsJson) return fail("access audit: no project registry \u2014 Hub API unreachable and projects.json not found; run from the MMI-Hub repo root or pass --repo <owner/repo>");
9467
- const fanoutJson = (0, import_node_fs5.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs5.readFileSync)(".github/fanout-targets.json", "utf8") : null;
10113
+ const fanoutJson = (0, import_node_fs6.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs6.readFileSync)(".github/fanout-targets.json", "utf8") : null;
9468
10114
  targets = loadAccessTargets(projectsJson, fanoutJson);
9469
10115
  }
9470
10116
  const derivedMatrix = registryProjects ? accessMatrixFromProjects(registryProjects) : {};
9471
- const fileMatrix = (0, import_node_fs5.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs5.readFileSync)("access-matrix.json", "utf8")) : {};
10117
+ const fileMatrix = (0, import_node_fs6.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs6.readFileSync)("access-matrix.json", "utf8")) : {};
9472
10118
  const matrix = mergeAccessMatrix(fileMatrix, derivedMatrix);
9473
10119
  const derivedContracts = registryProjects ? dataAccessContractsFromProjects(registryProjects) : { consumers: {} };
9474
- const fileContracts = (0, import_node_fs5.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs5.readFileSync)("data-access-contracts.json", "utf8")) : { consumers: {} };
10120
+ const fileContracts = (0, import_node_fs6.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs6.readFileSync)("data-access-contracts.json", "utf8")) : { consumers: {} };
9475
10121
  const dataAccess = mergeDataAccessContracts(fileContracts, derivedContracts);
9476
10122
  const report = await auditOrgAccess(targets, deps, matrix, dataAccess);
9477
10123
  console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
@@ -9480,18 +10126,18 @@ access.command("audit").description("audit collaborator roles + train-branch pus
9480
10126
  var isWin = process.platform === "win32";
9481
10127
  var installedPluginsPath = (surface = detectSurface(process.env)) => {
9482
10128
  const homeDir = surface === "codex" ? ".codex" : ".claude";
9483
- return (0, import_node_path6.join)((0, import_node_os2.homedir)(), homeDir, "plugins", "installed_plugins.json");
10129
+ return (0, import_node_path7.join)((0, import_node_os3.homedir)(), homeDir, "plugins", "installed_plugins.json");
9484
10130
  };
9485
10131
  function readInstalledPlugins() {
9486
10132
  try {
9487
- return JSON.parse((0, import_node_fs5.readFileSync)(installedPluginsPath(), "utf8"));
10133
+ return JSON.parse((0, import_node_fs6.readFileSync)(installedPluginsPath(), "utf8"));
9488
10134
  } catch {
9489
10135
  return null;
9490
10136
  }
9491
10137
  }
9492
10138
  function readClaudeSettings() {
9493
10139
  try {
9494
- return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path6.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
10140
+ return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
9495
10141
  } catch {
9496
10142
  return null;
9497
10143
  }
@@ -9513,7 +10159,7 @@ function writeProjectInstallRecord(record) {
9513
10159
  const list = file.plugins[MMI_PLUGIN_ID] ?? [];
9514
10160
  list.push(record);
9515
10161
  file.plugins[MMI_PLUGIN_ID] = list;
9516
- (0, import_node_fs5.writeFileSync)(installedPluginsPath(), `${JSON.stringify(file, null, 2)}
10162
+ (0, import_node_fs6.writeFileSync)(installedPluginsPath(), `${JSON.stringify(file, null, 2)}
9517
10163
  `, "utf8");
9518
10164
  return true;
9519
10165
  } catch {
@@ -9526,9 +10172,9 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
9526
10172
  if (!file) return false;
9527
10173
  if (!file.plugins) file.plugins = {};
9528
10174
  const path2 = installedPluginsPath();
9529
- (0, import_node_fs5.copyFileSync)(path2, `${path2}.bak`);
10175
+ (0, import_node_fs6.copyFileSync)(path2, `${path2}.bak`);
9530
10176
  file.plugins[pluginId] = records;
9531
- (0, import_node_fs5.writeFileSync)(path2, `${JSON.stringify(file, null, 2)}
10177
+ (0, import_node_fs6.writeFileSync)(path2, `${JSON.stringify(file, null, 2)}
9532
10178
  `, "utf8");
9533
10179
  return true;
9534
10180
  } catch {
@@ -9537,14 +10183,14 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
9537
10183
  }
9538
10184
  function mmiPluginCacheRootSnapshots() {
9539
10185
  const roots = [
9540
- { surface: "claude", root: (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
9541
- { surface: "codex", root: (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".codex", "plugins", "cache", "mmi", "mmi") }
10186
+ { surface: "claude", root: (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
10187
+ { surface: "codex", root: (0, import_node_path7.join)((0, import_node_os3.homedir)(), ".codex", "plugins", "cache", "mmi", "mmi") }
9542
10188
  ];
9543
10189
  return roots.flatMap(({ surface, root }) => {
9544
10190
  try {
9545
- const entries = (0, import_node_fs5.readdirSync)(root, { withFileTypes: true }).map((entry) => ({
10191
+ const entries = (0, import_node_fs6.readdirSync)(root, { withFileTypes: true }).map((entry) => ({
9546
10192
  name: entry.name,
9547
- path: (0, import_node_path6.join)(root, entry.name),
10193
+ path: (0, import_node_path7.join)(root, entry.name),
9548
10194
  isDirectory: entry.isDirectory()
9549
10195
  }));
9550
10196
  return [{ surface, root, entries }];
@@ -9554,10 +10200,10 @@ function mmiPluginCacheRootSnapshots() {
9554
10200
  });
9555
10201
  }
9556
10202
  function uniqueQuarantineTarget(path2) {
9557
- if (!(0, import_node_fs5.existsSync)(path2)) return path2;
10203
+ if (!(0, import_node_fs6.existsSync)(path2)) return path2;
9558
10204
  for (let i = 1; i < 100; i += 1) {
9559
10205
  const candidate = `${path2}-${i}`;
9560
- if (!(0, import_node_fs5.existsSync)(candidate)) return candidate;
10206
+ if (!(0, import_node_fs6.existsSync)(candidate)) return candidate;
9561
10207
  }
9562
10208
  return `${path2}-${Date.now()}`;
9563
10209
  }
@@ -9565,27 +10211,27 @@ function quarantinePluginCacheDirs(plan2) {
9565
10211
  let moved = 0;
9566
10212
  for (const move of plan2) {
9567
10213
  try {
9568
- if (!(0, import_node_fs5.existsSync)(move.from)) continue;
10214
+ if (!(0, import_node_fs6.existsSync)(move.from)) continue;
9569
10215
  const target = uniqueQuarantineTarget(move.to);
9570
- (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)(target), { recursive: true });
9571
- (0, import_node_fs5.renameSync)(move.from, target);
10216
+ (0, import_node_fs6.mkdirSync)((0, import_node_path7.dirname)(target), { recursive: true });
10217
+ (0, import_node_fs6.renameSync)(move.from, target);
9572
10218
  moved += 1;
9573
10219
  } catch {
9574
10220
  }
9575
10221
  }
9576
10222
  return moved;
9577
10223
  }
9578
- var gitignorePath = () => (0, import_node_path6.join)(process.cwd(), ".gitignore");
10224
+ var gitignorePath = () => (0, import_node_path7.join)(process.cwd(), ".gitignore");
9579
10225
  function readGitignore() {
9580
10226
  try {
9581
- return (0, import_node_fs5.readFileSync)(gitignorePath(), "utf8");
10227
+ return (0, import_node_fs6.readFileSync)(gitignorePath(), "utf8");
9582
10228
  } catch {
9583
10229
  return null;
9584
10230
  }
9585
10231
  }
9586
10232
  function writeGitignore(content) {
9587
10233
  try {
9588
- (0, import_node_fs5.writeFileSync)(gitignorePath(), content, "utf8");
10234
+ (0, import_node_fs6.writeFileSync)(gitignorePath(), content, "utf8");
9589
10235
  return true;
9590
10236
  } catch {
9591
10237
  return false;
@@ -9621,7 +10267,7 @@ async function runDoctor(opts, io = consoleIo) {
9621
10267
  let onPath = pathProbe;
9622
10268
  if (!onPath) {
9623
10269
  const root = process.env.CLAUDE_PLUGIN_ROOT;
9624
- if (root && (0, import_node_fs5.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
10270
+ if (root && (0, import_node_fs6.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
9625
10271
  }
9626
10272
  checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
9627
10273
  const surface = detectSurface(process.env);
@@ -9666,7 +10312,12 @@ async function runDoctor(opts, io = consoleIo) {
9666
10312
  if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite && !opts.json && !opts.banner) {
9667
10313
  if (writeGitignore(gitignoreCheck.contentToWrite)) {
9668
10314
  gitignoreCheck = { ...gitignoreCheck, ok: true };
9669
- io.err(" \u21BB repaired: enforced .gitignore managed block (.playwright-mcp/, .claude/worktrees/, /*.png)");
10315
+ const drift = gitignoreCheck.seeded ? "inserted the org-managed block" : [
10316
+ gitignoreCheck.added?.length ? `added ${gitignoreCheck.added.join(", ")}` : "",
10317
+ gitignoreCheck.removed?.length ? `removed ${gitignoreCheck.removed.join(", ")}` : ""
10318
+ ].filter(Boolean).join("; ") || "normalized the block";
10319
+ io.err(` \u21BB repaired: org-managed .gitignore block \u2014 ${drift}`);
10320
+ io.err(" this is an org-managed update (not unrelated churn) \u2014 stage & commit .gitignore so it stops recurring");
9670
10321
  }
9671
10322
  }
9672
10323
  checks.push(gitignoreCheck);