@mutmutco/cli 2.10.0 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.cjs +480 -361
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -3480,8 +3480,8 @@ function parseHookInput(stdin) {
3480
3480
  }
3481
3481
 
3482
3482
  // src/index.ts
3483
- var import_node_child_process5 = require("node:child_process");
3484
- var import_node_util5 = require("node:util");
3483
+ var import_node_child_process6 = require("node:child_process");
3484
+ var import_node_util6 = require("node:util");
3485
3485
  var import_node_path5 = require("node:path");
3486
3486
  var import_node_os = require("node:os");
3487
3487
 
@@ -3495,7 +3495,7 @@ var HEAD_PROMPT_ACTION_LIMIT = 50;
3495
3495
  var HEAD_PROMPT_DECISION_LIMIT = 80;
3496
3496
  function resolveEngine(platform, custom) {
3497
3497
  if (custom) return { cmd: custom, args: [], shell: true };
3498
- return { cmd: "claude", args: ["-p"], shell: platform === "win32" };
3498
+ return { cmd: "claude", args: ["-p", "--no-session-persistence"], shell: platform === "win32" };
3499
3499
  }
3500
3500
  function headTsPath(key) {
3501
3501
  const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -4025,32 +4025,153 @@ ${buildReportBody(body, sourceRepo)}`;
4025
4025
  }
4026
4026
 
4027
4027
  // src/board.ts
4028
+ var import_node_child_process4 = require("node:child_process");
4029
+ var import_node_util4 = require("node:util");
4030
+
4031
+ // src/github-client.ts
4028
4032
  var import_node_child_process3 = require("node:child_process");
4029
4033
  var import_node_util3 = require("node:util");
4034
+ var rawExecFileP = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
4035
+ var execFileP = (file, args, options = {}) => rawExecFileP(file, args, { encoding: "utf8", windowsHide: true, timeout: 1e4, killSignal: "SIGTERM", ...options });
4036
+ var cachedGhCliToken;
4037
+ async function githubToken() {
4038
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
4039
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
4040
+ cachedGhCliToken ??= execFileP("gh", ["auth", "token"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
4041
+ return cachedGhCliToken;
4042
+ }
4043
+ var GitHubApiError = class extends Error {
4044
+ status;
4045
+ stderr;
4046
+ graphqlErrors;
4047
+ constructor(message, opts = {}) {
4048
+ super(message);
4049
+ this.name = "GitHubApiError";
4050
+ this.status = opts.status ?? 0;
4051
+ this.stderr = message;
4052
+ this.graphqlErrors = opts.graphqlErrors;
4053
+ }
4054
+ };
4055
+ var DEFAULT_TIMEOUT_MS = 2e4;
4056
+ function joinUrl(base, path2) {
4057
+ if (path2.startsWith("http://") || path2.startsWith("https://")) return path2;
4058
+ return `${base.replace(/\/+$/, "")}/${path2.replace(/^\/+/, "")}`;
4059
+ }
4060
+ function withPerPage(url) {
4061
+ if (/[?&]per_page=/.test(url)) return url;
4062
+ return url.includes("?") ? `${url}&per_page=100` : `${url}?per_page=100`;
4063
+ }
4064
+ function nextLink(linkHeader) {
4065
+ if (!linkHeader) return void 0;
4066
+ for (const part of linkHeader.split(",")) {
4067
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
4068
+ if (match) return match[1];
4069
+ }
4070
+ return void 0;
4071
+ }
4072
+ async function errorFromResponse(res) {
4073
+ let detail = "";
4074
+ try {
4075
+ const text = await res.text();
4076
+ try {
4077
+ const parsed = JSON.parse(text);
4078
+ detail = parsed.message ?? text;
4079
+ } catch {
4080
+ detail = text;
4081
+ }
4082
+ } catch {
4083
+ detail = "";
4084
+ }
4085
+ const suffix = detail ? `: ${detail.trim()}` : "";
4086
+ return new GitHubApiError(`HTTP ${res.status}${suffix} (${res.url})`, { status: res.status });
4087
+ }
4088
+ function createGitHubClient(options = {}) {
4089
+ const baseUrl = options.baseUrl ?? process.env.GITHUB_API_URL ?? "https://api.github.com";
4090
+ const token = options.token ?? githubToken;
4091
+ const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
4092
+ const fetchImpl = options.fetchImpl ?? fetch;
4093
+ async function request(method, url, init = {}) {
4094
+ const t = await token();
4095
+ const headers = {
4096
+ Accept: "application/vnd.github+json",
4097
+ "X-GitHub-Api-Version": "2022-11-28",
4098
+ "User-Agent": "mmi-cli",
4099
+ ...t ? { Authorization: `Bearer ${t}` } : {},
4100
+ ...init.body !== void 0 ? { "Content-Type": "application/json" } : {},
4101
+ ...init.headers
4102
+ };
4103
+ const res = await fetchImpl(url, {
4104
+ method,
4105
+ headers,
4106
+ body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
4107
+ signal: AbortSignal.timeout(init.timeoutMs ?? defaultTimeoutMs)
4108
+ });
4109
+ if (!res.ok) throw await errorFromResponse(res);
4110
+ return res;
4111
+ }
4112
+ async function parseJson(res) {
4113
+ if (res.status === 204) return void 0;
4114
+ const text = await res.text();
4115
+ if (!text) return void 0;
4116
+ return JSON.parse(text);
4117
+ }
4118
+ return {
4119
+ async rest(method, path2, init) {
4120
+ const res = await request(method, joinUrl(baseUrl, path2), init);
4121
+ return parseJson(res);
4122
+ },
4123
+ async restPaginate(path2, init) {
4124
+ const items = [];
4125
+ let url = withPerPage(joinUrl(baseUrl, path2));
4126
+ while (url) {
4127
+ const res = await request("GET", url, init);
4128
+ const page = await parseJson(res);
4129
+ if (Array.isArray(page)) items.push(...page);
4130
+ url = nextLink(res.headers.get("link"));
4131
+ }
4132
+ return items;
4133
+ },
4134
+ async graphql(query, variables, init) {
4135
+ const res = await request("POST", joinUrl(baseUrl, "graphql"), {
4136
+ ...init,
4137
+ body: { query, ...variables ? { variables } : {} }
4138
+ });
4139
+ const parsed = await parseJson(res);
4140
+ if (parsed?.errors?.length) {
4141
+ const message = parsed.errors.map((e) => e.message ?? e.type ?? "unknown GraphQL error").join("; ");
4142
+ throw new GitHubApiError(`GraphQL: ${message}`, { status: 200, graphqlErrors: parsed.errors });
4143
+ }
4144
+ if (!parsed || parsed.data === void 0 || parsed.data === null) {
4145
+ throw new GitHubApiError("GraphQL response did not include data", { status: 200 });
4146
+ }
4147
+ return parsed.data;
4148
+ }
4149
+ };
4150
+ }
4151
+ var cachedDefaultClient;
4152
+ function defaultGitHubClient() {
4153
+ cachedDefaultClient ??= createGitHubClient();
4154
+ return cachedDefaultClient;
4155
+ }
4030
4156
 
4031
4157
  // ../infra/board-vocab.mjs
4032
4158
  var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
4033
4159
 
4034
4160
  // src/board.ts
4035
- var rawExecFileP = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
4036
- var BOARD_GH_TIMEOUT_MS = 2e4;
4161
+ var rawExecFileP2 = (0, import_node_util4.promisify)(import_node_child_process4.execFile);
4037
4162
  var BOARD_GIT_TIMEOUT_MS = 1e4;
4038
4163
  var WRITE_PROBE_CONCURRENCY = 8;
4039
- var execFileP = (file, args, options = {}) => (
4164
+ var execFileP2 = (file, args, options = {}) => (
4040
4165
  // encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
4041
4166
  // overloads widen to string|Buffer when options is spread in).
4042
- rawExecFileP(file, args, { encoding: "utf8", windowsHide: true, timeout: BOARD_GIT_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
4167
+ rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, timeout: BOARD_GIT_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
4043
4168
  );
4044
4169
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
4045
4170
  var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
4046
4171
  var TYPE_LABELS = ["bug", "feature", "task"];
4047
- var defaultGh = async (args) => {
4048
- const { stdout, stderr } = await execFileP("gh", args, { timeout: BOARD_GH_TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 });
4049
- return { stdout: String(stdout), stderr: String(stderr) };
4050
- };
4051
4172
  var defaultGit = async (args) => {
4052
4173
  try {
4053
- const { stdout } = await execFileP("git", args, { timeout: BOARD_GIT_TIMEOUT_MS });
4174
+ const { stdout } = await execFileP2("git", args, { timeout: BOARD_GIT_TIMEOUT_MS });
4054
4175
  return stdout.trim();
4055
4176
  } catch {
4056
4177
  return "";
@@ -4104,36 +4225,52 @@ query($owner: String!, $number: Int!, $after: String) {
4104
4225
  }
4105
4226
  }
4106
4227
  }`;
4107
- var ISSUE_PROJECT_ITEM_QUERY = `
4108
- query($repoOwner: String!, $repoName: String!, $number: Int!) {
4109
- repository(owner: $repoOwner, name: $repoName) {
4110
- issue(number: $number) {
4111
- id
4112
- number
4113
- title
4114
- url
4115
- state
4116
- repository { nameWithOwner }
4117
- labels(first: 10) { nodes { name } }
4118
- assignees(first: 10) { nodes { login } }
4119
- projectItems(first: 20) {
4120
- nodes {
4121
- id
4122
- project { id title }
4123
- fieldValues(first: 8) {
4124
- nodes {
4125
- ... on ProjectV2ItemFieldSingleSelectValue {
4126
- name
4127
- optionId
4128
- field { ... on ProjectV2SingleSelectField { id name } }
4228
+ var SINGLE_ITEM_CONTENT_FRAGMENT = `
4229
+ id
4230
+ number
4231
+ title
4232
+ url
4233
+ state
4234
+ repository { nameWithOwner }
4235
+ labels(first: 10) { nodes { name } }
4236
+ assignees(first: 10) { nodes { login } }
4237
+ projectItems(first: 20) {
4238
+ nodes {
4239
+ id
4240
+ project { id title }
4241
+ fieldValues(first: 8) {
4242
+ nodes {
4243
+ ... on ProjectV2ItemFieldSingleSelectValue {
4244
+ name
4245
+ optionId
4246
+ field { ... on ProjectV2SingleSelectField { id name } }
4247
+ }
4129
4248
  }
4130
4249
  }
4131
4250
  }
4132
- }
4251
+ }`;
4252
+ var ISSUE_PROJECT_ITEM_QUERY = `
4253
+ query($repoOwner: String!, $repoName: String!, $number: Int!) {
4254
+ viewer { login }
4255
+ repository(owner: $repoOwner, name: $repoName) {
4256
+ issueOrPullRequest(number: $number) {
4257
+ __typename
4258
+ ... on Issue {${SINGLE_ITEM_CONTENT_FRAGMENT}
4259
+ }
4260
+ ... on PullRequest {${SINGLE_ITEM_CONTENT_FRAGMENT}
4133
4261
  }
4134
4262
  }
4135
4263
  }
4136
4264
  }`;
4265
+ var UPDATE_ITEM_FIELD_MUTATION = `
4266
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
4267
+ updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) {
4268
+ projectV2Item { id }
4269
+ }
4270
+ }`;
4271
+ async function updateItemSingleSelect(client, projectId, itemId, fieldId, optionId) {
4272
+ await client.graphql(UPDATE_ITEM_FIELD_MUTATION, { projectId, itemId, fieldId, optionId });
4273
+ }
4137
4274
  function resolveBoardConfig(cfg) {
4138
4275
  const missing = [];
4139
4276
  if (!cfg.projectOwner) missing.push("projectOwner");
@@ -4256,7 +4393,7 @@ function renderBoardReport(report) {
4256
4393
  return lines.join("\n");
4257
4394
  }
4258
4395
  async function collectBoardItems(cfg, options, deps) {
4259
- const gh = deps.gh ?? defaultGh;
4396
+ const client = deps.client ?? defaultGitHubClient();
4260
4397
  const git = deps.git ?? defaultGit;
4261
4398
  const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
4262
4399
  if (!currentRepo) throw new Error("could not resolve current GitHub repo; pass --repo owner/repo");
@@ -4269,7 +4406,7 @@ async function collectBoardItems(cfg, options, deps) {
4269
4406
  let partial = false;
4270
4407
  do {
4271
4408
  try {
4272
- const page = await fetchProjectPage(gh, cfg, after);
4409
+ const page = await fetchProjectPage(client, cfg, after);
4273
4410
  viewer ||= page.viewer.login;
4274
4411
  const project2 = page.organization?.projectV2;
4275
4412
  if (!project2) throw new Error(`project ${cfg.projectOwner}#${cfg.projectNumber} not found`);
