@mutmutco/codex-plugin 4.6.31 → 4.6.33

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mmi",
3
- "version": "4.6.31",
3
+ "version": "4.6.33",
4
4
  "mmiCompat": "4.x",
5
5
  "description": "MMI workflow skills and org gates delivery.",
6
6
  "author": {
package/cli/dist/main.cjs CHANGED
@@ -27062,6 +27062,71 @@ function contentPutInputArgs(repo, path2, inputFile) {
27062
27062
  inputFile
27063
27063
  ];
27064
27064
  }
27065
+ var BANK_REQUEST_REPO = "mutmutco/Jerv-Hub";
27066
+ var BANK_REQUEST_LABEL = "bank-request";
27067
+ var BANK_REQUEST_SOURCE = "MMI-Hub#9958";
27068
+ function bankRequestTitle(repoName2) {
27069
+ return `bank-request: coding-agent::${repoName2}`;
27070
+ }
27071
+ function bankRequestBody(fullName) {
27072
+ return `Create the coding-agent memory bank for \`${fullName}\` (asked by \`mmi-cli devops bootstrap apply\`; see ${BANK_REQUEST_SOURCE}).`;
27073
+ }
27074
+ function planBankRequest(fullName, existing) {
27075
+ const parsed = parseOwnerRepo(fullName);
27076
+ const base = { title: bankRequestTitle(parsed.name), label: BANK_REQUEST_LABEL, body: bankRequestBody(parsed.fullName) };
27077
+ if (existing == null) return { ...base, action: "skip", reason: `could not read ${BANK_REQUEST_REPO} issues` };
27078
+ const found = existing.find((issue) => issue?.title === base.title);
27079
+ if (found) {
27080
+ return {
27081
+ ...base,
27082
+ action: "exists",
27083
+ number: found.number,
27084
+ state: found.state ?? "open",
27085
+ url: found.url ?? `https://github.com/${BANK_REQUEST_REPO}/issues/${found.number}`
27086
+ };
27087
+ }
27088
+ return { ...base, action: "create" };
27089
+ }
27090
+ var oneLine = (e) => String(e?.message ?? e).split("\n")[0].trim();
27091
+ function bankRequestManualStep(request, reason) {
27092
+ return `file it by hand at https://github.com/${BANK_REQUEST_REPO}/issues/new \u2014 title "${request.title}", label ${request.label}, body: ${request.body} (${reason})`;
27093
+ }
27094
+ function renderBankRequestPlan(result) {
27095
+ return [
27096
+ "bootstrap apply \u2014 coding-agent memory bank request (dry-run; no issue filed):",
27097
+ ` PLAN file issue "${result.title}" (label ${result.label}) in ${BANK_REQUEST_REPO}`,
27098
+ ` body: ${result.body}`
27099
+ ].join("\n");
27100
+ }
27101
+ function renderBankRequestLine(result) {
27102
+ if (result.status === "opened") {
27103
+ return `memory bank request: ${BANK_REQUEST_REPO}#${result.number} opened \u2014 "${result.title}" (label ${result.label})${result.url ? ` ${result.url}` : ""}`;
27104
+ }
27105
+ if (result.status === "exists") {
27106
+ return `memory bank request: already requested \u2014 ${BANK_REQUEST_REPO}#${result.number} (${result.state})${result.url ? ` ${result.url}` : ""}`;
27107
+ }
27108
+ return `memory bank request: SKIPPED (${result.reason ?? "not attempted"}) \u2014 ${bankRequestManualStep(result, result.reason ?? "not attempted")}`;
27109
+ }
27110
+ async function runBankRequest(fullName, options, deps) {
27111
+ const parsed = parseOwnerRepo(fullName);
27112
+ const request = { title: bankRequestTitle(parsed.name), label: BANK_REQUEST_LABEL, body: bankRequestBody(parsed.fullName) };
27113
+ if (options.dryRun) return { ...request, status: "planned" };
27114
+ let existing;
27115
+ try {
27116
+ existing = await deps.listIssues();
27117
+ } catch (e) {
27118
+ return { ...request, status: "skipped", reason: `could not read ${BANK_REQUEST_REPO} issues: ${oneLine(e)}` };
27119
+ }
27120
+ const plan = planBankRequest(fullName, existing);
27121
+ if (plan.action === "exists") return { ...request, status: "exists", number: plan.number, state: plan.state, url: plan.url };
27122
+ if (plan.action === "skip") return { ...request, status: "skipped", reason: plan.reason };
27123
+ try {
27124
+ const created = await deps.createIssue(request);
27125
+ return { ...request, status: "opened", number: created.number, url: created.url };
27126
+ } catch (e) {
27127
+ return { ...request, status: "skipped", reason: `App issue write failed: ${oneLine(e)}` };
27128
+ }
27129
+ }
27065
27130
 
27066
27131
  // src/train-apply-contracts.ts
27067
27132
  var import_node_fs21 = require("node:fs");
@@ -28301,10 +28366,10 @@ var rollout_plan_default = {
28301
28366
  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)."
28302
28367
  },
28303
28368
  baseline: {
28304
- version: "4.6.31",
28305
- tag: "v4.6.31",
28306
- commit: "53e22eaf158d",
28307
- npm: "@mutmutco/cli@4.6.31"
28369
+ version: "4.6.33",
28370
+ tag: "v4.6.33",
28371
+ commit: "80768b0b2052",
28372
+ npm: "@mutmutco/cli@4.6.33"
28308
28373
  },
28309
28374
  exitCriterion: "fleet-n-of-n",
28310
28375
  hubOnlyShortcut: "forbidden",
@@ -28321,14 +28386,14 @@ var rollout_plan_default = {
28321
28386
  repo: "mutmutco/mmi-hub",
28322
28387
  role: "canary",
28323
28388
  schedule: "train",
28324
- v3Target: "v4.6.31"
28389
+ v3Target: "v4.6.33"
28325
28390
  }
28326
28391
  ],
28327
28392
  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.",
28328
28393
  rollback: {
28329
28394
  independent: true,
28330
- mechanism: "npm dist-tag latest -> 4.6.31 and redeploy the Hub Lambda from tag v4.6.31 (53e22eaf158d); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
28331
- v3Target: "v4.6.31 (@mutmutco/cli@4.6.31, tag commit 53e22eaf158d \u2014 last known-good release carrying the repo-index v4-only contract)"
28395
+ mechanism: "npm dist-tag latest -> 4.6.33 and redeploy the Hub Lambda from tag v4.6.33 (80768b0b2052); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
28396
+ v3Target: "v4.6.33 (@mutmutco/cli@4.6.33, tag commit 80768b0b2052 \u2014 last known-good release carrying the repo-index v4-only contract)"
28332
28397
  }
28333
28398
  },
