@mutmutco/claude-plugin 4.5.174 → 4.5.175

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.
package/cli/dist/main.cjs CHANGED
@@ -13366,6 +13366,234 @@ function findDuplicateLesson(source, openLessons) {
13366
13366
  // src/board-doctor-config.ts
13367
13367
  init_github_client();
13368
13368
 
13369
+ // src/sub-issue.ts
13370
+ init_error_codes();
13371
+ init_github_quota();
13372
+ function buildResolveIdArgs(ref) {
13373
+ const args = ["issue", "view", String(ref.number), "--json", "id", "--jq", ".id"];
13374
+ if (ref.repo) args.push("--repo", ref.repo);
13375
+ return args;
13376
+ }
13377
+ function buildAddSubIssueArgs(parentId, subIssueId) {
13378
+ if (!parentId) throw new Error("addSubIssue: parentId is required");
13379
+ if (!subIssueId) throw new Error("addSubIssue: subIssueId is required");
13380
+ return [
13381
+ "api",
13382
+ "graphql",
13383
+ "-f",
13384
+ "query=mutation($p:ID!,$c:ID!){addSubIssue(input:{issueId:$p,subIssueId:$c}){issue{number subIssues{totalCount}} subIssue{number}}}",
13385
+ "-f",
13386
+ `p=${parentId}`,
13387
+ "-f",
13388
+ `c=${subIssueId}`
13389
+ ];
13390
+ }
13391
+ function parseAddSubIssueResult(stdout) {
13392
+ try {
13393
+ const issue = JSON.parse(stdout)?.data?.addSubIssue;
13394
+ const parentNumber = issue?.issue?.number;
13395
+ const subIssueNumber = issue?.subIssue?.number;
13396
+ const totalCount = issue?.issue?.subIssues?.totalCount;
13397
+ if (typeof parentNumber !== "number" || typeof subIssueNumber !== "number") return void 0;
13398
+ return { parentNumber, subIssueNumber, totalCount: typeof totalCount === "number" ? totalCount : 0 };
13399
+ } catch {
13400
+ return void 0;
13401
+ }
13402
+ }
13403
+ var RESOLVE_ID_TIMEOUT_MS = 1e4;
13404
+ async function resolveIssueNodeId(runGh, ref, fallbackRepo) {
13405
+ const resolved = ref.repo ? ref : { ...ref, repo: fallbackRepo };
13406
+ const id = (await runGh(buildResolveIdArgs(resolved), RESOLVE_ID_TIMEOUT_MS)).trim();
13407
+ if (!id) throw new Error(`could not resolve node id for issue #${ref.number}${resolved.repo ? ` in ${resolved.repo}` : ""}`);
13408
+ return id;
13409
+ }
13410
+ var SubIssueRestFallbackError = class extends Error {
13411
+ constructor(message2, reason) {
13412
+ super(message2);
13413
+ this.reason = reason;
13414
+ this.name = "SubIssueRestFallbackError";
13415
+ }
13416
+ reason;
13417
+ };
13418
+ async function linkSubIssue(runGh, parentRef, childRef, defaultRepo) {
13419
+ const parent = parseIssueRef(parentRef);
13420
+ const child2 = parseIssueRef(childRef);
13421
+ try {
13422
+ const parentId = await resolveIssueNodeId(runGh, parent, defaultRepo);
13423
+ const subIssueId = await resolveIssueNodeId(runGh, child2, defaultRepo);
13424
+ const stdout = await runGh(buildAddSubIssueArgs(parentId, subIssueId), GH_MUTATION_TIMEOUT_MS);
13425
+ const result = parseAddSubIssueResult(stdout);
13426
+ if (!result) throw new Error(`addSubIssue returned an unexpected response:
13427
+ ${stdout.trim() || "(empty)"}`);
13428
+ return result;
13429
+ } catch (graphqlError) {
13430
+ if (!isRateLimitRefusal(graphqlError)) throw graphqlError;
13431
+ const parentRepo = parent.repo ?? defaultRepo;
13432
+ const childRepo = child2.repo ?? defaultRepo;
13433
+ if (!parentRepo || !childRepo) throw graphqlError;
13434
+ try {
13435
+ const childPath = `repos/${childRepo}/issues/${child2.number}`;
13436
+ const childIssue = JSON.parse(await runGh(["api", childPath], RESOLVE_ID_TIMEOUT_MS));
13437
+ if (!Number.isSafeInteger(childIssue.id) || !childIssue.id) throw new Error(`REST issue ${childRepo}#${child2.number} has no numeric id`);
13438
+ await runGh([
13439
+ "api",
13440
+ "-X",
13441
+ "POST",
13442
+ `repos/${parentRepo}/issues/${parent.number}/sub_issues`,
13443
+ "-F",
13444
+ `sub_issue_id=${childIssue.id}`
13445
+ ], GH_MUTATION_TIMEOUT_MS);
13446
+ const confirmed = JSON.parse(await runGh(["api", childPath], RESOLVE_ID_TIMEOUT_MS));
13447
+ const actual = resolveParentField(confirmed);
13448
+ if (!("parent" in actual) || !actual.parent || actual.parent.repo.toLowerCase() !== parentRepo.toLowerCase() || actual.parent.number !== parent.number) {
13449
+ throw new Error(`REST link not verified for ${childRepo}#${child2.number}: ${"parentReadError" in actual ? actual.parentReadError : "parent differs or is absent"}`);
13450
+ }
13451
+ let totalCount = 0;
13452
+ try {
13453
+ const parentIssue = JSON.parse(await runGh(["api", `repos/${parentRepo}/issues/${parent.number}`], RESOLVE_ID_TIMEOUT_MS));
13454
+ if (typeof parentIssue.sub_issues_summary?.total === "number") totalCount = parentIssue.sub_issues_summary.total;
13455
+ } catch {
13456
+ }
13457
+ return { parentNumber: parent.number, subIssueNumber: child2.number, totalCount };
13458
+ } catch (restError) {
13459
+ const err = restError;
13460
+ const detail = (err.stderr || err.message || String(restError)).trim();
13461
+ throw new SubIssueRestFallbackError(
13462
+ `GraphQL link rate-limited; REST link not verified: ${detail}`,
13463
+ isRateLimitRefusal(restError) ? "rate_limited" : "failed"
13464
+ );
13465
+ }
13466
+ }
13467
+ }
13468
+ function buildRemoveSubIssueArgs(parentId, subIssueId) {
13469
+ if (!parentId) throw new Error("removeSubIssue: parentId is required");
13470
+ if (!subIssueId) throw new Error("removeSubIssue: subIssueId is required");
13471
+ return [
13472
+ "api",
13473
+ "graphql",
13474
+ "-f",
13475
+ "query=mutation($p:ID!,$c:ID!){removeSubIssue(input:{issueId:$p,subIssueId:$c}){issue{number subIssues{totalCount}} subIssue{number}}}",
13476
+ "-f",
13477
+ `p=${parentId}`,
13478
+ "-f",
13479
+ `c=${subIssueId}`
13480
+ ];
13481
+ }
13482
+ function parseRemoveSubIssueResult(stdout) {
13483
+ try {
13484
+ const issue = JSON.parse(stdout)?.data?.removeSubIssue;
13485
+ const parentNumber = issue?.issue?.number;
13486
+ const subIssueNumber = issue?.subIssue?.number;
13487
+ const totalCount = issue?.issue?.subIssues?.totalCount;
13488
+ if (typeof parentNumber !== "number" || typeof subIssueNumber !== "number") return void 0;
13489
+ return { parentNumber, subIssueNumber, totalCount: typeof totalCount === "number" ? totalCount : 0 };
13490
+ } catch {
13491
+ return void 0;
13492
+ }
13493
+ }
13494
+ async function unlinkSubIssue(runGh, parentRef, childRef, defaultRepo) {
13495
+ const parent = parseIssueRef(parentRef);
13496
+ const child2 = parseIssueRef(childRef);
13497
+ const parentId = await resolveIssueNodeId(runGh, parent, defaultRepo);
13498
+ const subIssueId = await resolveIssueNodeId(runGh, child2, defaultRepo);
13499
+ const stdout = await runGh(buildRemoveSubIssueArgs(parentId, subIssueId), GH_MUTATION_TIMEOUT_MS);
13500
+ const result = parseRemoveSubIssueResult(stdout);
13501
+ if (!result) throw new Error(`removeSubIssue returned an unexpected response:
13502
+ ${stdout.trim() || "(empty)"}`);
13503
+ return result;
13504
+ }
13505
+ function parseParentIssueUrl(apiUrl) {
13506
+ if (typeof apiUrl !== "string" || !apiUrl.trim()) return null;
13507
+ const match = /\/repos\/([^/]+\/[^/]+)\/issues\/(\d+)\/?$/.exec(apiUrl.trim());
13508
+ if (!match) return null;
13509
+ const [, repo, number] = match;
13510
+ return { repo, number: Number(number), url: `https://github.com/${repo}/issues/${number}` };
13511
+ }
13512
+ function resolveParentField(payload) {
13513
+ if (!payload || typeof payload !== "object") {
13514
+ return { parentReadError: "issue payload was empty or not an object" };
13515
+ }
13516
+ if (!("sub_issues_summary" in payload)) {
13517
+ return { parentReadError: "issue payload carries no sub-issue fields \u2014 parent state is unknown, not absent" };
13518
+ }
13519
+ const raw = payload.parent_issue_url;
13520
+ if (raw === void 0 || raw === null) return { parent: null };
13521
+ const parsed = typeof raw === "string" ? parseParentIssueUrl(raw) : null;
13522
+ return parsed ? { parent: parsed } : { parentReadError: `unrecognised parent_issue_url: ${JSON.stringify(raw)}` };
13523
+ }
13524
+ async function readIssueParent(runGh, repo, number) {
13525
+ try {
13526
+ const stdout = await runGh(["api", `repos/${repo}/issues/${number}`], RESOLVE_ID_TIMEOUT_MS);
13527
+ const resolved = resolveParentField(JSON.parse(stdout));
13528
+ return "parent" in resolved ? resolved.parent : void 0;
13529
+ } catch {
13530
+ return void 0;
13531
+ }
13532
+ }
13533
+ function isParentConflictMessage(text) {
13534
+ return typeof text === "string" && /sub[- ]?issue may only have one parent/i.test(text);
13535
+ }
13536
+ function buildReparentConflictPayload(input) {
13537
+ const { childRepo, childNumber, requestedParent, currentParent, offendingFlag } = input;
13538
+ const child2 = `${childRepo}#${childNumber}`;
13539
+ const payload = { code: ERROR_CODES.ERR_STATE_CONFLICT };
13540
+ if (offendingFlag) payload.offending_flag = offendingFlag;
13541
+ if (!currentParent) {
13542
+ return {
13543
+ message: currentParent === null ? `${child2} cannot be reparented under ${requestedParent} \u2014 GitHub refused it under the one-parent rule, but reading the issue back showed no parent at all. Something changed it concurrently; re-read with \`mmi-cli oracle issue view ${childNumber} --repo ${childRepo}\` and retry` : `${child2} cannot be reparented under ${requestedParent} \u2014 GitHub allows a sub-issue exactly one parent and it already has one. Its current parent could not be read; check with \`mmi-cli oracle issue view ${childNumber} --repo ${childRepo}\``,
13544
+ payload
13545
+ };
13546
+ }
13547
+ const current = `${currentParent.repo}#${currentParent.number}`;
13548
+ const sameRepo2 = currentParent.repo.toLowerCase() === childRepo.toLowerCase();
13549
+ const repoFlag = ` --repo ${childRepo}`;
13550
+ payload.current_parent = current;
13551
+ const requested = parseIssueRef(requestedParent);
13552
+ const alreadyThere = currentParent.number === requested.number && (requested.repo ?? childRepo).toLowerCase() === currentParent.repo.toLowerCase();
13553
+ if (!alreadyThere) {
13554
+ payload.corrected_command = `mmi-cli oracle issue unlink-child ${sameRepo2 ? String(currentParent.number) : current} ${childNumber}${repoFlag} && mmi-cli oracle issue edit ${childNumber}${repoFlag} --parent ${requestedParent}`;
13555
+ }
13556
+ return {
13557
+ message: alreadyThere ? `${child2} is already a sub-issue of ${current} \u2014 GitHub allows a sub-issue exactly one parent, so this reparent is a no-op it refuses rather than performs; nothing to do` : `${child2} already has a parent (${current}) \u2014 GitHub allows a sub-issue exactly one parent. Reparenting it under ${requestedParent} would REMOVE it from ${current}; unlink it there first if that is what you want`,
13558
+ payload
13559
+ };
13560
+ }
13561
+ async function classifyReparentFailure(e, runGh, childRepo, childNumber, requestedParent, offendingFlag) {
13562
+ const err = e;
13563
+ if (!isParentConflictMessage((err.stderr || err.message || String(e)).trim())) return void 0;
13564
+ const currentParent = await readIssueParent(runGh, childRepo, childNumber);
13565
+ return buildReparentConflictPayload({ childRepo, childNumber, requestedParent, currentParent, offendingFlag });
13566
+ }
13567
+ function parentLinkFields(result, error, parentRef, childRef, reason = "failed") {
13568
+ if (result) return { parent: result, parentLink: "linked" };
13569
+ if (parentRef !== void 0 && childRef) return {
13570
+ parentLink: reason,
13571
+ parentLinkError: error ?? "parent link was not verified",
13572
+ parentLinkRecovery: `mmi-cli oracle issue link-child ${parentRef} ${childRef}`
13573
+ };
13574
+ return {};
13575
+ }
13576
+
13577
+ // src/issue-comment.ts
13578
+ async function postIssueComment(client, input) {
13579
+ const parsed = parseIssueRef(input.ref);
13580
+ const repo = parsed.repo ?? input.defaultRepo;
13581
+ if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
13582
+ if (input.body.trim().length === 0) throw new Error("comment body is empty");
13583
+ const comment = await client.rest(
13584
+ "POST",
13585
+ `repos/${repo}/issues/${parsed.number}/comments`,
13586
+ { body: { body: input.body } }
13587
+ );
13588
+ if (!comment?.html_url) throw new Error("GitHub did not return a comment URL");
13589
+ return {
13590
+ number: parsed.number,
13591
+ repo,
13592
+ url: `https://github.com/${repo}/issues/${parsed.number}`,
13593
+ commentUrl: comment.html_url
13594
+ };
13595
+ }
13596
+
13369
13597
  // ../infra/board-vocab.mjs
13370
13598
  var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
13371
13599
 
@@ -13468,17 +13696,55 @@ async function boardDoctor(options, deps = {}) {
13468
13696
  if (claimedItems.length > 0) {
13469
13697
  let next = 0;
13470
13698
  const fetchOpenPulls = openPullsFetcher(client);
13699
+ const actor = deps.session ?? describeSessionIdentity();
13471
13700
  const livenessWorker = async () => {
13472
13701
  while (next < claimedItems.length) {
13473
13702
  const item = claimedItems[next++];
13474
13703
  const evidence = await gatherClaimLiveness(client, item.repository, item.number, fetchOpenPulls);
13704
+ const holderProvenDead = Boolean(
13705
+ evidence.marker && evidence.sessionState === "dead" && isHostSessionId(evidence.marker.session) && !evidence.openPr && !evidence.branch
13706
+ );
13475
13707
  const freshMarker = evidence.sessionState === "live" || evidence.sessionState !== "dead" && evidence.markerAgeMs !== void 0 && evidence.markerAgeMs < CLAIM_MARKER_LIVE_MS;
13476
13708
  const bits = [
13477
13709
  evidence.marker ? `claim marker ${describeClaimMarker(evidence.marker)} ${formatClaimAge(evidence.markerAgeMs)} old` + (evidence.sessionState ? `; local session ${evidence.sessionState}` : "") : evidence.failed.includes("comments") ? "claim marker unreadable" : "no claim marker",
13478
13710
  evidence.openPr,
13479
13711
  evidence.branch ? `branch ${evidence.branch}` : void 0
13480
13712
  ].filter((bit) => Boolean(bit)).join("; ");
13481
- if (freshMarker || evidence.openPr || evidence.branch) {
13713
+ if (holderProvenDead && evidence.marker) {
13714
+ const holder = describeClaimMarker(evidence.marker);
13715
+ try {
13716
+ await client.rest("DELETE", `repos/${item.repository}/issues/${item.number}/assignees`, {
13717
+ body: { assignees: item.assignees }
13718
+ });
13719
+ try {
13720
+ const release = {
13721
+ v: 1,
13722
+ session: actor.session,
13723
+ surface: actor.surface,
13724
+ host: actor.host,
13725
+ ts: (/* @__PURE__ */ new Date()).toISOString()
13726
+ };
13727
+ await postIssueComment(client, { ref: item.ref, body: renderClaimReleaseBody(release) });
13728
+ } catch (e) {
13729
+ process.stderr.write(`warning: ${item.ref} was unclaimed by board doctor, but recording the release marker comment failed: ${ghError(e)}
13730
+ `);
13731
+ }
13732
+ await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.statusFieldId, cfg.statusOptions["Todo"]);
13733
+ append(
13734
+ "possibly-abandoned-claim",
13735
+ { ...item, assignees: [], status: "Todo", statusOptionId: cfg.statusOptions["Todo"] },
13736
+ `${item.ref}: auto-released proven-dead claim holder ${holder} -> Todo (${bits})`
13737
+ );
13738
+ result.fixed += 1;
13739
+ } catch (e) {
13740
+ append(
13741
+ "possibly-abandoned-claim",
13742
+ item,
13743
+ `${item.ref}: proven-dead claim holder ${holder} auto-release failed (${ghError(e)}); ${bits}`
13744
+ );
13745
+ result.failed += 1;
13746
+ }
13747
+ } else if (freshMarker || evidence.openPr || evidence.branch) {
13482
13748
  liveClaims.push({ type: "live-claim", item, detail: `${item.ref}: live \u2014 ${bits}` });
13483
13749
  } else if (evidence.failed.length > 0) {
13484
13750
  liveClaims.push({
@@ -14161,7 +14427,11 @@ async function readBoard(options, deps = {}) {
14161
14427
  }
14162
14428
  const readComments = claimMarkerCommentsReader(client);
14163
14429
  for (const scope of ["primary", "secondary"]) {
14164
- await markStaleClaimStamps(report[scope], readComments, { pullRequests, writable: writableOrUnknown(writable) });
14430
+ await markStaleClaimStamps(report[scope], readComments, {
14431
+ viewerLogin: report.viewer,
14432
+ pullRequests,
14433
+ writable: writableOrUnknown(writable)
14434
+ });
14165
14435
  }
14166
14436
  for (const scope of ["primary", "secondary"]) {
14167
14437
  const filtered = await filterDependencyBlockedClaimables(report[scope].claimable, client, {
@@ -14171,7 +14441,10 @@ async function readBoard(options, deps = {}) {
14171
14441
  report.warnings.push(...filtered.warnings);
14172
14442
  }
14173
14443
  for (const scope of ["primary", "secondary"]) {
14174
- const demoted = await demoteLiveClaimedClaimables(report[scope], client, void 0, { fetchComments: readComments });
14444
+ const demoted = await demoteLiveClaimedClaimables(report[scope], client, void 0, {
14445
+ fetchComments: readComments,
14446
+ viewerLogin: report.viewer
14447
+ });
14175
14448
  report.warnings.push(...demoted.warnings);
14176
14449
  report.partial = report.partial || demoted.partial;
14177
14450
  }
@@ -14695,11 +14968,22 @@ function claimFromMarker(item, marker, linked = {}, now = Date.now()) {
14695
14968
  ...claimStampStaleness(marker, { activityAt: [...linked.activityAt ?? []], linkedWork: linked.linkedWork }, now)
14696
14969
  };
14697
14970
  }
14698
- function classifyClaimableMarker(comments, actor, now = Date.now(), activityAt = []) {
14971
+ function latestClaimMarkerAuthor(comments) {
14972
+ if (!latestClaimMarker(comments)) return void 0;
14973
+ for (let i = comments.length - 1; i >= 0; i -= 1) {
14974
+ if (latestClaimMarker([comments[i]])) {
14975
+ return comments[i].author || comments[i].login || comments[i].user?.login || void 0;
14976
+ }
14977
+ }
14978
+ return void 0;
14979
+ }
14980
+ function classifyClaimableMarker(comments, actor, now = Date.now(), activityAt = [], viewerLogin) {
14699
14981
  const marker = comments ? latestClaimMarker(comments) : void 0;
14700
14982
  if (!marker) return { verdict: "claimable" };
14701
- const ownership = laneOwnership(marker, actor);
14702
- if (ownership === "mine") return { verdict: "owned", holder: describeClaimMarker(marker) };
14983
+ const markerBy = comments ? latestClaimMarkerAuthor(comments) : void 0;
14984
+ if (isSeatClaimMarker(marker, actor, { viewerLogin: viewerLogin ?? markerBy, markerBy, now })) {
14985
+ return { verdict: "owned", holder: describeClaimMarker(marker) };
14986
+ }
14703
14987
  if (!claimStampStaleness(marker, { activityAt }, now).stale) {
14704
14988
  return { verdict: "taken", holder: describeClaimMarker(marker) };
14705
14989
  }
@@ -14714,7 +14998,10 @@ function claimMarkerCommentsReader(client) {
14714
14998
  if (item.detailsUnavailable) return void 0;
14715
14999
  if (item.details && !item.details.commentsUnread && !item.details.omittedComments) return item.details.comments;
14716
15000
  const comments = await client.restPaginate(`repos/${item.repository}/issues/${item.number}/comments`);
14717
- return comments.map((comment) => ({ body: comment.body ?? "" }));
15001
+ return comments.map((comment) => ({
15002
+ body: comment.body ?? "",
15003
+ ...comment.user?.login ? { author: comment.user.login } : {}
15004
+ }));
14718
15005
  })();
14719
15006
  cache.set(item, read);
14720
15007
  }
@@ -14725,6 +15012,7 @@ async function markStaleClaimStamps(scope, readComments, options = {}) {
14725
15012
  const actor = options.actor ?? describeSessionIdentity();
14726
15013
  const now = options.now ?? Date.now();
14727
15014
  const promoted = [];
15015
+ const adoptedOwned = [];
14728
15016
  const buckets = ["userOwned", "claimable", "unownedInFlight", "taken"];
14729
15017
  const issues = buckets.flatMap((bucket) => scope[bucket]).filter((item) => item.contentType === "Issue");
14730
15018
  const read = /* @__PURE__ */ new Map();
@@ -14744,19 +15032,35 @@ async function markStaleClaimStamps(scope, readComments, options = {}) {
14744
15032
  const comments = read.get(item);
14745
15033
  const marker = comments ? latestClaimMarker(comments) : void 0;
14746
15034
  if (!marker) continue;
15035
+ const markerBy = comments ? latestClaimMarkerAuthor(comments) : void 0;
15036
+ const effectiveViewer = options.viewerLogin ?? (bucket === "userOwned" ? item.assignees[0] : markerBy);
15037
+ const ownSeat = isSeatClaimMarker(marker, actor, {
15038
+ viewerLogin: effectiveViewer,
15039
+ markerBy: markerBy ?? (bucket === "userOwned" ? item.assignees[0] : void 0),
15040
+ now
15041
+ });
14747
15042
  const prs = (options.pullRequests ?? []).filter((pr) => pr.closesIssues.some((ref) => ref.toLowerCase() === item.ref.toLowerCase()));
14748
15043
  item.claim = claimFromMarker(item, marker, {
14749
15044
  activityAt: prs.map((pr) => pr.updatedAt),
14750
15045
  linkedWork: prs.some((pr) => !pr.updatedAt)
14751
15046
  }, now);
15047
+ if (ownSeat && bucket !== "userOwned" && bucket !== "claimable") {
15048
+ kept.pop();
15049
+ adoptedOwned.push(item);
15050
+ continue;
15051
+ }
14752
15052
  const movable = bucket === "userOwned" || bucket === "unownedInFlight";
14753
- if (movable && item.claim.stale && laneOwnership(marker, actor) !== "mine" && (!options.writable || options.writable.has(item.repository.toLowerCase()))) {
15053
+ if (movable && item.claim.stale && !ownSeat && (!options.writable || options.writable.has(item.repository.toLowerCase()))) {
14754
15054
  kept.pop();
14755
15055
  promoted.push(item);
14756
15056
  }
14757
15057
  }
14758
15058
  scope[bucket] = kept;
14759
15059
  }
15060
+ if (adoptedOwned.length) {
15061
+ scope.userOwned.push(...adoptedOwned);
15062
+ scope.userOwned.sort(compareItems);
15063
+ }
14760
15064
  if (promoted.length) {
14761
15065
  scope.claimable.push(...promoted);
14762
15066
  scope.claimable.sort(compareItems);
@@ -14785,7 +15089,7 @@ async function demoteLiveClaimedClaimables(scope, client, actor = describeSessio
14785
15089
  warnings.push(`${item.ref} left claimable: claim-marker evidence could not be read`);
14786
15090
  continue;
14787
15091
  }
14788
- const verdict = classifyClaimableMarker(comments, actor, deps.now);
15092
+ const verdict = classifyClaimableMarker(comments, actor, deps.now, [], deps.viewerLogin);
14789
15093
  if (verdict.verdict === "claimable") {
14790
15094
  stillClaimable.push(item);
14791
15095
  } else if (verdict.verdict === "owned") {
@@ -14811,7 +15115,7 @@ var CLAIM_SESSION_ACTIVITY_MS = 30 * 6e4;
14811
15115
  var CLAIM_SESSION_PROBE_CACHE_MS = 6e4;
14812
15116
  var claimSessionProbeCache = /* @__PURE__ */ new Map();
14813
15117
  function probeLocalClaimSession(marker, now = Date.now()) {
14814
- if (!marker.session || !marker.host || marker.host.toLowerCase() !== (0, import_node_os11.hostname)().toLowerCase()) return void 0;
15118
+ if (!isHostSessionId(marker.session) || !marker.host || marker.host.toLowerCase() !== (0, import_node_os11.hostname)().toLowerCase()) return void 0;
14815
15119
  if (!marker.surface?.toLowerCase().startsWith("claude")) return void 0;
14816
15120
  const cacheKey = `${marker.host.toLowerCase()}/${marker.session}`;
14817
15121
  const cached = claimSessionProbeCache.get(cacheKey);
@@ -14989,8 +15293,19 @@ function laneOwnership(marker, current) {
14989
15293
  }
14990
15294
  return "unknown";
14991
15295
  }
15296
+ function isSeatClaimMarker(marker, current, context = {}) {
15297
+ if (!marker) return false;
15298
+ if (laneOwnership(marker, current) === "mine") return true;
15299
+ if (!context.viewerLogin || !context.markerBy) return false;
15300
+ if (context.markerBy.toLowerCase() !== context.viewerLogin.toLowerCase()) return false;
15301
+ if (!sameClaimHost(marker.host, current.host)) return false;
15302
+ if (!marker.surface || !current.surface || marker.surface !== current.surface) return false;
15303
+ if (!isHostSessionId(marker.session)) return false;
15304
+ const state = context.sessionState ?? probeLocalClaimSession(marker, context.now);
15305
+ return state === "dead";
15306
+ }
14992
15307
  function laneResumeEvidence(evidence) {
14993
- if (!evidence.marker || evidence.sessionState !== "dead" || evidence.failed.length > 0) return void 0;
15308
+ if (!evidence.marker || evidence.sessionState !== "dead") return void 0;
14994
15309
  if (!isHostSessionId(evidence.marker.session)) return void 0;
14995
15310
  const artifacts = [evidence.openPr, evidence.branch ? `live branch ${evidence.branch}` : void 0].filter(Boolean);
14996
15311
  return `prior claim by lane ${describeClaimMarker(evidence.marker)} (@${evidence.markerBy}, ${formatClaimAge(evidence.markerAgeMs)} old) is verifiably not running on this host \u2014 its local session transcript is gone or stale${artifacts.length ? `; resuming over ${artifacts.join("; ")}` : ""}`;
@@ -15002,13 +15317,17 @@ async function checkLaneContest(client, item, actor = describeSessionIdentity(),
15002
15317
  evidence = await gatherClaimLiveness(livenessClient, item.repository, item.number, openPullsFetcher(livenessClient));
15003
15318
  }
15004
15319
  const ownership = laneOwnership(evidence.marker, actor);
15005
- const sameOwner = Boolean(viewerLogin && evidence.markerBy && evidence.markerBy.toLowerCase() === viewerLogin.toLowerCase());
15006
- const resume = ownership !== "mine" && sameOwner ? laneResumeEvidence(evidence) : void 0;
15007
- const live = ownership === "mine" ? [] : liveEvidenceLines(evidence, item.repository);
15008
- const unverifiable = ownership === "mine" ? [] : evidence.failed;
15009
- const contested = ownership !== "mine" && !resume && (live.length > 0 || unverifiable.length > 0);
15320
+ const ownSeat = isSeatClaimMarker(evidence.marker, actor, {
15321
+ viewerLogin,
15322
+ markerBy: evidence.markerBy,
15323
+ sessionState: evidence.sessionState
15324
+ });
15325
+ const resume = ownership !== "mine" && ownSeat ? laneResumeEvidence(evidence) : void 0;
15326
+ const live = ownSeat ? [] : liveEvidenceLines(evidence, item.repository);
15327
+ const unverifiable = ownSeat ? [] : evidence.failed;
15328
+ const contested = !ownSeat && !resume && (live.length > 0 || unverifiable.length > 0);
15010
15329
  const carry = staleWorkLines(evidence);
15011
- const staleStamp = !contested && !resume && ownership !== "mine" && evidence.marker ? `stale claim stamp ${describeClaimMarker(evidence.marker)} (idle ${formatClaimAge(claimStampStaleness(evidence.marker, { activityAt: [evidence.lastCommentAt] }).idleMs)})${carry.length ? `; continue its ${carry.join(" and ")}` : ""}` : void 0;
15330
+ const staleStamp = !contested && !resume && !ownSeat && evidence.marker ? `stale claim stamp ${describeClaimMarker(evidence.marker)} (idle ${formatClaimAge(claimStampStaleness(evidence.marker, { activityAt: [evidence.lastCommentAt] }).idleMs)})${carry.length ? `; continue its ${carry.join(" and ")}` : ""}` : void 0;
15012
15331
  return {
15013
15332
  contested,
15014
15333
  live,
@@ -15043,236 +15362,6 @@ function ghError(e) {
15043
15362
  // src/board-claim-move.ts
15044
15363
  init_github_client();
15045
15364
  init_github_quota();
15046
-
15047
- // src/sub-issue.ts
15048
- init_error_codes();
15049
- init_github_quota();
15050
- function buildResolveIdArgs(ref) {
15051
- const args = ["issue", "view", String(ref.number), "--json", "id", "--jq", ".id"];
15052
- if (ref.repo) args.push("--repo", ref.repo);
15053
- return args;
15054
- }
15055
- function buildAddSubIssueArgs(parentId, subIssueId) {
15056
- if (!parentId) throw new Error("addSubIssue: parentId is required");
15057
- if (!subIssueId) throw new Error("addSubIssue: subIssueId is required");
15058
- return [
15059
- "api",
15060
- "graphql",
15061
- "-f",
15062
- "query=mutation($p:ID!,$c:ID!){addSubIssue(input:{issueId:$p,subIssueId:$c}){issue{number subIssues{totalCount}} subIssue{number}}}",
15063
- "-f",
15064
- `p=${parentId}`,
15065
- "-f",
15066
- `c=${subIssueId}`
15067
- ];
15068
- }
15069
- function parseAddSubIssueResult(stdout) {
15070
- try {
15071
- const issue = JSON.parse(stdout)?.data?.addSubIssue;
15072
- const parentNumber = issue?.issue?.number;
15073
- const subIssueNumber = issue?.subIssue?.number;
15074
- const totalCount = issue?.issue?.subIssues?.totalCount;
15075
- if (typeof parentNumber !== "number" || typeof subIssueNumber !== "number") return void 0;
15076
- return { parentNumber, subIssueNumber, totalCount: typeof totalCount === "number" ? totalCount : 0 };
15077
- } catch {
15078
- return void 0;
15079
- }
15080
- }
15081
- var RESOLVE_ID_TIMEOUT_MS = 1e4;
15082
- async function resolveIssueNodeId(runGh, ref, fallbackRepo) {
15083
- const resolved = ref.repo ? ref : { ...ref, repo: fallbackRepo };
15084
- const id = (await runGh(buildResolveIdArgs(resolved), RESOLVE_ID_TIMEOUT_MS)).trim();
15085
- if (!id) throw new Error(`could not resolve node id for issue #${ref.number}${resolved.repo ? ` in ${resolved.repo}` : ""}`);
15086
- return id;
15087
- }
15088
- var SubIssueRestFallbackError = class extends Error {
15089
- constructor(message2, reason) {
15090
- super(message2);
15091
- this.reason = reason;
15092
- this.name = "SubIssueRestFallbackError";
15093
- }
15094
- reason;
15095
- };
15096
- async function linkSubIssue(runGh, parentRef, childRef, defaultRepo) {
15097
- const parent = parseIssueRef(parentRef);
15098
- const child2 = parseIssueRef(childRef);
15099
- try {
15100
- const parentId = await resolveIssueNodeId(runGh, parent, defaultRepo);
15101
- const subIssueId = await resolveIssueNodeId(runGh, child2, defaultRepo);
15102
- const stdout = await runGh(buildAddSubIssueArgs(parentId, subIssueId), GH_MUTATION_TIMEOUT_MS);
15103
- const result = parseAddSubIssueResult(stdout);
15104
- if (!result) throw new Error(`addSubIssue returned an unexpected response:
15105
- ${stdout.trim() || "(empty)"}`);
15106
- return result;
15107
- } catch (graphqlError) {
15108
- if (!isRateLimitRefusal(graphqlError)) throw graphqlError;
15109
- const parentRepo = parent.repo ?? defaultRepo;
15110
- const childRepo = child2.repo ?? defaultRepo;
15111
- if (!parentRepo || !childRepo) throw graphqlError;
15112
- try {
15113
- const childPath = `repos/${childRepo}/issues/${child2.number}`;
15114
- const childIssue = JSON.parse(await runGh(["api", childPath], RESOLVE_ID_TIMEOUT_MS));
15115
- if (!Number.isSafeInteger(childIssue.id) || !childIssue.id) throw new Error(`REST issue ${childRepo}#${child2.number} has no numeric id`);
15116
- await runGh([
15117
- "api",
15118
- "-X",
15119
- "POST",
15120
- `repos/${parentRepo}/issues/${parent.number}/sub_issues`,
15121
- "-F",
15122
- `sub_issue_id=${childIssue.id}`
15123
- ], GH_MUTATION_TIMEOUT_MS);
15124
- const confirmed = JSON.parse(await runGh(["api", childPath], RESOLVE_ID_TIMEOUT_MS));
15125
- const actual = resolveParentField(confirmed);
15126
- if (!("parent" in actual) || !actual.parent || actual.parent.repo.toLowerCase() !== parentRepo.toLowerCase() || actual.parent.number !== parent.number) {
15127
- throw new Error(`REST link not verified for ${childRepo}#${child2.number}: ${"parentReadError" in actual ? actual.parentReadError : "parent differs or is absent"}`);
15128
- }
15129
- let totalCount = 0;
15130
- try {
15131
- const parentIssue = JSON.parse(await runGh(["api", `repos/${parentRepo}/issues/${parent.number}`], RESOLVE_ID_TIMEOUT_MS));
15132
- if (typeof parentIssue.sub_issues_summary?.total === "number") totalCount = parentIssue.sub_issues_summary.total;
15133
- } catch {
15134
- }
15135
- return { parentNumber: parent.number, subIssueNumber: child2.number, totalCount };
15136
- } catch (restError) {
15137
- const err = restError;
15138
- const detail = (err.stderr || err.message || String(restError)).trim();
15139
- throw new SubIssueRestFallbackError(
15140
- `GraphQL link rate-limited; REST link not verified: ${detail}`,
15141
- isRateLimitRefusal(restError) ? "rate_limited" : "failed"
15142
- );
15143
- }
15144
- }
15145
- }
15146
- function buildRemoveSubIssueArgs(parentId, subIssueId) {
15147
- if (!parentId) throw new Error("removeSubIssue: parentId is required");
15148
- if (!subIssueId) throw new Error("removeSubIssue: subIssueId is required");
15149
- return [
15150
- "api",
15151
- "graphql",
15152
- "-f",
15153
- "query=mutation($p:ID!,$c:ID!){removeSubIssue(input:{issueId:$p,subIssueId:$c}){issue{number subIssues{totalCount}} subIssue{number}}}",
15154
- "-f",
15155
- `p=${parentId}`,
15156
- "-f",
15157
- `c=${subIssueId}`
15158
- ];
15159
- }
15160
- function parseRemoveSubIssueResult(stdout) {
15161
- try {
15162
- const issue = JSON.parse(stdout)?.data?.removeSubIssue;
15163
- const parentNumber = issue?.issue?.number;
15164
- const subIssueNumber = issue?.subIssue?.number;
15165
- const totalCount = issue?.issue?.subIssues?.totalCount;
15166
- if (typeof parentNumber !== "number" || typeof subIssueNumber !== "number") return void 0;
15167
- return { parentNumber, subIssueNumber, totalCount: typeof totalCount === "number" ? totalCount : 0 };
15168
- } catch {
15169
- return void 0;
15170
- }
15171
- }
15172
- async function unlinkSubIssue(runGh, parentRef, childRef, defaultRepo) {
15173
- const parent = parseIssueRef(parentRef);
15174
- const child2 = parseIssueRef(childRef);
15175
- const parentId = await resolveIssueNodeId(runGh, parent, defaultRepo);
15176
- const subIssueId = await resolveIssueNodeId(runGh, child2, defaultRepo);
15177
- const stdout = await runGh(buildRemoveSubIssueArgs(parentId, subIssueId), GH_MUTATION_TIMEOUT_MS);
15178
- const result = parseRemoveSubIssueResult(stdout);
15179
- if (!result) throw new Error(`removeSubIssue returned an unexpected response:
15180
- ${stdout.trim() || "(empty)"}`);
15181
- return result;
15182
- }
15183
- function parseParentIssueUrl(apiUrl) {
15184
- if (typeof apiUrl !== "string" || !apiUrl.trim()) return null;
15185
- const match = /\/repos\/([^/]+\/[^/]+)\/issues\/(\d+)\/?$/.exec(apiUrl.trim());
15186
- if (!match) return null;
15187
- const [, repo, number] = match;
15188
- return { repo, number: Number(number), url: `https://github.com/${repo}/issues/${number}` };
15189
- }
15190
- function resolveParentField(payload) {
15191
- if (!payload || typeof payload !== "object") {
15192
- return { parentReadError: "issue payload was empty or not an object" };
15193
- }
15194
- if (!("sub_issues_summary" in payload)) {
15195
- return { parentReadError: "issue payload carries no sub-issue fields \u2014 parent state is unknown, not absent" };
15196
- }
15197
- const raw = payload.parent_issue_url;
15198
- if (raw === void 0 || raw === null) return { parent: null };
15199
- const parsed = typeof raw === "string" ? parseParentIssueUrl(raw) : null;
15200
- return parsed ? { parent: parsed } : { parentReadError: `unrecognised parent_issue_url: ${JSON.stringify(raw)}` };
15201
- }
15202
- async function readIssueParent(runGh, repo, number) {
15203
- try {
15204
- const stdout = await runGh(["api", `repos/${repo}/issues/${number}`], RESOLVE_ID_TIMEOUT_MS);
15205
- const resolved = resolveParentField(JSON.parse(stdout));
15206
- return "parent" in resolved ? resolved.parent : void 0;
15207
- } catch {
15208
- return void 0;
15209
- }
15210
- }
15211
- function isParentConflictMessage(text) {
15212
- return typeof text === "string" && /sub[- ]?issue may only have one parent/i.test(text);
15213
- }
15214
- function buildReparentConflictPayload(input) {
15215
- const { childRepo, childNumber, requestedParent, currentParent, offendingFlag } = input;
15216
- const child2 = `${childRepo}#${childNumber}`;
15217
- const payload = { code: ERROR_CODES.ERR_STATE_CONFLICT };
15218
- if (offendingFlag) payload.offending_flag = offendingFlag;
15219
- if (!currentParent) {
15220
- return {
15221
- message: currentParent === null ? `${child2} cannot be reparented under ${requestedParent} \u2014 GitHub refused it under the one-parent rule, but reading the issue back showed no parent at all. Something changed it concurrently; re-read with \`mmi-cli oracle issue view ${childNumber} --repo ${childRepo}\` and retry` : `${child2} cannot be reparented under ${requestedParent} \u2014 GitHub allows a sub-issue exactly one parent and it already has one. Its current parent could not be read; check with \`mmi-cli oracle issue view ${childNumber} --repo ${childRepo}\``,
15222
- payload
15223
- };
15224
- }
15225
- const current = `${currentParent.repo}#${currentParent.number}`;
15226
- const sameRepo2 = currentParent.repo.toLowerCase() === childRepo.toLowerCase();
15227
- const repoFlag = ` --repo ${childRepo}`;
15228
- payload.current_parent = current;
15229
- const requested = parseIssueRef(requestedParent);
15230
- const alreadyThere = currentParent.number === requested.number && (requested.repo ?? childRepo).toLowerCase() === currentParent.repo.toLowerCase();
15231
- if (!alreadyThere) {
15232
- payload.corrected_command = `mmi-cli oracle issue unlink-child ${sameRepo2 ? String(currentParent.number) : current} ${childNumber}${repoFlag} && mmi-cli oracle issue edit ${childNumber}${repoFlag} --parent ${requestedParent}`;
15233
- }
15234
- return {
15235
- message: alreadyThere ? `${child2} is already a sub-issue of ${current} \u2014 GitHub allows a sub-issue exactly one parent, so this reparent is a no-op it refuses rather than performs; nothing to do` : `${child2} already has a parent (${current}) \u2014 GitHub allows a sub-issue exactly one parent. Reparenting it under ${requestedParent} would REMOVE it from ${current}; unlink it there first if that is what you want`,
15236
- payload
15237
- };
15238
- }
15239
- async function classifyReparentFailure(e, runGh, childRepo, childNumber, requestedParent, offendingFlag) {
15240
- const err = e;
15241
- if (!isParentConflictMessage((err.stderr || err.message || String(e)).trim())) return void 0;
15242
- const currentParent = await readIssueParent(runGh, childRepo, childNumber);
15243
- return buildReparentConflictPayload({ childRepo, childNumber, requestedParent, currentParent, offendingFlag });
15244
- }
15245
- function parentLinkFields(result, error, parentRef, childRef, reason = "failed") {
15246
- if (result) return { parent: result, parentLink: "linked" };
15247
- if (parentRef !== void 0 && childRef) return {
15248
- parentLink: reason,
15249
- parentLinkError: error ?? "parent link was not verified",
15250
- parentLinkRecovery: `mmi-cli oracle issue link-child ${parentRef} ${childRef}`
15251
- };
15252
- return {};
15253
- }
15254
-
15255
- // src/issue-comment.ts
15256
- async function postIssueComment(client, input) {
15257
- const parsed = parseIssueRef(input.ref);
15258
- const repo = parsed.repo ?? input.defaultRepo;
15259
- if (!repo) throw new Error("could not resolve repo \u2014 pass --repo owner/repo");
15260
- if (input.body.trim().length === 0) throw new Error("comment body is empty");
15261
- const comment = await client.rest(
15262
- "POST",
15263
- `repos/${repo}/issues/${parsed.number}/comments`,
15264
- { body: { body: input.body } }
15265
- );
15266
- if (!comment?.html_url) throw new Error("GitHub did not return a comment URL");
15267
- return {
15268
- number: parsed.number,
15269
- repo,
15270
- url: `https://github.com/${repo}/issues/${parsed.number}`,
15271
- commentUrl: comment.html_url
15272
- };
15273
- }
15274
-
15275
- // src/board-claim-move.ts
15276
15365
  var CLAIM_CONCURRENCY = 5;
15277
15366
  function withGatedWriteChainHint(message2) {
15278
15367
  return `${message2}
@@ -15694,7 +15783,7 @@ async function unclaimBoardIssue(options, deps = {}) {
15694
15783
  }
15695
15784
  const toStatus = options.toStatus ?? "Todo";
15696
15785
  if (item.contentType === "Issue") {
15697
- const contest = await checkLaneContest(client, item);
15786
+ const contest = await checkLaneContest(client, item, deps.session ?? describeSessionIdentity(), viewer);
15698
15787
  if (contest.contested) {
15699
15788
  if (!options.force) {
15700
15789
  throw new Error(withGatedWriteChainHint(laneContestMessage(item.ref, contest, "unclaim")));
@@ -19104,10 +19193,10 @@ var rollout_plan_default = {
19104
19193
  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)."
19105
19194
  },
19106
19195
  baseline: {
19107
- version: "4.5.174",
19108
- tag: "v4.5.174",
19109
- commit: "2db083c19203",
19110
- npm: "@mutmutco/cli@4.5.174"
19196
+ version: "4.5.175",
19197
+ tag: "v4.5.175",
19198
+ commit: "8efcf884c10f",
19199
+ npm: "@mutmutco/cli@4.5.175"
19111
19200
  },
19112
19201
  exitCriterion: "fleet-n-of-n",
19113
19202
  hubOnlyShortcut: "forbidden",
@@ -19124,14 +19213,14 @@ var rollout_plan_default = {
19124
19213
  repo: "mutmutco/mmi-hub",
19125
19214
  role: "canary",
19126
19215
  schedule: "train",
19127
- v3Target: "v4.5.174"
19216
+ v3Target: "v4.5.175"
19128
19217
  }
19129
19218
  ],
19130
19219
  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.",
19131
19220
  rollback: {
19132
19221
  independent: true,
19133
- mechanism: "npm dist-tag latest -> 4.5.174 and redeploy the Hub Lambda from tag v4.5.174 (2db083c19203); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
19134
- v3Target: "v4.5.174 (@mutmutco/cli@4.5.174, tag commit 2db083c19203 \u2014 last known-good release carrying the repo-index v4-only contract)"
19222
+ mechanism: "npm dist-tag latest -> 4.5.175 and redeploy the Hub Lambda from tag v4.5.175 (8efcf884c10f); installed clients repair via `mmi-cli doctor`. No other cohort is touched.",
19223
+ v3Target: "v4.5.175 (@mutmutco/cli@4.5.175, tag commit 8efcf884c10f \u2014 last known-good release carrying the repo-index v4-only contract)"
19135
19224
  }
19136
19225
  },
19137
19226
  {
@@ -32417,6 +32506,76 @@ async function waitBoardEvents(since, waitS, deps) {
32417
32506
 
32418
32507
  // src/board-commands.ts
32419
32508
  init_github_quota();
32509
+ init_github_client();
32510
+
32511
+ // src/issue-decision.ts
32512
+ var DECISION_KINDS = ["decision", "correction", "approval"];
32513
+ var DECISION_SOURCES = ["typed", "ask-user", "relay"];
32514
+ var MARKER_PATTERN = /<!--\s*mmi-decision:\s*(\{[\s\S]*?\})\s*-->/g;
32515
+ var JSON_GT_ESCAPE = "\\u003e";
32516
+ function decisionReminders(m) {
32517
+ const out = [];
32518
+ if (m.via === "relay" && !m.by) out.push("reminder: name the relaying session with --by <endpoint> so readers can trace the relay");
32519
+ if (m.kind === "correction" && !m.learning) out.push("reminder: a correction also gets its learning note (jerv_learning_note); pass it as --learning when you have it");
32520
+ return out;
32521
+ }
32522
+ function buildDecisionMarker(input) {
32523
+ const kind = input.kind;
32524
+ if (!DECISION_KINDS.includes(kind)) throw new Error(`--kind must be one of: ${DECISION_KINDS.join(", ")}`);
32525
+ const via = input.via;
32526
+ if (!DECISION_SOURCES.includes(via)) throw new Error(`--via must be one of: ${DECISION_SOURCES.join(", ")}`);
32527
+ const quote = (input.quote ?? "").trim();
32528
+ if (!quote) throw new Error("--quote is required: the owner's words verbatim, never a paraphrase");
32529
+ const at = input.at ?? "";
32530
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(at) || Number.isNaN(Date.parse(at))) throw new Error("--at must be an ISO time, e.g. 2026-10-17T10:29:00Z");
32531
+ return {
32532
+ v: 1,
32533
+ kind,
32534
+ quote,
32535
+ at: new Date(at).toISOString(),
32536
+ via,
32537
+ ...input.by?.trim() ? { by: input.by.trim() } : {},
32538
+ ...input.learning?.trim() ? { learning: input.learning.trim() } : {}
32539
+ };
32540
+ }
32541
+ function renderDecisionComment(m) {
32542
+ const source = m.via === "relay" ? `relayed by ${m.by}` : m.via;
32543
+ const quoted = m.quote.split("\n").map((l) => `> ${l}`).join("\n");
32544
+ const json = JSON.stringify(m).replace(/-->/g, `--${JSON_GT_ESCAPE}`);
32545
+ return `**Owner ${m.kind}** (${source}, ${m.at})
32546
+
32547
+ ${quoted}${m.learning ? `
32548
+
32549
+ Learning record: ${m.learning}` : ""}
32550
+
32551
+ <!-- mmi-decision: ${json} -->`;
32552
+ }
32553
+ function parseDecisionMarkers(comments) {
32554
+ const out = [];
32555
+ for (const comment of comments ?? []) {
32556
+ for (const match of (comment.body ?? "").matchAll(MARKER_PATTERN)) {
32557
+ try {
32558
+ const m = JSON.parse(match[1]);
32559
+ if (m.v === 1 && DECISION_KINDS.includes(m.kind) && DECISION_SOURCES.includes(m.via) && typeof m.quote === "string" && typeof m.at === "string") out.push(m);
32560
+ } catch {
32561
+ }
32562
+ }
32563
+ }
32564
+ return out;
32565
+ }
32566
+ function formatDecisionLines(ref, markers) {
32567
+ if (!markers.length) return [];
32568
+ return [
32569
+ `Owner rulings on ${ref} (${markers.length}) \u2014 read before acting:`,
32570
+ ...markers.map((m) => ` [${m.kind}] ${m.at} ${m.via === "relay" ? `relay ${m.by}` : m.via}: "${m.quote.replace(/\s+/g, " ")}"${m.learning ? ` (learning ${m.learning})` : ""}`)
32571
+ ];
32572
+ }
32573
+ async function readIssueDecisions(client, repo, number) {
32574
+ const comments = await client.restPaginate(`repos/${repo}/issues/${number}/comments`);
32575
+ return parseDecisionMarkers(comments);
32576
+ }
32577
+
32578
+ // src/board-commands.ts
32420
32579
  function refuseRateLimited(e, json) {
32421
32580
  if (!isRateLimitedError(e)) return false;
32422
32581
  if (json) console.log(JSON.stringify(e.receipt));
@@ -32486,6 +32645,13 @@ function registerBoardCommands(program3) {
32486
32645
  else console.log(`Warning: ${warning}`);
32487
32646
  }
32488
32647
  }
32648
+ async function claimDecisions(item) {
32649
+ try {
32650
+ return { decisions: await readIssueDecisions(defaultGitHubClient(), item.repository, item.number) };
32651
+ } catch (e) {
32652
+ return { warning: `owner rulings on ${item.repository}#${item.number} unread (${e.message}) \u2014 run \`mmi-cli oracle issue view ${item.number} --repo ${item.repository} --comments\` before acting` };
32653
+ }
32654
+ }
32489
32655
  function claimVerdict(ref, result) {
32490
32656
  const holder = formatClaimHolder(result.holder);
32491
32657
  const previousHolder = result.staleStamp ?? (result.previousHolder ? formatClaimHolder(result.previousHolder) : "another lane");
@@ -32541,9 +32707,11 @@ function registerBoardCommands(program3) {
32541
32707
  check: o.check,
32542
32708
  allowPartial: o.allowPartial
32543
32709
  }, { snapshot: registryClientDeps(config) });
32544
- if (o.json) return console.log(JSON.stringify(result));
32710
+ const ruled = await claimDecisions(result.item);
32711
+ if (o.json) return console.log(JSON.stringify({ ...result, ...ruled.decisions ? { decisions: ruled.decisions } : {}, ...ruled.warning ? { warnings: [...result.warnings ?? [], ruled.warning] } : {} }));
32545
32712
  console.log(claimVerdict(result.item.ref, result));
32546
- printClaimWarnings(result.warnings);
32713
+ for (const line of formatDecisionLines(result.item.ref, ruled.decisions ?? [])) console.log(line);
32714
+ printClaimWarnings([...result.warnings ?? [], ...ruled.warning ? [ruled.warning] : []]);
32547
32715
  } catch (e) {
32548
32716
  if (refuseRateLimited(e, o.json)) return;
32549
32717
  if (refuseNotClaimable(e, o.json)) return;
@@ -32562,14 +32730,17 @@ function registerBoardCommands(program3) {
32562
32730
  check: o.check,
32563
32731
  allowPartial: o.allowPartial
32564
32732
  }, { snapshot: registryClientDeps(config) });
32733
+ const ruled = await Promise.all(bulk.results.map((r) => r.claimed ? claimDecisions(r.item) : Promise.resolve({})));
32734
+ const rulingWarnings = ruled.flatMap((r) => r.warning ? [r.warning] : []);
32565
32735
  if (o.json) {
32566
- console.log(JSON.stringify(bulk.results));
32567
- printClaimWarnings(bulk.warnings, true);
32736
+ console.log(JSON.stringify(bulk.results.map((r, i) => ruled[i].decisions ? { ...r, decisions: ruled[i].decisions } : r)));
32737
+ printClaimWarnings([...bulk.warnings ?? [], ...rulingWarnings], true);
32568
32738
  } else {
32569
- for (const result of bulk.results) {
32739
+ bulk.results.forEach((result, i) => {
32570
32740
  console.log(result.claimed ? claimVerdict(result.ref, result) : `Skipped ${result.ref}: ${result.reason}`);
32571
- }
32572
- printClaimWarnings(bulk.warnings);
32741
+ if (result.claimed) for (const line of formatDecisionLines(result.ref, ruled[i].decisions ?? [])) console.log(line);
32742
+ });
32743
+ printClaimWarnings([...bulk.warnings ?? [], ...rulingWarnings]);
32573
32744
  }
32574
32745
  if (bulk.failed > 0) process.exitCode = 1;
32575
32746
  } catch (e) {
@@ -32733,18 +32904,18 @@ function registerBoardCommands(program3) {
32733
32904
  concurrency: Number(o.concurrency) || void 0
32734
32905
  });
32735
32906
  if (o.json) return console.log(JSON.stringify(result));
32736
- console.log(`board doctor: scanned ${result.scanned}, findings ${result.findings}${o.fix ? `, fixed ${result.fixed}, failed ${result.failed}` : ""}`);
32907
+ console.log(`board doctor: scanned ${result.scanned}, findings ${result.findings}${o.fix || result.fixed || result.failed ? `, fixed ${result.fixed}, failed ${result.failed}` : ""}`);
32737
32908
  for (const finding of result.items) {
32738
32909
  console.log(` [${finding.type}] ${finding.detail}`);
32739
32910
  }
32740
32911
  for (const claim of result.liveClaims) {
32741
32912
  console.log(` [${claim.type}] ${claim.detail}`);
32742
32913
  }
32743
- if (result.findings > 0 && !o.fix) {
32914
+ if (result.findings > result.fixed && !o.fix) {
32744
32915
  console.log("Run `mmi-cli oracle board doctor --fix` to apply automatic fixes.");
32745
32916
  process.exitCode = 1;
32746
32917
  }
32747
- if (o.fix && result.failed) process.exitCode = 1;
32918
+ if ((o.fix || result.failed) && result.failed) process.exitCode = 1;
32748
32919
  } catch (e) {
32749
32920
  return failGraceful(`board doctor failed: ${e.message}`);
32750
32921
  }
@@ -47642,7 +47813,8 @@ function registerCollaborationCommands(program3) {
47642
47813
  if (o.comments || o.context) {
47643
47814
  const boardProjectId = o.context ? await resolveBoardProjectId(o.repo) : void 0;
47644
47815
  const data2 = await runIssueViewContext(queryDeps(), { number: targetNumber, repo: targetRepo2, comments: Boolean(o.comments), context: Boolean(o.context), jsonFields: gh2.ghFields, boardProjectId });
47645
- return { data: data2, parent: await readParentField(targetNumber, targetRepo2) };
47816
+ const decisions = parseDecisionMarkers(data2.comments);
47817
+ return { data: { ...data2, decisions }, parent: await readParentField(targetNumber, targetRepo2) };
47646
47818
  }
47647
47819
  const data = await ghJson(["issue", "view", String(targetNumber), "--repo", targetRepo2, "--json", gh2.ghFields]);
47648
47820
  return { data, parent: await readParentField(targetNumber, targetRepo2) };
@@ -47755,6 +47927,25 @@ function registerCollaborationCommands(program3) {
47755
47927
  return failGraceful(`issue comment: ${(err.stderr || err.message || String(e)).trim()}`);
47756
47928
  }
47757
47929
  });
47930
+ jsonParity(issue.command("decision <ref>").description("record an owner decision, correction or approval VERBATIM on the issue it governs (machine-readable mmi-decision marker; board claim and issue view --comments show every one) and print {number,repo,url,commentUrl} JSON").requiredOption("--kind <kind>", `one of: ${DECISION_KINDS.join(", ")}`).requiredOption("--quote <text>", "the owner's words verbatim (never a paraphrase)").requiredOption("--at <iso>", "when the owner said it, ISO time (e.g. 2026-10-17T10:29:00Z)").requiredOption("--via <source>", `one of: ${DECISION_SOURCES.join(", ")}`).option("--by <endpoint>", "relaying session, for --via relay").option("--learning <ref>", "learning record id or issue ref, for --kind correction").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)")).action(async (ref, o) => {
47931
+ let marker;
47932
+ try {
47933
+ parseIssueRef(ref, o.repo);
47934
+ marker = buildDecisionMarker(o);
47935
+ } catch (e) {
47936
+ return fail(`issue decision: ${e.message}`);
47937
+ }
47938
+ const repo = await resolveRepo(parseIssueRef(ref, o.repo).repo ?? o.repo);
47939
+ if (!repo) return fail("issue decision: could not resolve repo \u2014 pass --repo owner/repo");
47940
+ try {
47941
+ console.log(JSON.stringify(await postIssueComment(defaultGitHubClient(), { ref, defaultRepo: repo, body: renderDecisionComment(marker) })));
47942
+ for (const line of decisionReminders(marker)) process.stderr.write(`${line}
47943
+ `);
47944
+ } catch (e) {
47945
+ const err = e;
47946
+ return failGraceful(`issue decision: ${(err.stderr || err.message || String(e)).trim()}`);
47947
+ }
47948
+ });
47758
47949
  jsonParity(issue.command("check <ref>").description("tick (or with --off untick) a task-list checkbox in an issue/epic body by its item text and print {number,repo,item,checked,changed} JSON").requiredOption("--item <text>", "the checklist item to match \u2014 exact item text, else a unique substring").option("--off", "untick the item ([x] \u2014 [ ]) instead of ticking it").option("--repo <owner/repo>", "repo for a bare ref (defaults to the current repo)")).action(async (ref, o) => {
47759
47950
  let parsed;
47760
47951
  try {