@mutmutco/claude-plugin 4.6.31 → 4.6.32

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,7 +1,7 @@
1
1
  {
2
2
  "name": "mmi",
3
3
  "displayName": "MMI",
4
- "version": "4.6.31",
4
+ "version": "4.6.32",
5
5
  "mmiCompat": "4.x",
6
6
  "description": "MMI workflow skills and org gates delivery.",
7
7
  "author": {
package/cli/dist/main.cjs CHANGED
@@ -28301,10 +28301,10 @@ var rollout_plan_default = {
28301
28301
  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
28302
  },
28303
28303
  baseline: {
28304
- version: "4.6.31",
28305
- tag: "v4.6.31",
28306
- commit: "53e22eaf158d",
28307
- npm: "@mutmutco/cli@4.6.31"
28304
+ version: "4.6.32",
28305
+ tag: "v4.6.32",
28306
+ commit: "0e477e5e2765",
28307
+ npm: "@mutmutco/cli@4.6.32"
28308
28308
  },
28309
28309
  exitCriterion: "fleet-n-of-n",
28310
28310
  hubOnlyShortcut: "forbidden",
@@ -28321,14 +28321,14 @@ var rollout_plan_default = {
28321
28321
  repo: "mutmutco/mmi-hub",
28322
28322
  role: "canary",
28323
28323
  schedule: "train",
28324
- v3Target: "v4.6.31"
28324
+ v3Target: "v4.6.32"
28325
28325
  }
28326
28326
  ],
28327
28327
  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
28328
  rollback: {
28329
28329
  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)"
28330
+ mechanism: "npm dist-tag latest -> 4.6.32 and redeploy the Hub Lambda from tag v4.6.32 (0e477e5e2765); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
28331
+ v3Target: "v4.6.32 (@mutmutco/cli@4.6.32, tag commit 0e477e5e2765 \u2014 last known-good release carrying the repo-index v4-only contract)"
28332
28332
  }
28333
28333
  },
28334
28334
  {
@@ -42556,6 +42556,9 @@ async function runPrLandOnce(prNumber, options, deps) {
42556
42556
  base.mergeStatus = merge.mergeStatus;
42557
42557
  if (merge.credential) base.credential = merge.credential;
42558
42558
  if (merge.credentialRole) base.credentialRole = merge.credentialRole;
42559
+ if (merge.ejectedThenReenqueued) base.ejectedThenReenqueued = true;
42560
+ if (merge.reconcileNeeded) base.reconcileNeeded = true;
42561
+ if (merge.queueRemovalEvidence) base.queueRemovalEvidence = merge.queueRemovalEvidence;
42559
42562
  if (merge.mergeStatus === "queue-admission-pending") {
42560
42563
  return { ...base, status: "queue-admission-pending", outcomeUnknown: true, queueAdmission: merge.queueAdmission, error: merge.error };
42561
42564
  }
@@ -43727,6 +43730,7 @@ var autoMergeApi = async (pr, repo, head) => {
43727
43730
  };
43728
43731
  var ASYNC_HEADERS = ["-H", "Accept: application/vnd.github+json", "-H", "X-GitHub-Api-Version: 2026-03-10"];
43729
43732
  var UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i;
43733
+ var UNWITNESSED_EJECTION_GRACE_MS = 6e4;
43730
43734
  function admissionJournal(repo, pr, root) {
43731
43735
  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
43736
  const directory = (0, import_node_path31.join)(stateRoot, (0, import_node_crypto12.createHash)("sha256").update(`${repo.toLowerCase()}#${pr}`).digest("hex"));
@@ -43740,12 +43744,12 @@ function admissionJournal(repo, pr, root) {
43740
43744
  const path2 = (number) => (0, import_node_path31.join)(directory, `${number}.jsonl`);
43741
43745
  const validate = (value, previous) => {
43742
43746
  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)))) {
43747
+ 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
43748
  throw new Error("queue admission receipt is invalid; preserve it and reconcile the prior request");
43745
43749
  }
43746
43750
  if (!previous) {
43747
43751
  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)) {
43752
+ } 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
43753
  throw new Error("queue admission receipt has an illegal transition or changed operation identity");
43750
43754
  }
43751
43755
  return row;
@@ -43828,13 +43832,23 @@ function admissionRejected(reason, repo, pr) {
43828
43832
  };
43829
43833
  }