@@ -4287,19 +4424,15 @@ async function collectBoardItems(cfg, options, deps) {
4287
4424
  } while (after);
4288
4425
  return { items: nodesToItems(nodes, warnings, cfg), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
4289
4426
  }
4290
- async function repoCanPush(repo, gh) {
4427
+ async function repoCanPush(repo, client) {
4291
4428
  try {
4292
- const { stdout } = await gh(["api", `repos/${repo}`, "--jq", ".permissions.push"]);
4293
- const value = stdout.trim();
4294
- if (value === "true") return true;
4295
- if (value === "false") return false;
4296
- const parsed = JSON.parse(value);
4297
- return typeof parsed.permissions?.push === "boolean" ? parsed.permissions.push : void 0;
4429
+ const parsed = await client.rest("GET", `repos/${repo}`);
4430
+ return typeof parsed?.permissions?.push === "boolean" ? parsed.permissions.push : void 0;
4298
4431
  } catch {
4299
4432
  return void 0;
4300
4433
  }
4301
4434
  }
4302
- async function resolveWritableReposForClaimables(items, gh, allowPartial) {
4435
+ async function resolveWritableReposForClaimables(items, client, allowPartial) {
4303
4436
  const candidateRepos = [...new Set(items.filter((item) => item.status === "Todo" && item.assignees.length === 0).map((item) => item.repository))];
4304
4437
  const repos = /* @__PURE__ */ new Set();
4305
4438
  const warnings = [];
@@ -4308,7 +4441,7 @@ async function resolveWritableReposForClaimables(items, gh, allowPartial) {
4308
4441
  const worker = async () => {
4309
4442
  while (next < candidateRepos.length) {
4310
4443
  const repo = candidateRepos[next++];
4311
- const canPush = await repoCanPush(repo, gh);
4444
+ const canPush = await repoCanPush(repo, client);
4312
4445
  if (canPush === true) {
4313
4446
  repos.add(repo.toLowerCase());
4314
4447
  continue;
@@ -4326,9 +4459,9 @@ async function resolveWritableReposForClaimables(items, gh, allowPartial) {
4326
4459
  }
4327
4460
  async function readBoard(options, deps = {}) {
4328
4461
  const cfg = resolveBoardConfig(options.config);
4329
- const gh = deps.gh ?? defaultGh;
4462
+ const client = deps.client ?? defaultGitHubClient();
4330
4463
  const collected = await collectBoardItems(cfg, options, deps);
4331
- const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
4464
+ const writable = await resolveWritableReposForClaimables(collected.items, client, options.allowPartial ?? false);
4332
4465
  collected.warnings.push(...writable.warnings);
4333
4466
  collected.partial = collected.partial || writable.partial;
4334
4467
  const groups = partitionBoardItems(collected.items, collected.viewer, collected.repo, writable.repos);
@@ -4341,7 +4474,7 @@ async function readBoard(options, deps = {}) {
4341
4474
  partial: collected.partial
4342
4475
  };
4343
4476
  if (options.includeBundleDetails) {
4344
- await attachBundleDetails(report, gh, options.allowPartial ?? false);
4477
+ await attachBundleDetails(report, client, options.allowPartial ?? false);
4345
4478
  }
4346
4479
  return report;
4347
4480
  }
@@ -4352,67 +4485,50 @@ function findBoardItem(items, selector) {
4352
4485
  if (!found) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
4353
4486
  return found;
4354
4487
  }
4488
+ async function resolveCurrentRepo(options, deps) {
4489
+ const git = deps.git ?? defaultGit;
4490
+ const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
4491
+ if (!currentRepo) throw new Error("could not resolve current GitHub repo; pass --repo owner/repo");
4492
+ return currentRepo;
4493
+ }
4355
4494
  async function moveBoardItem(options, deps = {}) {
4356
4495
  if (!BOARD_STATUSES.includes(options.status)) {
4357
4496
  throw new Error(`unknown status '${options.status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
4358
4497
  }
4359
4498
  const cfg = resolveBoardConfig(options.config);
4360
- const gh = deps.gh ?? defaultGh;
4361
- const collected = await collectBoardItems(cfg, options, deps);
4362
- const selector = parseIssueSelector(options.selector, collected.repo);
4363
- const item = findBoardItem(collected.items, selector);
4499
+ const client = deps.client ?? defaultGitHubClient();
4500
+ const currentRepo = await resolveCurrentRepo(options, deps);
4501
+ const selector = parseIssueSelector(options.selector, currentRepo);
4502
+ const lookup = await fetchIssueProjectItem(client, cfg, selector);
4503
+ const item = lookup.item;
4504
+ if (!item) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
4364
4505
  if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
4365
4506
  const optionId = cfg.statusOptions[options.status];
4366
4507
  try {
4367
- await gh([
4368
- "project",
4369
- "item-edit",
4370
- "--id",
4371
- item.itemId,
4372
- "--project-id",
4373
- cfg.projectId,
4374
- "--field-id",
4375
- cfg.statusFieldId,
4376
- "--single-select-option-id",
4377
- optionId
4378
- ]);
4508
+ await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.statusFieldId, optionId);
4379
4509
  } catch (e) {
4380
4510
  const warning = `partial move: ${item.ref} status was not changed to ${options.status} (${ghError(e)})`;
4381
4511
  if (!options.allowPartial) throw new Error(warning);
4382
- return { item, viewer: collected.viewer, repo: collected.repo, status: item.status, partial: true, warning };
4512
+ return { item, viewer: lookup.viewer, repo: currentRepo, status: item.status, partial: true, warning };
4383
4513
  }
4384
4514
  return {
4385
4515
  item: { ...item, status: options.status, statusOptionId: optionId },
4386
- viewer: collected.viewer,
4387
- repo: collected.repo,
4516
+ viewer: lookup.viewer,
4517
+ repo: currentRepo,
4388
4518
  status: options.status,
4389
4519
  partial: false
4390
4520
  };
4391
4521
  }
4392
4522
  async function showBoardItem(options, deps = {}) {
4393
4523
  const cfg = resolveBoardConfig(options.config);
4394
- const gh = deps.gh ?? defaultGh;
4395
- const collected = await collectBoardItems(cfg, options, deps);
4396
- const selector = parseIssueSelector(options.selector, collected.repo);
4397
- let item;
4398
- try {
4399
- item = findBoardItem(collected.items, selector);
4400
- } catch (e) {
4401
- const fallback = await fetchIssueProjectItem(gh, cfg, selector);
4402
- if (!fallback) throw e;
4403
- item = fallback;
4404
- }
4524
+ const client = deps.client ?? defaultGitHubClient();
4525
+ const currentRepo = await resolveCurrentRepo(options, deps);
4526
+ const selector = parseIssueSelector(options.selector, currentRepo);
4527
+ const { item } = await fetchIssueProjectItem(client, cfg, selector);
4528
+ if (!item) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
4405
4529
  if (item.contentType === "Issue") {
4406
4530
  try {
4407
- const { stdout } = await gh(["issue", "view", String(item.number), "--repo", item.repository, "--json", "body,comments"]);
4408
- const detail = JSON.parse(stdout);
4409
- item.details = {
4410
- body: detail.body ?? "",
4411
- comments: (detail.comments ?? []).map((comment) => ({
4412
- author: comment.author?.login ?? "",
4413
- body: comment.body ?? ""
4414
- }))
4415
- };
4531
+ item.details = await fetchIssueDetails(client, item.repository, item.number);
4416
4532
  } catch (e) {
4417
4533
  if (!options.allowPartial) throw new Error(`detail read failed: ${item.ref}: ${ghError(e)}`);
4418
4534
  }
@@ -4421,17 +4537,17 @@ async function showBoardItem(options, deps = {}) {
4421
4537
  }
4422
4538
  async function claimBoardIssue(options, deps = {}) {
4423
4539
  const cfg = resolveBoardConfig(options.config);
4424
- const gh = deps.gh ?? defaultGh;
4540
+ const client = deps.client ?? defaultGitHubClient();
4425
4541
  const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
4426
4542
  const selector = parseIssueSelector(options.selector, collected.repo);
4427
4543
  try {
4428
4544
  findBoardItem(collected.items, selector);
4429
4545
  } catch (e) {
4430
- const fallback = await fetchIssueProjectItem(gh, cfg, selector);
4546
+ const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
4431
4547
  if (!fallback) throw e;
4432
4548
  collected.items.push(fallback);
4433
4549
  }
4434
- const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
4550
+ const writable = await resolveWritableReposForClaimables(collected.items, client, options.allowPartial ?? false);
4435
4551
  collected.warnings.push(...writable.warnings);
4436
4552
  collected.partial = collected.partial || writable.partial;
4437
4553
  const report = {
@@ -4448,7 +4564,7 @@ async function claimBoardIssue(options, deps = {}) {
4448
4564
  }
4449
4565
  let item = findClaimableItem(report, selector);
4450
4566
  if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
4451
- const fresh = await fetchIssueProjectItem(gh, cfg, { repo: item.repository, number: item.number });
4567
+ const fresh = (await fetchIssueProjectItem(client, cfg, { repo: item.repository, number: item.number })).item;
4452
4568
  if (!fresh) throw new Error(`${item.ref} is not on this project board`);
4453
4569
  if (fresh.status !== "Todo") throw new Error(`${item.ref} is not claimable: Status is ${fresh.status}`);
4454
4570
  if (fresh.assignees.length) throw new Error(`${item.ref} is already assigned to @${fresh.assignees.join(", @")}`);
@@ -4456,23 +4572,12 @@ async function claimBoardIssue(options, deps = {}) {
4456
4572
  const assignee = options.assignee ?? "@me";
4457
4573
  const assignedLogin = assignee === "@me" ? report.viewer : assignee.replace(/^@/, "");
4458
4574
  try {
4459
- await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", assignee]);
4575
+ await client.rest("POST", `repos/${item.repository}/issues/${item.number}/assignees`, { body: { assignees: [assignedLogin] } });
4460
4576
  } catch (e) {
4461
4577
  throw new Error(`claim failed before board status changed: ${ghError(e)}`);
4462
4578
  }
4463
4579
  try {
4464
- await gh([
4465
- "project",
4466
- "item-edit",
4467
- "--id",
4468
- item.itemId,
4469
- "--project-id",
4470
- cfg.projectId,
4471
- "--field-id",
4472
- cfg.statusFieldId,
4473
- "--single-select-option-id",
4474
- cfg.statusOptions["In Progress"]
4475
- ]);
4580
+ await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.statusFieldId, cfg.statusOptions["In Progress"]);
4476
4581
  } catch (e) {
4477
4582
  const warning = `partial claim: ${item.ref} was assigned to @${assignedLogin}, but Status was not moved to In Progress (${ghError(e)})`;
4478
4583
  if (!options.allowPartial) throw new Error(warning);
@@ -4491,22 +4596,11 @@ async function claimBoardIssue(options, deps = {}) {
4491
4596
  partial: false
4492
4597
  };
4493
4598
  }
4494
- async function setBoardItemPriority(gh, cfg, itemId, priority) {
4599
+ async function setBoardItemPriority(client, cfg, itemId, priority) {
4495
4600
  if (!isPriorityFieldConfigured(cfg)) return void 0;
4496
4601
  const optionId = resolvePriorityOptionId(cfg, priority);
4497
4602
  if (!optionId || !cfg.priorityFieldId || !cfg.projectId) return void 0;
4498
- await gh([
4499
- "project",
4500
- "item-edit",
4501
- "--id",
4502
- itemId,
4503
- "--project-id",
4504
- cfg.projectId,
4505
- "--field-id",
4506
- cfg.priorityFieldId,
4507
- "--single-select-option-id",
4508
- optionId
4509
- ]);
4603
+ await updateItemSingleSelect(client, cfg.projectId, itemId, cfg.priorityFieldId, optionId);
4510
4604
  return cliPriorityToFieldName(priority);
4511
4605
  }
4512
4606
  async function backfillBoardPriorities(options, deps = {}) {
@@ -4514,7 +4608,7 @@ async function backfillBoardPriorities(options, deps = {}) {
4514
4608
  if (!isPriorityFieldConfigured(cfg)) {
4515
4609
  throw new Error("priority field is not configured in Hub registry META (priorityFieldId + priorityOptions)");
4516
4610
  }
4517
- const gh = deps.gh ?? defaultGh;
4611
+ const client = deps.client ?? defaultGitHubClient();
4518
4612
  const collected = await collectBoardItems(cfg, { repo: options.repo }, deps);
4519
4613
  const issues = collected.items.filter((item) => item.contentType === "Issue");
4520
4614
  const concurrency = Math.max(1, options.concurrency ?? 8);
@@ -4525,7 +4619,7 @@ async function backfillBoardPriorities(options, deps = {}) {
4525
4619
  return;
4526
4620
  }
4527
4621
  try {
4528
- const priority = await recoverIssuePriority(gh, item);
4622
+ const priority = await recoverIssuePriority(client, item);
4529
4623
  if (!priority) {
4530
4624
  result.skipped += 1;
4531
4625
  return;
@@ -4537,18 +4631,7 @@ async function backfillBoardPriorities(options, deps = {}) {
4537
4631
  }
4538
4632
  const optionId = cfg.priorityOptions?.[priority];
4539
4633
  if (!optionId) throw new Error(`no option id for ${priority}`);
4540
- await gh([
4541
- "project",
4542
- "item-edit",
4543
- "--id",
4544
- item.itemId,
4545
- "--project-id",
4546
- cfg.projectId,
4547
- "--field-id",
4548
- cfg.priorityFieldId,
4549
- "--single-select-option-id",
4550
- optionId
4551
- ]);
4634
+ await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.priorityFieldId, optionId);
4552
4635
  result.set += 1;
4553
4636
  result.details.push(`${item.ref} \u2192 ${priority}`);
4554
4637
  } catch (e) {
@@ -4561,88 +4644,65 @@ async function backfillBoardPriorities(options, deps = {}) {
4561
4644
  }
4562
4645
  return result;
4563
4646
  }
4564
- async function recoverIssuePriority(gh, item) {
4647
+ async function recoverIssuePriority(client, item) {
4565
4648
  for (const label of item.labels) {
4566
4649
  const fromLabel = labelToFieldPriority(label);
4567
4650
  if (fromLabel) return fromLabel;
4568
4651
  }
4569
- const { stdout } = await gh(["api", `repos/${item.repository}/issues/${item.number}/events`, "--paginate"]);
4570
- return recoverPriorityFromEvents(parsePaginatedEvents(stdout));
4652
+ const events = await client.restPaginate(
4653
+ `repos/${item.repository}/issues/${item.number}/events`
4654
+ );
4655
+ return recoverPriorityFromEvents(events);
4571
4656
  }
4572
- function parsePaginatedEvents(stdout) {
4573
- const trimmed = stdout.trim();
4574
- if (!trimmed) return [];
4575
- try {
4576
- const parsed = JSON.parse(trimmed);
4577
- if (Array.isArray(parsed)) return parsed;
4578
- } catch {
4579
- }
4580
- return trimmed.split(/\r?\n/).filter(Boolean).flatMap((line) => {
4581
- try {
4582
- const parsed = JSON.parse(line);
4583
- return Array.isArray(parsed) ? parsed : [];
4584
- } catch {
4585
- return [];
4586
- }
4657
+ async function fetchProjectPage(client, cfg, after) {
4658
+ return client.graphql(PROJECT_ITEMS_QUERY, {
4659
+ owner: cfg.projectOwner,
4660
+ number: cfg.projectNumber,
4661
+ ...after ? { after } : {}
4587
4662
  });
4588
4663
  }
4589
- async function fetchProjectPage(gh, cfg, after) {
4590
- const args = [
4591
- "api",
4592
- "graphql",
4593
- "-f",
4594
- `query=${PROJECT_ITEMS_QUERY}`,
4595
- "-f",
4596
- `owner=${cfg.projectOwner}`,
4597
- "-F",
4598
- `number=${cfg.projectNumber}`
4599
- ];
4600
- if (after) args.push("-f", `after=${after}`);
4601
- const { stdout } = await gh(args);
4602
- const parsed = JSON.parse(stdout);
4603
- if (!parsed.data) throw new Error("gh GraphQL response did not include data");
4604
- return parsed.data;
4664
+ function isGraphQLNotFound(e) {
4665
+ const errors = e.graphqlErrors;
4666
+ return Boolean(e instanceof GitHubApiError && errors?.length && errors.every((entry) => entry.type === "NOT_FOUND"));
4605
4667
  }
4606
- async function fetchIssueProjectItem(gh, cfg, selector) {
4668
+ async function fetchIssueProjectItem(client, cfg, selector) {
4607
4669
  const [repoOwner, repoName] = selector.repo.split("/");
4608
- if (!repoOwner || !repoName) return void 0;
4609
- const { stdout } = await gh([
4610
- "api",
4611
- "graphql",
4612
- "-f",
4613
- `query=${ISSUE_PROJECT_ITEM_QUERY}`,
4614
- "-f",
4615
- `repoOwner=${repoOwner}`,
4616
- "-f",
4617
- `repoName=${repoName}`,
4618
- "-F",
4619
- `number=${selector.number}`
4620
- ]);
4621
- const parsed = JSON.parse(stdout);
4622
- const issue2 = parsed.data?.repository?.issue;
4623
- if (!issue2) {
4624
- const pageNodes = parsed.data?.organization?.projectV2?.items.nodes ?? [];
4625
- return nodesToItems(pageNodes, [], cfg).find(
4626
- (item) => item.repository.toLowerCase() === selector.repo.toLowerCase() && item.number === selector.number
4627
- );
4670
+ if (!repoOwner || !repoName) return { viewer: "" };
4671
+ let data;
4672
+ try {
4673
+ data = await client.graphql(ISSUE_PROJECT_ITEM_QUERY, {
4674
+ repoOwner,
4675
+ repoName,
4676
+ number: selector.number
4677
+ });
4678
+ } catch (e) {
4679
+ if (isGraphQLNotFound(e)) return { viewer: "" };
4680
+ throw e;
4628
4681
  }
4629
- const projectItem = (issue2.projectItems?.nodes ?? []).find((item) => item.project?.id === cfg.projectId);
4630
- if (!projectItem) return void 0;
4631
- return nodeToItem({
4632
- id: projectItem.id,
4633
- fieldValues: projectItem.fieldValues,
4634
- content: {
4635
- __typename: "Issue",
4636
- id: issue2.id,
4637
- number: issue2.number,
4638
- title: issue2.title,
4639
- url: issue2.url,
4640
- state: issue2.state,
4641
- repository: issue2.repository,
4642
- labels: issue2.labels,
4643
- assignees: issue2.assignees
4644
- }
4645
- }, cfg);
4682
+ const viewer = data.viewer?.login ?? "";
4683
+ const content = data.repository?.issueOrPullRequest;
4684
+ if (!content) return { viewer };
4685
+ const projectItem = (content.projectItems?.nodes ?? []).find((item) => item.project?.id === cfg.projectId);
4686
+ if (!projectItem) return { viewer };
4687
+ const { projectItems: _projectItems, ...contentFields } = content;
4688
+ return {
4689
+ viewer,
4690
+ item: nodeToItem({
4691
+ id: projectItem.id,
4692
+ fieldValues: projectItem.fieldValues,
4693
+ content: contentFields
4694
+ }, cfg)
4695
+ };
4696
+ }
4697
+ async function fetchIssueDetails(client, repo, number) {
4698
+ const issue2 = await client.rest("GET", `repos/${repo}/issues/${number}`);
4699
+ const comments = await client.restPaginate(
4700
+ `repos/${repo}/issues/${number}/comments`
4701
+ );
4702
+ return {
4703
+ body: issue2?.body ?? "",
4704
+ comments: comments.map((comment) => ({ author: comment.user?.login ?? "", body: comment.body ?? "" }))
4705
+ };
4646
4706
  }
4647
4707
  function nodesToItems(nodes, warnings, cfg) {
4648
4708
  const items = [];
@@ -4701,20 +4761,12 @@ function nodeToItem(node, cfg) {
4701
4761
  function isSupportedContent(content) {
4702
4762
  return Boolean(content && (content.__typename === "Issue" || content.__typename === "PullRequest"));
4703
4763
  }
4704
- async function attachBundleDetails(report, gh, allowPartial) {
4764
+ async function attachBundleDetails(report, client, allowPartial) {
4705
4765
  const candidates = detailCandidates(report);
4706
4766
  if (candidates.length <= 1) return;
4707
4767
  for (const item of candidates) {
4708
4768
  try {
4709
- const { stdout } = await gh(["issue", "view", String(item.number), "--repo", item.repository, "--json", "body,comments"]);
4710
- const detail = JSON.parse(stdout);
4711
- item.details = {
4712
- body: detail.body ?? "",
4713
- comments: (detail.comments ?? []).map((comment) => ({
4714
- author: comment.author?.login ?? "",
4715
- body: comment.body ?? ""
4716
- }))
4717
- };
4769
+ item.details = await fetchIssueDetails(client, item.repository, item.number);
4718
4770
  } catch (e) {
4719
4771
  const warning = `partial detail read: ${item.ref}: ${ghError(e)}`;
4720
4772
  if (!allowPartial) throw new Error(warning);
@@ -5140,8 +5192,9 @@ function buildAwsCrossAccountCheck(input) {
5140
5192
  }
5141
5193
  var MMI_PLUGIN_ID = "mmi@mmi";
5142
5194
  var PLUGIN_LABEL = "plugin install record (mmi@mmi for this project)";
5143
- function pluginInstallManualFix(projectPath) {
5144
- return `run \`/plugin install ${MMI_PLUGIN_ID}\` then \`/reload-plugins\` (VS Code extension: reopen the workspace) to register the project install record for ${projectPath}`;
5195
+ function pluginInstallManualFix(projectPath, surface = "claude-cli") {
5196
+ const register = surface === "codex" ? `\`codex plugin add ${MMI_PLUGIN_ID}\`` : surface === "shell" ? `enable the MMI plugin in your client` : surface === "claude-vscode" ? `\`claude plugin enable ${MMI_PLUGIN_ID}\`` : `\`/plugin install ${MMI_PLUGIN_ID}\``;
5197
+ return `run ${register} then ${reloadAction(surface)} to register the install record for ${projectPath}`;
5145
5198
  }
5146
5199
  function isMmiPluginEnabled(settings) {
5147
5200
  return Boolean(settings?.enabledPlugins?.[MMI_PLUGIN_ID]);
@@ -5161,7 +5214,7 @@ function buildPluginInstallRecordCheck(input) {
5161
5214
  const base = {
5162
5215
  ok: true,
5163
5216
  label: PLUGIN_LABEL,
5164
- fix: pluginInstallManualFix(input.projectPath),
5217
+ fix: pluginInstallManualFix(input.projectPath, input.surface),
5165
5218
  pluginId
5166
5219
  };
5167
5220
  if (!input.isOrgRepo || !isMmiPluginEnabled(input.settings)) return base;
@@ -5177,7 +5230,7 @@ function buildPluginInstallRecordCheck(input) {
5177
5230
  return {
5178
5231
  ok: false,
5179
5232
  label: PLUGIN_LABEL,
5180
- fix: pluginInstallManualFix(input.projectPath),
5233
+ fix: pluginInstallManualFix(input.projectPath, input.surface),
5181
5234
  pluginId,
5182
5235
  recordToInsert
5183
5236
  };
@@ -5196,8 +5249,7 @@ function bestRecord(records) {
5196
5249
  }
5197
5250
  function pluginConfigDriftFix(pluginId, surface = "claude-cli") {
5198
5251
  const file = surface === "codex" ? "~/.codex/plugins/installed_plugins.json" : "~/.claude/plugins/installed_plugins.json";
5199
- const reload = surface === "codex" ? "restart Codex" : surface === "claude-vscode" ? "reopen the VS Code workspace" : "restart Claude Code, or run /reload-plugins";
5200
- return `\`${pluginId}\` has duplicate install rows or stale gitCommitSha in ${file} \u2014 run \`mmi-cli doctor\` interactively to collapse them to one user-scope row (a .bak backup is written first), then ${reload}`;
5252
+ return `\`${pluginId}\` has duplicate install rows or stale gitCommitSha in ${file} \u2014 run \`mmi-cli doctor\` interactively to collapse them to one user-scope row (a .bak backup is written first), then ${reloadAction(surface)}`;
5201
5253
  }
5202
5254
  function buildPluginConfigDriftCheck(input) {
5203
5255
  const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
@@ -5293,13 +5345,24 @@ function detectSurface(env) {
5293
5345
  if (isClaude) return "claude-cli";
5294
5346
  return "shell";
5295
5347
  }
5348
+ function reloadAction(surface) {
5349
+ switch (surface) {
5350
+ case "claude-vscode":
5351
+ return "restart VS Code";
5352
+ case "codex":
5353
+ return "restart Codex";
5354
+ case "claude-cli":
5355
+ case "shell":
5356
+ default:
5357
+ return "restart Claude Code (or run /reload-plugins)";
5358
+ }
5359
+ }
5296
5360
  function pluginRecoveryFix(surface) {
5297
5361
  const claude = "claude plugin marketplace update mmi && claude plugin update mmi@mmi && claude plugin enable mmi@mmi";
5298
5362
  switch (surface) {
5299
5363
  case "claude-vscode":
5300
- return `${claude} # then reopen the VS Code workspace to reload MMI commands`;
5301
5364
  case "claude-cli":
5302
- return `${claude} # then restart Claude Code, or run /reload-plugins`;
5365
+ return `${claude} # then ${reloadAction(surface)} to reload MMI commands`;
5303
5366
  case "codex":
5304
5367
  return "codex plugin marketplace upgrade mmi && codex plugin add mmi@mmi # then restart Codex";
5305
5368
  case "shell":
@@ -5336,12 +5399,12 @@ function buildInstalledPluginVersionCheck(input) {
5336
5399
  }
5337
5400
 
5338
5401
  // src/stage-runner.ts
5339
- var import_node_child_process4 = require("node:child_process");
5402
+ var import_node_child_process5 = require("node:child_process");
5340
5403
  var import_node_fs3 = require("node:fs");
5341
5404
  var import_node_path3 = require("node:path");
5342
5405
  var import_node_net = require("node:net");
5343
- var import_node_util4 = require("node:util");
5344
- var execFileP2 = (0, import_node_util4.promisify)(import_node_child_process4.execFile);
5406
+ var import_node_util5 = require("node:util");
5407
+ var execFileP3 = (0, import_node_util5.promisify)(import_node_child_process5.execFile);
5345
5408
  function stageStatePath(cwd = process.cwd()) {
5346
5409
  return (0, import_node_path3.join)(cwd, "tmp", "stage", "state.json");
5347
5410
  }
@@ -5376,7 +5439,7 @@ function isPortFree(port) {
5376
5439
  });
5377
5440
  }
5378
5441
  async function shell(command, cwd, timeoutMs) {
5379
- await execFileP2(command, [], {
5442
+ await execFileP3(command, [], {
5380
5443
  cwd,
5381
5444
  shell: true,
5382
5445
  timeout: timeoutMs,
@@ -5395,7 +5458,7 @@ function readState(path2) {
5395
5458
  async function killTree(pid) {
5396
5459
  if (!Number.isInteger(pid) || pid <= 0) return;
5397
5460
  if (process.platform === "win32") {
5398
- await execFileP2("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }).catch(() => void 0);
5461
+ await execFileP3("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }).catch(() => void 0);
5399
5462
  return;
5400
5463
  }
5401
5464
  try {
@@ -5458,7 +5521,7 @@ async function startStage(config = {}, opts = {}) {
5458
5521
  }
5459
5522
  const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
5460
5523
  const up = sub(config.up.trim());
5461
- const child = (0, import_node_child_process4.spawn)(up, {
5524
+ const child = (0, import_node_child_process5.spawn)(up, {
5462
5525
  cwd,
5463
5526
  shell: true,
5464
5527
  detached: true,
@@ -5748,22 +5811,29 @@ function safeJson(text, fallback) {
5748
5811
  return fallback;
5749
5812
  }
5750
5813
  }
5751
- async function ghJson(deps, args, fallback) {
5814
+ async function restJson(deps, path2, fallback) {
5752
5815
  try {
5753
- return safeJson((await deps.gh(args)).stdout, fallback);
5816
+ return await deps.client.rest("GET", path2) ?? fallback;
5817
+ } catch {
5818
+ return fallback;
5819
+ }
5820
+ }
5821
+ async function restPagedJson(deps, path2, fallback) {
5822
+ try {
5823
+ return await deps.client.restPaginate(path2);
5754
5824
  } catch {
5755
5825
  return fallback;
5756
5826
  }
5757
5827
  }
5758
5828
  async function resolveOwners(deps) {
5759
- const members = await ghJson(deps, ["api", `orgs/${OWNER}/members?role=admin`, "--paginate"], []);
5829
+ const members = await restPagedJson(deps, `orgs/${OWNER}/members?role=admin`, []);
5760
5830
  return members.map((m) => m.login);
5761
5831
  }
5762
5832
  function collaboratorRole(c) {
5763
5833
  return c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : "write");
5764
5834
  }
5765
5835
  async function auditRepoCollaborators(repo, owners, deps) {
5766
- const collabs = await ghJson(deps, ["api", `repos/${repo}/collaborators?affiliation=direct`, "--paginate"], []);
5836
+ const collabs = await restPagedJson(deps, `repos/${repo}/collaborators?affiliation=direct`, []);
5767
5837
  const findings = [];
5768
5838
  for (const c of collabs) {
5769
5839
  if (owners.has(c.login)) continue;
@@ -5784,7 +5854,7 @@ async function auditRepoCollaborators(repo, owners, deps) {
5784
5854
  async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
5785
5855
  let restrictions = null;
5786
5856
  try {
5787
- restrictions = safeJson((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection/restrictions`])).stdout, null);
5857
+ restrictions = await deps.client.rest("GET", `repos/${repo}/branches/${branch}/protection/restrictions`) ?? null;
5788
5858
  } catch {
5789
5859
  restrictions = null;
5790
5860
  }
@@ -5868,7 +5938,7 @@ async function auditRepoAccess(repo, repoClass, owners, deps, projectAdmins = /*
5868
5938
  return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
5869
5939
  }
5870
5940
  async function auditOrgBasePermission(deps) {
5871
- const org = await ghJson(deps, ["api", `orgs/${OWNER}`], {});
5941
+ const org = await restJson(deps, `orgs/${OWNER}`, {});
5872
5942
  const perm = org.default_repository_permission;
5873
5943
  if (perm && perm !== "read" && perm !== "none") {
5874
5944
  return [{
@@ -6011,9 +6081,16 @@ function safeJson2(text, fallback) {
6011
6081
  return fallback;
6012
6082
  }
6013
6083
  }
6014
- async function ghJson2(deps, args, fallback) {
6084
+ async function restJson2(deps, path2, fallback) {
6015
6085
  try {
6016
- return safeJson2((await deps.gh(args)).stdout, fallback);
6086
+ return await deps.client.rest("GET", path2) ?? fallback;
6087
+ } catch {
6088
+ return fallback;
6089
+ }
6090
+ }
6091
+ async function restPagedJson2(deps, path2, fallback) {
6092
+ try {
6093
+ return await deps.client.restPaginate(path2);
6017
6094
  } catch {
6018
6095
  return fallback;
6019
6096
  }
@@ -6021,7 +6098,7 @@ async function ghJson2(deps, args, fallback) {
6021
6098
  async function contentExists(deps, repo, branch, path2) {
6022
6099
  try {
6023
6100
  const encodedPath = path2.split("/").map(encodeURIComponent).join("/");
6024
- await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`]);
6101
+ await deps.client.rest("GET", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`);
6025
6102
  return true;
6026
6103
  } catch {
6027
6104
  return false;
@@ -6029,7 +6106,7 @@ async function contentExists(deps, repo, branch, path2) {
6029
6106
  }
6030
6107
  async function getProtection(deps, repo, branch) {
6031
6108
  try {
6032
- return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
6109
+ return await deps.client.rest("GET", `repos/${repo}/branches/${branch}/protection`) ?? {};
6033
6110
  } catch {
6034
6111
  return null;
6035
6112
  }
@@ -6057,7 +6134,7 @@ async function rulesetDetails(deps, repo, list) {
6057
6134
  details.push(ruleset);
6058
6135
  continue;
6059
6136
  }
6060
- details.push(await ghJson2(deps, ["api", `repos/${repo}/rulesets/${ruleset.id}`], ruleset));
6137
+ details.push(await restJson2(deps, `repos/${repo}/rulesets/${ruleset.id}`, ruleset));
6061
6138
  }
6062
6139
  return details;
6063
6140
  }
@@ -6070,11 +6147,11 @@ async function verifyBootstrap(repo, repoClass, deps) {
6070
6147
  const branchesWanted = expectedBranches(repoClass);
6071
6148
  const baseBranch = repoClass === "content" ? "main" : "development";
6072
6149
  const checks = [];
6073
- const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
6150
+ const repoInfo = await restJson2(deps, `repos/${repo}`, {});
6074
6151
  checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
6075
6152
  checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
6076
6153
  checks.push({ ok: repoInfo.has_wiki === true, label: "wiki enabled", detail: repoInfo.has_wiki === true ? void 0 : "has_wiki is false or unavailable" });
6077
- const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
6154
+ const branchList = await restPagedJson2(deps, `repos/${repo}/branches`, []);
6078
6155
  const branchNames = new Set(branchList.map((b) => b.name));
6079
6156
  for (const branch of branchesWanted) {
6080
6157
  checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
@@ -6107,14 +6184,14 @@ async function verifyBootstrap(repo, repoClass, deps) {
6107
6184
  checks.push({ ok: await contentExists(deps, repo, baseBranch, trainScript), label: `train tooling script exists: ${trainScript}` });
6108
6185
  }
6109
6186
  checks.push({ ok: await contentExists(deps, repo, baseBranch, ".cursor/environment.json"), label: "Cursor environment committed" });
6110
- const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
6187
+ const labels = await restPagedJson2(deps, `repos/${repo}/labels`, []);
6111
6188
  const labelNames = new Set(labels.map((l) => l.name));
6112
6189
  for (const label of requiredLabels) {
6113
6190
  checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
6114
6191
  }
6115
6192
  const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
6116
6193
  checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
6117
- const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
6194
+ const actions = await restJson2(deps, `repos/${repo}/actions/permissions`, {});
6118
6195
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
6119
6196
  const config = deps.projectMeta ?? null;
6120
6197
  checks.push({
@@ -6122,12 +6199,18 @@ async function verifyBootstrap(repo, repoClass, deps) {
6122
6199
  label: "registry project board META exists"
6123
6200
  });
6124
6201
  if (config?.projectOwner && config.projectNumber != null) {
6125
- const project2 = await ghJson2(
6126
- deps,
6127
- ["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
6128
- {}
6129
- );
6130
- const fields = project2.fields || [];
6202
+ const fieldsQuery = `query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { fields(first: 50) { nodes { ... on ProjectV2FieldCommon { id name } ... on ProjectV2SingleSelectField { id name options { id name } } } } } } }`;
6203
+ const fields = await (async () => {
6204
+ try {
6205
+ const data = await deps.client.graphql(fieldsQuery, {
6206
+ login: config.projectOwner,
6207
+ number: config.projectNumber
6208
+ });
6209
+ return (data.organization?.projectV2?.fields?.nodes ?? []).filter((f) => Boolean(f?.id && f?.name));
6210
+ } catch {
6211
+ return [];
6212
+ }
6213
+ })();
6131
6214
  const statusField = fields.find((field) => field.name === "Status");
6132
6215
  const labelField = fields.find((field) => field.name === "Labels");
6133
6216
  checks.push({
@@ -6184,12 +6267,17 @@ async function verifyBootstrap(repo, repoClass, deps) {
6184
6267
  }
6185
6268
  }
6186
6269
  const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
6187
- const workflowResponse = await ghJson2(
6188
- deps,
6189
- ["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
6190
- {}
6191
- );
6192
- const workflows = workflowResponse.data?.organization?.projectV2?.workflows?.nodes || [];
6270
+ const workflowResponse = await (async () => {
6271
+ try {
6272
+ return await deps.client.graphql(workflowQuery, {
6273
+ login: config.projectOwner,
6274
+ number: config.projectNumber
6275
+ });
6276
+ } catch {
6277
+ return {};
6278
+ }
6279
+ })();
6280
+ const workflows = workflowResponse.organization?.projectV2?.workflows?.nodes || [];
6193
6281
  for (const workflowName of requiredProjectWorkflows) {
6194
6282
  checks.push({
6195
6283
  ok: workflows.some((workflow) => workflow.name === workflowName && workflow.enabled === true),
@@ -6201,11 +6289,7 @@ async function verifyBootstrap(repo, repoClass, deps) {
6201
6289
  if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
6202
6290
  const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
6203
6291
  if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
6204
- const rulesetList = await ghJson2(
6205
- deps,
6206
- ["api", `repos/${repo}/rulesets?includes_parents=true`],
6207
- []
6208
- );
6292
+ const rulesetList = await restJson2(deps, `repos/${repo}/rulesets?includes_parents=true`, []);
6209
6293
  const rulesets = await rulesetDetails(deps, repo, rulesetList);
6210
6294
  const activeOrgRulesets = rulesets.filter(
6211
6295
  (r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
@@ -6391,7 +6475,7 @@ var ORG_CONFIG_PATH = "/org/config";
6391
6475
  var PROJECTS_ENVELOPE_KEY = "projects";
6392
6476
 
6393
6477
  // src/registry-client.ts
6394
- var DEFAULT_TIMEOUT_MS = 8e3;
6478
+ var DEFAULT_TIMEOUT_MS2 = 8e3;
6395
6479
  async function fetchTrainAuthority(repo, deps) {
6396
6480
  if (!deps.baseUrl) return { ok: false, error: "Hub API URL not configured" };
6397
6481
  const token = await deps.token();
@@ -6401,7 +6485,7 @@ async function fetchTrainAuthority(repo, deps) {
6401
6485
  const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/train-authority?repo=${encodeURIComponent(repo)}`, {
6402
6486
  method: "GET",
6403
6487
  headers: { Authorization: `Bearer ${token}` },
6404
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6488
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
6405
6489
  });
6406
6490
  if (!res.ok) return { ok: false, error: `train-authority HTTP ${res.status}` };
6407
6491
  const body = await res.json();
@@ -6420,7 +6504,7 @@ async function fetchProjectsList(deps) {
6420
6504
  const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}${PROJECTS_LIST_PATH}`, {
6421
6505
  method: "GET",
6422
6506
  headers: { Authorization: `Bearer ${token}` },
6423
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6507
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
6424
6508
  });
6425
6509
  if (!res.ok) return null;
6426
6510
  const body = await res.json();
@@ -6444,7 +6528,7 @@ async function fetchProjectBySlugChecked(slug, deps) {
6444
6528
  const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}`, {
6445
6529
  method: "GET",
6446
6530
  headers: { Authorization: `Bearer ${token}` },
6447
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6531
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
6448
6532
  });
6449
6533
  if (res.status === 404) return { ok: true, project: null };
6450
6534
  if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
@@ -6466,7 +6550,7 @@ async function fetchDeployStatusBySlug(slug, deps) {
6466
6550
  const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}/deploy-status`, {
6467
6551
  method: "GET",
6468
6552
  headers: { Authorization: `Bearer ${token}` },
6469
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6553
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
6470
6554
  });
6471
6555
  if (!res.ok) return null;
6472
6556
  const body = await res.json();
@@ -6485,7 +6569,7 @@ async function fetchOrgConfig(deps) {
6485
6569
  const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}${ORG_CONFIG_PATH}`, {
6486
6570
  method: "GET",
6487
6571
  headers: { Authorization: `Bearer ${token}` },
6488
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6572
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
6489
6573
  });
6490
6574
  if (!res.ok) return null;
6491
6575
  return await res.json();
@@ -6503,7 +6587,7 @@ async function postJson(pathSuffix, payload, deps, method = "POST") {
6503
6587
  method,
6504
6588
  headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
6505
6589
  body: JSON.stringify(payload),
6506
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6590
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2)
6507
6591
  });
6508
6592
  let body = null;
6509
6593
  try {
@@ -6521,6 +6605,9 @@ async function registerProject(payload, deps) {
6521
6605
  async function upsertProject(slug, patch, deps) {
6522
6606
  return postJson(`/projects/${encodeURIComponent(slug)}`, patch, deps);
6523
6607
  }
6608
+ async function attestAppGaps(slug, repo, deps) {
6609
+ return postJson(`/projects/${encodeURIComponent(slug)}/attest-app`, { repo }, deps);
6610
+ }
6524
6611
  async function tenantControl(payload, deps) {
6525
6612
  return postJson("/tenant-control", payload, deps);
6526
6613
  }
@@ -6559,7 +6646,21 @@ function stageRequiredSecrets(stage2, meta) {
6559
6646
  function stageKey(stage2, key) {
6560
6647
  return key.includes("/") ? key : `${stage2}/${key}`;
6561
6648
  }
6649
+ function hasRuntimeSecretContract(contract) {
6650
+ return Boolean(contract) && !Array.isArray(contract);
6651
+ }
6652
+ function appAttestationOf(meta) {
6653
+ const a = meta?.appAttested;
6654
+ if (!a || typeof a !== "object" || Array.isArray(a)) return null;
6655
+ const { at, by } = a;
6656
+ return typeof at === "string" && at.length > 0 && typeof by === "string" && by.length > 0 ? { at, by } : null;
6657
+ }
6658
+ function attestedLine(att) {
6659
+ return `App-owned readiness attested by @${att.by} on ${att.at.slice(0, 10)} \u2014 the static checklist is cleared (the doctor reads no product repo files); re-run \`mmi-cli project attest\` after app-owned structural changes.`;
6660
+ }
6562
6661
  function appGapsFor(meta, model, slug, projectType) {
6662
+ const attested = appAttestationOf(meta);
6663
+ if (attested) return [attestedLine(attested)];
6563
6664
  if (projectType === "content" || model === "content") return ["Content/KB repo: keep app-owned work to docs/content changes; release train does not apply."];
6564
6665
  if (projectType === "desktop-game") {
6565
6666
  return [
@@ -6591,6 +6692,9 @@ function appGapsFor(meta, model, slug, projectType) {
6591
6692
  "Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
6592
6693
  "Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
6593
6694
  ];
6695
+ if (meta && !hasRuntimeSecretContract(meta.requiredRuntimeSecrets)) {
6696
+ gaps.unshift("No runtime secrets declared \u2014 declare requiredRuntimeSecrets (a per-stage name map) in the registry META, or attest the app needs none with an explicit empty stage map ({ dev: [], rc: [], main: [] }).");
6697
+ }
6594
6698
  if (slug === "mmi-katip") {
6595
6699
  gaps.push("Katip-specific app plan: declare Google Workspace service-account requirements, use the service account numeric OAuth2 client ID for DWD, remove prod-hidden impersonation defaults, and make non-critical Google Workspace failures degrade instead of crash-looping.");
6596
6700
  }
@@ -6730,7 +6834,8 @@ async function buildV2Doctor(repoOrSlug, deps) {
6730
6834
  hubOwned: { meta: { ok: metaMissing.length === 0, missing: metaMissing }, deployCoords, deployState, secrets: secrets2 },
6731
6835
  secretsError,
6732
6836
  autoHealAvailable: Object.keys(autoHeal.patch),
6733
- appOwnedGaps: autoHeal.appOwnedGaps
6837
+ appOwnedGaps: autoHeal.appOwnedGaps,
6838
+ appAttested: appAttestationOf(meta) ?? void 0
6734
6839
  };
6735
6840
  }
6736
6841
  function renderReadinessIssueBody(existingBody, report, opts = {}) {
@@ -6823,6 +6928,10 @@ function buildProjectSetPatch(input) {
6823
6928
  }
6824
6929
  return patch;
6825
6930
  }
6931
+ function repoFromRemoteUrl(remoteUrl) {
6932
+ const m = remoteUrl.trim().match(/^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@\s/]+@)?github\.com[:/]([^/\s:]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
6933
+ return m ? `${m[1]}/${m[2]}` : void 0;
6934
+ }
6826
6935
  function requireProjectTarget(commandName, explicitTarget, currentRepo) {
6827
6936
  const target = explicitTarget?.trim() || currentRepo?.trim();
6828
6937
  if (!target) {
@@ -7102,12 +7211,14 @@ function formatVaultPointer(p) {
7102
7211
  }
7103
7212
  var TIMEOUT_MS2 = 8e3;
7104
7213
  var repoOf = (slug) => `${OWNER2}/${slug}`;
7214
+ async function vaultSlug(deps, opts) {
7215
+ return (opts.repo ? opts.repo.split("/").pop() : await deps.slug()).toLowerCase();
7216
+ }
7105
7217
  async function targetRepo(deps, opts) {
7106
- return opts.repo ?? repoOf(await deps.slug());
7218
+ return opts.repo ?? repoOf(await vaultSlug(deps, opts));
7107
7219
  }
7108
7220
  async function secretsWhere(deps, opts) {
7109
- const slug = opts.repo ? opts.repo.split("/").pop().toLowerCase() : await deps.slug();
7110
- deps.log(formatVaultPointer(vaultPointer(slug)));
7221
+ deps.log(formatVaultPointer(vaultPointer(await vaultSlug(deps, opts))));
7111
7222
  }
7112
7223
  async function readErr(res) {
7113
7224
  try {
@@ -7289,7 +7400,7 @@ async function secretsSet(deps, key, opts) {
7289
7400
  );
7290
7401
  return;
7291
7402
  }
7292
- deps.log(`set ${key} (${classifyTier(await deps.slug(), key)} tier)`);
7403
+ deps.log(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier)`);
7293
7404
  }
7294
7405
  async function secretsEdit(deps, key, opts) {
7295
7406
  return secretsSet(deps, key, opts);
@@ -7341,8 +7452,8 @@ async function secretsRevoke(deps, repo, login, key, _opts) {
7341
7452
  }
7342
7453
  deps.log(`revoked @${login}'s access to ${key} in ${repo}`);
7343
7454
  }
7344
- async function secretsUse(deps, key, _opts) {
7345
- const slug = await deps.slug();
7455
+ async function secretsUse(deps, key, opts) {
7456
+ const slug = await vaultSlug(deps, opts);
7346
7457
  const tier = classifyTier(slug, key);
7347
7458
  const path2 = secretParamName(slug, key);
7348
7459
  deps.log(
@@ -7427,30 +7538,23 @@ function authorizeBodyHasMismatch(body) {
7427
7538
  }
7428
7539
 
7429
7540
  // src/index.ts
7430
- var rawExecFileP2 = (0, import_node_util5.promisify)(import_node_child_process5.execFile);
7541
+ var rawExecFileP3 = (0, import_node_util6.promisify)(import_node_child_process6.execFile);
7431
7542
  var DEFAULT_EXEC_TIMEOUT_MS = 1e4;
7432
- var execFileP3 = (file, args, options = {}) => (
7543
+ var execFileP4 = (file, args, options = {}) => (
7433
7544
  // encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
7434
7545
  // promisify(execFile)'s overloads widen to string|Buffer when options is spread in.
7435
- rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, timeout: DEFAULT_EXEC_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
7546
+ rawExecFileP3(file, args, { encoding: "utf8", windowsHide: true, timeout: DEFAULT_EXEC_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
7436
7547
  );
7437
7548
  var GIT_TIMEOUT_MS = DEFAULT_EXEC_TIMEOUT_MS;
7438
7549
  var GC_GH_TIMEOUT_MS = 2e4;
7439
- var cachedGhCliToken;
7440
- async function githubToken() {
7441
- if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
7442
- if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
7443
- cachedGhCliToken ??= execFileP3("gh", ["auth", "token"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
7444
- return cachedGhCliToken;
7445
- }
7446
7550
  var cachedGithubLogin;
7447
7551
  async function githubLogin() {
7448
- cachedGithubLogin ??= execFileP3("gh", ["api", "user", "--jq", ".login"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
7552
+ cachedGithubLogin ??= execFileP4("gh", ["api", "user", "--jq", ".login"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
7449
7553
  return cachedGithubLogin;
7450
7554
  }
7451
7555
  async function awsCallerArn() {
7452
7556
  try {
7453
- const { stdout } = await execFileP3(
7557
+ const { stdout } = await execFileP4(
7454
7558
  "aws",
7455
7559
  ["sts", "get-caller-identity", "--query", "Arn", "--output", "text"],
7456
7560
  { timeout: GIT_TIMEOUT_MS }
@@ -7510,7 +7614,7 @@ var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/d
7510
7614
  var SESSION_FILE = ".mmi/.session";
7511
7615
  var gitOut = async (args) => {
7512
7616
  try {
7513
- return (await execFileP3("git", [...args])).stdout.trim();
7617
+ return (await execFileP4("git", [...args])).stdout.trim();
7514
7618
  } catch {
7515
7619
  return "";
7516
7620
  }
@@ -7582,18 +7686,18 @@ async function readStdin() {
7582
7686
  async function ghPrs(limit) {
7583
7687
  const args = (state) => ["pr", "list", "--state", state, "--limit", String(limit), "--json", "number,headRefName,state"];
7584
7688
  const [open, closed] = await Promise.all([
7585
- execFileP3("gh", args("open"), { timeout: GC_GH_TIMEOUT_MS }),
7586
- execFileP3("gh", args("closed"), { timeout: GC_GH_TIMEOUT_MS })
7689
+ execFileP4("gh", args("open"), { timeout: GC_GH_TIMEOUT_MS }),
7690
+ execFileP4("gh", args("closed"), { timeout: GC_GH_TIMEOUT_MS })
7587
7691
  ]);
7588
7692
  return [...JSON.parse(open.stdout || "[]"), ...JSON.parse(closed.stdout || "[]")];
7589
7693
  }
7590
7694
  async function worktreeBranches() {
7591
- const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7695
+ const { stdout } = await execFileP4("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7592
7696
  const parsed = parseWorktreePorcelain(stdout);
7593
7697
  return await Promise.all(parsed.map(async (w) => {
7594
7698
  let dirty = true;
7595
7699
  try {
7596
- const { stdout: status } = await execFileP3("git", ["-C", w.path, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7700
+ const { stdout: status } = await execFileP4("git", ["-C", w.path, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7597
7701
  dirty = status.trim().length > 0;
7598
7702
  } catch {
7599
7703
  dirty = true;
@@ -7605,7 +7709,7 @@ async function gcPlan(remote, limit) {
7605
7709
  const [branches, current, stale, prs, worktrees] = await Promise.all([
7606
7710
  gitOut(["branch", "--format=%(refname:short)"]),
7607
7711
  gitOut(["rev-parse", "--abbrev-ref", "HEAD"]),
7608
- execFileP3("git", ["remote", "prune", remote, "--dry-run"], { timeout: GIT_TIMEOUT_MS }).then((r) => parseRemotePruneDryRun(`${r.stdout}${r.stderr}`)).catch(() => []),
7712
+ execFileP4("git", ["remote", "prune", remote, "--dry-run"], { timeout: GIT_TIMEOUT_MS }).then((r) => parseRemotePruneDryRun(`${r.stdout}${r.stderr}`)).catch(() => []),
7609
7713
  ghPrs(limit),
7610
7714
  worktreeBranches()
7611
7715
  ]);
@@ -7633,8 +7737,8 @@ async function applyGcPlan(plan2, remote) {
7633
7737
  const result = { removedBranches: [], removedTrackingRefs: [], failed: [], pruned: false };
7634
7738
  for (const branch of plan2.branches) {
7635
7739
  try {
7636
- if (branch.worktreePath) await execFileP3("git", ["worktree", "remove", "--force", branch.worktreePath], { timeout: GIT_TIMEOUT_MS });
7637
- await execFileP3("git", ["branch", "-D", branch.branch], { timeout: GIT_TIMEOUT_MS });
7740
+ if (branch.worktreePath) await execFileP4("git", ["worktree", "remove", "--force", branch.worktreePath], { timeout: GIT_TIMEOUT_MS });
7741
+ await execFileP4("git", ["branch", "-D", branch.branch], { timeout: GIT_TIMEOUT_MS });
7638
7742
  result.removedBranches.push(branch.branch);
7639
7743
  } catch (e) {
7640
7744
  result.failed.push(`${branch.branch}: ${e.message.split("\n")[0]}`);
@@ -7642,7 +7746,7 @@ async function applyGcPlan(plan2, remote) {
7642
7746
  }
7643
7747
  for (const ref of plan2.trackingRefs) {
7644
7748
  try {
7645
- await execFileP3("git", ["update-ref", "-d", `refs/remotes/${remote}/${ref.branch}`], { timeout: GIT_TIMEOUT_MS });
7749
+ await execFileP4("git", ["update-ref", "-d", `refs/remotes/${remote}/${ref.branch}`], { timeout: GIT_TIMEOUT_MS });
7646
7750
  result.removedTrackingRefs.push(ref.branch);
7647
7751
  } catch (e) {
7648
7752
  result.failed.push(`${remote}/${ref.branch}: ${e.message.split("\n")[0]}`);
@@ -7650,7 +7754,7 @@ async function applyGcPlan(plan2, remote) {
7650
7754
  }
7651
7755
  if (plan2.branches.some((b) => b.worktreePath)) {
7652
7756
  try {
7653
- await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
7757
+ await execFileP4("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
7654
7758
  result.pruned = true;
7655
7759
  } catch (e) {
7656
7760
  result.failed.push(`worktree prune: ${e.message.split("\n")[0]}`);
@@ -7680,7 +7784,7 @@ function readRepoVersion() {
7680
7784
  }
7681
7785
  async function fetchReleasedVersion() {
7682
7786
  try {
7683
- const { stdout } = await execFileP3("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
7787
+ const { stdout } = await execFileP4("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
7684
7788
  return parseManifestVersion(stdout);
7685
7789
  } catch {
7686
7790
  return void 0;
@@ -7694,9 +7798,9 @@ async function applyVersionAutoUpdate(report, log) {
7694
7798
  const target = report.releasedVersion ?? "latest";
7695
7799
  if (action === "plugin-pull") {
7696
7800
  try {
7697
- const root = (await execFileP3("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
7801
+ const root = (await execFileP4("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
7698
7802
  log(` \u21BB refreshing MMI plugin ${report.currentVersion} \u2192 ${target} (effective next session)\u2026`);
7699
- await execFileP3("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
7803
+ await execFileP4("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
7700
7804
  return { ...report, ok: true };
7701
7805
  } catch {
7702
7806
  return report;
@@ -7723,7 +7827,7 @@ async function requireFreshTrainCli(commandName) {
7723
7827
  var consoleIo = { log: (m) => console.log(m), err: (m) => console.error(m) };
7724
7828
  var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
7725
7829
  function runHostBin(bin, args, opts) {
7726
- return isWin ? execFileP3("cmd.exe", ["/c", bin, ...args], opts) : execFileP3(bin, args, opts);
7830
+ return isWin ? execFileP4("cmd.exe", ["/c", bin, ...args], opts) : execFileP4(bin, args, opts);
7727
7831
  }
7728
7832
  async function runClaudePlugin(args) {
7729
7833
  try {
@@ -7794,7 +7898,7 @@ async function runDocsSync(opts, io = consoleIo) {
7794
7898
  isDirty: async (f) => await gitOut(["status", "--porcelain", "--", f]) !== "",
7795
7899
  originContent: async (f) => {
7796
7900
  try {
7797
- return (await execFileP3("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
7901
+ return (await execFileP4("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
7798
7902
  } catch {
7799
7903
  return null;
7800
7904
  }
@@ -7827,14 +7931,17 @@ async function runSagaShow(opts, io = consoleIo) {
7827
7931
  try {
7828
7932
  const key = await sagaKey(cfg);
7829
7933
  const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
7830
- const res = await fetch(`${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
7934
+ const res = await fetch(`${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(3e3) });
7831
7935
  if (res.ok) {
7832
7936
  io.log(resumeCue());
7833
7937
  return io.log(await res.text());
7834
7938
  }
7835
7939
  if (!opts.quiet) io.log(`saga show failed: HTTP ${res.status}`);
7836
7940
  } catch (e) {
7837
- if (!opts.quiet) io.err(`saga show: ${e.message}`);
7941
+ if (!opts.quiet) {
7942
+ const reason = e.name === "TimeoutError" ? "backend unreachable (timed out after 3s)" : e.message;
7943
+ io.err(`saga show: ${reason} \u2014 continuing without saga; diagnose with \`mmi-cli saga health --json\``);
7944
+ }
7838
7945
  }
7839
7946
  }
7840
7947
  saga.command("show").option("--quiet", "no-op silently when unconfigured/unreachable (SessionStart hook)").option("--latest-anywhere", "resume the newest saga across all repos (default: current repo)").description("print your resume block \u2014 current repo HEAD + project memory (where you left off)").action((opts) => runSagaShow(opts));
@@ -7854,7 +7961,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
7854
7961
  if (!headGateDue(tsPath)) return;
7855
7962
  markHeadRun(tsPath);
7856
7963
  try {
7857
- (0, import_node_child_process5.spawn)(process.execPath, [process.argv[1], "saga", "head-update", "--run"], {
7964
+ (0, import_node_child_process6.spawn)(process.execPath, [process.argv[1], "saga", "head-update", "--run"], {
7858
7965
  detached: true,
7859
7966
  stdio: "ignore",
7860
7967
  windowsHide: true
@@ -7971,7 +8078,7 @@ var kb = program2.command("kb").description("org knowledgebase (read-only)");
7971
8078
  kb.command("get <path>").description("print a KB document by path").action(async (path2) => {
7972
8079
  const src = resolveKbSource((await loadConfig()).kbSource);
7973
8080
  try {
7974
- const { stdout } = await execFileP3("gh", buildKbGetArgs(src, path2), { timeout: 1e4 });
8081
+ const { stdout } = await execFileP4("gh", buildKbGetArgs(src, path2), { timeout: 1e4 });
7975
8082
  process.stdout.write(stdout);
7976
8083
  } catch (e) {
7977
8084
  const err = e;
@@ -7981,7 +8088,7 @@ kb.command("get <path>").description("print a KB document by path").action(async
7981
8088
  kb.command("list [prefix]").description("list KB document paths (optionally under a prefix)").action(async (prefix) => {
7982
8089
  const src = resolveKbSource((await loadConfig()).kbSource);
7983
8090
  try {
7984
- const { stdout } = await execFileP3("gh", buildKbTreeArgs(src), { timeout: 1e4 });
8091
+ const { stdout } = await execFileP4("gh", buildKbTreeArgs(src), { timeout: 1e4 });
7985
8092
  const paths = parseKbTree(stdout, prefix);
7986
8093
  if (!paths.length) return fail(`kb list: no documents${prefix ? ` under ${prefix}` : ""}`);
7987
8094
  console.log(paths.join("\n"));
@@ -7992,21 +8099,23 @@ kb.command("list [prefix]").description("list KB document paths (optionally unde
7992
8099
  });
7993
8100
  async function ghCreate(args) {
7994
8101
  try {
7995
- const { stdout } = await execFileP3("gh", args);
8102
+ const { stdout } = await execFileP4("gh", args);
7996
8103
  return parseCreatedUrl(stdout);
7997
8104
  } catch (e) {
7998
8105
  const err = e;
7999
8106
  fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
8000
8107
  }
8001
8108
  }
8002
- async function ghJson3(args, timeout = 1e4) {
8003
- const { stdout } = await execFileP3("gh", args, { timeout });
8109
+ async function ghJson(args, timeout = 1e4) {
8110
+ const { stdout } = await execFileP4("gh", args, { timeout });
8004
8111
  return JSON.parse(stdout);
8005
8112
  }
8006
8113
  async function resolveRepo(repo) {
8007
8114
  if (repo) return repo;
8115
+ const fromOrigin = repoFromRemoteUrl(await gitOut(["remote", "get-url", "origin"]));
8116
+ if (fromOrigin) return fromOrigin;
8008
8117
  try {
8009
- const { stdout } = await execFileP3("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], { timeout: 5e3 });
8118
+ const { stdout } = await execFileP4("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], { timeout: 5e3 });
8010
8119
  return stdout.trim() || void 0;
8011
8120
  } catch {
8012
8121
  return void 0;
@@ -8022,19 +8131,14 @@ async function attachToProject(issueNumber, repo, priority) {
8022
8131
  try {
8023
8132
  const viewArgs = ["issue", "view", String(issueNumber), "--json", "id", "--jq", ".id"];
8024
8133
  if (targetRepo2) viewArgs.push("--repo", targetRepo2);
8025
- const { stdout: idOut } = await execFileP3("gh", viewArgs, { timeout: 1e4 });
8134
+ const { stdout: idOut } = await execFileP4("gh", viewArgs, { timeout: 1e4 });
8026
8135
  const contentId = idOut.trim();
8027
8136
  if (!contentId) throw new Error("could not resolve issue node id");
8028
- const { stdout } = await execFileP3("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: 1e4 });
8137
+ const { stdout } = await execFileP4("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: 1e4 });
8029
8138
  const projectItemId = parseAddedItemId(stdout);
8030
8139
  if (projectItemId && priority) {
8031
8140
  try {
8032
- await setBoardItemPriority(
8033
- async (args) => execFileP3("gh", args, { timeout: 1e4 }),
8034
- cfg,
8035
- projectItemId,
8036
- priority
8037
- );
8141
+ await setBoardItemPriority(defaultGitHubClient(), cfg, projectItemId, priority);
8038
8142
  } catch (e) {
8039
8143
  const err = e;
8040
8144
  process.stderr.write(`warning: issue #${issueNumber} board Priority not set: ${(err.stderr || err.message || String(e)).trim()}
@@ -8053,7 +8157,7 @@ function scheduleRelatedDiscovery(o) {
8053
8157
  try {
8054
8158
  const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
8055
8159
  if (o.repo) args.push("--repo", o.repo);
8056
- (0, import_node_child_process5.spawn)(process.execPath, [process.argv[1], ...args], {
8160
+ (0, import_node_child_process6.spawn)(process.execPath, [process.argv[1], ...args], {
8057
8161
  detached: true,
8058
8162
  stdio: "ignore",
8059
8163
  windowsHide: true,
@@ -8109,7 +8213,7 @@ function openInEditor(path2) {
8109
8213
  return;
8110
8214
  }
8111
8215
  try {
8112
- (0, import_node_child_process5.spawn)(editor, [path2], { stdio: "inherit" });
8216
+ (0, import_node_child_process6.spawn)(editor, [path2], { stdio: "inherit" });
8113
8217
  } catch {
8114
8218
  console.log(`open ${path2} manually`);
8115
8219
  }
@@ -8167,7 +8271,9 @@ function makeSecretsDeps(cfg) {
8167
8271
  apiUrl: cfg.sagaApiUrl,
8168
8272
  fetch: (url, init = {}) => fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(1e4) }),
8169
8273
  headers: (extra) => sagaHeaders(extra),
8170
- slug: async () => (await sagaKey(cfg)).project,
8274
+ // Vault paths are lowercase kebab (AGENTS.md naming); sagaKey carries the repo name's original
8275
+ // casing, which leaked mixed-case into `secrets where` output (#681).
8276
+ slug: async () => (await sagaKey(cfg)).project.toLowerCase(),
8171
8277
  readSecretValue: () => readSecretStdin(),
8172
8278
  log: (m) => console.log(m),
8173
8279
  err: (m) => console.error(m)
@@ -8267,22 +8373,22 @@ async function v2ReadinessDeps(cfg) {
8267
8373
  async function updateV2ReadinessIssue(repo, report, healed) {
8268
8374
  const title = "v2 readiness: central deploy + secrets alignment";
8269
8375
  const freshBody = renderReadinessIssueBody("", report, { healed });
8270
- const list = await execFileP3("gh", ["issue", "list", "--repo", repo, "--state", "open", "--search", "v2 readiness in:title", "--json", "number,title", "--limit", "20"], { timeout: 2e4 });
8376
+ const list = await execFileP4("gh", ["issue", "list", "--repo", repo, "--state", "open", "--search", "v2 readiness in:title", "--json", "number,title", "--limit", "20"], { timeout: 2e4 });
8271
8377
  const issues = JSON.parse(list.stdout || "[]");
8272
8378
  const existing = issues.find((i) => i.title.toLowerCase().includes("v2 readiness"));
8273
8379
  if (!existing) {
8274
- const created = await execFileP3("gh", ["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"], { timeout: 2e4 });
8380
+ const created = await execFileP4("gh", ["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"], { timeout: 2e4 });
8275
8381
  const url = created.stdout.trim();
8276
8382
  const number = Number(url.match(/\/issues\/(\d+)$/)?.[1] ?? 0);
8277
8383
  return { number, url, action: "created" };
8278
8384
  }
8279
- const view = await execFileP3("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
8385
+ const view = await execFileP4("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
8280
8386
  const current = JSON.parse(view.stdout || "{}");
8281
8387
  const nextBody = renderReadinessIssueBody(current.body ?? "", report, { healed });
8282
- await execFileP3("gh", ["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody], { timeout: 2e4 });
8388
+ await execFileP4("gh", ["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody], { timeout: 2e4 });
8283
8389
  return { number: existing.number, url: current.url ?? `https://github.com/${repo}/issues/${existing.number}`, action: "updated" };
8284
8390
  }
8285
- var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
8391
+ var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); attest is project-admin; set is master-only");
8286
8392
  async function projectTarget(commandName, explicitTarget) {
8287
8393
  return requireProjectTarget(commandName, explicitTarget, explicitTarget ? void 0 : await resolveRepo());
8288
8394
  }
@@ -8319,7 +8425,7 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
8319
8425
  }
8320
8426
  fail(msg);
8321
8427
  });
8322
- project.command("doctor [owner/repo]").description("diagnose Hub v2 readiness for a repo without reading product repo files; defaults to the current repo").option("--v2", "compatibility flag; v2 readiness is the default").option("--json", "machine-readable output").action(async (repo, _o) => {
8428
+ project.command("doctor [owner/repo]").description("diagnose Hub v2 readiness for a repo without reading product repo files \u2014 appOwnedGaps are advisory templates; clear them with `project attest`; defaults to the current repo").option("--v2", "compatibility flag; v2 readiness is the default").option("--json", "machine-readable output").action(async (repo, _o) => {
8323
8429
  const cfg = await loadConfig();
8324
8430
  let target;
8325
8431
  try {
@@ -8369,6 +8475,18 @@ project.command("readiness [owner/repo]").description("update the repo v2 readin
8369
8475
  const issue2 = await updateV2ReadinessIssue(target, report, []);
8370
8476
  console.log(JSON.stringify({ ok: true, repo: target, issue: issue2, ready: report.ok }));
8371
8477
  });
8478
+ project.command("attest [owner/repo]").description("attest this repo's app-owned v2 readiness work is done (project-admin of the repo or master) \u2014 clears doctor's static appOwnedGaps; re-running overwrites; defaults to the current repo").option("--json", "machine-readable output").action(async (repoOrSlug) => {
8479
+ const cfg = await loadConfig();
8480
+ let target;
8481
+ try {
8482
+ target = await projectTarget("project attest", repoOrSlug);
8483
+ } catch (e) {
8484
+ return fail(e.message);
8485
+ }
8486
+ const repo = target.includes("/") ? target : `mutmutco/${slugOf(target)}`;
8487
+ const res = await attestAppGaps(slugOf(target), repo, registryClientDeps(cfg));
8488
+ reportWrite("project attest", res);
8489
+ });
8372
8490
  project.command("set [owner/repo]").description("MASTER-ONLY: upsert a project META (idempotent merge; defaults to the current repo; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--project-type <type>", `${PROJECT_TYPES.join(" | ")} (v2 capability shape)`).option("--deploy-model <model>", `${DEPLOY_MODELS.join(" | ")} (release/deploy path; none means no Hub deploy registration)`).option("--var <KEY=VALUE...>", "META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer").option("--unset <KEY...>", "META field to remove (repeatable): oauth, requiredRuntimeSecrets, edgeDomains, requiredGcpApis, publishRequired").option("--clear-web-profile", "remove web-only registry fields (oauth, edgeDomains) for non-web/content projects").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
8373
8491
  const cfg = await loadConfig();
8374
8492
  let target;
@@ -8483,7 +8601,7 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
8483
8601
  const la = ["label", "create", label, "--color", "ededed"];
8484
8602
  if (o.repo) la.push("--repo", o.repo);
8485
8603
  try {
8486
- await execFileP3("gh", la, { timeout: 1e4 });
8604
+ await execFileP4("gh", la, { timeout: 1e4 });
8487
8605
  } catch {
8488
8606
  }
8489
8607
  }
@@ -8498,7 +8616,7 @@ issue.command("discover-related").description("find related issues for an existi
8498
8616
  const repo = await resolveRepo(o.repo);
8499
8617
  if (!repo) return fail("issue discover-related: could not resolve repo");
8500
8618
  try {
8501
- const issues = await ghJson3([
8619
+ const issues = await ghJson([
8502
8620
  "issue",
8503
8621
  "list",
8504
8622
  "--repo",
@@ -8513,7 +8631,7 @@ issue.command("discover-related").description("find related issues for an existi
8513
8631
  const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
8514
8632
  if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
8515
8633
  if (!candidates.length) return;
8516
- const viewed = await ghJson3([
8634
+ const viewed = await ghJson([
8517
8635
  "issue",
8518
8636
  "view",
8519
8637
  String(number),
@@ -8523,11 +8641,11 @@ issue.command("discover-related").description("find related issues for an existi
8523
8641
  "comments"
8524
8642
  ]);
8525
8643
  if (viewed.comments.some((comment) => comment.body.includes(relatedMarker(number)))) return;
8526
- await execFileP3("gh", ["issue", "comment", String(number), "--repo", repo, "--body", buildRelatedComment(number, candidates)], { timeout: 1e4 });
8644
+ await execFileP4("gh", ["issue", "comment", String(number), "--repo", repo, "--body", buildRelatedComment(number, candidates)], { timeout: 1e4 });
8527
8645
  } catch {
8528
8646
  }
8529
8647
  });
8530
- program2.command("report").description("file a friction report on the Hub board (GitHub auth, dedups open reports) and print {number,url} JSON").requiredOption("--title <title>", "one-line friction summary").option("--body <body>", "report body (markdown)").option("--body-file <path|->", "read report body from a UTF-8 file, or from stdin with -").option("--type <type>", "bug | feature | task (sets the matching label)", "task").option("--priority <priority>", "urgent | high | medium | low (board Priority field when configured)", "medium").option("--repo <owner/repo>", `target repo (defaults to the org Hub: ${HUB_REPO})`).option("--force", "file a new issue even when an open report looks like a duplicate").action(async (o) => {
8648
+ program2.command("report").description("file a friction report on the Hub board (GitHub auth, dedups open reports) and print {number,url} JSON").requiredOption("--title <title>", "one-line friction summary").option("--body <body>", "report body (markdown)").option("--body-file <path|->", "read report body from a UTF-8 file, or from stdin with -").option("--type <type>", "bug | feature | task (sets the matching label)", "task").option("--priority <priority>", "urgent | high | medium | low (board Priority field when configured)", "medium").option("--repo <owner/repo>", `target repo (defaults to the org Hub: ${HUB_REPO})`).option("--force", "file a new issue even when an open report looks like a duplicate").option("--json", "machine-readable output (already the default \u2014 report always prints JSON; #682)").action(async (o) => {
8531
8649
  let body;
8532
8650
  let priority;
8533
8651
  let args;
@@ -8550,7 +8668,7 @@ program2.command("report").description("file a friction report on the Hub board
8550
8668
  if (!o.force) {
8551
8669
  let openReports = [];
8552
8670
  try {
8553
- openReports = await ghJson3([
8671
+ openReports = await ghJson([
8554
8672
  "issue",
8555
8673
  "list",
8556
8674
  "--repo",
@@ -8569,7 +8687,7 @@ program2.command("report").description("file a friction report on the Hub board
8569
8687
  const dup = findDuplicateReport({ title: o.title, body }, openReports);
8570
8688
  if (dup) {
8571
8689
  try {
8572
- await execFileP3("gh", ["issue", "comment", String(dup.number), "--repo", targetRepo2, "--body", buildDupComment(dup.number, body, sourceRepo)], { timeout: 1e4 });
8690
+ await execFileP4("gh", ["issue", "comment", String(dup.number), "--repo", targetRepo2, "--body", buildDupComment(dup.number, body, sourceRepo)], { timeout: 1e4 });
8573
8691
  } catch (e) {
8574
8692
  const err = e;
8575
8693
  return fail(`report: duplicate of #${dup.number} (${dup.url}) but the +1 comment failed: ${(err.stderr || err.message || String(e)).trim()}`);
@@ -8578,7 +8696,7 @@ program2.command("report").description("file a friction report on the Hub board
8578
8696
  }
8579
8697
  }
8580
8698
  try {
8581
- await execFileP3("gh", ["label", "create", REPORT_LABEL, "--color", "ededed", "--repo", targetRepo2], { timeout: 1e4 });
8699
+ await execFileP4("gh", ["label", "create", REPORT_LABEL, "--color", "ededed", "--repo", targetRepo2], { timeout: 1e4 });
8582
8700
  } catch {
8583
8701
  }
8584
8702
  const created = await ghCreate(args);
@@ -8593,7 +8711,7 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
8593
8711
  async function remoteBranchExists(branch) {
8594
8712
  if (!branch) return void 0;
8595
8713
  try {
8596
- const { stdout } = await execFileP3("git", ["ls-remote", "--heads", "origin", branch], { timeout: GIT_TIMEOUT_MS });
8714
+ const { stdout } = await execFileP4("git", ["ls-remote", "--heads", "origin", branch], { timeout: GIT_TIMEOUT_MS });
8597
8715
  return stdout.trim().length > 0;
8598
8716
  } catch {
8599
8717
  return void 0;
@@ -8602,15 +8720,15 @@ async function remoteBranchExists(branch) {
8602
8720
  pr.command("merge <number>").description("merge a PR (squash by default) and clean up its branch + worktree \u2014 no leftover local branch").option("--squash", "squash merge (default)").option("--merge", "create a merge commit").option("--rebase", "rebase merge").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (number, o) => {
8603
8721
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
8604
8722
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
8605
- const headRef = (await execFileP3("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
8606
- const startingPath = (await execFileP3("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
8723
+ const headRef = (await execFileP4("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
8724
+ const startingPath = (await execFileP4("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
8607
8725
  const beforeWorktrees = parseWorktreePorcelain(
8608
- (await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
8726
+ (await execFileP4("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
8609
8727
  );
8610
8728
  const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
8611
8729
  let remoteDeleteAttempted = false;
8612
8730
  let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
8613
- await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
8731
+ await execFileP4("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
8614
8732
  const message = String(e.message || "");
8615
8733
  if (/already been merged/i.test(message)) {
8616
8734
  remoteNotAttemptedReason = "pr-already-merged";
@@ -8633,7 +8751,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
8633
8751
  } : await cleanupPrMergeLocalBranch(headRef, {
8634
8752
  beforeWorktrees,
8635
8753
  startingPath,
8636
- execGit: async (args) => (await execFileP3("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
8754
+ execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
8637
8755
  });
8638
8756
  console.log(JSON.stringify({
8639
8757
  merged: number,
@@ -8758,7 +8876,7 @@ function stageKeepAlive() {
8758
8876
  program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block via the atomic ORG#config.portCursor allocator (committed-file fallback)").option("--json", "machine-readable output").action(async (repo, o) => {
8759
8877
  const path2 = (0, import_node_path5.join)(process.cwd(), "infra", "port-ranges.json");
8760
8878
  const allocate = async (seed) => {
8761
- const { stdout } = await execFileP3("node", [(0, import_node_path5.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
8879
+ const { stdout } = await execFileP4("node", [(0, import_node_path5.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
8762
8880
  const parsed = JSON.parse(stdout);
8763
8881
  if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
8764
8882
  return parsed.range;
@@ -8864,8 +8982,8 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
8864
8982
  if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
8865
8983
  try {
8866
8984
  const result = await runTrainApply(commandName, {
8867
- run: async (file, args) => (await execFileP3(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
8868
- runSelf: async (args) => (await execFileP3(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
8985
+ run: async (file, args) => (await execFileP4(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
8986
+ runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
8869
8987
  trainAuthority: async (repo) => {
8870
8988
  const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
8871
8989
  return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
@@ -8896,7 +9014,7 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
8896
9014
  const slug = (repo.includes("/") ? repo.split("/")[1] : repo).toLowerCase();
8897
9015
  const meta = await fetchProjectBySlug(slug, { baseUrl: cfg.sagaApiUrl, token: githubToken });
8898
9016
  const report = await verifyBootstrap(repo, o.class, {
8899
- gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
9017
+ client: defaultGitHubClient(),
8900
9018
  projectMeta: meta,
8901
9019
  readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs5.existsSync)(path2) ? (0, import_node_fs5.readFileSync)(path2, "utf8") : null,
8902
9020
  // requiredGcpApis is stored as an array by a JSON write, but `project set --var KEY=VALUE` stores a raw
@@ -8909,7 +9027,7 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
8909
9027
  })(),
8910
9028
  listEnabledGcpApis: async (gcpProject) => {
8911
9029
  try {
8912
- const { stdout } = await execFileP3(
9030
+ const { stdout } = await execFileP4(
8913
9031
  "gcloud",
8914
9032
  ["services", "list", "--enabled", "--project", gcpProject, "--format", "value(config.name)"],
8915
9033
  { timeout: 3e4 }
@@ -8943,7 +9061,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
8943
9061
  const manifest = loadBootstrapSeeds((0, import_node_fs5.readFileSync)(manifestPath, "utf8"));
8944
9062
  const baseBranch = o.class === "content" ? "main" : "development";
8945
9063
  const slug = parsedRepo.slug;
8946
- const gh = async (args) => execFileP3("gh", args, { timeout: 2e4 });
9064
+ const gh = async (args) => execFileP4("gh", args, { timeout: 2e4 });
8947
9065
  const readFile2 = (p) => (0, import_node_fs5.existsSync)(p) ? (0, import_node_fs5.readFileSync)(p, "utf8") : null;
8948
9066
  const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
8949
9067
  const vars = {};
@@ -9060,7 +9178,7 @@ access.command("role [repo]").description("D14 train authority for a repo (serve
9060
9178
  });
9061
9179
  access.command("audit").description("audit collaborator roles + train-branch push allowlists vs the locked state; read-only, emits gh remediation, never applies").option("--json", "machine-readable output").option("--repo <owner/repo>", "audit a single repo instead of the whole org").option("--class <class>", "repo class for --repo (deployable | content)", "deployable").action(async () => {
9062
9180
  const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
9063
- const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
9181
+ const deps = { client: defaultGitHubClient() };
9064
9182
  let targets;
9065
9183
  const cfg = await loadConfig();
9066
9184
  const registryProjects = await fetchProjectsList(registryClientDeps(cfg));
@@ -9208,17 +9326,17 @@ async function runDoctor(opts, io = consoleIo) {
9208
9326
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
9209
9327
  const [login, pathProbe, releasedVersion, cfg, callerArn, cloneProbe] = await Promise.all([
9210
9328
  githubLogin(),
9211
- execFileP3(isWin ? "where" : "which", ["mmi-cli"]).then(() => true).catch(() => false),
9329
+ execFileP4(isWin ? "where" : "which", ["mmi-cli"]).then(() => true).catch(() => false),
9212
9330
  fetchReleasedVersion(),
9213
9331
  loadConfig(),
9214
9332
  awsCallerArn(),
9215
- execFileP3("git", ["config", "--global", "--get-all", REWRITE_KEY]).then(({ stdout }) => stdout.split("\n").some((l) => l.trim() === "git@github.com:")).catch(() => false)
9333
+ execFileP4("git", ["config", "--global", "--get-all", REWRITE_KEY]).then(({ stdout }) => stdout.split("\n").some((l) => l.trim() === "git@github.com:")).catch(() => false)
9216
9334
  // unset → repair below
9217
9335
  ]);
9218
9336
  let ghInstalled = true;
9219
9337
  if (!login) {
9220
9338
  try {
9221
- await execFileP3("gh", ["--version"]);
9339
+ await execFileP4("gh", ["--version"]);
9222
9340
  } catch {
9223
9341
  ghInstalled = false;
9224
9342
  }
@@ -9231,6 +9349,7 @@ async function runDoctor(opts, io = consoleIo) {
9231
9349
  }
9232
9350
  checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
9233
9351
  const surface = detectSurface(process.env);
9352
+ const reloadHint = reloadAction(surface);
9234
9353
  let versionReport = buildVersionLagReport({
9235
9354
  currentVersion: resolveVersion(),
9236
9355
  repoVersion: readRepoVersion(),
@@ -9244,7 +9363,7 @@ async function runDoctor(opts, io = consoleIo) {
9244
9363
  let cloneOk = cloneProbe;
9245
9364
  if (!cloneOk && !opts.banner && !opts.json) {
9246
9365
  try {
9247
- await execFileP3("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
9366
+ await execFileP4("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
9248
9367
  cloneOk = true;
9249
9368
  io.err(" \u21BB repaired: git insteadOf git@github.com \u2192 https (plugin clone over HTTPS)");
9250
9369
  } catch {
@@ -9257,12 +9376,13 @@ async function runDoctor(opts, io = consoleIo) {
9257
9376
  settings: readClaudeSettings(),
9258
9377
  installed,
9259
9378
  projectPath: process.cwd(),
9260
- mirrorFrom: existingMirrorRecord(installed)
9379
+ mirrorFrom: existingMirrorRecord(installed),
9380
+ surface
9261
9381
  });
9262
- if (!pluginCheck.ok && pluginCheck.recordToInsert && !opts.json && !opts.banner) {
9382
+ if (!pluginCheck.ok && pluginCheck.recordToInsert && !opts.json) {
9263
9383
  if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
9264
9384
  pluginCheck = { ...pluginCheck, ok: true };
9265
- io.err(" \u21BB repaired: registered mmi@mmi project install record \u2014 run /reload-plugins to load it this session");
9385
+ io.err(` \u21BB repaired: registered mmi@mmi project install record \u2014 ${reloadHint} to load MMI commands`);
9266
9386
  }
9267
9387
  }
9268
9388
  checks.push(pluginCheck);
@@ -9275,10 +9395,10 @@ async function runDoctor(opts, io = consoleIo) {
9275
9395
  }
9276
9396
  checks.push(gitignoreCheck);
9277
9397
  let driftCheck = buildPluginConfigDriftCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), installed, surface });
9278
- if (!driftCheck.ok && driftCheck.recordsToWrite && !opts.json && !opts.banner) {
9398
+ if (!driftCheck.ok && driftCheck.recordsToWrite && !opts.json) {
9279
9399
  if (backupAndWriteInstalledPlugins(driftCheck.recordsToWrite, driftCheck.pluginId)) {
9280
9400
  driftCheck = { ...driftCheck, ok: true };
9281
- io.err(" \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 run /reload-plugins");
9401
+ io.err(` \u21BB repaired: collapsed mmi@mmi to one user-scope entry (backup at installed_plugins.json.bak) \u2014 ${reloadHint} to load MMI commands`);
9282
9402
  }
9283
9403
  }
9284
9404
  checks.push(driftCheck);
@@ -9298,8 +9418,7 @@ async function runDoctor(opts, io = consoleIo) {
9298
9418
  });
9299
9419
  installedVersionCheck = healed;
9300
9420
  if (healed.ok) {
9301
- const reload = surface === "claude-vscode" ? "reopen the VS Code workspace" : "restart Claude Code (or run /reload-plugins)";
9302
- io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reload} to load the new commands`);
9421
+ io.err(` \u21BB updated MMI plugin \u2192 ${releasedVersion ?? "latest"} via claude plugin \u2014 ${reloadAction(surface)} to load the new commands`);
9303
9422
  }
9304
9423
  }
9305
9424
  }
@@ -9311,12 +9430,12 @@ async function runDoctor(opts, io = consoleIo) {
9311
9430
  releasedVersion,
9312
9431
  installedVersions: installedPluginVersions(installed)
9313
9432
  });
9314
- if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan && !opts.json && !opts.banner) {
9433
+ if (!cacheCleanupCheck.ok && cacheCleanupCheck.quarantinePlan && !opts.json) {
9315
9434
  const moved = quarantinePluginCacheDirs(cacheCleanupCheck.quarantinePlan);
9316
9435
  if (moved > 0) {
9317
9436
  const surfaces = [...new Set(cacheCleanupCheck.leftovers?.map((entry) => entry.surface) ?? [])].join("/");
9318
9437
  const names = cacheCleanupCheck.leftovers?.map((entry) => entry.name).join(", ");
9319
- io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 reload affected sessions`);
9438
+ io.err(` \u21BB quarantined ${moved} stale MMI plugin cache dir(s) for ${surfaces || "agent surfaces"}: ${names} \u2014 ${reloadHint} to load MMI commands`);
9320
9439
  }
9321
9440
  cacheCleanupCheck = buildMmiPluginCacheCleanupCheck({
9322
9441
  isOrgRepo: Boolean(cfg.sagaApiUrl),