@mutmutco/cli 4.3.38 → 4.3.40

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 +6 -4
  2. package/dist/main.cjs +310 -124
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -6,21 +6,23 @@ This package is published from [mutmutco/MMI-Hub](https://github.com/mutmutco/MM
6
6
 
7
7
  The CLI carries the org **Hub endpoint** intrinsically (override with the `MMI_HUB_URL` env var), so a product repo needs **no committed control-plane config** to reach the Hub — board coords, deploy coordinates, OAuth, and the secrets layout are all discovered from the Hub registry at runtime.
8
8
 
9
+ Local stage recipes and printed plans use Bash on every platform. Windows requires Git Bash in its standard system or user installation, or `SHELL` set to the absolute `bash.exe` path. A missing Bash installation fails explicitly. Native Windows administration helpers remain independent of the stage shell.
10
+
9
11
  ## Install
10
12
 
11
- ```powershell
13
+ ```bash
12
14
  npm install -g @mutmutco/cli
13
15
  ```
14
16
 
15
17
  Authenticate GitHub once for Hub session issuance and Project board operations:
16
18
 
17
- ```powershell
19
+ ```bash
18
20
  gh auth login --hostname github.com --git-protocol https --web --scopes "project"
19
21
  ```
20
22
 
21
23
  Then verify the installed command:
22
24
 
23
- ```powershell
25
+ ```bash
24
26
  mmi-cli --version
25
27
  mmi-cli doctor --json
26
28
  ```
@@ -79,7 +81,7 @@ they don't get re-derived or re-litigated per session:
79
81
 
80
82
  When working inside an `MMI-Hub` checkout before npm is available, use the committed bundle directly:
81
83
 
82
- ```powershell
84
+ ```bash
83
85
  node cli/dist/index.cjs --version
84
86
  node cli/dist/index.cjs doctor --json
85
87
  ```
package/dist/main.cjs CHANGED
@@ -4149,6 +4149,20 @@ var HOUSE_MAP = {
4149
4149
  onboard: "core"
4150
4150
  // repo-readiness orientation (front door — torn toward oracle, kept core)
4151
4151
  };
4152
+ var HOUSE_ALIASES = {
4153
+ issue: ["devops"],
4154
+ // filing/managing issues is oracle, but reads as shipping work
4155
+ "issue create": ["devops"]
4156
+ };
4157
+ function isDeclaredHouseAlias(houseToken, lookupPath) {
4158
+ const segments = lookupPath.split(" ");
4159
+ for (let end = segments.length; end > 0; end -= 1) {
4160
+ const key = segments.slice(0, end).join(" ");
4161
+ const aliases = HOUSE_ALIASES[key];
4162
+ if (aliases) return aliases.includes(houseToken);
4163
+ }
4164
+ return false;
4165
+ }
4152
4166
  function houseForPath(path2) {
4153
4167
  if (path2 === "") return "core";
4154
4168
  const segments = path2.split(" ");
@@ -5256,6 +5270,39 @@ function unknownCommandDomainGuide(parentPath, token) {
5256
5270
  return GUIDES.get(`${parentPath}:${token}`);
5257
5271
  }
5258
5272
 
5273
+ // src/verb-alias.ts
5274
+ var VERB_ALIAS_TABLE = [
5275
+ // #6489: `board` has no `status` leaf (read|claim|show|move|done|doctor only); the read that
5276
+ // answers "what is this issue/PR's board column" is `board show <ref>`.
5277
+ { typed: ["oracle", "board", "status"], canonical: ["oracle", "board", "show"] },
5278
+ { typed: ["board", "status"], canonical: ["board", "show"] },
5279
+ // #6494: there is no top-level or housed `registry` command; org/registry project metadata is
5280
+ // `oracle org project get`.
5281
+ { typed: ["registry"], canonical: ["oracle", "org", "project", "get"] },
5282
+ // #6486: filing/managing an issue reads as devops work (it ships something) but issues are oracle's
5283
+ // live truth; the real command is `oracle issue create`. (A narrower house-token-only reroute for
5284
+ // this same case may also exist elsewhere — this entry keeps the general table a complete answer on
5285
+ // its own, not dependent on that mechanism landing.)
5286
+ { typed: ["devops", "issue", "create"], canonical: ["oracle", "issue", "create"] }
5287
+ ];
5288
+ var TABLE_BY_LENGTH = [...VERB_ALIAS_TABLE].sort((a, b) => b.typed.length - a.typed.length);
5289
+ function matchesAt(argv, start, tokens2) {
5290
+ if (start + tokens2.length > argv.length) return false;
5291
+ return tokens2.every((tok, i) => argv[start + i] === tok);
5292
+ }
5293
+ function applyVerbAlias(argv) {
5294
+ const entry = TABLE_BY_LENGTH.find((e) => matchesAt(argv, 2, e.typed));
5295
+ if (!entry) return void 0;
5296
+ argv.splice(2, entry.typed.length, ...entry.canonical);
5297
+ return { typed: entry.typed.join(" "), canonical: entry.canonical.join(" ") };
5298
+ }
5299
+ function resolveVerbAliasShim(argv) {
5300
+ const hit = applyVerbAlias(argv);
5301
+ if (!hit) return;
5302
+ process.stderr.write(`mmi-cli: '${hit.typed}' is '${hit.canonical}' \u2014 ran it for you
5303
+ `);
5304
+ }
5305
+
5259
5306
  // src/command-composition.ts
5260
5307
  var import_node_os22 = require("node:os");
5261
5308
  var import_node_path47 = require("node:path");
@@ -7195,6 +7242,50 @@ async function readRepoSurfaceLabels(repo, deps = {}) {
7195
7242
  return void 0;
7196
7243
  }
7197
7244
  }
7245
+ function surfaceWords(label) {
7246
+ return label.slice(SURFACE_PREFIX.length).toLowerCase().split(/[-_]+/).filter(Boolean);
7247
+ }
7248
+ function pathWords(text) {
7249
+ const words = [];
7250
+ for (const hit of text.match(/[\w.-]+(?:\/[\w.-]+)+/g) ?? []) {
7251
+ for (const segment of hit.split("/")) words.push(...segment.toLowerCase().split(/[-_.]+/).filter(Boolean));
7252
+ }
7253
+ return words;
7254
+ }
7255
+ function proseWords(text) {
7256
+ return text.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
7257
+ }
7258
+ function inferSurface(candidates, context = {}) {
7259
+ const sorted = [...candidates].sort();
7260
+ if (sorted.length === 0) throw new Error("inferSurface: no surface:* candidates to choose from");
7261
+ const pWords = new Set(pathWords(context.body ?? ""));
7262
+ const tWords = /* @__PURE__ */ new Set([...proseWords(context.title ?? ""), ...proseWords(context.body ?? "")]);
7263
+ let best = sorted[0];
7264
+ let bestScore = 0;
7265
+ let bestFromPath = false;
7266
+ for (const label of sorted) {
7267
+ const words = surfaceWords(label);
7268
+ if (!words.length) continue;
7269
+ const pathHits = words.filter((w) => pWords.has(w)).length;
7270
+ const proseHits = words.filter((w) => tWords.has(w)).length;
7271
+ const score = pathHits * 2 + proseHits;
7272
+ if (score > bestScore) {
7273
+ bestScore = score;
7274
+ best = label;
7275
+ bestFromPath = pathHits > 0;
7276
+ }
7277
+ }
7278
+ if (bestScore === 0) {
7279
+ return {
7280
+ label: sorted[0],
7281
+ reason: `no path or title/body word matched any of ${sorted.length} surface label(s) \u2014 defaulted to the alphabetically-first`
7282
+ };
7283
+ }
7284
+ return {
7285
+ label: best,
7286
+ reason: bestFromPath ? "matched a path mentioned in the issue body" : "matched a word in the issue title/body"
7287
+ };
7288
+ }
7198
7289
  async function checkSurfaceRequirement(input, deps = {}) {
7199
7290
  if (input.waiver) {
7200
7291
  const reason = input.waiver.reason.trim();
@@ -7226,20 +7317,7 @@ async function checkSurfaceRequirement(input, deps = {}) {
7226
7317
  }
7227
7318
  if (known.length === 0) return { enforcing: false, taxonomyAbsent: true };
7228
7319
  if (labelsCarrySurface(input.labels)) return { enforcing: true };
7229
- const command = input.command ?? "issue create";
7230
- const where = input.rowLabel ? `${input.rowLabel}: ` : "";
7231
- return {
7232
- enforcing: true,
7233
- refusal: {
7234
- message: `${command}: ${where}${input.repo} requires every open issue to carry exactly one surface:* label, and this one sets none \u2014 filing it unlabeled reds the board check on every open PR in that repo. Pass --surface <value>, or --no-surface if this filing is genuinely exempt`,
7235
- payload: {
7236
- code: ERROR_CODES.ERR_MISSING_FLAG,
7237
- offending_flag: "--surface",
7238
- // Advisory, not a closed enum: the board rule is "exactly one surface:* label", whatever its value.
7239
- expected: [...known].sort()
7240
- }
7241
- }
7242
- };
7320
+ return { enforcing: true, inferred: inferSurface(known, { title: input.title, body: input.body }) };
7243
7321
  }
7244
7322
  function conflictingSurfaceInputs(surfaceFlag, labels) {
7245
7323
  if (!surfaceFlag) return void 0;
@@ -12120,10 +12198,9 @@ async function postIssueComment(client, input) {
12120
12198
 
12121
12199
  // src/board-claim-move.ts
12122
12200
  var CLAIM_CONCURRENCY = 5;
12123
- function withPowerShellChainHint(message2, platform2 = process.platform) {
12124
- if (platform2 !== "win32") return message2;
12201
+ function withGatedWriteChainHint(message2) {
12125
12202
  return `${message2}
12126
- note: in PowerShell a ';' chain runs every statement regardless of the previous exit code, so the chain ran past this refusal \u2014 rerun the refused write solo or chain gated writes with '&&'`;
12203
+ note: run refused writes separately or join dependent commands with '&&'; a ';' chain continues after failure`;
12127
12204
  }
12128
12205
  function isArchivedItemRefusal(message2) {
12129
12206
  return /The item is archived and cannot be updated/i.test(message2);
@@ -12309,7 +12386,7 @@ async function claimOneBoardItem(ctx, selector, options) {
12309
12386
  }
12310
12387
  if (!options.force) {
12311
12388
  const refusal = laneContestMessage(item.ref, contest, "claim");
12312
- throw new Error(options.bulk ? refusal : withPowerShellChainHint(refusal));
12389
+ throw new Error(options.bulk ? refusal : withGatedWriteChainHint(refusal));
12313
12390
  }
12314
12391
  previousHolder = displaced();
12315
12392
  };
@@ -12493,7 +12570,7 @@ async function unclaimBoardIssue(options, deps = {}) {
12493
12570
  const toStatus = options.toStatus ?? "Todo";
12494
12571
  if (!options.force && item.contentType === "Issue") {
12495
12572
  const contest = await checkLaneContest(client, item);
12496
- if (contest.contested) throw new Error(withPowerShellChainHint(laneContestMessage(item.ref, contest, "unclaim")));
12573
+ if (contest.contested) throw new Error(withGatedWriteChainHint(laneContestMessage(item.ref, contest, "unclaim")));
12497
12574
  }
12498
12575
  try {
12499
12576
  await client.rest("DELETE", `repos/${item.repository}/issues/${item.number}/assignees`, {
@@ -15617,10 +15694,10 @@ var rollout_plan_default = {
15617
15694
  note: "The v4.0.0 stamp happens at cut time (D6e #4463); until then the candidate is the origin/development head artifacts (built cli/dist + npm pack), identity proven by dist content hash (D6a)."
15618
15695
  },
15619
15696
  baseline: {
15620
- version: "4.3.38",
15621
- tag: "v4.3.38",
15622
- commit: "da4541fdb81e",
15623
- npm: "@mutmutco/cli@4.3.38"
15697
+ version: "4.3.40",
15698
+ tag: "v4.3.40",
15699
+ commit: "487948fabf28",
15700
+ npm: "@mutmutco/cli@4.3.40"
15624
15701
  },
15625
15702
  exitCriterion: "fleet-n-of-n",
15626
15703
  hubOnlyShortcut: "forbidden",
@@ -15637,14 +15714,14 @@ var rollout_plan_default = {
15637
15714
  repo: "mutmutco/mmi-hub",
15638
15715
  role: "canary",
15639
15716
  schedule: "train",
15640
- v3Target: "v4.3.38"
15717
+ v3Target: "v4.3.40"
15641
15718
  }
15642
15719
  ],
15643
15720
  rollbackTrigger: "Any red inside the post-contract soak window: `devops train gate` FAIL attributable to the v4 doors, Hub endpoint health probe failure, a pre-v4 client admitted instead of receiving actionable HTTP 426, or npm consumer install/doctor failure on the v4-only dist.",
15644
15721
  rollback: {
15645
15722
  independent: true,
15646
- mechanism: "npm dist-tag latest -> 4.3.38 and redeploy the Hub Lambda from tag v4.3.38 (da4541fdb81e); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15647
- v3Target: "v4.3.38 (@mutmutco/cli@4.3.38, tag commit da4541fdb81e \u2014 last known-good release carrying the repo-index v4-only contract)"
15723
+ mechanism: "npm dist-tag latest -> 4.3.40 and redeploy the Hub Lambda from tag v4.3.40 (487948fabf28); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
15724
+ v3Target: "v4.3.40 (@mutmutco/cli@4.3.40, tag commit 487948fabf28 \u2014 last known-good release carrying the repo-index v4-only contract)"
15648
15725
  }
15649
15726
  },
15650
15727
  {
@@ -21645,13 +21722,9 @@ async function runTrainDoctor(input) {
21645
21722
  add(linked ? { code: "worktree-isolated", severity: "blocker", source: "local", title: `this is a linked worktree on ${currentBranch2 || "(detached)"}, not the lane's start branch ${startBranch} (#2770)`, remedy: `run the ${lane} lane from the primary checkout on ${startBranch}; a linked worktree never carries the train` } : { code: "branch-mismatch", severity: "blocker", source: "local", title: `on ${currentBranch2 || "(detached)"}; the ${lane} lane starts from ${startBranch}`, remedy: `git checkout ${startBranch}, then rerun; --heal never switches branches \u2014 the branch you stand on names the lane you meant, and healing it would run the train off a branch you did not pick (#6432)` });
21646
21723
  }
21647
21724
  const stage = laneStage(lane);
21648
- const workflowsRef = `origin/${startBranch ?? "main"}`;
21649
- const workflows = deps.readWorkflows(cwd, workflowsRef);
21650
- if (workflows === null) {
21651
- add({ code: "workflows-unreadable", severity: "blocker", source: "local", title: `.github/workflows at ${workflowsRef} could not be read from ${cwd} \u2014 the tag-addressability (#5428) and npm-major (#5666) preflights did not run`, remedy: `run from a checkout whose git can read ${workflowsRef} (fetch origin first), then rerun; an unread workflow set is unverified, not green` });
21652
- }
21725
+ let missing = [];
21653
21726
  if (hints) {
21654
- const missing = [
21727
+ missing = [
21655
21728
  ...!hints.hasDevelopmentBranch && lane !== "hotfix" ? ["development"] : [],
21656
21729
  ...!hints.hasMainBranch ? ["main"] : [],
21657
21730
  // #6318: a `--dev` release never reads, merges or tags rc — a missing rc branch cannot block it.
@@ -21659,30 +21732,38 @@ async function runTrainDoctor(input) {
21659
21732
  ];
21660
21733
  if (missing.length) add({ code: "bootstrap-gap", severity: "blocker", source: "origin", title: `train branch(es) missing on origin: ${missing.join(", ")}`, remedy: `bootstrap the repo train: \`mmi-cli devops bootstrap apply ${repo} --execute\` (from the MMI-Hub root), then rerun` });
21661
21734
  }
21662
- try {
21663
- const required = await discoverRequiredCheckContexts(train, ctx, stage);
21664
- if (required.length === 0) {
21665
- add({ code: "bootstrap-gap", severity: "warning", source: "origin", title: `no ruleset requires a status check on ${stage} \u2014 the train tags without a check wait (the GitHub push gate is the backstop)`, remedy: `activate the product ruleset: \`mmi-cli devops bootstrap apply ${repo} --execute\` (from the MMI-Hub root), or \`mmi-cli devops ci audit --repo ${repo}\`` });
21666
- } else {
21667
- try {
21668
- assertTagAddressableRequiredContexts({ readWorkflows: () => workflows }, required, repo);
21669
- } catch (e) {
21670
- add({ code: "contexts-not-tag-addressable", severity: "blocker", source: "local", title: `required ${stage} check(s) cannot materialize on a tag push (#5428)`, remedy: message(e) });
21671
- }
21672
- if (startBranch) {
21673
- const tip = await git3(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${startBranch}`]);
21674
- const checkRuns = JSON.parse(await train.run("gh", ["api", `repos/${repo}/commits/${tip}/check-runs`, "--jq", TRAIN_CHECK_RUNS_JQ]));
21675
- const statuses = JSON.parse(await train.run("gh", ["api", `repos/${repo}/commits/${tip}/status`, "--jq", TRAIN_COMMIT_STATUS_JQ]));
21676
- const observed = required.filter((c) => checkRuns.some((r) => r.name === c) || statuses.some((s) => s.context === c));
21677
- const red = observed.filter((c) => resolveContextState(c, checkRuns, statuses) === "failed");
21678
- const inFlight = observed.filter((c) => resolveContextState(c, checkRuns, statuses) === "pending");
21679
- const at = `${startBranch}@${tip.slice(0, 12)}`;
21680
- if (red.length) add({ code: "required-check-red", severity: "blocker", source: "origin", title: `required ${stage} check(s) failed on the lane's start branch tip: ${red.join(", ")} at ${at}`, remedy: `--heal never clears this one \u2014 a red trunk needs a fix, not a heal: land the failure's fix on ${startBranch} through a CI-gated PR, then rerun once its run is green (#6461)` });
21681
- else if (inFlight.length) add({ code: "required-check-red", severity: "warning", source: "origin", title: `required ${stage} check(s) still in flight on the lane's start branch tip: ${inFlight.join(", ")} at ${at}`, remedy: `wait for the run to conclude and rerun \u2014 the tip's verdict is not known yet, and the train would tag before it is` });
21735
+ const startBranchMissing = startBranch != null && missing.includes(startBranch);
21736
+ const workflowsRef = `origin/${startBranch ?? "main"}`;
21737
+ const workflows = startBranchMissing ? null : deps.readWorkflows(cwd, workflowsRef);
21738
+ if (workflows === null && !startBranchMissing) {
21739
+ add({ code: "workflows-unreadable", severity: "blocker", source: "local", title: `.github/workflows at ${workflowsRef} could not be read from ${cwd} \u2014 the tag-addressability (#5428) and npm-major (#5666) preflights did not run`, remedy: `run from a checkout whose git can read ${workflowsRef} (fetch origin first), then rerun; an unread workflow set is unverified, not green` });
21740
+ }
21741
+ if (!startBranchMissing) {
21742
+ try {
21743
+ const required = await discoverRequiredCheckContexts(train, ctx, stage);
21744
+ if (required.length === 0) {
21745
+ add({ code: "bootstrap-gap", severity: "warning", source: "origin", title: `no ruleset requires a status check on ${stage} \u2014 the train tags without a check wait (the GitHub push gate is the backstop)`, remedy: `activate the product ruleset: \`mmi-cli devops bootstrap apply ${repo} --execute\` (from the MMI-Hub root), or \`mmi-cli devops ci audit --repo ${repo}\`` });
21746
+ } else {
21747
+ try {
21748
+ assertTagAddressableRequiredContexts({ readWorkflows: () => workflows }, required, repo);
21749
+ } catch (e) {
21750
+ add({ code: "contexts-not-tag-addressable", severity: "blocker", source: "local", title: `required ${stage} check(s) cannot materialize on a tag push (#5428)`, remedy: message(e) });
21751
+ }
21752
+ if (startBranch) {
21753
+ const tip = await git3(["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${startBranch}`]);
21754
+ const checkRuns = JSON.parse(await train.run("gh", ["api", `repos/${repo}/commits/${tip}/check-runs`, "--jq", TRAIN_CHECK_RUNS_JQ]));
21755
+ const statuses = JSON.parse(await train.run("gh", ["api", `repos/${repo}/commits/${tip}/status`, "--jq", TRAIN_COMMIT_STATUS_JQ]));
21756
+ const observed = required.filter((c) => checkRuns.some((r) => r.name === c) || statuses.some((s) => s.context === c));
21757
+ const red = observed.filter((c) => resolveContextState(c, checkRuns, statuses) === "failed");
21758
+ const inFlight = observed.filter((c) => resolveContextState(c, checkRuns, statuses) === "pending");
21759
+ const at = `${startBranch}@${tip.slice(0, 12)}`;
21760
+ if (red.length) add({ code: "required-check-red", severity: "blocker", source: "origin", title: `required ${stage} check(s) failed on the lane's start branch tip: ${red.join(", ")} at ${at}`, remedy: `--heal never clears this one \u2014 a red trunk needs a fix, not a heal: land the failure's fix on ${startBranch} through a CI-gated PR, then rerun once its run is green (#6461)` });
21761
+ else if (inFlight.length) add({ code: "required-check-red", severity: "warning", source: "origin", title: `required ${stage} check(s) still in flight on the lane's start branch tip: ${inFlight.join(", ")} at ${at}`, remedy: `wait for the run to conclude and rerun \u2014 the tip's verdict is not known yet, and the train would tag before it is` });
21762
+ }
21682
21763
  }
21764
+ } catch (e) {
21765
+ unverified(`required status checks on ${stage}`, e);
21683
21766
  }
21684
- } catch (e) {
21685
- unverified(`required status checks on ${stage}`, e);
21686
21767
  }
21687
21768
  let ledgerTag;
21688
21769
  let ledgerPath;
@@ -24606,7 +24687,7 @@ function ciReconcileExitFailed(input) {
24606
24687
  }
24607
24688
  return input.applyResults.some((result) => result.errors.length > 0 || result.postApply == null || result.postApply.state !== "clean" && result.postApply.state !== "repaired");
24608
24689
  }
24609
- async function authoritativeSafetyHold(authority, deps, repo, baseBranch) {
24690
+ async function authoritativeSafetyHold(authority, deps, repo, baseBranch, meta) {
24610
24691
  if (authority.coversReleaseBranches) {
24611
24692
  const prOnly = authority.contexts.filter((context) => TRAIN_PR_ONLY_CONTEXTS.has(context));
24612
24693
  if (prOnly.length > 0) {
@@ -24620,6 +24701,22 @@ async function authoritativeSafetyHold(authority, deps, repo, baseBranch) {
24620
24701
  if (missing.length > 0) {
24621
24702
  return `${authority.source} requires context(s) with no pull_request workflow emitter [${missing.join(", ")}]; no source or live mutation was made`;
24622
24703
  }
24704
+ if (authority.coversReleaseBranches) {
24705
+ const track = resolveReleaseTrack(meta, void 0, repo);
24706
+ const releaseBranches = branchesForTrack(track).filter((b) => b !== baseBranch);
24707
+ const ignore = /* @__PURE__ */ new Set([...AGENT_PR_BOOKKEEPING_CONTEXTS, ...TRAIN_PR_ONLY_CONTEXTS]);
24708
+ const requiredForReleaseBranches = authority.contexts.filter((context) => !ignore.has(context));
24709
+ for (const branch of releaseBranches) {
24710
+ if (await branchPresence(deps, repo, branch) !== true) continue;
24711
+ const emittedThere = await resolveEmittedPrContexts(deps, repo, branch);
24712
+ if (emittedThere === void 0) continue;
24713
+ const emittedThereSet = new Set(emittedThere);
24714
+ const unreachable = requiredForReleaseBranches.filter((context) => !emittedThereSet.has(context));
24715
+ if (unreachable.length > 0) {
24716
+ return `${authority.source} requires context(s) [${unreachable.join(", ")}] on ${branch}, but ${branch}'s own workflows emit none of them \u2014 a base-${branch} PR (e.g. a hotfix) could never satisfy the gate; no source or live mutation was made. Remove [${unreachable.join(", ")}] from ${branch} requirements, or add the emitting workflow to ${branch}`;
24717
+ }
24718
+ }
24719
+ }
24623
24720
  return null;
24624
24721
  }
24625
24722
  async function applyCiReconcileRepo(repo, deps) {
@@ -24636,7 +24733,7 @@ async function applyCiReconcileRepo(repo, deps) {
24636
24733
  empty.errors.push(`${PRODUCT_RULESET_REF} is not parseable: ${e.message}`);
24637
24734
  return finalizeCiReconcile(repo, deps, empty, report);
24638
24735
  }
24639
- const hold = await authoritativeSafetyHold(authority, deps, repo, baseBranch);
24736
+ const hold = await authoritativeSafetyHold(authority, deps, repo, baseBranch, meta);
24640
24737
  if (hold) {
24641
24738
  empty.skipped.push(hold);
24642
24739
  return finalizeCiReconcile(repo, deps, empty, report, hold);
@@ -24644,7 +24741,7 @@ async function applyCiReconcileRepo(repo, deps) {
24644
24741
  } else if (report.class === "deployable" && !report.explicitNoCi) {
24645
24742
  const registryAuthority = registryAuthorityWithoutReference(meta, repo);
24646
24743
  if (registryAuthority) {
24647
- const hold = await authoritativeSafetyHold(registryAuthority, deps, repo, baseBranch);
24744
+ const hold = await authoritativeSafetyHold(registryAuthority, deps, repo, baseBranch, meta);
24648
24745
  if (hold) {
24649
24746
  empty.skipped.push(hold);
24650
24747
  return finalizeCiReconcile(repo, deps, empty, report, hold);
@@ -24673,7 +24770,7 @@ async function applyCiReconcileRepo(repo, deps) {
24673
24770
  result.errors.push(`${PRODUCT_RULESET_REF} is not parseable: ${e.message}`);
24674
24771
  return finalizeCiReconcile(repo, deps, result, report);
24675
24772
  }
24676
- const hold = await authoritativeSafetyHold(authority, deps, repo, baseBranch);
24773
+ const hold = await authoritativeSafetyHold(authority, deps, repo, baseBranch, meta);
24677
24774
  if (hold) {
24678
24775
  result.skipped.push(hold);
24679
24776
  return finalizeCiReconcile(repo, deps, result, report, hold);
@@ -25859,7 +25956,7 @@ function registerBoardCommands(program3) {
25859
25956
  return `Claimed ${ref} for ${holder} - In Progress${reclaimed}`;
25860
25957
  }
25861
25958
  const board = program3.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
25862
- board.command("read", { isDefault: true }).alias("list").description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--direct", "bypass the Hub snapshot and read the live board through direct GitHub GraphQL").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--bodies", "fetch body/comments for EVERY scoped row, including taken and unowned in-flight ones \u2014 for consumers that scope by Status rather than ownership (#4861); implies --bundle-details and costs one extra read per row").option("--allow-partial", "return partial board results when later page/detail reads fail").option("--out <path>", "write the output to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5802)").addHelpText("after", "\nread is always the authoritative live GitHub Project v2 board (#4926).\n--direct skips the Hub snapshot and uses the existing direct GitHub GraphQL read immediately.\n--allow-partial applies to the paginated path and detail reads.\n\nNever capture the JSON with a shell redirect on Windows: PowerShell 5.1's `> file.json` is Out-File,\nwhich writes UTF-16LE with a BOM, and Node reading it as 'utf8' then fails JSON.parse at position 1\n(#5802). Use --out instead \u2014 the CLI writes the file itself as UTF-8:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n").action((o) => runBoardRead(o));
25959
+ board.command("read", { isDefault: true }).alias("list").description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--direct", "bypass the Hub snapshot and read the live board through direct GitHub GraphQL").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--bodies", "fetch body/comments for EVERY scoped row, including taken and unowned in-flight ones \u2014 for consumers that scope by Status rather than ownership (#4861); implies --bundle-details and costs one extra read per row").option("--allow-partial", "return partial board results when later page/detail reads fail").option("--out <path>", "write the output to this file as UTF-8 (no BOM) instead of stdout \u2014 the shell-free receipt path (#5802)").addHelpText("after", "\nread is always the authoritative live GitHub Project v2 board (#4926).\n--direct skips the Hub snapshot and uses the existing direct GitHub GraphQL read immediately.\n--allow-partial applies to the paginated path and detail reads.\n\nWhen using Windows PowerShell 5.1, avoid shell redirects: PowerShell 5.1's `> file.json` is Out-File,\nwhich writes UTF-16LE with a BOM, and Node reading it as 'utf8' then fails JSON.parse at position 1\n(#5802). Use --out instead \u2014 the CLI writes the file itself as UTF-8:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n").action((o) => runBoardRead(o));
25863
25960
  withExamples(mutating(
25864
25961
  board.command("claim <issues...>").description("claim issues: assign them and move their Project v2 Status to In Progress \u2014 idempotent, so an item already yours and In Progress succeeds unchanged (one or more refs); the board scan rides the Hub snapshot, same as board read").addHelpText("after", "\nclaim reads the board through the same Hub snapshot leg as `board read` (the App-installation\ncredential, never your personal GraphQL pool); the direct user-auth read is an emergency fallback\nand is named in a Warning line after the verdict (#6162).\n\nevery claim stamps a lane-identity marker comment on the issue (`<!-- mmi-claim: \u2026 -->`,\nsurface/session@host) so other agents can attribute the hold (#3727). The session is the\nhost-exported id when the surface provides one, otherwise a per-process `synth-` fallback \u2014\na claim is never anonymous (#5245). `board show`, doctor and unclaim read the latest marker.\n\nsame-owner resume (#6035): when the prior marker was posted by YOUR login on THIS host and its\nlocal session is verifiably dead (transcript probe), the claim proceeds as a resume without\n--force and names the evidence. A live, foreign-host, or unprobeable prior lane still refuses.\n\nreclaim from In Review (#6339): an In Review item with no holder, or one held by the claiming\nlogin, is claimable \u2014 it moves back to In Progress and the receipt names the status it came from.\nAn In Review item another login holds is refused (ask the holder, or wait for the review to land),\nand Done stays refused. The board status is the authority; no PR state is consulted.\n").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--force", "take an item already claimed by another lane that shows live evidence of active work (#3727)").option("--check", "read-only: run every claim gate and report the verdict, writing nothing \u2014 exits 1 with the same refusal a real claim would raise (#4511)").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails"),
25865
25962
  (_opts, args) => ({ command: "oracle board claim", issues: args[0] ?? [] })
@@ -29253,8 +29350,11 @@ function parseOauthVar(raw) {
29253
29350
  const out = {};
29254
29351
  for (const [key, value] of Object.entries(map)) {
29255
29352
  if (key === "subdomains" || key === "domains") {
29256
- if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || !v.trim())) {
29257
- throw new Error(`org project set: oauth.${key} must be an array of non-empty strings`);
29353
+ const allowEmpty = key === "subdomains";
29354
+ if (!Array.isArray(value) || value.some((v) => typeof v !== "string" || v.trim() === "" && !(allowEmpty && v === ""))) {
29355
+ throw new Error(
29356
+ allowEmpty ? `org project set: oauth.${key} must be an array of strings ("" selects the apex \u2014 the domain itself)` : `org project set: oauth.${key} must be an array of non-empty strings`
29357
+ );
29258
29358
  }
29259
29359
  out[key] = value.map((v) => v.trim());
29260
29360
  } else if (key === "callbackPath") {
@@ -29996,6 +30096,18 @@ function stageComposeFileEnv(files) {
29996
30096
  // src/stage-runner.ts
29997
30097
  var execFileP3 = (0, import_node_util5.promisify)(import_node_child_process11.execFile);
29998
30098
  var DOCKER_TIMEOUT_MS = 15e3;
30099
+ function stageBash() {
30100
+ if (process.platform !== "win32") return "bash";
30101
+ const configured = process.env.SHELL;
30102
+ if (configured && (0, import_node_path27.isAbsolute)(configured) && /[/\\]bash(?:\.exe)?$/i.test(configured) && !/[/\\](?:System32|Sysnative)[/\\]bash(?:\.exe)?$/i.test(configured) && (0, import_node_fs28.existsSync)(configured)) return configured;
30103
+ const candidates = [
30104
+ (0, import_node_path27.join)(process.env.ProgramFiles || "C:\\Program Files", "Git", "bin", "bash.exe"),
30105
+ ...process.env.LOCALAPPDATA ? [(0, import_node_path27.join)(process.env.LOCALAPPDATA, "Programs", "Git", "bin", "bash.exe")] : []
30106
+ ];
30107
+ const bash = candidates.find((path2) => (0, import_node_fs28.existsSync)(path2));
30108
+ if (!bash) throw new Error("Local stages require Git Bash on Windows. Install Git for Windows or set SHELL to its absolute bash.exe path.");
30109
+ return bash;
30110
+ }
29999
30111
  var EARLY_EXIT_GRACE_MS = 2e3;
30000
30112
  function earlyExitGraceMs() {
30001
30113
  if (process.env.NODE_ENV === "test") {
@@ -30186,24 +30298,6 @@ function mergeEnvSecretsIntoFile(content, secrets) {
30186
30298
  return body.endsWith("\n") ? body : `${body}
30187
30299
  `;
30188
30300
  }
30189
- var POSIX_ONLY_VERBS = ["cp", "mv", "rm", "ln", "cat", "touch", "chmod", "export"];
30190
- function posixOnlyShellProblems(command, field, platform2 = process.platform) {
30191
- if (platform2 !== "win32" || !command?.trim()) return [];
30192
- const problems = [];
30193
- if (/(^|&&|\||;)\s*[A-Za-z_][A-Za-z0-9_]*=\S/.test(command)) {
30194
- problems.push(
30195
- `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`
30196
- );
30197
- }
30198
- for (const verb of POSIX_ONLY_VERBS) {
30199
- if (new RegExp(`(^|&&|\\||;|\\()\\s*${verb}\\b`).test(command)) {
30200
- problems.push(
30201
- `stage.${field} calls POSIX '${verb}' which does not exist in cmd.exe on Windows; use the cmd/PowerShell equivalent or a cross-platform script`
30202
- );
30203
- }
30204
- }
30205
- return problems;
30206
- }
30207
30301
  function validateStageConfig(config = {}, action) {
30208
30302
  const problems = [];
30209
30303
  if (action === "run" && !config.build?.trim() && !config.ensureEnv) problems.push("stage.build is required for stage run");
@@ -30211,8 +30305,6 @@ function validateStageConfig(config = {}, action) {
30211
30305
  if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
30212
30306
  problems.push("stage.healthUrl must be an http(s) URL");
30213
30307
  }
30214
- if (action === "run") problems.push(...posixOnlyShellProblems(config.build, "build"));
30215
- problems.push(...posixOnlyShellProblems(config.up, "up"));
30216
30308
  if (config.portRange != null) {
30217
30309
  const r = config.portRange;
30218
30310
  const ok = Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n) && n >= 1024 && n <= 65535) && r[0] <= r[1];
@@ -30237,14 +30329,27 @@ function isPortFree(port) {
30237
30329
  });
30238
30330
  }
30239
30331
  async function shell(command, cwd, timeoutMs, env) {
30240
- await execFileP3(command, [], {
30332
+ const execution = execFileP3(stageBash(), ["-c", command], {
30241
30333
  cwd,
30242
- shell: true,
30243
- timeout: timeoutMs,
30334
+ timeout: process.platform === "win32" ? timeoutMs + 5e3 : timeoutMs,
30244
30335
  windowsHide: true,
30245
30336
  maxBuffer: 1024 * 1024 * 4,
30246
30337
  ...env ? { env: { ...process.env, ...env } } : {}
30247
30338
  });
30339
+ let cleanup;
30340
+ const timer = process.platform === "win32" ? setTimeout(() => {
30341
+ cleanup = killTree(execution.child.pid ?? 0);
30342
+ }, timeoutMs) : void 0;
30343
+ try {
30344
+ await execution;
30345
+ if (cleanup) throw new Error(`stage command timed out after ${timeoutMs}ms`);
30346
+ } catch (error) {
30347
+ if (cleanup) throw new Error(`stage command timed out after ${timeoutMs}ms`, { cause: error });
30348
+ throw error;
30349
+ } finally {
30350
+ clearTimeout(timer);
30351
+ await cleanup;
30352
+ }
30248
30353
  }
30249
30354
  async function listDockerContainers() {
30250
30355
  const { stdout } = await execFileP3("docker", ["container", "ls", "--format", "{{json .}}"], {
@@ -30484,7 +30589,7 @@ async function cleanupStageState(state, paths, timeoutMs, fallbackCwd, currentEn
30484
30589
  async function killTree(pid) {
30485
30590
  if (!Number.isInteger(pid) || pid <= 0) return;
30486
30591
  if (process.platform === "win32") {
30487
- await execFileP3("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }).catch(() => void 0);
30592
+ await execFileP3("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true, timeout: 5e3 }).catch(() => void 0);
30488
30593
  return;
30489
30594
  }
30490
30595
  try {
@@ -30550,6 +30655,7 @@ async function stopStage(opts = {}) {
30550
30655
  };
30551
30656
  }
30552
30657
  async function startStage(config = {}, opts = {}) {
30658
+ const bash = stageBash();
30553
30659
  const problems = validateStageConfig(config, "start");
30554
30660
  if (problems.length) throw new Error(problems.join("; "));
30555
30661
  const cwd = opts.cwd ?? process.cwd();
@@ -30571,9 +30677,8 @@ async function startStage(config = {}, opts = {}) {
30571
30677
  let up = sub(config.up.trim());
30572
30678
  if (opts.forceRecreate) up = appendForceRecreate(up);
30573
30679
  const identity = await resolveStageIdentity(cwd);
30574
- const child2 = (0, import_node_child_process11.spawn)(up, {
30680
+ const child2 = (0, import_node_child_process11.spawn)(bash, ["-c", up], {
30575
30681
  cwd,
30576
- shell: true,
30577
30682
  // POSIX-only: the process group exists for the group-kill in stopStage. On win32 teardown is
30578
30683
  // `taskkill /T /F` (no group needed), and detached+shell defeats windowsHide — every spawn would
30579
30684
  // flash a Windows Terminal window (0x800700e8) on dev machines.
@@ -30627,6 +30732,7 @@ function stageEnvRefusal(cwd) {
30627
30732
  return `stage refuses to run: .env file(s) present in the repo (${envFiles.join(", ")}) \u2014 secrets belong in the Hub vault; delete the file(s) and re-run (#5908)`;
30628
30733
  }
30629
30734
  async function runStage(config = {}, opts = {}) {
30735
+ stageBash();
30630
30736
  const problems = validateStageConfig(config, "run");
30631
30737
  if (problems.length) throw new Error(problems.join("; "));
30632
30738
  const cwd = opts.cwd ?? process.cwd();
@@ -33492,18 +33598,33 @@ async function preflightBatchSurfaces(validated, rowRepo, options) {
33492
33598
  }
33493
33599
  if (options.noSurface) return [];
33494
33600
  const errors = [];
33495
- const cache = /* @__PURE__ */ new Map();
33601
+ const censusCache = /* @__PURE__ */ new Map();
33602
+ const censusFor = (repo) => {
33603
+ let pending = censusCache.get(repo);
33604
+ if (!pending) {
33605
+ pending = readRepoSurfaceLabels(repo);
33606
+ censusCache.set(repo, pending);
33607
+ }
33608
+ return pending;
33609
+ };
33496
33610
  for (const { row, spec } of validated) {
33611
+ if (labelsCarrySurface(spec.labels)) continue;
33497
33612
  const repo = rowRepo(spec);
33498
- const key = [repo, labelsCarrySurface(spec.labels) ? "has" : "none"].join("::");
33499
- let verdict = cache.get(key);
33500
- if (!verdict) {
33501
- verdict = await checkSurfaceRequirement({ repo, labels: spec.labels, command: "issue create --batch" });
33502
- cache.set(key, verdict);
33503
- if (verdict.warn) process.stderr.write(`${verdict.warn}
33504
- `);
33613
+ const known = await censusFor(repo);
33614
+ if (known === void 0) {
33615
+ process.stderr.write(
33616
+ `warning: could not read ${repo}'s labels, so row ${row}'s surface-label requirement was not checked; if that board enforces one surface:* label per open issue, add one with \`mmi-cli oracle issue edit <n> --add-label surface:<value>\`
33617
+ `
33618
+ );
33619
+ continue;
33505
33620
  }
33506
- if (verdict.refusal) errors.push({ row, error: verdict.refusal.message });
33621
+ if (known.length === 0) continue;
33622
+ const inferred = inferSurface(known, { title: spec.title, body: spec.body });
33623
+ spec.labels = [...spec.labels ?? [], inferred.label];
33624
+ process.stderr.write(
33625
+ `mmi-cli: no --surface given \u2014 row ${row} filed under ${inferred.label} (${inferred.reason}). Pass --surface to choose.
33626
+ `
33627
+ );
33507
33628
  }
33508
33629
  return errors;
33509
33630
  }
@@ -35597,8 +35718,8 @@ function registerSecretsCommands(program3) {
35597
35718
  const secrets = program3.command("secrets").description("project vault \u2014 project-admins self-serve their own repo's full tree (stageless + dev/rc/main); org-infra namespaces are master-gated");
35598
35719
  secrets.command("where").description("print where this repo's secrets live \u2014 the two-tier vault layout + well-known keys (no values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action((o) => withSecrets((d) => secretsWhere(d, o)));
35599
35720
  secrets.command("list").description("list secret NAMES + tier for THIS repo (never values). To locate a key you cannot place \u2014 including org-infra (_org) \u2014 use the cross-slug view: `mmi-cli oracle org access capabilities` (#3436)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action((o) => withSecrets((d) => secretsList(d, o)));
35600
- secrets.command("find <intent>").description("resolve a plain-language intent to canonical secret names + the exact keyless-use command (master-only, #2244)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action((intent, o) => withSecrets(async (d) => {
35601
- if (!await secretsFind(d, intent, o)) process.exitCode = 1;
35721
+ secrets.command("find <intent...>").description("resolve a plain-language intent to canonical secret names + the exact keyless-use command (master-only, #2244); an unquoted multi-word intent is joined with spaces (#6495)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action((intentWords, o) => withSecrets(async (d) => {
35722
+ if (!await secretsFind(d, intentWords.join(" "), o)) process.exitCode = 1;
35602
35723
  }));
35603
35724
  secrets.command("catalog").description("the secret catalog \u2014 grouped, with each secret's exact keyless-use command (master-only, #2244)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--drift", "include the bidirectional reconciler drift report").option("--json", "machine-readable output").action((o) => withSecrets((d) => secretsCatalog(d, o)));
35604
35725
  secrets.command("doctor").description("vault drift report \u2014 declared-missing / orphan / off-scheme / duplicate (master-only, #2244)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "machine-readable output").action((o) => withSecrets(async (d) => {
@@ -35922,8 +36043,8 @@ var import_node_fs34 = require("node:fs");
35922
36043
  var import_node_path31 = require("node:path");
35923
36044
 
35924
36045
  // src/stage-default.ts
35925
- function shellFor(platform2 = process.platform) {
35926
- return platform2 === "win32" ? "powershell" : "bash";
36046
+ function shellFor() {
36047
+ return "bash";
35927
36048
  }
35928
36049
  function isCentralContainerModel(model) {
35929
36050
  return model === "tenant-container" || model === "solo-container";
@@ -36394,7 +36515,7 @@ function registerStageCommands(program3) {
36394
36515
  fail(`stage stop: ${e.message}`);
36395
36516
  }
36396
36517
  });
36397
- 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("--port <port>", "loopback port for this worktree stage (1024..65535)").option("--timeout-ms <ms>", "bounded health timeout", "60000").option("--allow-stale-env", "start despite a stale ensureEnv target file").action(async () => {
36518
+ 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 in Bash (Git Bash on Windows)").option("--port <port>", "loopback port for this worktree stage (1024..65535)").option("--timeout-ms <ms>", "bounded health timeout", "60000").option("--allow-stale-env", "start despite a stale ensureEnv target file").action(async () => {
36398
36519
  const o = { json: rawFlag("--json"), apply: rawFlag("--apply"), timeoutMs: rawValue("--timeout-ms", "60000"), allowStaleEnv: rawFlag("--allow-stale-env") };
36399
36520
  const { resolution: res, project: project2, cfg: stageCfg } = await resolveStage();
36400
36521
  if (!o.apply) {
@@ -37080,8 +37201,20 @@ function matchedMandatoryGlobs(paths, mandatory) {
37080
37201
  return list.some((path2) => re.test(path2));
37081
37202
  });
37082
37203
  }
37083
- function evaluateTestCommandPolicy({ paths, mandatory, regulated = true, override = null, addedPaths = [] } = {}) {
37204
+ function evaluateTestCommandPolicy({ paths, mandatory, regulated = true, override = null, addedPaths = [], structuralRefusal = false } = {}) {
37084
37205
  const configuredMandatoryCount = mandatoryGlobList(mandatory).length;
37206
+ if (structuralRefusal) {
37207
+ return {
37208
+ configuredMandatoryCount,
37209
+ matchedMandatoryGlobs: [],
37210
+ matchedMandatoryCount: 0,
37211
+ testCommandsAllowed: false,
37212
+ editsExistingTest: false,
37213
+ reasonId: "test-command-outside-mandatory-zone",
37214
+ testCommandNote: null,
37215
+ commandClasses: { allowed: [], refused: [TEST_COMMAND_CLASS] }
37216
+ };
37217
+ }
37085
37218
  if (!regulated) {
37086
37219
  return {
37087
37220
  configuredMandatoryCount: 0,
@@ -37090,25 +37223,30 @@ function evaluateTestCommandPolicy({ paths, mandatory, regulated = true, overrid
37090
37223
  testCommandsAllowed: true,
37091
37224
  editsExistingTest: false,
37092
37225
  reasonId: null,
37226
+ testCommandNote: null,
37093
37227
  commandClasses: { allowed: [TEST_COMMAND_CLASS], refused: [] }
37094
37228
  };
37095
37229
  }
37096
37230
  const matched = matchedMandatoryGlobs(paths, mandatory);
37097
37231
  const overrideAuthorizes = Array.isArray(override?.kinds) && override.kinds.includes(TEST_WORK_KIND);
37098
37232
  const existingTestEdited = editsExistingTest(paths, addedPaths);
37099
- const testCommandsAllowed = matched.length > 0 || overrideAuthorizes || existingTestEdited;
37233
+ const inMandatoryZone = matched.length > 0 || overrideAuthorizes || existingTestEdited;
37234
+ const testCommandsAllowed = true;
37100
37235
  return {
37101
37236
  configuredMandatoryCount,
37102
37237
  matchedMandatoryGlobs: matched,
37103
37238
  matchedMandatoryCount: matched.length,
37104
37239
  testCommandsAllowed,
37105
- /** #5842: true when the diff edits a test file that already existed — one of the facts that can
37106
- * authorize the class, named so a refusal can say what was missing. */
37240
+ /** #5842: true when the diff edits a test file that already existed — one of the facts that used
37241
+ * to authorize the class, kept for callers that still branch on it. */
37107
37242
  editsExistingTest: existingTestEdited,
37108
- reasonId: testCommandsAllowed ? null : "test-command-outside-mandatory-zone",
37243
+ reasonId: null,
37244
+ /** Informational only (#1166): set when the diff is outside the mandatory zone, so a caller can
37245
+ * note that CI does not require a test here — never a reason to refuse the run. */
37246
+ testCommandNote: inMandatoryZone ? null : "test-command-outside-mandatory-zone",
37109
37247
  commandClasses: {
37110
- allowed: testCommandsAllowed ? [TEST_COMMAND_CLASS] : [],
37111
- refused: testCommandsAllowed ? [] : [TEST_COMMAND_CLASS]
37248
+ allowed: [TEST_COMMAND_CLASS],
37249
+ refused: []
37112
37250
  }
37113
37251
  };
37114
37252
  }
@@ -37778,7 +37916,12 @@ function runTestPolicy(root, deps = {}) {
37778
37916
  addedPaths: changed.filter((f) => f.status === "A" || f.status === "R").map((f) => f.path),
37779
37917
  mandatory: policy.mandatory,
37780
37918
  regulated: policy.declared !== false || policyRefusals.length > 0,
37781
- override: override && lookup.refusals.length === 0 ? { kinds: override.kinds } : null
37919
+ override: override && lookup.refusals.length === 0 ? { kinds: override.kinds } : null,
37920
+ // #1166: a still-blocking pre-diff finding (untrusted range, unresolvable base, a missing policy
37921
+ // on the hotfix lane, a malformed trailer, an unwaived stale entry) means the diff itself was
37922
+ // never evaluated — the mandatory-zone question this evaluator now answers permissively was
37923
+ // never reached, so the refusal stands exactly as before.
37924
+ structuralRefusal: blocking.length > 0
37782
37925
  });
37783
37926
  const result = {
37784
37927
  ok: findings.length === 0,
@@ -37792,6 +37935,7 @@ function runTestPolicy(root, deps = {}) {
37792
37935
  matchedMandatoryCount: commandPolicy.matchedMandatoryCount,
37793
37936
  testCommandsAllowed: commandPolicy.testCommandsAllowed,
37794
37937
  testCommandReasonId: commandPolicy.reasonId,
37938
+ testCommandNote: commandPolicy.testCommandNote,
37795
37939
  commandClasses: commandPolicy.commandClasses
37796
37940
  };
37797
37941
  if (override) {
@@ -39808,7 +39952,7 @@ function registerCollaborationCommands(program3) {
39808
39952
  }
39809
39953
  const issue = program3.command("issue").description("issues \u2014 create and view with structured JSON (view; show, get and read alias view for board-verb/gh-shaped callers). Claims are board mutations: use `oracle board claim`, never `oracle issue claim` or the flat `board claim`");
39810
39954
  withExamples(mutating(
39811
- issue.command("create").description("create an issue (type \u2014 label) and print {number,url,label} JSON").addOption(new Option("--type <type>", "bug | feature | task (sets the matching label; required unless --batch)").choices([...ISSUE_TYPES])).option("--title <title>", "issue title").option("--title-file <path|->", "read the issue title from a UTF-8 file, or from stdin with -").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file. `-` (stdin) needs a heredoc, which the agent inline-body guard denies (#1473/#2125) \u2014 prefer a real path; a title with backticks needs --title-file for the same reason (#3381)").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only \u2014 never a priority:* label, #416)").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("--surface <surface>", "issue surface, with or without the surface: prefix (#3789). Required when the target repo runs the one-surface-label board rule; any value satisfies it, so this is not a closed enum").option("--no-surface", "file without a surface label on a repo that requires one \u2014 for a genuinely exempt filing (e.g. a coop proof issue that spans every surface)").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"),
39955
+ issue.command("create").description("create an issue (type \u2014 label) and print {number,url,label} JSON").addOption(new Option("--type <type>", "bug | feature | task (sets the matching label; required unless --batch)").choices([...ISSUE_TYPES])).option("--title <title>", "issue title").option("--title-file <path|->", "read the issue title from a UTF-8 file, or from stdin with -").option("--body <body>", "issue body (markdown)").option("--body-file <path|->", "read issue body from a UTF-8 file. `-` (stdin) needs a heredoc, which the agent inline-body guard denies (#1473/#2125) \u2014 prefer a real path; a title with backticks needs --title-file for the same reason (#3381)").option("--priority <priority>", "urgent | high | medium | low (defaults to medium; sets the board Priority field only \u2014 never a priority:* label, #416)").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("--surface <surface>", "issue surface, with or without the surface: prefix (#3789). Any value satisfies the one-surface-label board rule, so this is not a closed enum. Omit it on an enforcing repo and one is inferred from the title/body (#1164) \u2014 the pick is printed and included in --json as surface_inferred/surface/surface_reason").option("--no-surface", "file without a surface label on a repo that requires one \u2014 for a genuinely exempt filing (e.g. a coop proof issue that spans every surface). Suppresses inference too").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"),
39812
39956
  // --dry-run/--validate-only plan: resolve the same title source and validate the same type, priority,
39813
39957
  // and surface contract as the real action. A plan that echoes the title-file PATH instead of its value
39814
39958
  // is not a plan of the mutation that will run (#3914).
@@ -39832,14 +39976,24 @@ function registerCollaborationCommands(program3) {
39832
39976
  if (clash) fail(clash.message, clash.payload);
39833
39977
  const planRepo = opts.batch || surfaceWaived() ? void 0 : await resolveRepo(opts.repo);
39834
39978
  const surface = resolveCreateSurface(opts);
39979
+ let planInferred;
39835
39980
  if (planRepo) {
39836
- const { refusal, warn } = await checkSurfaceRequirement({
39981
+ const { refusal, warn, inferred } = await checkSurfaceRequirement({
39837
39982
  repo: planRepo,
39838
- labels: [...planLabels, ...surface ? [surface] : []]
39983
+ labels: [...planLabels, ...surface ? [surface] : []],
39984
+ title,
39985
+ body: opts.body
39839
39986
  });
39840
39987
  if (warn) process.stderr.write(`${warn}
39841
39988
  `);
39842
39989
  if (refusal) fail(refusal.message, refusal.payload);
39990
+ if (inferred && !surface) {
39991
+ planInferred = inferred;
39992
+ process.stderr.write(
39993
+ `mmi-cli: no --surface given \u2014 filed under ${inferred.label} (${inferred.reason}). Pass --surface to choose.
39994
+ `
39995
+ );
39996
+ }
39843
39997
  }
39844
39998
  return {
39845
39999
  command: "issue create",
@@ -39847,7 +40001,8 @@ function registerCollaborationCommands(program3) {
39847
40001
  title,
39848
40002
  priority,
39849
40003
  repo: opts.repo,
39850
- ...surface ? { surface } : {}
40004
+ ...surface ? { surface } : {},
40005
+ ...planInferred ? { surface_inferred: true, surface: planInferred.label, surface_reason: planInferred.reason } : {}
39851
40006
  };
39852
40007
  }
39853
40008
  ).action(async (o) => {
@@ -39859,6 +40014,7 @@ function registerCollaborationCommands(program3) {
39859
40014
  let extraLabels = [];
39860
40015
  let targetRepo2;
39861
40016
  let surfaceFlagLabel;
40017
+ let surfaceInferred;
39862
40018
  try {
39863
40019
  issueType = resolveCreateType(o.type, "issue create", o.label);
39864
40020
  title = await resolveIssueTitle({ title: o.title, titleFile: o.titleFile }, { readFile: import_promises7.readFile, readStdin });
@@ -39890,8 +40046,8 @@ function registerCollaborationCommands(program3) {
39890
40046
  return fail(`issue create: ${e.message}`, e instanceof TextArgError ? { code: e.code, offending_flag: e.offendingFlag } : void 0);
39891
40047
  }
39892
40048
  {
39893
- const surfaceCheck = await checkSurfaceRequirement({ repo: targetRepo2, labels: extraLabels });
39894
- const { refusal, warn } = surfaceCheck;
40049
+ const surfaceCheck = await checkSurfaceRequirement({ repo: targetRepo2, labels: extraLabels, title, body });
40050
+ const { refusal, warn, inferred } = surfaceCheck;
39895
40051
  if (warn) process.stderr.write(`${warn}
39896
40052
  `);
39897
40053
  if (shouldWithdrawSurfaceFlag(surfaceCheck) && surfaceFlagLabel) {
@@ -39906,6 +40062,22 @@ function registerCollaborationCommands(program3) {
39906
40062
  });
39907
40063
  process.stderr.write(
39908
40064
  `warning: --surface ${surfaceFlagLabel} was dropped \u2014 ${targetRepo2} defines no surface:* labels, and creating one here would switch the one-surface-label rule on for every later filing in it. Use --label ${surfaceFlagLabel} if you really mean to start that taxonomy.
40065
+ `
40066
+ );
40067
+ }
40068
+ if (inferred && !surfaceFlagLabel && !surfaceWaived()) {
40069
+ extraLabels = [...extraLabels, inferred.label];
40070
+ args = buildIssueArgs({
40071
+ type: issueType,
40072
+ title,
40073
+ body,
40074
+ priority,
40075
+ repo: targetRepo2,
40076
+ labels: extraLabels.length ? extraLabels : void 0
40077
+ });
40078
+ surfaceInferred = inferred;
40079
+ process.stderr.write(
40080
+ `mmi-cli: no --surface given \u2014 filed under ${inferred.label} (${inferred.reason}). Pass --surface to choose.
39909
40081
  `
39910
40082
  );
39911
40083
  }
@@ -39946,7 +40118,9 @@ function registerCollaborationCommands(program3) {
39946
40118
  ...attached.resetAt ? { resetAt: attached.resetAt } : {},
39947
40119
  ...attached.resetEpochSeconds !== void 0 ? { resetEpochSeconds: attached.resetEpochSeconds } : {}
39948
40120
  } : {},
39949
- ...parentLinkFields(parent, parentLinkError)
40121
+ ...parentLinkFields(parent, parentLinkError),
40122
+ // #1164: surfaced so a caller can see (and override with --surface) a pick it never asked for.
40123
+ ...surfaceInferred ? { surface_inferred: true, surface: surfaceInferred.label, surface_reason: surfaceInferred.reason } : {}
39950
40124
  }));
39951
40125
  }), [
39952
40126
  'mmi-cli oracle issue create --type task --title "Wire the schema"',
@@ -42091,7 +42265,7 @@ function registerDeveloperCommands(program3) {
42091
42265
  }
42092
42266
  });
42093
42267
  const tests = program3.command("tests").description("a repo's test-policy.json \u2014 the opt-in test contract and its enforcement");
42094
- tests.command("policy").description("enforce this repo's test-policy.json against the diff: a mandatory-zone change must carry a test, an unrequested new test file is refused, a `protected` test file may not be deleted or renamed away, and a `protected` entry naming a missing file is refused. Override any of them with a `Test-Policy-Override: <reason>` commit trailer (#3605)").option("--json", "machine-readable result: { ok, base, policySource: { ref, sha, path }, root, changedCount, mandatoryCount, matchedMandatoryCount, matchedMandatoryGlobs, testCommandsAllowed, commandClasses, findings[] }").option("--base <ref>", "comparison base (default: TEST_POLICY_BASE, then origin/development, then origin/main)").option("--policy-ref <commit>", "load test-policy.json from the exact fetched origin/development commit and audit its protected/satisfiedBy paths against that commit's tree, not this checkout; valid only with --base origin/main").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 tests policy reads the current checkout").hideHelp()).action(async (o) => {
42268
+ tests.command("policy").description("enforce this repo's test-policy.json against the diff: a mandatory-zone change must carry a test, an unrequested new test file is refused, a `protected` test file may not be deleted or renamed away, and a `protected` entry naming a missing file is refused. Override any of them with a `Test-Policy-Override: <reason>` commit trailer (#3605)").option("--json", "machine-readable result: { ok, base, policySource: { ref, sha, path }, root, changedCount, mandatoryCount, matchedMandatoryCount, matchedMandatoryGlobs, testCommandsAllowed, testCommandNote, commandClasses, findings[] }").option("--base <ref>", "comparison base (default: TEST_POLICY_BASE, then origin/development, then origin/main)").option("--policy-ref <commit>", "load test-policy.json from the exact fetched origin/development commit and audit its protected/satisfiedBy paths against that commit's tree, not this checkout; valid only with --base origin/main").addOption(new Option("--repo <owner/repo>", "not accepted \u2014 tests policy reads the current checkout").hideHelp()).action(async (o) => {
42095
42269
  try {
42096
42270
  if (o.repo) {
42097
42271
  const rerun = `mmi-cli tests policy${o.base ? ` --base ${o.base}` : ""}${o.policyRef ? ` --policy-ref ${o.policyRef}` : ""}${o.json ? " --json" : ""}`;
@@ -42114,7 +42288,7 @@ function registerDeveloperCommands(program3) {
42114
42288
  return;
42115
42289
  }
42116
42290
  if (result.ok) {
42117
- const commandVerdict = result.testCommandsAllowed ? "test commands: allowed" : `test commands: refused [${result.testCommandReasonId}] \u2014 resolved repo: ${result.root}; if that is not the repo under test, start the command with a literal Set-Location "<your worktree>"`;
42291
+ const commandVerdict = result.testCommandsAllowed ? result.testCommandNote ? `test commands: allowed (informational [${result.testCommandNote}] \u2014 CI does not require a test here)` : "test commands: allowed" : `test commands: refused [${result.testCommandReasonId}] \u2014 resolved repo: ${result.root}; if that is not the repo under test, start the command with a literal Set-Location "<your worktree>"`;
42118
42292
  console.log(
42119
42293
  `tests policy: OK (${result.changedCount} changed file(s); ${result.matchedMandatoryCount} of ${result.mandatoryCount} mandatory glob(s) matched, ${result.protectedCount} protected file(s); ${commandVerdict}).`
42120
42294
  );
@@ -45050,8 +45224,7 @@ project.command("sync-info [owner/repo]").description("synchronize the owning Gi
45050
45224
  return failGraceful(e.message);
45051
45225
  }
45052
45226
  });
45053
- var projectDeploy = project.command("deploy").description("read nonsecret DEPLOY# facts (domain, port, deploy path, substrate, host presence)");
45054
- projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# facts for one project; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
45227
+ async function runProjectDeployGet(repoOrSlug, o) {
45055
45228
  const cfg = await loadConfig();
45056
45229
  let target;
45057
45230
  try {
@@ -45065,7 +45238,9 @@ projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# fa
45065
45238
  if (stage && !["dev", "rc", "main"].includes(stage)) return fail("org project deploy get: --stage must be dev, rc, or main");
45066
45239
  const payload = stage ? { slug: out.slug, stage, deploy: out.stages[stage] ?? null } : out;
45067
45240
  console.log(JSON.stringify(payload));
45068
- });
45241
+ }
45242
+ var projectDeploy = project.command("deploy").description("read nonsecret DEPLOY# facts (domain, port, deploy path, substrate, host presence)").argument("[ownerRepo]", "shorthand for `get <owner/repo>`").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--json", "machine-readable output").action(async (ownerRepo, o) => runProjectDeployGet(ownerRepo, o));
45243
+ projectDeploy.command("get [owner/repo]").description("read nonsecret DEPLOY# facts for one project; defaults to the current repo").addOption(new Option("--stage <stage>", "dev | rc | main").choices(["dev", "rc", "main"])).option("--json", "machine-readable output").action(async (repoOrSlug, o) => runProjectDeployGet(repoOrSlug, o));
45069
45244
  projectDeploy.command("doctor").description("read-only estate scan for duplicate Hetzner (sshHost, port) DEPLOY# coordinates (#5431); master-only; never auto-reassigns ports").option("--json", "machine-readable output").action(async (o) => {
45070
45245
  const cfg = await loadConfig();
45071
45246
  const report = await fetchDeployPortCollisions(registryClientDeps(cfg));
@@ -45313,11 +45488,13 @@ oauth.command("plan", { isDefault: true }).description("print the canonical JS o
45313
45488
  oc = parseOauthConfig(meta ?? {}, slug);
45314
45489
  } catch (e) {
45315
45490
  const message2 = e.message;
45491
+ const declareCommand = `mmi-cli oracle org project set ${o.repo ?? `mutmutco/${slug}`} --var 'oauth={"subdomains":["${defaultSubdomain(slug)}"],"callbackPath":"${DEFAULT_CALLBACK_PATH}"}'`;
45316
45492
  if (/^oauth is not configured for /.test(message2)) {
45317
- return failGraceful(
45493
+ return failGracefulEnvelope(
45318
45494
  `org oauth plan: ${message2}. Declare it in the registry META first:
45319
- mmi-cli oracle org project set ${o.repo ?? `mutmutco/${slug}`} --var 'oauth={"subdomains":["${defaultSubdomain(slug)}"],"callbackPath":"${DEFAULT_CALLBACK_PATH}"}'
45320
- New repo? The GCP project and its Google Auth Platform consent screen must exist too \u2014 docs/Guides/oauth-provision.md \u2014 New repo.`
45495
+ ${declareCommand}
45496
+ New repo? The GCP project and its Google Auth Platform consent screen must exist too \u2014 docs/Guides/oauth-provision.md \u2014 New repo.`,
45497
+ { code: ERROR_CODES.ERR_STATE_CONFLICT, corrected_command: declareCommand }
45321
45498
  );
45322
45499
  }
45323
45500
  return failGraceful(`org oauth plan: ${message2}`);
@@ -45704,6 +45881,14 @@ function resolveHouseShim(argv) {
45704
45881
  argv.splice(2, 1);
45705
45882
  return;
45706
45883
  }
45884
+ if (actual && isDeclaredHouseAlias(houseToken, lookupPath)) {
45885
+ process.stderr.write(
45886
+ `mmi-cli: '${lookupPath}' lives in house '${actual}', not '${houseToken}' \u2014 routed to \`mmi-cli ${canonicalPathFor(lookupPath)}\`
45887
+ `
45888
+ );
45889
+ argv.splice(2, 1);
45890
+ return;
45891
+ }
45707
45892
  if (actual) {
45708
45893
  refuseHouseShim(
45709
45894
  lookupPath,
@@ -45732,6 +45917,7 @@ function printHouseRootHelp(house) {
45732
45917
  );
45733
45918
  consoleIo.log(lines2.join("\n"));
45734
45919
  }
45920
+ resolveVerbAliasShim(process.argv);
45735
45921
  resolveHouseShim(process.argv);
45736
45922
  program2.parseAsync(process.argv).then(() => finishCliRun()).catch((e) => failGraceful(e.message));
45737
45923
  // Annotate the CommonJS export names for ESM import in node:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.3.38",
3
+ "version": "4.3.40",
4
4
  "description": "MMI Future CLI — the org dev toolbox and shared cross-IDE engine for every registry-declared MMI coding surface.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",