43830
43834
  async function submitQueueAdmission(pr, repo, head, read, write, options, resume = false) {
43831
- const unknown = (reason) => ({
43835
+ let reenqueuedAfterEjection = false;
43836
+ let removalEvidence;
43837
+ const markReenqueued = (result) => reenqueuedAfterEjection ? {
43838
+ ...result,
43839
+ ejectedThenReenqueued: true,
43840
+ ...removalEvidence ? { queueRemovalEvidence: removalEvidence } : {},
43841
+ ...result.error ? { error: `re-enqueued after ejection: ${result.error}` } : {}
43842
+ } : result;
43843
+ const unknown = (reason) => markReenqueued({
43832
43844
  mergeStatus: "failed",
43833
43845
  retryable: false,
43834
43846
  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.`
43847
+ reconcileNeeded: true,
43848
+ ...removalEvidence ? { queueRemovalEvidence: removalEvidence } : {},
43849
+ 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
43850
  });
43837
- const rejected = (reason) => admissionRejected(reason, repo, pr);
43851
+ const rejected = (reason) => markReenqueued(admissionRejected(reason, repo, pr));
43838
43852
  const reconcile = async (reason, recordEntry = false, resumingSaved = false) => {
43839
43853
  const maxReads = options.enqueueOnly ? 3 : 1;
43840
43854
  const sleep3 = options.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
@@ -43842,13 +43856,13 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
43842
43856
  if (readAttempt) await sleep3(readAttempt * 1e3);
43843
43857
  try {
43844
43858
  const observed = await readPrMergeQueue(pr, repo, read);
43845
- if (observed.state === "MERGED" && observed.headRefOid === head) return { mergeStatus: "merged" };
43859
+ if (observed.state === "MERGED" && observed.headRefOid === head) return markReenqueued({ mergeStatus: "merged" });
43846
43860
  if (observed.isInMergeQueue && observed.headRefOid === head) {
43847
43861
  const entry = observed.mergeQueueEntry;
43848
43862
  if (recordEntry && receipt?.head === head && entry?.headCommit && entry.enqueuedAt) {
43849
43863
  journal.save("enqueued", receipt?.uuid, { headCommitOid: entry.headCommit.oid, enqueuedAt: entry.enqueuedAt });
43850
43864
  }
43851
- return { mergeStatus: "auto-merge-enqueued" };
43865
+ return markReenqueued({ mergeStatus: "auto-merge-enqueued" });
43852
43866
  }
43853
43867
  const saved = recordEntry ? admissionJournal(repo, pr, options.receiptDirectory).receipt : void 0;
43854
43868
  const boundMs = saved?.queueEntry ? Date.parse(saved.queueEntry.enqueuedAt) : resumingSaved && saved?.state === "enqueued" && saved.queueWitnessed === void 0 ? saved.observedAt : void 0;
@@ -43880,11 +43894,37 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
43880
43894
  let receipt = journal.receipt;
43881
43895
  let response;
43882
43896
  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 {
43897
+ if (receipt && (receipt.state === "enqueued" || receipt.state === "merged")) {
43898
+ if (receipt.head.toLowerCase() !== head.toLowerCase()) {
43899
+ try {
43900
+ const observed = await readPrMergeQueue(pr, repo, read);
43901
+ superseded = observed.state === "OPEN" && !observed.isInMergeQueue;
43902
+ } catch {
43903
+ }
43904
+ } else {
43905
+ try {
43906
+ const observed = await readPrMergeQueue(pr, repo, read);
43907
+ if (observed.state === "OPEN" && !observed.isInMergeQueue && observed.headRefOid?.toLowerCase() === receipt.head.toLowerCase()) {
43908
+ const boundMs = receipt.queueEntry ? Date.parse(receipt.queueEntry.enqueuedAt) : receipt.observedAt + UNWITNESSED_EJECTION_GRACE_MS;
43909
+ const events = await readArrayPages(
43910
+ `repos/${repo}/issues/${pr}/timeline?per_page=100`,
43911
+ options.timelineRead ?? read,
43912
+ "queue ejection timeline unreadable"
43913
+ );
43914
+ 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);
43915
+ removalEvidence = {
43916
+ source: receipt.queueEntry ? "queue-entry" : "receipt-checkpoint",
43917
+ boundAt: new Date(boundMs).toISOString(),
43918
+ removalsAfterBound: removals.map((at) => new Date(at).toISOString()),
43919
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString()
43920
+ };
43921
+ if (removals.length > 0) {
43922
+ reenqueuedAfterEjection = true;
43923
+ superseded = true;
43924
+ }
43925
+ }
43926
+ } catch {
43927
+ }
43888
43928
  }
43889
43929
  }
43890
43930
  if (superseded && receipt?.state === "enqueued") {
@@ -43894,7 +43934,7 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
43894
43934
  return unknown("the superseded admission could not be checkpointed");
43895
43935
  }
43896
43936
  }
43897
- if (receipt && receipt.state !== "failed" && receipt.state !== "ejected" && !superseded) {
43937
+ if (receipt && receipt.state !== "failed" && receipt.state !== "ejected" && receipt.state !== "abandoned" && !superseded) {
43898
43938
  if (options.selection) {
43899
43939
  options.selection.selected = true;
43900
43940
  options.selection.intentId = receipt.intentId;
@@ -44040,13 +44080,13 @@ async function submitQueueAdmission(pr, repo, head, read, write, options, resume
44040
44080
  if (receipt.head !== head) return reconcile("the previous head completed admission; inspect current head and membership before continuing");
44041
44081
  return reconcile(`operation ${receipt.uuid} reported ${status}, but matching-head membership or merged state is unconfirmed`, status === "enqueued");
44042
44082
  }
44043
- return {
44083
+ return markReenqueued({
44044
44084
  mergeStatus: "queue-admission-pending",
44045
44085
  retryable: false,
44046
44086
  outcomeUnknown: true,
44047
44087
  queueAdmission: { state: "pending", uuid: receipt.uuid, head: receipt.head },
44048
44088
  error: "GitHub accepted queue admission but has not confirmed queue membership. Rerun pr land to resume the saved operation read-only."
44049
- };
44089
+ });
44050
44090
  }
44051
44091
  function githubQueueSquashMessage(title, pr, commits) {
44052
44092
  const list = commits.map((commit) => `* ${commit.headline}
@@ -44251,6 +44291,38 @@ async function enqueuePrMergeQueue(pr, repo, options = {}) {
44251
44291
  const result = await submitQueueAdmission(pr, repo, head, queueRead, write, { ...options, timelineRead: read, selection });
44252
44292
  return { ...stamp(result, selection.selected, selection.intentId), ...inspected.warning ? { warning: inspected.warning } : {} };
44253
44293
  }
44294
+ async function abandonQueueAdmission(pr, repo, options = {}) {
44295
+ if (!/^\d+$/.test(pr)) return { status: "refused", repo, pr, reason: "a bare PR number is required" };
44296
+ const refuse = (reason) => ({ status: "refused", repo, pr, reason });
44297
+ let journal;
44298
+ try {
44299
+ journal = admissionJournal(repo, pr, options.receiptDirectory);
44300
+ } catch (error) {
44301
+ return refuse(`the durable admission receipt cannot be read (${error.message})`);
44302
+ }
44303
+ const receipt = journal.receipt;
44304
+ if (!receipt) return refuse("no queue-admission receipt exists for this PR; nothing to abandon");
44305
+ if (!["pending", "enqueued"].includes(receipt.state)) {
44306
+ return refuse(`the saved admission is '${receipt.state}', not a stuck non-terminal 'pending'/'enqueued' receipt; nothing to abandon`);
44307
+ }
44308
+ let snapshot;
44309
+ try {
44310
+ snapshot = await readPrMergeQueue(pr, repo, options.read ?? actorReadApi);
44311
+ } catch (error) {
44312
+ return refuse(`the PR and queue state could not be read (${error.message}); the admission is unchanged`);
44313
+ }
44314
+ if (snapshot.state !== "OPEN") return refuse(`the PR is '${snapshot.state}', not OPEN; the admission is unchanged`);
44315
+ if (!snapshot.headRefOid || snapshot.headRefOid.toLowerCase() !== receipt.head.toLowerCase()) {
44316
+ return refuse(`the PR head is ${snapshot.headRefOid ?? "unreadable"}, not the receipt's submitted head ${receipt.head}; the admission is unchanged`);
44317
+ }
44318
+ if (snapshot.isInMergeQueue) return refuse("the PR is currently in the merge queue, so the admission is not stuck; the admission is unchanged");
44319
+ try {
44320
+ journal.save("abandoned");
44321
+ } catch (error) {
44322
+ return refuse(`the abandoned state could not be checkpointed (${error.message}); the admission is unchanged`);
44323
+ }
44324
+ return { status: "abandoned", repo, pr, head: receipt.head, ...receipt.uuid ? { operation: receipt.uuid } : {} };
44325
+ }
44254
44326
  function closingGuardDiagnostic(message2) {
44255
44327
  if (!message2) return "";
44256
44328
  const remedy = message2.search(/GitHub's merge queue composes|If this PR is meant to close/);
@@ -46163,7 +46235,7 @@ function refuseNotClaimable(e, json) {
46163
46235
  process.exitCode = 1;
46164
46236
  return true;
46165
46237
  }
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";
46238
+ 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
46239
  function flattenBoardReport(report) {
46168
46240
  const closingByRef = /* @__PURE__ */ new Map();
46169
46241
  for (const pull of report.pullRequests ?? []) {
@@ -60899,6 +60971,32 @@ function rejectionReason(error) {
60899
60971
  const text = (error instanceof Error ? error.message : String(error)).trim().split("\n")[0] || "no error detail";
60900
60972
  return text.length > 300 ? `${text.slice(0, 300)}\u2026` : text;
60901
60973
  }
60974
+ 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;
60975
+ async function observePolicy(deps, candidate) {
60976
+ if (!candidate) return void 0;
60977
+ try {
60978
+ return await deps.policy(candidate);
60979
+ } catch (error) {
60980
+ const refusal = rejectionReason(error);
60981
+ if (!PERMISSION_REFUSAL.test(refusal)) throw error;
60982
+ await deps.fetchCandidate?.(candidate).catch(() => void 0);
60983
+ let retried;
60984
+ try {
60985
+ retried = await deps.policy(candidate);
60986
+ } catch (retryError) {
60987
+ const second = rejectionReason(retryError);
60988
+ if (!PERMISSION_REFUSAL.test(second)) throw retryError;
60989
+ return {
60990
+ check: "policy",
60991
+ state: "skipped",
60992
+ 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.`
60993
+ };
60994
+ }
60995
+ 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)
60996
+ `);
60997
+ return retried;
60998
+ }
60999
+ }
60902
61000
  async function preparePrCreate(input, deps = createPreparationDeps(input)) {
60903
61001
  const observations = [];
60904
61002
  const refs = await deps.candidate().catch((error) => ({ findings: [`candidate refs unavailable (${rejectionReason(error)}); verify the repository and pushed head/base before retrying`] }));
@@ -60907,7 +61005,7 @@ async function preparePrCreate(input, deps = createPreparationDeps(input)) {
60907
61005
  const reads = await Promise.allSettled([
60908
61006
  deps.claims(refs.candidate),
60909
61007
  refs.candidate ? deps.docs(refs.candidate) : Promise.resolve(void 0),
60910
- refs.candidate ? deps.policy(refs.candidate) : Promise.resolve(void 0)
61008
+ observePolicy(deps, refs.candidate)
60911
61009
  ]);
60912
61010
  const [claims, docs, policy] = reads;
60913
61011
  if (claims.status === "fulfilled") {
@@ -60944,7 +61042,7 @@ async function preparePrCreate(input, deps = createPreparationDeps(input)) {
60944
61042
  await claims.value.restamp();
60945
61043
  const beforeCreate = await deps.candidate();
60946
61044
  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");
61045
+ throw new Error("pr create: candidate identity changed during preparation while restamping claims; prepare the current head and base again");
60948
61046
  }
60949
61047
  }
60950
61048
  };
@@ -61017,6 +61115,15 @@ function createPreparationDeps(input, io = {}) {
61017
61115
  return {
61018
61116
  candidate: readCandidate,
61019
61117
  publish: (proof) => publishPreparedPrHead(proof, exec3),
61118
+ // #9946: the retry needs the exact candidate objects. Fetch each ref on its own so a fork-only head
61119
+ // that origin cannot resolve never costs the base fetch too, and never surface a fetch failure as the
61120
+ // reason the policy read stayed unreadable.
61121
+ fetchCandidate: async (candidate) => {
61122
+ if (!candidate.root) return;
61123
+ const branch = (name) => name.slice(name.lastIndexOf(":") + 1);
61124
+ const refspecs = [.../* @__PURE__ */ new Set([candidate.head, candidate.base])].map((name) => `+refs/heads/${branch(name)}:refs/remotes/origin/${branch(name)}`);
61125
+ await Promise.allSettled(refspecs.map((refspec) => exec3("git", ["-C", candidate.root, "fetch", "--no-tags", "origin", refspec], { timeout: GIT_TIMEOUT_MS })));
61126
+ },
61020
61127
  claims: (candidate) => observePrCreateClaims(input.body, candidate?.repo ?? input.repo, { head: candidate?.head, headSha: candidate?.headSha }),
61021
61128
  docs: (candidate) => checkDocsIndexAtHead({ repo: candidate.repo, head: candidate.headSha }, docs),
61022
61129
  policy: async (candidate) => {
@@ -62672,6 +62779,46 @@ var RESIDUE_LEDGER_FILE = "mmi-worktree-residue.json";
62672
62779
  function canonicalWorktreeRoot(primary) {
62673
62780
  return (0, import_node_path49.join)((0, import_node_path49.dirname)(primary), "mmi-worktrees", (0, import_node_path49.basename)(primary));
62674
62781
  }
62782
+ function lastSegment(p) {
62783
+ return (0, import_node_path49.basename)(p.split(/[\\/]+/).filter(Boolean).pop() ?? p);
62784
+ }
62785
+ function readProvenance(folder, gitCommonDir, exists) {
62786
+ const found = {
62787
+ path: folder,
62788
+ gitdirPresent: false,
62789
+ registrationId: (0, import_node_path49.basename)(folder),
62790
+ registrationPresent: false
62791
+ };
62792
+ try {
62793
+ const pointer = /^\s*gitdir:\s*(.+?)\s*$/m.exec((0, import_node_fs53.readFileSync)((0, import_node_path49.join)(folder, ".git"), "utf8"));
62794
+ if (pointer) {
62795
+ const target = pointer[1];
62796
+ found.gitdir = (0, import_node_path49.isAbsolute)(target) ? target : (0, import_node_path49.join)(folder, target);
62797
+ found.gitdirPresent = exists(found.gitdir);
62798
+ found.registrationId = lastSegment(found.gitdir);
62799
+ }
62800
+ } catch {
62801
+ }
62802
+ try {
62803
+ found.registrationPresent = exists((0, import_node_path49.join)(gitCommonDir, "worktrees", found.registrationId));
62804
+ } catch {
62805
+ }
62806
+ if (found.gitdir && found.gitdirPresent) {
62807
+ try {
62808
+ const head = (0, import_node_fs53.readFileSync)((0, import_node_path49.join)(found.gitdir, "HEAD"), "utf8").trim();
62809
+ const branch = /^ref:\s*(.+?)\s*$/.exec(head);
62810
+ if (branch) found.head = branch[1].startsWith("refs/heads/") ? branch[1].slice("refs/heads/".length) : `ref ${branch[1]}`;
62811
+ else if (/^[0-9a-f]{7,64}$/i.test(head)) found.head = `detached ${head.slice(0, 12)}`;
62812
+ else if (head) found.head = `ref ${head}`;
62813
+ } catch {
62814
+ }
62815
+ }
62816
+ try {
62817
+ found.mtime = (0, import_node_fs53.statSync)(folder).mtime.toISOString();
62818
+ } catch {
62819
+ }
62820
+ return found;
62821
+ }
62675
62822
  function norm2(p) {
62676
62823
  const s = p.replace(/\\/g, "/").replace(/\/+$/, "");
62677
62824
  return process.platform === "win32" ? s.toLowerCase() : s;
@@ -62766,7 +62913,7 @@ async function sweepWorktreeResidue(gitCommonDir, registered, deps = {}) {
62766
62913
  } catch {
62767
62914
  }
62768
62915
  } else {
62769
- report.unrecorded.push(child2);
62916
+ report.unrecorded.push(readProvenance(child2, gitCommonDir, exists));
62770
62917
  }
62771
62918
  }
62772
62919
  }
@@ -62780,12 +62927,19 @@ function renderResidueSweepLines(report) {
62780
62927
  if (cleaned) lines2.push(`pr merge: removed ${cleaned} leftover worktree folder(s) from earlier merges${where}`);
62781
62928
  for (const k of report.kept) lines2.push(`pr merge: leftover folder still locked, retried on the next merge \u2014 ${k.path}`);
62782
62929
  if (report.unrecorded.length) {
62783
- const shown = report.unrecorded.slice(0, 5).join(", ");
62930
+ const listed = report.unrecorded.slice(0, 5);
62784
62931
  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}`);
62932
+ 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}`);
62933
+ for (const u of listed) lines2.push(`pr merge: left untouched ${u.path} \u2014 ${provenanceText(u)}`);
62786
62934
  }
62787
62935
  return lines2;
62788
62936
  }
62937
+ function provenanceText(u) {
62938
+ const pointer = u.gitdir ? `.git \u2192 ${u.gitdir}${u.gitdirPresent ? "" : " (admin dir gone)"}` : ".git pointer missing";
62939
+ const registration = `worktrees/${u.registrationId} ${u.registrationPresent ? "registered" : "not registered"}`;
62940
+ const head = u.head ? `HEAD ${u.head}` : "HEAD unknown";
62941
+ return `${pointer}; ${registration}; ${head}; ${u.mtime ? `modified ${u.mtime}` : "mtime unknown"}`;
62942
+ }
62789
62943
 
62790
62944
  // src/command-register-collaboration.ts
62791
62945
  var DEFAULT_PRIORITY = "medium";
@@ -63935,9 +64089,19 @@ ${preparation.findings.map((finding) => `[${finding.check}/${finding.state}] ${f
63935
64089
  }
63936
64090
  class PrHeadBehindBaseError extends Error {
63937
64091
  }
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) => {
64092
+ 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
64093
  const timeoutMinutes = opts.timeout === void 0 ? void 0 : Number(opts.timeout);
63940
64094
  if (timeoutMinutes !== void 0 && !(timeoutMinutes > 0)) return fail("pr land: --timeout must be a positive number of minutes");
64095
+ if (opts.abandonAdmission) {
64096
+ if (numbers.length !== 1) return fail("pr land: --abandon-admission abandons one PR per call");
64097
+ const repo = await resolveRepo(opts.repo).catch(() => void 0) ?? opts.repo;
64098
+ if (!repo) return fail("pr land: --abandon-admission needs --repo <owner/repo>; the PR repo could not be resolved");
64099
+ const outcome = await abandonQueueAdmission(numbers[0], repo);
64100
+ if (opts.json) printLine(JSON.stringify(outcome));
64101
+ 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}`);
64102
+ if (outcome.status !== "abandoned") process.exitCode = 1;
64103
+ return;
64104
+ }
63941
64105
  if (opts.expectHead !== void 0) {
63942
64106
  if (!/^[0-9a-f]{40}$/i.test(opts.expectHead)) return fail("pr land: --expect-head needs the full 40-hex head SHA");
63943
64107
  if (numbers.length !== 1) return fail("pr land: --expect-head pins one PR head; land one PR per call");
@@ -71555,6 +71719,54 @@ function renderTenantLogs(r) {
71555
71719
  return out.join("\n");
71556
71720
  }
71557
71721
 
71722
+ // src/tenant-status-wait.ts
71723
+ var TENANT_STATUS_WAIT_INTERVAL_MS = 12e3;
71724
+ var TENANT_STATUS_WAIT_TIMEOUT_DEFAULT_S = 660;
71725
+ var TENANT_STATUS_WAIT_TIMEOUT_MAX_S = 1800;
71726
+ var TENANT_STATUS_WAIT_MIN_SHA_LENGTH = 7;
71727
+ function parseWaitForSha(raw) {
71728
+ const sha = raw.trim().toLowerCase();
71729
+ if (!new RegExp(`^[0-9a-f]{${TENANT_STATUS_WAIT_MIN_SHA_LENGTH},40}$`).test(sha)) {
71730
+ return { ok: false, reason: `--wait-for must be a commit SHA (${TENANT_STATUS_WAIT_MIN_SHA_LENGTH}-40 hex characters), got "${raw.trim()}"` };
71731
+ }
71732
+ return { ok: true, sha };
71733
+ }
71734
+ function parseWaitTimeoutSeconds(raw) {
71735
+ if (raw === void 0) return { ok: true, seconds: TENANT_STATUS_WAIT_TIMEOUT_DEFAULT_S, clamped: false };
71736
+ const seconds = Number(raw.trim());
71737
+ if (!Number.isInteger(seconds) || seconds <= 0) {
71738
+ return { ok: false, reason: `--timeout must be a positive whole number of seconds, got "${raw.trim()}"` };
71739
+ }
71740
+ if (seconds > TENANT_STATUS_WAIT_TIMEOUT_MAX_S) return { ok: true, seconds: TENANT_STATUS_WAIT_TIMEOUT_MAX_S, clamped: true };
71741
+ return { ok: true, seconds, clamped: false };
71742
+ }
71743
+ function deployedTagReaches(tag, sha) {
71744
+ const stamped = (tag ?? "").trim().toLowerCase();
71745
+ const needle = sha.trim().toLowerCase();
71746
+ if (!stamped || !needle) return false;
71747
+ if (stamped === needle) return true;
71748
+ const [shorter, longer] = stamped.length <= needle.length ? [stamped, needle] : [needle, stamped];
71749
+ return shorter.length >= TENANT_STATUS_WAIT_MIN_SHA_LENGTH && longer.startsWith(shorter);
71750
+ }
71751
+ async function waitForDeployedTag(deps) {
71752
+ const intervalMs = deps.intervalMs ?? TENANT_STATUS_WAIT_INTERVAL_MS;
71753
+ const now = deps.now ?? Date.now;
71754
+ const sleep2 = deps.sleep ?? ((ms) => new Promise((resolve8) => setTimeout(resolve8, ms)));
71755
+ const startedAt = now();
71756
+ const deadline = startedAt + deps.timeoutMs;
71757
+ let polls = 0;
71758
+ for (; ; ) {
71759
+ const status = await deps.readStatus();
71760
+ polls += 1;
71761
+ if (deployedTagReaches(deps.deployedTagOf(status), deps.sha)) {
71762
+ return { matched: true, status, polls, elapsedMs: now() - startedAt };
71763
+ }
71764
+ const remaining = deadline - now();
71765
+ if (remaining <= 0) return { matched: false, status, polls, elapsedMs: now() - startedAt };
71766
+ await sleep2(Math.min(intervalMs, remaining));
71767
+ }
71768
+ }
71769
+
71558
71770
  // src/tenant-sweep.ts
71559
71771
  function isRcBearingTenant(p) {
71560
71772
  if ((p.deployModel ?? "") !== "tenant-container") return false;
@@ -73613,12 +73825,33 @@ tenant.command("reconcile <owner/repo> <stage>").description("re-render this ten
73613
73825
  return failGraceful(`runtime tenant reconcile: ${e.message}`);
73614
73826
  }
73615
73827
  });
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) => {
73828
+ 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
73829
  if (!["dev", "rc", "main"].includes(stage)) return fail("runtime tenant status: <stage> must be dev, rc, or main");
73830
+ let wait;
73831
+ if (o.waitFor !== void 0) {
73832
+ const sha = parseWaitForSha(o.waitFor);
73833
+ if (!sha.ok) return fail(`runtime tenant status: ${sha.reason}`);
73834
+ const timeout = parseWaitTimeoutSeconds(o.timeout);
73835
+ if (!timeout.ok) return fail(`runtime tenant status: ${timeout.reason}`);
73836
+ wait = { sha: sha.sha, seconds: timeout.seconds, clamped: timeout.clamped };
73837
+ } else if (o.timeout !== void 0) {
73838
+ return fail("runtime tenant status: --timeout only applies with --wait-for <sha>");
73839
+ }
73618
73840
  const cfg = await loadConfig();
73619
- const result = await buildTenantRuntimeStatusFor(repo, stage, cfg);
73841
+ const readStatus = () => buildTenantRuntimeStatusFor(repo, stage, cfg);
73842
+ let result;
73843
+ let waitMatched;
73844
+ if (!wait) result = await readStatus();
73845
+ else {
73846
+ 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)` : ""}`);
73847
+ const waited = await waitForDeployedTag({ sha: wait.sha, timeoutMs: wait.seconds * 1e3, readStatus, deployedTagOf: (status) => status.deploy?.deployedTag });
73848
+ result = waited.status;
73849
+ waitMatched = waited.matched;
73850
+ 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}`);
73851
+ }
73620
73852
  console.log(JSON.stringify(result, null, 2));
73621
- if (result.publicProbe?.ok === false || result.edgeCertificate?.missing) process.exitCode = 1;
73853
+ if (waitMatched !== void 0) process.exitCode = waitMatched ? 0 : 1;
73854
+ else if (result.publicProbe?.ok === false || result.edgeCertificate?.missing) process.exitCode = 1;
73622
73855
  });
73623
73856
  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
73857
  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.32",
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/claude-plugin",
3
- "version": "4.6.31",
3
+ "version": "4.6.32",
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