28334
28399
  {
@@ -42556,6 +42621,9 @@ async function runPrLandOnce(prNumber, options, deps) {
42556
42621
  base.mergeStatus = merge.mergeStatus;
42557
42622
  if (merge.credential) base.credential = merge.credential;
42558
42623
  if (merge.credentialRole) base.credentialRole = merge.credentialRole;
42624
+ if (merge.ejectedThenReenqueued) base.ejectedThenReenqueued = true;
42625
+ if (merge.reconcileNeeded) base.reconcileNeeded = true;
42626
+ if (merge.queueRemovalEvidence) base.queueRemovalEvidence = merge.queueRemovalEvidence;
42559
42627
  if (merge.mergeStatus === "queue-admission-pending") {
42560
42628
  return { ...base, status: "queue-admission-pending", outcomeUnknown: true, queueAdmission: merge.queueAdmission, error: merge.error };
42561
42629
  }
@@ -43727,6 +43795,7 @@ var autoMergeApi = async (pr, repo, head) => {
43727
43795
  };
43728
43796
  var ASYNC_HEADERS = ["-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2026-03-10"];
43729
43797
  var UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
43798
+ var UNWITNESSED_EJECTION_GRACE_MS = 6e4;
43730
43799
  function admissionJournal(repo, pr, root) {
43731
43800
  const stateRoot = root ?? (process.platform === "win32" ? (0, import_node_path31.join)(process.env.LOCALAPPDATA || (0, import_node_path31.join)((0, import_node_os18.homedir)(), "AppData", "Local"), "MMI Future", "mmi-cli", "queue-admissions") : (0, import_node_path31.join)(process.env.XDG_STATE_HOME || (0, import_node_path31.join)((0, import_node_os18.homedir)(), ".local", "state"), "mmi-cli", "queue-admissions"));
43732
43801
  const directory = (0, import_node_path31.join)(stateRoot, (0, import_node_crypto12.createHash)("sha256").update(`${repo.toLowerCase()}#${pr}`).digest("hex"));
@@ -43740,12 +43809,12 @@ function admissionJournal(repo, pr, root) {
43740
43809
  const path2 = (number) => (0, import_node_path31.join)(directory, `${number}.jsonl`);
43741
43810
  const validate = (value, previous) => {
43742
43811
  const row = value;
43743
- if (!row || row.version !== 1 || row.repo !== repo.toLowerCase() || row.pr !== pr || !/^[a-f0-9]{40}$/i.test(row.head) || !UUID.test(row.intentId) || !Number.isSafeInteger(row.createdAt) || row.createdAt <= 0 || !Number.isSafeInteger(row.observedAt) || row.observedAt < row.createdAt || !["dispatching", "pending", "enqueued", "merged", "failed", "ejected"].includes(row.state) || row.credential !== void 0 && !["app_installation", "user"].includes(row.credential) || row.queueWitnessed !== void 0 && typeof row.queueWitnessed !== "boolean" || row.uuid !== void 0 && !UUID.test(row.uuid) || row.state === "pending" && !row.uuid || row.queueEntry !== void 0 && (!row.queueEntry || typeof row.queueEntry.headCommitOid !== "string" || !/^[a-f0-9]{40}$/i.test(row.queueEntry.headCommitOid) || typeof row.queueEntry.enqueuedAt !== "string" || !Number.isFinite(Date.parse(row.queueEntry.enqueuedAt)))) {
43812
+ if (!row || row.version !== 1 || row.repo !== repo.toLowerCase() || row.pr !== pr || !/^[a-f0-9]{40}$/i.test(row.head) || !UUID.test(row.intentId) || !Number.isSafeInteger(row.createdAt) || row.createdAt <= 0 || !Number.isSafeInteger(row.observedAt) || row.observedAt < row.createdAt || !["dispatching", "pending", "enqueued", "merged", "failed", "ejected", "abandoned"].includes(row.state) || row.credential !== void 0 && !["app_installation", "user"].includes(row.credential) || row.queueWitnessed !== void 0 && typeof row.queueWitnessed !== "boolean" || row.uuid !== void 0 && !UUID.test(row.uuid) || row.state === "pending" && !row.uuid || row.queueEntry !== void 0 && (!row.queueEntry || typeof row.queueEntry.headCommitOid !== "string" || !/^[a-f0-9]{40}$/i.test(row.queueEntry.headCommitOid) || typeof row.queueEntry.enqueuedAt !== "string" || !Number.isFinite(Date.parse(row.queueEntry.enqueuedAt)))) {
43744
43813
  throw new Error("queue admission receipt is invalid; preserve it and reconcile the prior request");
43745
43814
  }
43746
43815
  if (!previous) {
43747
43816
  if (row.state !== "dispatching" || row.uuid || row.queueEntry || row.observedAt !== row.createdAt) throw new Error("queue admission intent record is invalid");
43748
- } else if (row.head !== previous.head || row.intentId !== previous.intentId || row.createdAt !== previous.createdAt || row.observedAt < previous.observedAt || row.state === "dispatching" || previous.uuid && row.uuid !== previous.uuid || previous.credential !== void 0 && row.credential !== previous.credential || ["enqueued", "merged", "failed", "ejected"].includes(previous.state) && (row.state !== previous.state || row.uuid !== previous.uuid) && !(previous.state === "enqueued" && row.state === "ejected" && row.uuid === previous.uuid)) {
43817
+ } else if (row.head !== previous.head || row.intentId !== previous.intentId || row.createdAt !== previous.createdAt || row.observedAt < previous.observedAt || row.state === "dispatching" || previous.uuid && row.uuid !== previous.uuid || previous.credential !== void 0 && row.credential !== previous.credential || ["enqueued", "merged", "failed", "ejected"].includes(previous.state) && (row.state !== previous.state || row.uuid !== previous.uuid) && !(previous.state === "enqueued" && ["ejected", "abandoned"].includes(row.state) && row.uuid === previous.uuid)) {
43749
43818
  throw new Error("queue admission receipt has an illegal transition or changed operation identity");
43750
43819
  }
43751
43820
  return row;
@@ -43828,13 +43897,23 @@ function admissionRejected(reason, repo, pr) {
43828
43897
  };
43829
43898
  }
43830
43899
  async function submitQueueAdmission(pr, repo, head, read, write, options, resume = false) {
43831
- const unknown = (reason) => ({
43900
+ let reenqueuedAfterEjection = false;
43901
+ let removalEvidence;
43902
+ const markReenqueued = (result) => reenqueuedAfterEjection ? {
43903
+ ...result,
43904
+ ejectedThenReenqueued: true,
43905
+ ...removalEvidence ? { queueRemovalEvidence: removalEvidence } : {},
43906
+ ...result.error ? { error: `re-enqueued after ejection: ${result.error}` } : {}
43907
+ } : result;
43908
+ const unknown = (reason) => markReenqueued({
43832
43909
  mergeStatus: "failed",
43833
43910
  retryable: false,
43834
43911
  outcomeUnknown: true,
43835
- error: `queue submission outcome unknown: ${reason}. Never hand-edit files under the mmi-cli queue-admissions receipt store (%LOCALAPPDATA%/MMI Future/mmi-cli/queue-admissions); read the queue entry with \`mmi-cli devops read pr queue ${pr} --repo ${repo} --json\` and rerun \`mmi-cli devops pr land ${pr} --repo ${repo} --json\` to reconcile read-only \u2014 no new request was sent after this uncertainty.`
43912
+ reconcileNeeded: true,
43913
+ ...removalEvidence ? { queueRemovalEvidence: removalEvidence } : {},
43914
+ error: `queue submission outcome unknown: ${reason}. Never hand-edit files under the mmi-cli queue-admissions receipt store (%LOCALAPPDATA%/MMI Future/mmi-cli/queue-admissions); read the queue entry with \`mmi-cli devops read pr queue ${pr} --repo ${repo} --json\`; once the PR is verifiably OPEN at the submitted head and absent from the queue, abandon the stuck admission with \`mmi-cli devops pr land ${pr} --repo ${repo} --abandon-admission\` so a new intent needs no new head \u2014 no new request was sent after this uncertainty.`
43836
43915
  });
43837
- const rejected = (reason) => admissionRejected(reason, repo, pr);
43916
+ const rejected = (reason) => markReenqueued(admissionRejected(reason, repo, pr));
43838
43917
  const reconcile = async (reason, recordEntry = false, resumingSaved = false) => {
43839
43918
  const maxReads = options.enqueueOnly ? 3 : 1;
43840
43919
  const sleep3 = options.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
@@ -43842,13 +43921,13 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
43842
43921
  if (readAttempt) await sleep3(readAttempt * 1e3);
43843
43922
  try {
43844
43923
  const observed = await readPrMergeQueue(pr, repo, read);
43845
- if (observed.state === "MERGED" && observed.headRefOid === head) return { mergeStatus: "merged" };
43924
+ if (observed.state === "MERGED" && observed.headRefOid === head) return markReenqueued({ mergeStatus: "merged" });
43846
43925
  if (observed.isInMergeQueue && observed.headRefOid === head) {
43847
43926
  const entry = observed.mergeQueueEntry;
43848
43927
  if (recordEntry && receipt?.head === head && entry?.headCommit && entry.enqueuedAt) {
43849
43928
  journal.save("enqueued", receipt?.uuid, { headCommitOid: entry.headCommit.oid, enqueuedAt: entry.enqueuedAt });
43850
43929
  }
43851
- return { mergeStatus: "auto-merge-enqueued" };
43930
+ return markReenqueued({ mergeStatus: "auto-merge-enqueued" });
43852
43931
  }
43853
43932
  const saved = recordEntry ? admissionJournal(repo, pr, options.receiptDirectory).receipt : void 0;
43854
43933
  const boundMs = saved?.queueEntry ? Date.parse(saved.queueEntry.enqueuedAt) : resumingSaved && saved?.state === "enqueued" && saved.queueWitnessed === void 0 ? saved.observedAt : void 0;
@@ -43880,11 +43959,37 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
43880
43959
  let receipt = journal.receipt;
43881
43960
  let response;
43882
43961
  let superseded = false;
43883
- if (receipt && (receipt.state === "enqueued" || receipt.state === "merged") && receipt.head.toLowerCase() !== head.toLowerCase()) {
43884
- try {
43885
- const observed = await readPrMergeQueue(pr, repo, read);
43886
- superseded = observed.state === "OPEN" && !observed.isInMergeQueue;
43887
- } catch {
43962
+ if (receipt && (receipt.state === "enqueued" || receipt.state === "merged")) {
43963
+ if (receipt.head.toLowerCase() !== head.toLowerCase()) {
43964
+ try {
43965
+ const observed = await readPrMergeQueue(pr, repo, read);
43966
+ superseded = observed.state === "OPEN" && !observed.isInMergeQueue;
43967
+ } catch {
43968
+ }
43969
+ } else {
43970
+ try {
43971
+ const observed = await readPrMergeQueue(pr, repo, read);
43972
+ if (observed.state === "OPEN" && !observed.isInMergeQueue && observed.headRefOid?.toLowerCase() === receipt.head.toLowerCase()) {
43973
+ const boundMs = receipt.queueEntry ? Date.parse(receipt.queueEntry.enqueuedAt) : receipt.observedAt + UNWITNESSED_EJECTION_GRACE_MS;
43974
+ const events = await readArrayPages(
43975
+ `repos/${repo}/issues/${pr}/timeline?per_page=100`,
43976
+ options.timelineRead ?? read,
43977
+ "queue ejection timeline unreadable"
43978
+ );
43979
+ const removals = events.filter((event) => event.event === "removed_from_merge_queue" && Number.isFinite(Date.parse(event.created_at))).map((event) => Date.parse(event.created_at)).filter((at) => at > boundMs).sort((a, b) => a - b);
43980
+ removalEvidence = {
43981
+ source: receipt.queueEntry ? "queue-entry" : "receipt-checkpoint",
43982
+ boundAt: new Date(boundMs).toISOString(),
43983
+ removalsAfterBound: removals.map((at) => new Date(at).toISOString()),
43984
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
43985
+ };
43986
+ if (removals.length > 0) {
43987
+ reenqueuedAfterEjection = true;
43988
+ superseded = true;
43989
+ }
43990
+ }
43991
+ } catch {
43992
+ }
43888
43993
  }
43889
43994
  }
43890
43995
  if (superseded && receipt?.state === "enqueued") {
@@ -43894,7 +43999,7 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
43894
43999
  return unknown("the superseded admission could not be checkpointed");
43895
44000
  }
43896
44001
  }
43897
- if (receipt && receipt.state !== "failed" && receipt.state !== "ejected" && !superseded) {
44002
+ if (receipt && receipt.state !== "failed" && receipt.state !== "ejected" && receipt.state !== "abandoned" && !superseded) {
43898
44003
  if (options.selection) {
43899
44004
  options.selection.selected = true;
43900
44005
  options.selection.intentId = receipt.intentId;
@@ -44040,13 +44145,13 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
44040
44145
  if (receipt.head !== head) return reconcile("the previous head completed admission; inspect current head and membership before continuing");
44041
44146
  return reconcile(`operation ${receipt.uuid} reported ${status}, but matching-head membership or merged state is unconfirmed`, status === "enqueued");
44042
44147
  }
44043
- return {
44148
+ return markReenqueued({
44044
44149
  mergeStatus: "queue-admission-pending",
44045
44150
  retryable: false,
44046
44151
  outcomeUnknown: true,
44047
44152
  queueAdmission: { state: "pending", uuid: receipt.uuid, head: receipt.head },
44048
44153
  error: "GitHub accepted queue admission but has not confirmed queue membership. Rerun pr land to resume the saved operation read-only."
44049
- };
44154
+ });
44050
44155
  }
44051
44156
  function githubQueueSquashMessage(title, pr, commits) {
44052
44157
  const list = commits.map((commit) => `* ${commit.headline}
@@ -44251,6 +44356,38 @@ async function enqueuePrMergeQueue(pr, repo, options = {}) {
44251
44356
  const result = await submitQueueAdmission(pr, repo, head, queueRead, write, { ...options, timelineRead: read, selection });
44252
44357
  return { ...stamp(result, selection.selected, selection.intentId), ...inspected.warning ? { warning: inspected.warning } : {} };
44253
44358
  }
44359
+ async function abandonQueueAdmission(pr, repo, options = {}) {
44360
+ if (!/^\d+$/.test(pr)) return { status: "refused", repo, pr, reason: "a bare PR number is required" };
44361
+ const refuse = (reason) => ({ status: "refused", repo, pr, reason });
44362
+ let journal;
44363
+ try {
44364
+ journal = admissionJournal(repo, pr, options.receiptDirectory);
44365
+ } catch (error) {
44366
+ return refuse(`the durable admission receipt cannot be read (${error.message})`);
44367
+ }
44368
+ const receipt = journal.receipt;
44369
+ if (!receipt) return refuse("no queue-admission receipt exists for this PR; nothing to abandon");
44370
+ if (!["pending", "enqueued"].includes(receipt.state)) {
44371
+ return refuse(`the saved admission is '${receipt.state}', not a stuck non-terminal 'pending'/'enqueued' receipt; nothing to abandon`);
44372
+ }
44373
+ let snapshot;
44374
+ try {
44375
+ snapshot = await readPrMergeQueue(pr, repo, options.read ?? actorReadApi);
44376
+ } catch (error) {
44377
+ return refuse(`the PR and queue state could not be read (${error.message}); the admission is unchanged`);
44378
+ }
44379
+ if (snapshot.state !== "OPEN") return refuse(`the PR is '${snapshot.state}', not OPEN; the admission is unchanged`);
44380
+ if (!snapshot.headRefOid || snapshot.headRefOid.toLowerCase() !== receipt.head.toLowerCase()) {
44381
+ return refuse(`the PR head is ${snapshot.headRefOid ?? "unreadable"}, not the receipt's submitted head ${receipt.head}; the admission is unchanged`);
44382
+ }
44383
+ if (snapshot.isInMergeQueue) return refuse("the PR is currently in the merge queue, so the admission is not stuck; the admission is unchanged");
44384
+ try {
44385
+ journal.save("abandoned");
44386
+ } catch (error) {
44387
+ return refuse(`the abandoned state could not be checkpointed (${error.message}); the admission is unchanged`);
44388
+ }
44389
+ return { status: "abandoned", repo, pr, head: receipt.head, ...receipt.uuid ? { operation: receipt.uuid } : {} };
44390
+ }
44254
44391
  function closingGuardDiagnostic(message2) {
44255
44392
  if (!message2) return "";
44256
44393
  const remedy = message2.search(/GitHub's merge queue composes|If this PR is meant to close/);
@@ -46163,7 +46300,7 @@ function refuseNotClaimable(e, json) {
46163
46300
  process.exitCode = 1;
46164
46301
  return true;
46165
46302
  }
46166
- var BOARD_READ_HELP = "\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\nThe --json report is an OBJECT \u2014 never an array, and there is no top-level `items` list:\n {project, viewer, repo, primary, secondary, warnings, partial[, source, builtAt, eventCursor, ...]}\n`primary` is this repo and `secondary` the other repos in scope; each is a bucket object\n {userOwned, claimable, taken, unownedInFlight}\nand each bucket is an array of items. Filter the BUCKET, not the report:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n node -e \"const b=require('./.jerv/tmp/board.json');console.log(b.primary.claimable.length)\"\n\nAdd --flat with --json for one array across both scopes: {ref,number,title,status,assignees,labels,bucket,claim,openPullRequests}\n(#9595: claim is the parsed mmi-claim evidence object, null when unclaimed; openPullRequests lists the OPEN\n PRs whose closing-issue body links name the item. On the FULL JSON object that list lives at\n `report.pullRequests` \u2014 there is no top-level `openPullRequests`, and #9596 hand-written projections\n that assume one crash on `.filter`.)\n\nA cached snapshot read renders `source: cache \xB7 built <timestamp> \xB7 fresh|stale` (#9584) and carries\n`builtAt` under --json, so a same-turn re-read of the same builtAt buys nothing new.\n\nUnder --json, parse stdout ONLY and check the exit code first. A failure still prints a\nhuman-readable reason on stderr, so never pipe 2>&1 into a JSON parser (#9151) \u2014 the merged\ntext is not JSON and hides the real board failure.\n\nA zero-byte or empty .jerv/tmp/*.json means the read FAILED \u2014 `Unexpected end of JSON input` is\nthe symptom of shell-redirected empty stdout, not malformed JSON \u2014 re-run with --out (which writes\nthe file only on success) and check the exit code before parsing.\n\nChanging an existing item's Status is a move, never a hand-written `gh api graphql`\n`updateProjectV2ItemFieldValue` mutation with copied `PVT_`/`PVTI_` node ids and a manual Status\nfield-id lookup:\n mmi-cli oracle board move <status> <ref>\n";
46303
+ var BOARD_READ_HELP = "\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\nThe --json report is an OBJECT \u2014 never an array, and there is no top-level `items` list:\n {project, viewer, repo, primary, secondary, warnings, partial[, source, builtAt, eventCursor, ...]}\n`primary` is this repo and `secondary` the other repos in scope; each is a bucket object\n {userOwned, claimable, taken, unownedInFlight}\nand each bucket is an array of items. Filter the BUCKET, not the report:\n mmi-cli oracle board read --json --out .jerv/tmp/board.json\n node -e \"const b=require('./.jerv/tmp/board.json');console.log(b.primary.claimable.length)\"\n\nAdd --flat with --json for one array across both scopes: {ref,number,title,status,assignees,labels,bucket,claim,openPullRequests}\n(#9595: claim is the parsed mmi-claim evidence object, null when unclaimed; openPullRequests lists the OPEN\n PRs whose closing-issue body links name the item. On the FULL JSON object that list lives at\n `report.pullRequests` \u2014 there is no top-level `openPullRequests`, and #9596 hand-written projections\n that assume one crash on `.filter`.)\n\nNever sniff the envelope \u2014 `j.items ?? j.rows ?? j`, `Object.values(j)` or any generic \"find the list\"\nwrapper throws `items is not iterable` before any row is read (#9956). For a number|status|claim|title\nprojection across non-Done rows, read --json --flat --out .jerv/tmp/board.json and filter that array locally:\n mmi-cli oracle board read --json --flat --out .jerv/tmp/board.json\n\nA cached snapshot read renders `source: cache \xB7 built <timestamp> \xB7 fresh|stale` (#9584) and carries\n`builtAt` under --json, so a same-turn re-read of the same builtAt buys nothing new.\n\nUnder --json, parse stdout ONLY and check the exit code first. A failure still prints a\nhuman-readable reason on stderr, so never pipe 2>&1 into a JSON parser (#9151) \u2014 the merged\ntext is not JSON and hides the real board failure.\n\nA zero-byte or empty .jerv/tmp/*.json means the read FAILED \u2014 `Unexpected end of JSON input` is\nthe symptom of shell-redirected empty stdout, not malformed JSON \u2014 re-run with --out (which writes\nthe file only on success) and check the exit code before parsing.\n\nChanging an existing item's Status is a move, never a hand-written `gh api graphql`\n`updateProjectV2ItemFieldValue` mutation with copied `PVT_`/`PVTI_` node ids and a manual Status\nfield-id lookup:\n mmi-cli oracle board move <status> <ref>\n";
46167
46304
  function flattenBoardReport(report) {
46168
46305
  const closingByRef = /* @__PURE__ */ new Map();
46169
46306
  for (const pull of report.pullRequests ?? []) {
@@ -51436,6 +51573,7 @@ function registerBootstrapCommands(program3) {
51436
51573
  }
51437
51574
  const actions = [];
51438
51575
  const applied = [];
51576
+ let bankRequest = null;
51439
51577
  let applyDeployModel;
51440
51578
  let applyProjectType;
51441
51579
  try {
@@ -51914,6 +52052,48 @@ ${onlyManagedBlock ? `Only the marker-bounded Hub-managed block inside repo-owne
51914
52052
  applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
51915
52053
  }
51916
52054
  }
52055
+ if (!onlyTarget) {
52056
+ bankRequest = await runBankRequest(repo, { dryRun: !o.execute }, {
52057
+ listIssues: async () => {
52058
+ const listed = await gh2([
52059
+ "issue",
52060
+ "list",
52061
+ "--repo",
52062
+ BANK_REQUEST_REPO,
52063
+ "--state",
52064
+ "all",
52065
+ "--search",
52066
+ `in:title "${bankRequestTitle(parsedRepo.name)}"`,
52067
+ "--json",
52068
+ "number,title,state,url",
52069
+ "--limit",
52070
+ "50"
52071
+ ]);
52072
+ const rows = JSON.parse(listed.stdout || "[]");
52073
+ if (!Array.isArray(rows)) throw new Error("issue list is not an array");
52074
+ return rows;
52075
+ },
52076
+ createIssue: async (request) => {
52077
+ const out = await gh2([
52078
+ "issue",
52079
+ "create",
52080
+ "--repo",
52081
+ BANK_REQUEST_REPO,
52082
+ "--title",
52083
+ request.title,
52084
+ "--body",
52085
+ request.body,
52086
+ "--label",
52087
+ request.label
52088
+ ]);
52089
+ const url = out.stdout.trim().split("\n").pop()?.trim() ?? "";
52090
+ const number = Number(/(\d+)$/.exec(url)?.[1]);
52091
+ if (!url || !Number.isFinite(number)) throw new Error(`issue create returned no issue URL: ${out.stdout.trim().slice(0, 200)}`);
52092
+ return { number, url };
52093
+ }
52094
+ });
52095
+ if (o.execute) applied.push(renderBankRequestLine(bankRequest));
52096
+ }
51917
52097
  if (o.execute && !onlyTarget) {
51918
52098
  const github = await readBootstrapGitHubFacts(repo, effectiveTrack, controlClient);
51919
52099
  controlDeployFacts = await fetchDeployFactsBySlug(slug, registryClientDeps(await loadConfig()));
@@ -52009,13 +52189,15 @@ ${onlyManagedBlock ? `Only the marker-bounded Hub-managed block inside repo-owne
52009
52189
  return failGraceful(`bootstrap apply: cannot plan labels/environments; no writes made: ${e.message}`);
52010
52190
  }
52011
52191
  }
52012
- if (o.json) console.log(JSON.stringify({ repo, class: o.class, sourceSha: seedSource.sha, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedBranch: seedPlan.branch, seedBaseSha: scopedPlan?.baseSha, seedPrUrl, actions, controlPlane: controlRows, labels: ancillaryPlan, environments: environmentPlan, applied, ddbWrites }, null, 2));
52192
+ if (o.json) console.log(JSON.stringify({ repo, class: o.class, sourceSha: seedSource.sha, only: onlyTarget || null, execute: o.execute, seedDelivery: seedPlan.mode, seedBranch: seedPlan.branch, seedBaseSha: scopedPlan?.baseSha, seedPrUrl, actions, controlPlane: controlRows, labels: ancillaryPlan, environments: environmentPlan, bankRequest, applied, ddbWrites }, null, 2));
52013
52193
  else {
52014
52194
  console.log(renderSeedPlan(actions));
52015
52195
  if (controlRows.length) console.log(`
52016
52196
  ${renderBootstrapControlPlan(controlRows)}`);
52017
52197
  if (ancillaryPlan && environmentPlan) console.log(`
52018
52198
  ${renderBootstrapAncillaryPlan(ancillaryPlan, environmentPlan, repoEnvironmentSpecs(repo, effectiveTrack, suiteSlug).filter((spec) => spec.exception).map((spec) => ({ name: spec.name, detail: spec.exception })))}`);
52199
+ if (bankRequest?.status === "planned") console.log(`
52200
+ ${renderBankRequestPlan(bankRequest)}`);
52019
52201
  if (o.execute) console.log(`
52020
52202
  LIVE apply to ${repo}:
52021
52203
  ${applied.join("\n ")}`);
@@ -56383,6 +56565,7 @@ async function verifyCustodyBootstrap(value, api, controller, requiredContexts,
56383
56565
  // src/release-custody.ts
56384
56566
  var RELEASE_CUSTODY_BRANCH = "mmi-release-state";
56385
56567
  var RELEASE_CUSTODY_PATH = "release-custody.json";
56568
+ var HUB_RELEASE_CUSTODY_JOB = "release";
56386
56569
  var ReleaseCustodyError = class extends Error {
56387
56570
  constructor(message2) {
56388
56571
  super("Release custody: " + message2);
@@ -56456,6 +56639,14 @@ function githubCustodyStore(repo, baseSha, api = defaultGitHubClient()) {
56456
56639
  `repos/${repo}/actions/runs/${controller.runId}/attempts/${controller.runAttempt}`
56457
56640
  );
56458
56641
  return run.id === controller.runId && run.run_attempt === controller.runAttempt && run.path === HUB_RELEASE_WORKFLOW && run.status === "completed";
56642
+ },
56643
+ async jobCompleted(controller) {
56644
+ const jobs = await api.rest(
56645
+ "GET",
56646
+ `repos/${repo}/actions/runs/${controller.runId}/attempts/${controller.runAttempt}/jobs?per_page=100`
56647
+ );
56648
+ const job = (jobs?.jobs ?? []).find((row) => row.name === (controller.job ?? HUB_RELEASE_CUSTODY_JOB));
56649
+ return job?.status === "completed" && job.conclusion === "success";
56459
56650
  }
56460
56651
  };
56461
56652
  }
@@ -56474,8 +56665,12 @@ var ReleaseCustodySession = class _ReleaseCustodySession {
56474
56665
  poison;
56475
56666
  static async acquire(store, controller, repo, initialize, prepare) {
56476
56667
  const found = await store.read();
56477
- if (found.state && !await store.terminal(found.state.controller)) {
56478
- throw new ReleaseCustodyError(`previous controller run ${found.state.controller.runId} attempt ${found.state.controller.runAttempt} is not proven terminal`);
56668
+ const prior = found.state?.controller;
56669
+ if (prior) {
56670
+ const handoff = prior.runId === controller.runId && prior.runAttempt === controller.runAttempt;
56671
+ if (!(handoff ? await store.jobCompleted(prior) : await store.terminal(prior))) {
56672
+ throw new ReleaseCustodyError(handoff ? `previous controller ${prior.job ?? HUB_RELEASE_CUSTODY_JOB} job of run ${prior.runId} attempt ${prior.runAttempt} is not proven completed` : `previous controller run ${prior.runId} attempt ${prior.runAttempt} is not proven terminal`);
56673
+ }
56479
56674
  }
56480
56675
  let state = {
56481
56676
  ...found.state ?? { schema: "mmi-release-custody/v1", repo, precutId: (0, import_node_crypto21.randomUUID)(), effects: {}, ...await initialize() },
@@ -56773,7 +56968,7 @@ function withHubReleaseCustody(deps) {
56773
56968
  if (!authority.ok || !authority.train || authority.authenticatedDelegation !== true) {
56774
56969
  throw new ReleaseCustodyError("repository custody requires the server-verified owner-triggered train session");
56775
56970
  }
56776
- const controller = { ...runIdentity, token: (0, import_node_crypto21.randomUUID)() };
56971
+ const controller = { ...runIdentity, token: (0, import_node_crypto21.randomUUID)(), ...process.env.GITHUB_JOB ? { job: process.env.GITHUB_JOB } : {} };
56777
56972
  const raw = localRaw(path2);
56778
56973
  const requiredContexts = () => discoverRequiredCheckContexts(
56779
56974
  deps,
@@ -60899,6 +61094,32 @@ function rejectionReason(error) {
60899
61094
  const text = (error instanceof Error ? error.message : String(error)).trim().split("\n")[0] || "no error detail";
60900
61095
  return text.length > 300 ? `${text.slice(0, 300)}\u2026` : text;
60901
61096
  }
61097
+ var PERMISSION_REFUSAL = /Resource not accessible|HTTP 40[13]|40[13] (?:Forbidden|Unauthorized)|Bad credentials|must have admin rights|insufficient (?:permissions?|scope)|not authorized/i;
61098
+ async function observePolicy(deps, candidate) {
61099
+ if (!candidate) return void 0;
61100
+ try {
61101
+ return await deps.policy(candidate);
61102
+ } catch (error) {
61103
+ const refusal = rejectionReason(error);
61104
+ if (!PERMISSION_REFUSAL.test(refusal)) throw error;
61105
+ await deps.fetchCandidate?.(candidate).catch(() => void 0);
61106
+ let retried;
61107
+ try {
61108
+ retried = await deps.policy(candidate);
61109
+ } catch (retryError) {
61110
+ const second = rejectionReason(retryError);
61111
+ if (!PERMISSION_REFUSAL.test(second)) throw retryError;
61112
+ return {
61113
+ check: "policy",
61114
+ state: "skipped",
61115
+ detail: `committed test policy was not observed: GitHub refused the policy read twice (${second}) \u2014 the identity running pr create lacks the permission that observation needs, repository Administration: read, which covers rulesets and the merge-queue configuration; grant it to the App installation (APP_POLL_PERMISSIONS or the installed App's permissions on ${candidate.repo}) or run with an owner GH_TOKEN. This is an unreadable guard, NOT a policy violation.`
61116
+ };
61117
+ }
61118
+ process.stderr.write(`pr create: WARNING \u2014 the committed test-policy read was refused (${refusal}); re-fetched the candidate objects and retried it once (#9946)
61119
+ `);
61120
+ return retried;
61121
+ }
61122
+ }
60902
61123
  async function preparePrCreate(input, deps = createPreparationDeps(input)) {
60903
61124
  const observations = [];
60904
61125
  const refs = await deps.candidate().catch((error) => ({ findings: [`candidate refs unavailable (${rejectionReason(error)}); verify the repository and pushed head/base before retrying`] }));
@@ -60907,7 +61128,7 @@ async function preparePrCreate(input, deps = createPreparationDeps(input)) {
60907
61128
  const reads = await Promise.allSettled([
60908
61129
  deps.claims(refs.candidate),
60909
61130
  refs.candidate ? deps.docs(refs.candidate) : Promise.resolve(void 0),
60910
- refs.candidate ? deps.policy(refs.candidate) : Promise.resolve(void 0)
61131
+ observePolicy(deps, refs.candidate)
60911
61132
  ]);
60912
61133
  const [claims, docs, policy] = reads;
60913
61134
  if (claims.status === "fulfilled") {
@@ -60944,7 +61165,7 @@ async function preparePrCreate(input, deps = createPreparationDeps(input)) {
60944
61165
  await claims.value.restamp();
60945
61166
  const beforeCreate = await deps.candidate();
60946
61167
  if (beforeCreate.findings.length || JSON.stringify(beforeCreate.candidate) !== JSON.stringify(expected)) {
60947
- throw new Error("pr create: candidate changed while restamping claims; prepare again before creating the PR");
61168
+ throw new Error("pr create: candidate identity changed during preparation while restamping claims; prepare the current head and base again");
60948
61169
  }
60949
61170
  }
60950
61171
  };
@@ -61017,6 +61238,15 @@ function createPreparationDeps(input, io = {}) {
61017
61238
  return {
61018
61239
  candidate: readCandidate,
61019
61240
  publish: (proof) => publishPreparedPrHead(proof, exec3),
61241
+ // #9946: the retry needs the exact candidate objects. Fetch each ref on its own so a fork-only head
61242
+ // that origin cannot resolve never costs the base fetch too, and never surface a fetch failure as the
61243
+ // reason the policy read stayed unreadable.
61244
+ fetchCandidate: async (candidate) => {
61245
+ if (!candidate.root) return;
61246
+ const branch = (name) => name.slice(name.lastIndexOf(":") + 1);
61247
+ const refspecs = [.../* @__PURE__ */ new Set([candidate.head, candidate.base])].map((name) => `+refs/heads/${branch(name)}:refs/remotes/origin/${branch(name)}`);
61248
+ await Promise.allSettled(refspecs.map((refspec) => exec3("git", ["-C", candidate.root, "fetch", "--no-tags", "origin", refspec], { timeout: GIT_TIMEOUT_MS })));
61249
+ },
61020
61250
  claims: (candidate) => observePrCreateClaims(input.body, candidate?.repo ?? input.repo, { head: candidate?.head, headSha: candidate?.headSha }),
61021
61251
  docs: (candidate) => checkDocsIndexAtHead({ repo: candidate.repo, head: candidate.headSha }, docs),
61022
61252
  policy: async (candidate) => {
@@ -62672,6 +62902,46 @@ var RESIDUE_LEDGER_FILE = "mmi-worktree-residue.json";
62672
62902
  function canonicalWorktreeRoot(primary) {
62673
62903
  return (0, import_node_path49.join)((0, import_node_path49.dirname)(primary), "mmi-worktrees", (0, import_node_path49.basename)(primary));
62674
62904
  }
62905
+ function lastSegment(p) {
62906
+ return (0, import_node_path49.basename)(p.split(/[\\/]+/).filter(Boolean).pop() ?? p);
62907
+ }
62908
+ function readProvenance(folder, gitCommonDir, exists) {
62909
+ const found = {
62910
+ path: folder,
62911
+ gitdirPresent: false,
62912
+ registrationId: (0, import_node_path49.basename)(folder),
62913
+ registrationPresent: false
62914
+ };
62915
+ try {
62916
+ const pointer = /^\s*gitdir:\s*(.+?)\s*$/m.exec((0, import_node_fs53.readFileSync)((0, import_node_path49.join)(folder, ".git"), "utf8"));
62917
+ if (pointer) {
62918
+ const target = pointer[1];
62919
+ found.gitdir = (0, import_node_path49.isAbsolute)(target) ? target : (0, import_node_path49.join)(folder, target);
62920
+ found.gitdirPresent = exists(found.gitdir);
62921
+ found.registrationId = lastSegment(found.gitdir);
62922
+ }
62923
+ } catch {
62924
+ }
62925
+ try {
62926
+ found.registrationPresent = exists((0, import_node_path49.join)(gitCommonDir, "worktrees", found.registrationId));
62927
+ } catch {
62928
+ }
62929
+ if (found.gitdir && found.gitdirPresent) {
62930
+ try {
62931
+ const head = (0, import_node_fs53.readFileSync)((0, import_node_path49.join)(found.gitdir, "HEAD"), "utf8").trim();
62932
+ const branch = /^ref:\s*(.+?)\s*$/.exec(head);
62933
+ if (branch) found.head = branch[1].startsWith("refs/heads/") ? branch[1].slice("refs/heads/".length) : `ref ${branch[1]}`;
62934
+ else if (/^[0-9a-f]{7,64}$/i.test(head)) found.head = `detached ${head.slice(0, 12)}`;
62935
+ else if (head) found.head = `ref ${head}`;
62936
+ } catch {
62937
+ }
62938
+ }
62939
+ try {
62940
+ found.mtime = (0, import_node_fs53.statSync)(folder).mtime.toISOString();
62941
+ } catch {
62942
+ }
62943
+ return found;
62944
+ }
62675
62945
  function norm2(p) {
62676
62946
  const s = p.replace(/\\/g, "/").replace(/\/+$/, "");
62677
62947
  return process.platform === "win32" ? s.toLowerCase() : s;
@@ -62766,7 +63036,7 @@ async function sweepWorktreeResidue(gitCommonDir, registered, deps = {}) {
62766
63036
  } catch {
62767
63037
  }
62768
63038
  } else {
62769
- report.unrecorded.push(child2);
63039
+ report.unrecorded.push(readProvenance(child2, gitCommonDir, exists));
62770
63040
  }
62771
63041
  }
62772
63042
  }
@@ -62780,12 +63050,19 @@ function renderResidueSweepLines(report) {
62780
63050
  if (cleaned) lines2.push(`pr merge: removed ${cleaned} leftover worktree folder(s) from earlier merges${where}`);
62781
63051
  for (const k of report.kept) lines2.push(`pr merge: leftover folder still locked, retried on the next merge \u2014 ${k.path}`);
62782
63052
  if (report.unrecorded.length) {
62783
- const shown = report.unrecorded.slice(0, 5).join(", ");
63053
+ const listed = report.unrecorded.slice(0, 5);
62784
63054
  const more = report.unrecorded.length > 5 ? `, \u2026 ${report.unrecorded.length - 5} more` : "";
62785
- lines2.push(`pr merge: ${report.unrecorded.length} unregistered folder(s) with content under the worktree root${where} were left untouched (not recorded by a merge): ${shown}${more}`);
63055
+ lines2.push(`pr merge: ${report.unrecorded.length} unregistered folder(s) with content under the worktree root${where} were left untouched (not recorded by a merge): ${listed.map((u) => u.path).join(", ")}${more}`);
63056
+ for (const u of listed) lines2.push(`pr merge: left untouched ${u.path} \u2014 ${provenanceText(u)}`);
62786
63057
  }
62787
63058
  return lines2;
62788
63059
  }
63060
+ function provenanceText(u) {
63061
+ const pointer = u.gitdir ? `.git \u2192 ${u.gitdir}${u.gitdirPresent ? "" : " (admin dir gone)"}` : ".git pointer missing";
63062
+ const registration = `worktrees/${u.registrationId} ${u.registrationPresent ? "registered" : "not registered"}`;
63063
+ const head = u.head ? `HEAD ${u.head}` : "HEAD unknown";
63064
+ return `${pointer}; ${registration}; ${head}; ${u.mtime ? `modified ${u.mtime}` : "mtime unknown"}`;
63065
+ }
62789
63066
 
62790
63067
  // src/command-register-collaboration.ts
62791
63068
  var DEFAULT_PRIORITY = "medium";
@@ -63935,9 +64212,19 @@ ${preparation.findings.map((finding) => `[${finding.check}/${finding.state}] ${f
63935
64212
  }
63936
64213
  class PrHeadBehindBaseError extends Error {
63937
64214
  }
63938
- pr.command("land <numbers...>").description("agent merge path (#1440): train probe \u2014 checks-wait \u2014 merge --auto \u2014 poll enqueued \u2014 development PRs only. Several PR numbers land together (#8322): all are enqueued, waited on together, a queue-ejected PR is updated and re-enqueued once, a queue merge is followed by worktree/branch cleanup and board Done, and one table (or --json) reports each outcome").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--enqueue-only", "return once queue membership is confirmed; without a merge queue, land normally").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--force", "silence the leftover-scratch warning (scratch never blocks, #8785) and acknowledge a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal").option("--without-review <reason>", "compatibility option; shared merge helpers do not require a review verdict").option("--timeout <minutes>", `merge-queue wait per PR (default ${PR_LAND_QUEUE_TIMEOUT_MS / 6e4} minutes, one window per ${PR_LAND_QUEUE_BATCH_SIZE} PRs \u2014 per-repo ruleset check_response_timeout_minutes is 15 default with 30/45/60 overrides in .github/rulesets/mmi-development-merge-queue.json; agents never edit the ruleset to shorten a queue wait)`).option("--preserve-worktree", "after a queue merge keep the local worktree and branch (skips the post-merge cleanup)").option("--expect-head <sha>", "pin the PR head to this full 40-hex SHA (#8456): checked before landing, after checks and by GitHub at enqueue (--match-head-commit); never updates a BEHIND head and never retries an ejection \u2014 it stops with pinnedHead.refused instead. One PR only").action(async (numbers, opts) => {
64215
+ pr.command("land <numbers...>").description("agent merge path (#1440): train probe \u2014 checks-wait \u2014 merge --auto \u2014 poll enqueued \u2014 development PRs only. Several PR numbers land together (#8322): all are enqueued, waited on together, a queue-ejected PR is updated and re-enqueued once, a queue merge is followed by worktree/branch cleanup and board Done, and one table (or --json) reports each outcome").option("--json", "machine-readable output").option("--repo <owner/repo>", "target repo (defaults to the PR repo)").option("--enqueue-only", "return once queue membership is confirmed; without a merge queue, land normally").option("--abandon-admission", "one stuck-receipt recovery, one PR per call: journal the saved non-terminal (pending/enqueued) queue-admission receipt terminal ONLY after positively observing the PR OPEN at the receipt head and absent from the queue; any unprovable observation refuses and changes nothing (#9951). The next pr land then submits a new intent with no new head").option("--no-require-train", "skip train-authority preflight (not recommended for autonomous agents)").option("--force", "silence the leftover-scratch warning (scratch never blocks, #8785) and acknowledge a negated-closing-keyword (#3718) or ambiguous-cross-repo-closing (#4279) refusal").option("--without-review <reason>", "compatibility option; shared merge helpers do not require a review verdict").option("--timeout <minutes>", `merge-queue wait per PR (default ${PR_LAND_QUEUE_TIMEOUT_MS / 6e4} minutes, one window per ${PR_LAND_QUEUE_BATCH_SIZE} PRs \u2014 per-repo ruleset check_response_timeout_minutes is 15 default with 30/45/60 overrides in .github/rulesets/mmi-development-merge-queue.json; agents never edit the ruleset to shorten a queue wait)`).option("--preserve-worktree", "after a queue merge keep the local worktree and branch (skips the post-merge cleanup)").option("--expect-head <sha>", "pin the PR head to this full 40-hex SHA (#8456): checked before landing, after checks and by GitHub at enqueue (--match-head-commit); never updates a BEHIND head and never retries an ejection \u2014 it stops with pinnedHead.refused instead. One PR only").action(async (numbers, opts) => {
63939
64216
  const timeoutMinutes = opts.timeout === void 0 ? void 0 : Number(opts.timeout);
63940
64217
  if (timeoutMinutes !== void 0 && !(timeoutMinutes > 0)) return fail("pr land: --timeout must be a positive number of minutes");
64218
+ if (opts.abandonAdmission) {
64219
+ if (numbers.length !== 1) return fail("pr land: --abandon-admission abandons one PR per call");
64220
+ const repo = await resolveRepo(opts.repo).catch(() => void 0) ?? opts.repo;
64221
+ if (!repo) return fail("pr land: --abandon-admission needs --repo <owner/repo>; the PR repo could not be resolved");
64222
+ const outcome = await abandonQueueAdmission(numbers[0], repo);
64223
+ if (opts.json) printLine(JSON.stringify(outcome));
64224
+ else printLine(outcome.status === "abandoned" ? `pr land: abandoned the stuck queue admission of PR #${outcome.pr} on ${repo}${outcome.operation ? ` (operation ${outcome.operation})` : ""} at head ${outcome.head} \u2014 the next pr land submits a new intent with no new head` : `pr land: refused to abandon the queue admission of PR #${outcome.pr} on ${repo} \u2014 ${outcome.reason}`);
64225
+ if (outcome.status !== "abandoned") process.exitCode = 1;
64226
+ return;
64227
+ }
63941
64228
  if (opts.expectHead !== void 0) {
63942
64229
  if (!/^[0-9a-f]{40}$/i.test(opts.expectHead)) return fail("pr land: --expect-head needs the full 40-hex head SHA");
63943
64230
  if (numbers.length !== 1) return fail("pr land: --expect-head pins one PR head; land one PR per call");
@@ -71555,6 +71842,54 @@ function renderTenantLogs(r) {
71555
71842
  return out.join("\n");
71556
71843
  }
71557
71844
 
71845
+ // src/tenant-status-wait.ts
71846
+ var TENANT_STATUS_WAIT_INTERVAL_MS = 12e3;
71847
+ var TENANT_STATUS_WAIT_TIMEOUT_DEFAULT_S = 660;
71848
+ var TENANT_STATUS_WAIT_TIMEOUT_MAX_S = 1800;
71849
+ var TENANT_STATUS_WAIT_MIN_SHA_LENGTH = 7;
71850
+ function parseWaitForSha(raw) {
71851
+ const sha = raw.trim().toLowerCase();
71852
+ if (!new RegExp(`^[0-9a-f]{${TENANT_STATUS_WAIT_MIN_SHA_LENGTH},40}$`).test(sha)) {
71853
+ return { ok: false, reason: `--wait-for must be a commit SHA (${TENANT_STATUS_WAIT_MIN_SHA_LENGTH}-40 hex characters), got "${raw.trim()}"` };
71854
+ }
71855
+ return { ok: true, sha };
71856
+ }
71857
+ function parseWaitTimeoutSeconds(raw) {
71858
+ if (raw === void 0) return { ok: true, seconds: TENANT_STATUS_WAIT_TIMEOUT_DEFAULT_S, clamped: false };
71859
+ const seconds = Number(raw.trim());
71860
+ if (!Number.isInteger(seconds) || seconds <= 0) {
71861
+ return { ok: false, reason: `--timeout must be a positive whole number of seconds, got "${raw.trim()}"` };
71862
+ }
71863
+ if (seconds > TENANT_STATUS_WAIT_TIMEOUT_MAX_S) return { ok: true, seconds: TENANT_STATUS_WAIT_TIMEOUT_MAX_S, clamped: true };
71864
+ return { ok: true, seconds, clamped: false };
71865
+ }
71866
+ function deployedTagReaches(tag, sha) {
71867
+ const stamped = (tag ?? "").trim().toLowerCase();
71868
+ const needle = sha.trim().toLowerCase();
71869
+ if (!stamped || !needle) return false;
71870
+ if (stamped === needle) return true;
71871
+ const [shorter, longer] = stamped.length <= needle.length ? [stamped, needle] : [needle, stamped];
71872
+ return shorter.length >= TENANT_STATUS_WAIT_MIN_SHA_LENGTH && longer.startsWith(shorter);
71873
+ }
71874
+ async function waitForDeployedTag(deps) {
71875
+ const intervalMs = deps.intervalMs ?? TENANT_STATUS_WAIT_INTERVAL_MS;
71876
+ const now = deps.now ?? Date.now;
71877
+ const sleep2 = deps.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
71878
+ const startedAt = now();
71879
+ const deadline = startedAt + deps.timeoutMs;
71880
+ let polls = 0;
71881
+ for (; ; ) {
71882
+ const status = await deps.readStatus();
71883
+ polls += 1;
71884
+ if (deployedTagReaches(deps.deployedTagOf(status), deps.sha)) {
71885
+ return { matched: true, status, polls, elapsedMs: now() - startedAt };
71886
+ }
71887
+ const remaining = deadline - now();
71888
+ if (remaining <= 0) return { matched: false, status, polls, elapsedMs: now() - startedAt };
71889
+ await sleep2(Math.min(intervalMs, remaining));
71890
+ }
71891
+ }
71892
+
71558
71893
  // src/tenant-sweep.ts
71559
71894
  function isRcBearingTenant(p) {
71560
71895
  if ((p.deployModel ?? "") !== "tenant-container") return false;
@@ -73613,12 +73948,33 @@ tenant.command("reconcile <owner/repo> <stage>").description("re-render this ten
73613
73948
  return failGraceful(`runtime tenant reconcile: ${e.message}`);
73614
73949
  }
73615
73950
  });
73616
- tenant.command("status <owner/repo> <stage>").description("read tenant runtime readiness without dispatching tenant-control: DEPLOY row, last deploy run, public URL probe, and TLS/Caddy/Cloudflare hints; a root answering 401/403/404 is re-probed at /health and the `url` field names the endpoint that answered (#6137)").action(async (repo, stage) => {
73951
+ tenant.command("status <owner/repo> <stage>").description("read tenant runtime readiness without dispatching tenant-control: DEPLOY row, last deploy run, public URL probe, and TLS/Caddy/Cloudflare hints; a root answering 401/403/404 is re-probed at /health and the `url` field names the endpoint that answered (#6137); `--wait-for <sha>` polls the same read to a deadline (#9942)").option("--wait-for <sha>", "poll until DEPLOY#<stage> deployedTag reaches this commit SHA (7-40 hex, short form ok), then print the final status and exit 0; exits 1 on timeout \u2014 use this instead of a shell sleep loop (#9942)").option("--timeout <secs>", `with --wait-for: give up after this many seconds (default ${TENANT_STATUS_WAIT_TIMEOUT_DEFAULT_S}, max ${TENANT_STATUS_WAIT_TIMEOUT_MAX_S}, polled every ${TENANT_STATUS_WAIT_INTERVAL_MS / 1e3}s)`).action(async (repo, stage, o) => {
73617
73952
  if (!["dev", "rc", "main"].includes(stage)) return fail("runtime tenant status: <stage> must be dev, rc, or main");
73953
+ let wait;
73954
+ if (o.waitFor !== void 0) {
73955
+ const sha = parseWaitForSha(o.waitFor);
73956
+ if (!sha.ok) return fail(`runtime tenant status: ${sha.reason}`);
73957
+ const timeout = parseWaitTimeoutSeconds(o.timeout);
73958
+ if (!timeout.ok) return fail(`runtime tenant status: ${timeout.reason}`);
73959
+ wait = { sha: sha.sha, seconds: timeout.seconds, clamped: timeout.clamped };
73960
+ } else if (o.timeout !== void 0) {
73961
+ return fail("runtime tenant status: --timeout only applies with --wait-for <sha>");
73962
+ }
73618
73963
  const cfg = await loadConfig();
73619
- const result = await buildTenantRuntimeStatusFor(repo, stage, cfg);
73964
+ const readStatus = () => buildTenantRuntimeStatusFor(repo, stage, cfg);
73965
+ let result;
73966
+ let waitMatched;
73967
+ if (!wait) result = await readStatus();
73968
+ else {
73969
+ console.error(`runtime tenant status: waiting up to ${wait.seconds}s for ${repo} ${stage} deployedTag to reach ${wait.sha}${wait.clamped ? ` (--timeout clamped to the ${TENANT_STATUS_WAIT_TIMEOUT_MAX_S}s max)` : ""}`);
73970
+ const waited = await waitForDeployedTag({ sha: wait.sha, timeoutMs: wait.seconds * 1e3, readStatus, deployedTagOf: (status) => status.deploy?.deployedTag });
73971
+ result = waited.status;
73972
+ waitMatched = waited.matched;
73973
+ if (!waited.matched) console.error(`runtime tenant status: timed out after ${Math.round(waited.elapsedMs / 1e3)}s (limit ${wait.seconds}s, ${waited.polls} read(s)); deployedTag is ${waited.status.deploy?.deployedTag ?? "unset"}, not ${wait.sha}`);
73974
+ }
73620
73975
  console.log(JSON.stringify(result, null, 2));
73621
- if (result.publicProbe?.ok === false || result.edgeCertificate?.missing) process.exitCode = 1;
73976
+ if (waitMatched !== void 0) process.exitCode = waitMatched ? 0 : 1;
73977
+ else if (result.publicProbe?.ok === false || result.edgeCertificate?.missing) process.exitCode = 1;
73622
73978
  });
73623
73979
  tenant.command("logs <owner/repo> <stage>").description("READ-ONLY: read a tenant stage's application journal (journald, so a deploy-recreated container still answers) \u2014 the unit namespace comes from the tenant's DEPLOY coords and the blue/green slot is chosen on the box, never from a flag; bounded and redacted (#9230)").option("--tail <n>", `lines to return (1-${TENANT_LOG_TAIL_MAX}, default ${TENANT_LOG_TAIL_DEFAULT}); the applied bound is printed`).option("--since <window>", "window start: a relative window like 15m, 6h or 2d, or a UTC time like 2026-10-07T13:10Z (default 1h)").option("--until <time>", "window end: same forms as --since (default: now) \u2014 the box returns the newest N lines OF the window, so end it earlier to reach older lines on a busy tenant (#9878)").option("--grep <needle>", "keep only returned lines containing this literal text (applied to the bounded tail, not the whole journal)").option("--json", "machine-readable output").action(async (repo, stage, o) => {
73624
73980
  try {
package/cli/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/cli",
3
- "version": "4.6.31",
3
+ "version": "4.6.33",
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mutmutco/codex-plugin",
3
- "version": "4.6.31",
3
+ "version": "4.6.33",
4
4
  "description": "MMI workflow skills and org gates delivery.",
5
5
  "author": {
6
6
  "name": "MMI Future",
@@ -13,17 +13,23 @@ permission to take anything.
13
13
 
14
14
  ```bash
15
15
  mkdir -p .jerv/tmp
16
- mmi-cli oracle board read --json --out .jerv/tmp/board.json
16
+ mmi-cli oracle board read --json --flat --out .jerv/tmp/board.json
17
17
  ```
18
18
 
19
- One call; the JSON carries `viewer`, `repo` and the project title. Use `primary` for this repo and
20
- `secondary` for other repos on the Project. Without `--flat`, `board read --json` returns an object
19
+ One call; `--flat` gives one array of rows to iterate. A routine read already excludes Done and closed
20
+ items, so no status post-filter is needed. The detailed form (`--json` without `--flat`) carries `viewer`,
21
+ `repo` and the project title; use its `primary` for this repo and `secondary` for other repos on the
22
+ Project. Without `--flat`, `board read --json` returns an object
21
23
  `{project, viewer, repo, primary, secondary, warnings, partial}` whose `primary` and `secondary` are bucket
22
24
  objects `{userOwned, claimable, taken, unownedInFlight}` of item arrays (no top-level `items` array), so filter
23
- a bucket (e.g. `report.primary.claimable`) or pass `--json --flat`, never `(j.items||j).filter`. `--flat` rows
24
- are `{ref,number,title,status,assignees,labels,bucket,claim,openPullRequests}` (`claim` is the parsed mmi-claim
25
- evidence object, null when unclaimed; `openPullRequests` lists the OPEN PRs whose closing-issue body links name
26
- the item — on the full JSON object that list lives at `report.pullRequests`, never `openPullRequests`). Partial reads
25
+ a bucket (e.g. `report.primary.claimable`) or pass `--json --flat`, never `(j.items||j).filter`. Never sniff
26
+ for the list (`j.items ?? j.rows ?? j`, `Object.values`, any generic "find the list" wrapper): it throws
27
+ `items is not iterable` before any row is read. For a number|status|claim|title projection across non-Done
28
+ rows, run `mmi-cli oracle board read --json --flat --out .jerv/tmp/board.json` and filter that array locally.
29
+ `--flat` rows are `{ref,number,title,status,assignees,labels,bucket,claim,openPullRequests}` (`claim` is the
30
+ parsed mmi-claim evidence object, null when unclaimed; `openPullRequests` lists the OPEN PRs whose
31
+ closing-issue body links name the item — on the full JSON object that list lives at `report.pullRequests`,
32
+ never `openPullRequests`). Partial reads
27
33
  exit nonzero; `--allow-partial` only when the dev accepts an incomplete board. The CLI partitions rows into
28
34
  **Yours**, **Free to claim** and **Taken**; stale stamps (4 hours without work) hold nothing. Recovery
29
35
  moves: a zero-byte or empty `.jerv/tmp/*.json` (`Unexpected end of JSON input`) means a shell-redirected
@@ -33,7 +39,7 @@ item is `mmi-cli oracle board move <status> <ref>`, never a hand-written `gh api
33
39
  lookup. Partition rules,
34
40
  stamp details and `--out` rationale: [reference.md](reference.md).
35
41
 
36
- **Under the App pool, never type raw `gh` for a read** (`gh issue list/view --jq`, `gh run list --event`/`--jq`, `gh run view --jq`, `gh api -q` all bounce). Use `MMI_ACTOR=app mmi-cli devops read issue view <n> --repo <owner/repo> --json <fields>` (or `issue list`, `pr view`, `run view`, `run list --event <event>`, or `MMI_ACTOR=app mmi-cli devops api get "repos/<owner>/<repo>/actions/runs?event=<event>&per_page=<n>"`); `--jq '<expr>'` on that typed read filters locally. A single issue or PR item (`repos/<owner>/<repo>/issues/<n>` and `pulls/<n>`) has no `devops api get` route — `MMI_ACTOR=app mmi-cli devops read issue view <n> --repo <owner>/<repo> --json <fields>` (and `pr view`) is the only read. Those typed reads take **one** number per call, exactly like native gh: a second number is refused as `too many arguments`, so read several issues by repeating the typed read once per number (one `--jq` each, joined `&&`). Filter in a pipe or save output (`>`, `2>&1`, `--out`) under this worktree's `.jerv/tmp/` (`mkdir -p .jerv/tmp` first), never `../` or a sibling path: the workspace output guard refuses those. Body edits are read-then-edit: `MMI_ACTOR=app mmi-cli devops read issue view <n> --repo <owner/repo> --json body > .jerv/tmp/issue-body.json`, append or filter the JSON locally (`node -e`; the typed read's own `--jq` also filters locally), then write back with `mmi-cli oracle issue edit <ref> --body-file .jerv/tmp/issue-body.md`. When `node -e` reads saved JSON, pass an absolute path or use `JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))`: `require()` of a relative path (e.g. `tmp\board.json` from `cygpath -w`) throws MODULE_NOT_FOUND.
42
+ **Under the App pool, never type raw `gh` for a read** (`gh issue list/view --jq`, `gh run list --event`/`--jq`, `gh run view --jq`, `gh api -q` all bounce). Use `MMI_ACTOR=app mmi-cli devops read issue view <n> --repo <owner/repo> --json <fields>` (or `issue list`, `pr view`, `run view`, `run list --event <event>`, or `MMI_ACTOR=app mmi-cli devops api get "repos/<owner>/<repo>/actions/runs?event=<event>&per_page=<n>"`); `--jq '<expr>'` on that typed read filters locally. A single issue or PR item (`repos/<owner>/<repo>/issues/<n>` and `pulls/<n>`) has no `devops api get` route — `MMI_ACTOR=app mmi-cli devops read issue view <n> --repo <owner>/<repo> --json <fields>` (and `pr view`) is the only read. Those typed reads take **one** number per call, exactly like native gh: a second number is refused as `too many arguments`, so read several issues by repeating the typed read once per number (one `--jq` each, joined `&&`). Filter in a pipe or save output (`--out`, or `>`; never `2>&1` into a JSON parser) under this worktree's `.jerv/tmp/` (`mkdir -p .jerv/tmp` first), never `../` or a sibling path: the workspace output guard refuses those. Body edits are read-then-edit: `MMI_ACTOR=app mmi-cli devops read issue view <n> --repo <owner/repo> --json body > .jerv/tmp/issue-body.json`, append or filter the JSON locally (`node -e`; the typed read's own `--jq` also filters locally), then write back with `mmi-cli oracle issue edit <ref> --body-file .jerv/tmp/issue-body.md`. When `node -e` reads saved JSON, pass an absolute path or use `JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'))`: `require()` of a relative path (e.g. `tmp\board.json` from `cygpath -w`) throws MODULE_NOT_FOUND.
37
43
 
38
44
  ## Step 2 — render it
39
45
 
@@ -15,7 +15,10 @@ the gate again.
15
15
  Prefer `--out` for a shell-independent UTF-8 receipt. Windows PowerShell 5.1's `>` writes
16
16
  UTF-16LE with a BOM (#5802); Bash redirects preserve the CLI's UTF-8 output.
17
17
 
18
- One call. Its JSON carries `viewer`, `repo` and the project title — do not spend extra calls on
18
+ The canonical read is `mmi-cli oracle board read --json --flat --out .jerv/tmp/board.json`: one array of
19
+ rows to iterate. A routine read already excludes Done and closed items, so no status post-filter is needed.
20
+
21
+ One call. The detailed form (`--json` without `--flat`) carries `viewer`, `repo` and the project title — do not spend extra calls on
19
22
  `gh api user` or `gh repo view`. Use `primary` for this repo and `secondary` for other repos on the same
20
23
  Project. Without `--flat`, `board read --json` returns an object
21
24
  `{project, viewer, repo, primary, secondary, warnings, partial}` whose `primary` and `secondary` are bucket
@@ -55,6 +55,8 @@ mmi-cli devops bootstrap apply "$OWNER/$REPO" --class deployable \
55
55
 
56
56
  `tenant-container` repos need a `docker-compose.yml` and Dockerfile building from the shipped source archive (no prebuilt `dist/`); bootstrap seeds both from `skills/bootstrap/seeds/`. The seeded compose is fileless: label `mmi.runtime-env: "true"`, no `env_file`, loopback-only port; deploy injects the stage's vault values (`DEPLOY#<stage>.noEnvFile: true`), so the app reads plain env vars, never self-loads SSM, never ships a `.env`. For an MM-Work module or non-suite web-app with OAuth META, read [reference.md](reference.md) before the first CI/deploy or OAuth provisioning.
57
57
 
58
+ `bootstrap apply --execute` also asks Jerv-Hub for this repo's coding-agent memory bank, by filing one `bank-request: coding-agent::<RepoName>` issue (label `bank-request`) there; if it reports that step SKIPPED with a manual next step, the MMI App could not file it — file that issue by hand at https://github.com/mutmutco/Jerv-Hub/issues/new and carry on.
59
+
58
60
  **Stage port block:** `tenant-container` and `solo-container` need Hub registry `portRange` (`bootstrap verify` fails without it). `bootstrap apply --execute` assigns it; for older META run `mmi-cli stage port-range <Repo>` (idempotent).
59
61
 
60
62
  ## Step 7 — org-managed .gitignore block