@mutmutco/cli 2.10.1 → 2.12.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 +834 -453
  2. package/package.json +1 -1
package/dist/index.cjs CHANGED
@@ -3392,9 +3392,9 @@ function useColor() {
3392
3392
  var program = new Command();
3393
3393
 
3394
3394
  // src/index.ts
3395
- var import_promises = require("node:fs/promises");
3395
+ var import_promises2 = require("node:fs/promises");
3396
3396
  var import_node_fs5 = require("node:fs");
3397
- var import_node_crypto = require("node:crypto");
3397
+ var import_node_crypto2 = require("node:crypto");
3398
3398
 
3399
3399
  // src/rules-sync.ts
3400
3400
  function normalizeEol(s) {
@@ -3468,6 +3468,25 @@ async function runSessionStart(parallel, sequential, io) {
3468
3468
  for (const lines of buffered) flush(lines, io);
3469
3469
  for (const step of sequential) flush(await runBufferedStep(step), io);
3470
3470
  }
3471
+ function buildSessionStartPlan(verbs) {
3472
+ return {
3473
+ parallel: [
3474
+ { name: "rules sync", run: verbs.rulesSync },
3475
+ { name: "saga show", run: verbs.sagaShow },
3476
+ { name: "saga health", run: verbs.sagaHealth }
3477
+ ],
3478
+ sequential: [{ name: "doctor", run: verbs.doctor }]
3479
+ };
3480
+ }
3481
+ function spawnDetachedSelf(args, deps) {
3482
+ try {
3483
+ deps.spawn(deps.execPath, [deps.scriptPath, ...args], { detached: true, stdio: "ignore", windowsHide: true }).unref();
3484
+ } catch {
3485
+ }
3486
+ }
3487
+ function northstarPointer() {
3488
+ return "North Stars: run `mmi-cli northstar relevant` to load plans relevant to your task (`northstar list` for all).";
3489
+ }
3471
3490
 
3472
3491
  // src/saga-capture.ts
3473
3492
  function parseHookInput(stdin) {
@@ -3480,10 +3499,10 @@ function parseHookInput(stdin) {
3480
3499
  }
3481
3500
 
3482
3501
  // src/index.ts
3483
- var import_node_child_process5 = require("node:child_process");
3484
- var import_node_util5 = require("node:util");
3485
- var import_node_path5 = require("node:path");
3486
- var import_node_os = require("node:os");
3502
+ var import_node_child_process6 = require("node:child_process");
3503
+ var import_node_util6 = require("node:util");
3504
+ var import_node_path6 = require("node:path");
3505
+ var import_node_os2 = require("node:os");
3487
3506
 
3488
3507
  // src/saga-head-maintainer.ts
3489
3508
  var import_node_child_process2 = require("node:child_process");
@@ -3495,7 +3514,7 @@ var HEAD_PROMPT_ACTION_LIMIT = 50;
3495
3514
  var HEAD_PROMPT_DECISION_LIMIT = 80;
3496
3515
  function resolveEngine(platform, custom) {
3497
3516
  if (custom) return { cmd: custom, args: [], shell: true };
3498
- return { cmd: "claude", args: ["-p"], shell: platform === "win32" };
3517
+ return { cmd: "claude", args: ["-p", "--no-session-persistence"], shell: platform === "win32" };
3499
3518
  }
3500
3519
  function headTsPath(key) {
3501
3520
  const safe = (s) => s.replace(/[^A-Za-z0-9._-]/g, "_");
@@ -3608,6 +3627,12 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
3608
3627
  });
3609
3628
  }
3610
3629
 
3630
+ // src/gh-create.ts
3631
+ var import_promises = require("node:fs/promises");
3632
+ var import_node_os = require("node:os");
3633
+ var import_node_path3 = require("node:path");
3634
+ var import_node_crypto = require("node:crypto");
3635
+
3611
3636
  // src/board-priority.ts
3612
3637
  var BOARD_PRIORITY_NAMES = ["Urgent", "High", "Medium", "Low"];
3613
3638
  var CLI_PRIORITIES = ["urgent", "high", "medium", "low"];
@@ -3646,6 +3671,11 @@ function recoverPriorityFromEvents(events) {
3646
3671
 
3647
3672
  // src/gh-create.ts
3648
3673
  var ISSUE_TYPES = ["bug", "feature", "task"];
3674
+ var GH_MUTATION_TIMEOUT_MS = 12e4;
3675
+ function timeoutKillNote(err, timeoutMs) {
3676
+ if (typeof err !== "object" || err === null || !err.killed) return void 0;
3677
+ return `killed at the ${timeoutMs}ms timeout \u2014 the write may have completed server-side; verify before retrying`;
3678
+ }
3649
3679
  function normalizePriority(priority) {
3650
3680
  const p = priority.trim().toLowerCase();
3651
3681
  if (!CLI_PRIORITIES.includes(p)) {
@@ -3673,6 +3703,24 @@ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
3673
3703
  for (const label of labels ?? []) args.push("--label", label);
3674
3704
  return args;
3675
3705
  }
3706
+ async function bodyArgsViaFile(args, deps = {}) {
3707
+ const i = args.indexOf("--body");
3708
+ if (i === -1 || i + 1 >= args.length) return { args, cleanup: async () => {
3709
+ } };
3710
+ const write = deps.write ?? import_promises.writeFile;
3711
+ const remove = deps.remove ?? import_promises.unlink;
3712
+ const file = (0, import_node_path3.join)(deps.dir ?? (0, import_node_os.tmpdir)(), `mmi-gh-body-${process.pid}-${(0, import_node_crypto.randomBytes)(4).toString("hex")}.md`);
3713
+ await write(file, args[i + 1], "utf8");
3714
+ return {
3715
+ args: [...args.slice(0, i), "--body-file", file, ...args.slice(i + 2)],
3716
+ cleanup: async () => {
3717
+ try {
3718
+ await remove(file);
3719
+ } catch {
3720
+ }
3721
+ }
3722
+ };
3723
+ }
3676
3724
  function buildAddToProjectArgs(projectId, contentId) {
3677
3725
  if (!projectId) throw new Error("addToProject: projectId is required");
3678
3726
  if (!contentId) throw new Error("addToProject: contentId is required");
@@ -3811,6 +3859,32 @@ function resumeCue() {
3811
3859
  return '> STATUS/RESUME CUE \u2014 For any status, resume, or "where do I stand" report: read THIS saga HEAD first (`mmi-cli saga show`), then reconcile its NEXT / LAST 5 / DECISIONS against the live board + git/gh before reporting. Do not rebuild the picture from board/issues/memory while skipping the HEAD. PRECEDENCE: the HEAD is prior-session belief and MAY BE SUPERSEDED \u2014 the current live user/master instruction WINS over any conflicting HEAD anchor, NEXT, or checklist; follow the live instruction and treat the stale HEAD item as superseded.';
3812
3860
  }
3813
3861
 
3862
+ // src/fetch-retry.ts
3863
+ async function fetchWithRetry(fetchImpl, url, init, opts = {}) {
3864
+ const attempts = opts.attempts ?? 3;
3865
+ const baseDelayMs = opts.baseDelayMs ?? 250;
3866
+ const retryOn = opts.retryOn ?? ((res) => res.status >= 500);
3867
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
3868
+ let lastErr;
3869
+ for (let i = 0; i < attempts; i++) {
3870
+ const isLast = i === attempts - 1;
3871
+ const attemptInit = opts.timeoutMs ? { ...init, signal: AbortSignal.timeout(opts.timeoutMs) } : init;
3872
+ try {
3873
+ const res = await fetchImpl(url, attemptInit);
3874
+ if (!isLast && retryOn(res)) {
3875
+ await sleep(baseDelayMs * 2 ** i);
3876
+ continue;
3877
+ }
3878
+ return res;
3879
+ } catch (e) {
3880
+ lastErr = e;
3881
+ if (isLast) throw e;
3882
+ await sleep(baseDelayMs * 2 ** i);
3883
+ }
3884
+ }
3885
+ throw lastErr;
3886
+ }
3887
+
3814
3888
  // src/saga-note.ts
3815
3889
  var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
3816
3890
  var ROUTE_LEVEL_403 = "saga API route-level 403 from GitHubAuthorizer cache/policy";
@@ -4025,32 +4099,153 @@ ${buildReportBody(body, sourceRepo)}`;
4025
4099
  }
4026
4100
 
4027
4101
  // src/board.ts
4102
+ var import_node_child_process4 = require("node:child_process");
4103
+ var import_node_util4 = require("node:util");
4104
+
4105
+ // src/github-client.ts
4028
4106
  var import_node_child_process3 = require("node:child_process");
4029
4107
  var import_node_util3 = require("node:util");
4108
+ var rawExecFileP = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
4109
+ var execFileP = (file, args, options = {}) => rawExecFileP(file, args, { encoding: "utf8", windowsHide: true, timeout: 1e4, killSignal: "SIGTERM", ...options });
4110
+ var cachedGhCliToken;
4111
+ async function githubToken() {
4112
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
4113
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
4114
+ cachedGhCliToken ??= execFileP("gh", ["auth", "token"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
4115
+ return cachedGhCliToken;
4116
+ }
4117
+ var GitHubApiError = class extends Error {
4118
+ status;
4119
+ stderr;
4120
+ graphqlErrors;
4121
+ constructor(message, opts = {}) {
4122
+ super(message);
4123
+ this.name = "GitHubApiError";
4124
+ this.status = opts.status ?? 0;
4125
+ this.stderr = message;
4126
+ this.graphqlErrors = opts.graphqlErrors;
4127
+ }
4128
+ };
4129
+ var DEFAULT_TIMEOUT_MS = 2e4;
4130
+ function joinUrl(base, path2) {
4131
+ if (path2.startsWith("http://") || path2.startsWith("https://")) return path2;
4132
+ return `${base.replace(/\/+$/, "")}/${path2.replace(/^\/+/, "")}`;
4133
+ }
4134
+ function withPerPage(url) {
4135
+ if (/[?&]per_page=/.test(url)) return url;
4136
+ return url.includes("?") ? `${url}&per_page=100` : `${url}?per_page=100`;
4137
+ }
4138
+ function nextLink(linkHeader) {
4139
+ if (!linkHeader) return void 0;
4140
+ for (const part of linkHeader.split(",")) {
4141
+ const match = part.match(/<([^>]+)>\s*;\s*rel="next"/);
4142
+ if (match) return match[1];
4143
+ }
4144
+ return void 0;
4145
+ }
4146
+ async function errorFromResponse(res) {
4147
+ let detail = "";
4148
+ try {
4149
+ const text = await res.text();
4150
+ try {
4151
+ const parsed = JSON.parse(text);
4152
+ detail = parsed.message ?? text;
4153
+ } catch {
4154
+ detail = text;
4155
+ }
4156
+ } catch {
4157
+ detail = "";
4158
+ }
4159
+ const suffix = detail ? `: ${detail.trim()}` : "";
4160
+ return new GitHubApiError(`HTTP ${res.status}${suffix} (${res.url})`, { status: res.status });
4161
+ }
4162
+ function createGitHubClient(options = {}) {
4163
+ const baseUrl = options.baseUrl ?? process.env.GITHUB_API_URL ?? "https://api.github.com";
4164
+ const token = options.token ?? githubToken;
4165
+ const defaultTimeoutMs = options.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
4166
+ const fetchImpl = options.fetchImpl ?? fetch;
4167
+ async function request(method, url, init = {}) {
4168
+ const t = await token();
4169
+ const headers = {
4170
+ Accept: "application/vnd.github+json",
4171
+ "X-GitHub-Api-Version": "2022-11-28",
4172
+ "User-Agent": "mmi-cli",
4173
+ ...t ? { Authorization: `Bearer ${t}` } : {},
4174
+ ...init.body !== void 0 ? { "Content-Type": "application/json" } : {},
4175
+ ...init.headers
4176
+ };
4177
+ const res = await fetchImpl(url, {
4178
+ method,
4179
+ headers,
4180
+ body: init.body !== void 0 ? JSON.stringify(init.body) : void 0,
4181
+ signal: AbortSignal.timeout(init.timeoutMs ?? defaultTimeoutMs)
4182
+ });
4183
+ if (!res.ok) throw await errorFromResponse(res);
4184
+ return res;
4185
+ }
4186
+ async function parseJson(res) {
4187
+ if (res.status === 204) return void 0;
4188
+ const text = await res.text();
4189
+ if (!text) return void 0;
4190
+ return JSON.parse(text);
4191
+ }
4192
+ return {
4193
+ async rest(method, path2, init) {
4194
+ const res = await request(method, joinUrl(baseUrl, path2), init);
4195
+ return parseJson(res);
4196
+ },
4197
+ async restPaginate(path2, init) {
4198
+ const items = [];
4199
+ let url = withPerPage(joinUrl(baseUrl, path2));
4200
+ while (url) {
4201
+ const res = await request("GET", url, init);
4202
+ const page = await parseJson(res);
4203
+ if (Array.isArray(page)) items.push(...page);
4204
+ url = nextLink(res.headers.get("link"));
4205
+ }
4206
+ return items;
4207
+ },
4208
+ async graphql(query, variables, init) {
4209
+ const res = await request("POST", joinUrl(baseUrl, "graphql"), {
4210
+ ...init,
4211
+ body: { query, ...variables ? { variables } : {} }
4212
+ });
4213
+ const parsed = await parseJson(res);
4214
+ if (parsed?.errors?.length) {
4215
+ const message = parsed.errors.map((e) => e.message ?? e.type ?? "unknown GraphQL error").join("; ");
4216
+ throw new GitHubApiError(`GraphQL: ${message}`, { status: 200, graphqlErrors: parsed.errors });
4217
+ }
4218
+ if (!parsed || parsed.data === void 0 || parsed.data === null) {
4219
+ throw new GitHubApiError("GraphQL response did not include data", { status: 200 });
4220
+ }
4221
+ return parsed.data;
4222
+ }
4223
+ };
4224
+ }
4225
+ var cachedDefaultClient;
4226
+ function defaultGitHubClient() {
4227
+ cachedDefaultClient ??= createGitHubClient();
4228
+ return cachedDefaultClient;
4229
+ }
4030
4230
 
4031
4231
  // ../infra/board-vocab.mjs
4032
4232
  var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
4033
4233
 
4034
4234
  // src/board.ts
4035
- var rawExecFileP = (0, import_node_util3.promisify)(import_node_child_process3.execFile);
4036
- var BOARD_GH_TIMEOUT_MS = 2e4;
4235
+ var rawExecFileP2 = (0, import_node_util4.promisify)(import_node_child_process4.execFile);
4037
4236
  var BOARD_GIT_TIMEOUT_MS = 1e4;
4038
4237
  var WRITE_PROBE_CONCURRENCY = 8;
4039
- var execFileP = (file, args, options = {}) => (
4238
+ var execFileP2 = (file, args, options = {}) => (
4040
4239
  // encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
4041
4240
  // 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 })
4241
+ rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, timeout: BOARD_GIT_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
4043
4242
  );
4044
4243
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
4045
4244
  var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
4046
4245
  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
4246
  var defaultGit = async (args) => {
4052
4247
  try {
4053
- const { stdout } = await execFileP("git", args, { timeout: BOARD_GIT_TIMEOUT_MS });
4248
+ const { stdout } = await execFileP2("git", args, { timeout: BOARD_GIT_TIMEOUT_MS });
4054
4249
  return stdout.trim();
4055
4250
  } catch {
4056
4251
  return "";
@@ -4104,36 +4299,52 @@ query($owner: String!, $number: Int!, $after: String) {
4104
4299
  }
4105
4300
  }
4106
4301
  }`;
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 } }
4302
+ var SINGLE_ITEM_CONTENT_FRAGMENT = `
4303
+ id
4304
+ number
4305
+ title
4306
+ url
4307
+ state
4308
+ repository { nameWithOwner }
4309
+ labels(first: 10) { nodes { name } }
4310
+ assignees(first: 10) { nodes { login } }
4311
+ projectItems(first: 20) {
4312
+ nodes {
4313
+ id
4314
+ project { id title }
4315
+ fieldValues(first: 8) {
4316
+ nodes {
4317
+ ... on ProjectV2ItemFieldSingleSelectValue {
4318
+ name
4319
+ optionId
4320
+ field { ... on ProjectV2SingleSelectField { id name } }
4321
+ }
4129
4322
  }
4130
4323
  }
4131
4324
  }
4132
- }
4325
+ }`;
4326
+ var ISSUE_PROJECT_ITEM_QUERY = `
4327
+ query($repoOwner: String!, $repoName: String!, $number: Int!) {
4328
+ viewer { login }
4329
+ repository(owner: $repoOwner, name: $repoName) {
4330
+ issueOrPullRequest(number: $number) {
4331
+ __typename
4332
+ ... on Issue {${SINGLE_ITEM_CONTENT_FRAGMENT}
4333
+ }
4334
+ ... on PullRequest {${SINGLE_ITEM_CONTENT_FRAGMENT}
4133
4335
  }
4134
4336
  }
4135
4337
  }
4136
4338
  }`;
4339
+ var UPDATE_ITEM_FIELD_MUTATION = `
4340
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
4341
+ updateProjectV2ItemFieldValue(input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId, value: { singleSelectOptionId: $optionId } }) {
4342
+ projectV2Item { id }
4343
+ }
4344
+ }`;
4345
+ async function updateItemSingleSelect(client, projectId, itemId, fieldId, optionId) {
4346
+ await client.graphql(UPDATE_ITEM_FIELD_MUTATION, { projectId, itemId, fieldId, optionId });
4347
+ }
4137
4348
  function resolveBoardConfig(cfg) {
4138
4349
  const missing = [];
4139
4350
  if (!cfg.projectOwner) missing.push("projectOwner");
@@ -4256,7 +4467,7 @@ function renderBoardReport(report) {
4256
4467
  return lines.join("\n");
4257
4468
  }
4258
4469
  async function collectBoardItems(cfg, options, deps) {
4259
- const gh = deps.gh ?? defaultGh;
4470
+ const client = deps.client ?? defaultGitHubClient();
4260
4471
  const git = deps.git ?? defaultGit;
4261
4472
  const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
4262
4473
  if (!currentRepo) throw new Error("could not resolve current GitHub repo; pass --repo owner/repo");
@@ -4269,7 +4480,7 @@ async function collectBoardItems(cfg, options, deps) {
4269
4480
  let partial = false;
4270
4481
  do {
4271
4482
  try {
4272
- const page = await fetchProjectPage(gh, cfg, after);
4483
+ const page = await fetchProjectPage(client, cfg, after);
4273
4484
  viewer ||= page.viewer.login;
4274
4485
  const project2 = page.organization?.projectV2;
4275
4486
  if (!project2) throw new Error(`project ${cfg.projectOwner}#${cfg.projectNumber} not found`);
@@ -4287,19 +4498,15 @@ async function collectBoardItems(cfg, options, deps) {
4287
4498
  } while (after);
4288
4499
  return { items: nodesToItems(nodes, warnings, cfg), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
4289
4500
  }
4290
- async function repoCanPush(repo, gh) {
4501
+ async function repoCanPush(repo, client) {
4291
4502
  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;
4503
+ const parsed = await client.rest("GET", `repos/${repo}`);
4504
+ return typeof parsed?.permissions?.push === "boolean" ? parsed.permissions.push : void 0;
4298
4505
  } catch {
4299
4506
  return void 0;
4300
4507
  }
4301
4508
  }
4302
- async function resolveWritableReposForClaimables(items, gh, allowPartial) {
4509
+ async function resolveWritableReposForClaimables(items, client, allowPartial) {
4303
4510
  const candidateRepos = [...new Set(items.filter((item) => item.status === "Todo" && item.assignees.length === 0).map((item) => item.repository))];
4304
4511
  const repos = /* @__PURE__ */ new Set();
4305
4512
  const warnings = [];
@@ -4308,7 +4515,7 @@ async function resolveWritableReposForClaimables(items, gh, allowPartial) {
4308
4515
  const worker = async () => {
4309
4516
  while (next < candidateRepos.length) {
4310
4517
  const repo = candidateRepos[next++];
4311
- const canPush = await repoCanPush(repo, gh);
4518
+ const canPush = await repoCanPush(repo, client);
4312
4519
  if (canPush === true) {
4313
4520
  repos.add(repo.toLowerCase());
4314
4521
  continue;
@@ -4326,9 +4533,9 @@ async function resolveWritableReposForClaimables(items, gh, allowPartial) {
4326
4533
  }
4327
4534
  async function readBoard(options, deps = {}) {
4328
4535
  const cfg = resolveBoardConfig(options.config);
4329
- const gh = deps.gh ?? defaultGh;
4536
+ const client = deps.client ?? defaultGitHubClient();
4330
4537
  const collected = await collectBoardItems(cfg, options, deps);
4331
- const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
4538
+ const writable = await resolveWritableReposForClaimables(collected.items, client, options.allowPartial ?? false);
4332
4539
  collected.warnings.push(...writable.warnings);
4333
4540
  collected.partial = collected.partial || writable.partial;
4334
4541
  const groups = partitionBoardItems(collected.items, collected.viewer, collected.repo, writable.repos);
@@ -4341,7 +4548,7 @@ async function readBoard(options, deps = {}) {
4341
4548
  partial: collected.partial
4342
4549
  };
4343
4550
  if (options.includeBundleDetails) {
4344
- await attachBundleDetails(report, gh, options.allowPartial ?? false);
4551
+ await attachBundleDetails(report, client, options.allowPartial ?? false);
4345
4552
  }
4346
4553
  return report;
4347
4554
  }
@@ -4352,67 +4559,50 @@ function findBoardItem(items, selector) {
4352
4559
  if (!found) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
4353
4560
  return found;
4354
4561
  }
4562
+ async function resolveCurrentRepo(options, deps) {
4563
+ const git = deps.git ?? defaultGit;
4564
+ const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
4565
+ if (!currentRepo) throw new Error("could not resolve current GitHub repo; pass --repo owner/repo");
4566
+ return currentRepo;
4567
+ }
4355
4568
  async function moveBoardItem(options, deps = {}) {
4356
4569
  if (!BOARD_STATUSES.includes(options.status)) {
4357
4570
  throw new Error(`unknown status '${options.status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
4358
4571
  }
4359
4572
  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);
4573
+ const client = deps.client ?? defaultGitHubClient();
4574
+ const currentRepo = await resolveCurrentRepo(options, deps);
4575
+ const selector = parseIssueSelector(options.selector, currentRepo);
4576
+ const lookup = await fetchIssueProjectItem(client, cfg, selector);
4577
+ const item = lookup.item;
4578
+ if (!item) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
4364
4579
  if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
4365
4580
  const optionId = cfg.statusOptions[options.status];
4366
4581
  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
- ]);
4582
+ await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.statusFieldId, optionId);
4379
4583
  } catch (e) {
4380
4584
  const warning = `partial move: ${item.ref} status was not changed to ${options.status} (${ghError(e)})`;
4381
4585
  if (!options.allowPartial) throw new Error(warning);
4382
- return { item, viewer: collected.viewer, repo: collected.repo, status: item.status, partial: true, warning };
4586
+ return { item, viewer: lookup.viewer, repo: currentRepo, status: item.status, partial: true, warning };
4383
4587
  }
4384
4588
  return {
4385
4589
  item: { ...item, status: options.status, statusOptionId: optionId },
4386
- viewer: collected.viewer,
4387
- repo: collected.repo,
4590
+ viewer: lookup.viewer,
4591
+ repo: currentRepo,
4388
4592
  status: options.status,
4389
4593
  partial: false
4390
4594
  };
4391
4595
  }
4392
4596
  async function showBoardItem(options, deps = {}) {
4393
4597
  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
- }
4598
+ const client = deps.client ?? defaultGitHubClient();
4599
+ const currentRepo = await resolveCurrentRepo(options, deps);
4600
+ const selector = parseIssueSelector(options.selector, currentRepo);
4601
+ const { item } = await fetchIssueProjectItem(client, cfg, selector);
4602
+ if (!item) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
4405
4603
  if (item.contentType === "Issue") {
4406
4604
  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
- };
4605
+ item.details = await fetchIssueDetails(client, item.repository, item.number);
4416
4606
  } catch (e) {
4417
4607
  if (!options.allowPartial) throw new Error(`detail read failed: ${item.ref}: ${ghError(e)}`);
4418
4608
  }
@@ -4421,17 +4611,17 @@ async function showBoardItem(options, deps = {}) {
4421
4611
  }
4422
4612
  async function claimBoardIssue(options, deps = {}) {
4423
4613
  const cfg = resolveBoardConfig(options.config);
4424
- const gh = deps.gh ?? defaultGh;
4614
+ const client = deps.client ?? defaultGitHubClient();
4425
4615
  const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
4426
4616
  const selector = parseIssueSelector(options.selector, collected.repo);
4427
4617
  try {
4428
4618
  findBoardItem(collected.items, selector);
4429
4619
  } catch (e) {
4430
- const fallback = await fetchIssueProjectItem(gh, cfg, selector);
4620
+ const fallback = (await fetchIssueProjectItem(client, cfg, selector)).item;
4431
4621
  if (!fallback) throw e;
4432
4622
  collected.items.push(fallback);
4433
4623
  }
4434
- const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
4624
+ const writable = await resolveWritableReposForClaimables(collected.items, client, options.allowPartial ?? false);
4435
4625
  collected.warnings.push(...writable.warnings);
4436
4626
  collected.partial = collected.partial || writable.partial;
4437
4627
  const report = {
@@ -4448,7 +4638,7 @@ async function claimBoardIssue(options, deps = {}) {
4448
4638
  }
4449
4639
  let item = findClaimableItem(report, selector);
4450
4640
  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 });
4641
+ const fresh = (await fetchIssueProjectItem(client, cfg, { repo: item.repository, number: item.number })).item;
4452
4642
  if (!fresh) throw new Error(`${item.ref} is not on this project board`);
4453
4643
  if (fresh.status !== "Todo") throw new Error(`${item.ref} is not claimable: Status is ${fresh.status}`);
4454
4644
  if (fresh.assignees.length) throw new Error(`${item.ref} is already assigned to @${fresh.assignees.join(", @")}`);
@@ -4456,23 +4646,12 @@ async function claimBoardIssue(options, deps = {}) {
4456
4646
  const assignee = options.assignee ?? "@me";
4457
4647
  const assignedLogin = assignee === "@me" ? report.viewer : assignee.replace(/^@/, "");
4458
4648
  try {
4459
- await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", assignee]);
4649
+ await client.rest("POST", `repos/${item.repository}/issues/${item.number}/assignees`, { body: { assignees: [assignedLogin] } });
4460
4650
  } catch (e) {
4461
4651
  throw new Error(`claim failed before board status changed: ${ghError(e)}`);
4462
4652
  }
4463
4653
  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
- ]);
4654
+ await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.statusFieldId, cfg.statusOptions["In Progress"]);
4476
4655
  } catch (e) {
4477
4656
  const warning = `partial claim: ${item.ref} was assigned to @${assignedLogin}, but Status was not moved to In Progress (${ghError(e)})`;
4478
4657
  if (!options.allowPartial) throw new Error(warning);
@@ -4491,22 +4670,11 @@ async function claimBoardIssue(options, deps = {}) {
4491
4670
  partial: false
4492
4671
  };
4493
4672
  }
4494
- async function setBoardItemPriority(gh, cfg, itemId, priority) {
4673
+ async function setBoardItemPriority(client, cfg, itemId, priority) {
4495
4674
  if (!isPriorityFieldConfigured(cfg)) return void 0;
4496
4675
  const optionId = resolvePriorityOptionId(cfg, priority);
4497
4676
  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
- ]);
4677
+ await updateItemSingleSelect(client, cfg.projectId, itemId, cfg.priorityFieldId, optionId);
4510
4678
  return cliPriorityToFieldName(priority);
4511
4679
  }
4512
4680
  async function backfillBoardPriorities(options, deps = {}) {
@@ -4514,7 +4682,7 @@ async function backfillBoardPriorities(options, deps = {}) {
4514
4682
  if (!isPriorityFieldConfigured(cfg)) {
4515
4683
  throw new Error("priority field is not configured in Hub registry META (priorityFieldId + priorityOptions)");
4516
4684
  }
4517
- const gh = deps.gh ?? defaultGh;
4685
+ const client = deps.client ?? defaultGitHubClient();
4518
4686
  const collected = await collectBoardItems(cfg, { repo: options.repo }, deps);
4519
4687
  const issues = collected.items.filter((item) => item.contentType === "Issue");
4520
4688
  const concurrency = Math.max(1, options.concurrency ?? 8);
@@ -4525,7 +4693,7 @@ async function backfillBoardPriorities(options, deps = {}) {
4525
4693
  return;
4526
4694
  }
4527
4695
  try {
4528
- const priority = await recoverIssuePriority(gh, item);
4696
+ const priority = await recoverIssuePriority(client, item);
4529
4697
  if (!priority) {
4530
4698
  result.skipped += 1;
4531
4699
  return;
@@ -4537,18 +4705,7 @@ async function backfillBoardPriorities(options, deps = {}) {
4537
4705
  }
4538
4706
  const optionId = cfg.priorityOptions?.[priority];
4539
4707
  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
- ]);
4708
+ await updateItemSingleSelect(client, cfg.projectId, item.itemId, cfg.priorityFieldId, optionId);
4552
4709
  result.set += 1;
4553
4710
  result.details.push(`${item.ref} \u2192 ${priority}`);
4554
4711
  } catch (e) {
@@ -4561,88 +4718,65 @@ async function backfillBoardPriorities(options, deps = {}) {
4561
4718
  }
4562
4719
  return result;
4563
4720
  }
4564
- async function recoverIssuePriority(gh, item) {
4721
+ async function recoverIssuePriority(client, item) {
4565
4722
  for (const label of item.labels) {
4566
4723
  const fromLabel = labelToFieldPriority(label);
4567
4724
  if (fromLabel) return fromLabel;
4568
4725
  }
4569
- const { stdout } = await gh(["api", `repos/${item.repository}/issues/${item.number}/events`, "--paginate"]);
4570
- return recoverPriorityFromEvents(parsePaginatedEvents(stdout));
4726
+ const events = await client.restPaginate(
4727
+ `repos/${item.repository}/issues/${item.number}/events`
4728
+ );
4729
+ return recoverPriorityFromEvents(events);
4571
4730
  }
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
- }
4731
+ async function fetchProjectPage(client, cfg, after) {
4732
+ return client.graphql(PROJECT_ITEMS_QUERY, {
4733
+ owner: cfg.projectOwner,
4734
+ number: cfg.projectNumber,
4735
+ ...after ? { after } : {}
4587
4736
  });
4588
4737
  }
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;
4738
+ function isGraphQLNotFound(e) {
4739
+ const errors = e.graphqlErrors;
4740
+ return Boolean(e instanceof GitHubApiError && errors?.length && errors.every((entry) => entry.type === "NOT_FOUND"));
4605
4741
  }
4606
- async function fetchIssueProjectItem(gh, cfg, selector) {
4742
+ async function fetchIssueProjectItem(client, cfg, selector) {
4607
4743
  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
- );
4744
+ if (!repoOwner || !repoName) return { viewer: "" };
4745
+ let data;
4746
+ try {
4747
+ data = await client.graphql(ISSUE_PROJECT_ITEM_QUERY, {
4748
+ repoOwner,
4749
+ repoName,
4750
+ number: selector.number
4751
+ });
4752
+ } catch (e) {
4753
+ if (isGraphQLNotFound(e)) return { viewer: "" };
4754
+ throw e;
4628
4755
  }
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);
4756
+ const viewer = data.viewer?.login ?? "";
4757
+ const content = data.repository?.issueOrPullRequest;
4758
+ if (!content) return { viewer };
4759
+ const projectItem = (content.projectItems?.nodes ?? []).find((item) => item.project?.id === cfg.projectId);
4760
+ if (!projectItem) return { viewer };
4761
+ const { projectItems: _projectItems, ...contentFields } = content;
4762
+ return {
4763
+ viewer,
4764
+ item: nodeToItem({
4765
+ id: projectItem.id,
4766
+ fieldValues: projectItem.fieldValues,
4767
+ content: contentFields
4768
+ }, cfg)
4769
+ };
4770
+ }
4771
+ async function fetchIssueDetails(client, repo, number) {
4772
+ const issue2 = await client.rest("GET", `repos/${repo}/issues/${number}`);
4773
+ const comments = await client.restPaginate(
4774
+ `repos/${repo}/issues/${number}/comments`
4775
+ );
4776
+ return {
4777
+ body: issue2?.body ?? "",
4778
+ comments: comments.map((comment) => ({ author: comment.user?.login ?? "", body: comment.body ?? "" }))
4779
+ };
4646
4780
  }
4647
4781
  function nodesToItems(nodes, warnings, cfg) {
4648
4782
  const items = [];
@@ -4701,20 +4835,12 @@ function nodeToItem(node, cfg) {
4701
4835
  function isSupportedContent(content) {
4702
4836
  return Boolean(content && (content.__typename === "Issue" || content.__typename === "PullRequest"));
4703
4837
  }
4704
- async function attachBundleDetails(report, gh, allowPartial) {
4838
+ async function attachBundleDetails(report, client, allowPartial) {
4705
4839
  const candidates = detailCandidates(report);
4706
4840
  if (candidates.length <= 1) return;
4707
4841
  for (const item of candidates) {
4708
4842
  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
- };
4843
+ item.details = await fetchIssueDetails(client, item.repository, item.number);
4718
4844
  } catch (e) {
4719
4845
  const warning = `partial detail read: ${item.ref}: ${ghError(e)}`;
4720
4846
  if (!allowPartial) throw new Error(warning);
@@ -5347,14 +5473,14 @@ function buildInstalledPluginVersionCheck(input) {
5347
5473
  }
5348
5474
 
5349
5475
  // src/stage-runner.ts
5350
- var import_node_child_process4 = require("node:child_process");
5476
+ var import_node_child_process5 = require("node:child_process");
5351
5477
  var import_node_fs3 = require("node:fs");
5352
- var import_node_path3 = require("node:path");
5478
+ var import_node_path4 = require("node:path");
5353
5479
  var import_node_net = require("node:net");
5354
- var import_node_util4 = require("node:util");
5355
- var execFileP2 = (0, import_node_util4.promisify)(import_node_child_process4.execFile);
5480
+ var import_node_util5 = require("node:util");
5481
+ var execFileP3 = (0, import_node_util5.promisify)(import_node_child_process5.execFile);
5356
5482
  function stageStatePath(cwd = process.cwd()) {
5357
- return (0, import_node_path3.join)(cwd, "tmp", "stage", "state.json");
5483
+ return (0, import_node_path4.join)(cwd, "tmp", "stage", "state.json");
5358
5484
  }
5359
5485
  function validateStageConfig(config = {}, action) {
5360
5486
  const problems = [];
@@ -5387,7 +5513,7 @@ function isPortFree(port) {
5387
5513
  });
5388
5514
  }
5389
5515
  async function shell(command, cwd, timeoutMs) {
5390
- await execFileP2(command, [], {
5516
+ await execFileP3(command, [], {
5391
5517
  cwd,
5392
5518
  shell: true,
5393
5519
  timeout: timeoutMs,
@@ -5406,7 +5532,7 @@ function readState(path2) {
5406
5532
  async function killTree(pid) {
5407
5533
  if (!Number.isInteger(pid) || pid <= 0) return;
5408
5534
  if (process.platform === "win32") {
5409
- await execFileP2("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }).catch(() => void 0);
5535
+ await execFileP3("taskkill", ["/PID", String(pid), "/T", "/F"], { windowsHide: true }).catch(() => void 0);
5410
5536
  return;
5411
5537
  }
5412
5538
  try {
@@ -5469,7 +5595,7 @@ async function startStage(config = {}, opts = {}) {
5469
5595
  }
5470
5596
  const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
5471
5597
  const up = sub(config.up.trim());
5472
- const child = (0, import_node_child_process4.spawn)(up, {
5598
+ const child = (0, import_node_child_process5.spawn)(up, {
5473
5599
  cwd,
5474
5600
  shell: true,
5475
5601
  detached: true,
@@ -5759,22 +5885,29 @@ function safeJson(text, fallback) {
5759
5885
  return fallback;
5760
5886
  }
5761
5887
  }
5762
- async function ghJson(deps, args, fallback) {
5888
+ async function restJson(deps, path2, fallback) {
5889
+ try {
5890
+ return await deps.client.rest("GET", path2) ?? fallback;
5891
+ } catch {
5892
+ return fallback;
5893
+ }
5894
+ }
5895
+ async function restPagedJson(deps, path2, fallback) {
5763
5896
  try {
5764
- return safeJson((await deps.gh(args)).stdout, fallback);
5897
+ return await deps.client.restPaginate(path2);
5765
5898
  } catch {
5766
5899
  return fallback;
5767
5900
  }
5768
5901
  }
5769
5902
  async function resolveOwners(deps) {
5770
- const members = await ghJson(deps, ["api", `orgs/${OWNER}/members?role=admin`, "--paginate"], []);
5903
+ const members = await restPagedJson(deps, `orgs/${OWNER}/members?role=admin`, []);
5771
5904
  return members.map((m) => m.login);
5772
5905
  }
5773
5906
  function collaboratorRole(c) {
5774
5907
  return c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : "write");
5775
5908
  }
5776
5909
  async function auditRepoCollaborators(repo, owners, deps) {
5777
- const collabs = await ghJson(deps, ["api", `repos/${repo}/collaborators?affiliation=direct`, "--paginate"], []);
5910
+ const collabs = await restPagedJson(deps, `repos/${repo}/collaborators?affiliation=direct`, []);
5778
5911
  const findings = [];
5779
5912
  for (const c of collabs) {
5780
5913
  if (owners.has(c.login)) continue;
@@ -5795,7 +5928,7 @@ async function auditRepoCollaborators(repo, owners, deps) {
5795
5928
  async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
5796
5929
  let restrictions = null;
5797
5930
  try {
5798
- restrictions = safeJson((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection/restrictions`])).stdout, null);
5931
+ restrictions = await deps.client.rest("GET", `repos/${repo}/branches/${branch}/protection/restrictions`) ?? null;
5799
5932
  } catch {
5800
5933
  restrictions = null;
5801
5934
  }
@@ -5879,7 +6012,7 @@ async function auditRepoAccess(repo, repoClass, owners, deps, projectAdmins = /*
5879
6012
  return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
5880
6013
  }
5881
6014
  async function auditOrgBasePermission(deps) {
5882
- const org = await ghJson(deps, ["api", `orgs/${OWNER}`], {});
6015
+ const org = await restJson(deps, `orgs/${OWNER}`, {});
5883
6016
  const perm = org.default_repository_permission;
5884
6017
  if (perm && perm !== "read" && perm !== "none") {
5885
6018
  return [{
@@ -6022,9 +6155,16 @@ function safeJson2(text, fallback) {
6022
6155
  return fallback;
6023
6156
  }
6024
6157
  }
6025
- async function ghJson2(deps, args, fallback) {
6158
+ async function restJson2(deps, path2, fallback) {
6159
+ try {
6160
+ return await deps.client.rest("GET", path2) ?? fallback;
6161
+ } catch {
6162
+ return fallback;
6163
+ }
6164
+ }
6165
+ async function restPagedJson2(deps, path2, fallback) {
6026
6166
  try {
6027
- return safeJson2((await deps.gh(args)).stdout, fallback);
6167
+ return await deps.client.restPaginate(path2);
6028
6168
  } catch {
6029
6169
  return fallback;
6030
6170
  }
@@ -6032,7 +6172,7 @@ async function ghJson2(deps, args, fallback) {
6032
6172
  async function contentExists(deps, repo, branch, path2) {
6033
6173
  try {
6034
6174
  const encodedPath = path2.split("/").map(encodeURIComponent).join("/");
6035
- await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`]);
6175
+ await deps.client.rest("GET", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`);
6036
6176
  return true;
6037
6177
  } catch {
6038
6178
  return false;
@@ -6040,7 +6180,7 @@ async function contentExists(deps, repo, branch, path2) {
6040
6180
  }
6041
6181
  async function getProtection(deps, repo, branch) {
6042
6182
  try {
6043
- return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
6183
+ return await deps.client.rest("GET", `repos/${repo}/branches/${branch}/protection`) ?? {};
6044
6184
  } catch {
6045
6185
  return null;
6046
6186
  }
@@ -6068,7 +6208,7 @@ async function rulesetDetails(deps, repo, list) {
6068
6208
  details.push(ruleset);
6069
6209
  continue;
6070
6210
  }
6071
- details.push(await ghJson2(deps, ["api", `repos/${repo}/rulesets/${ruleset.id}`], ruleset));
6211
+ details.push(await restJson2(deps, `repos/${repo}/rulesets/${ruleset.id}`, ruleset));
6072
6212
  }
6073
6213
  return details;
6074
6214
  }
@@ -6081,11 +6221,11 @@ async function verifyBootstrap(repo, repoClass, deps) {
6081
6221
  const branchesWanted = expectedBranches(repoClass);
6082
6222
  const baseBranch = repoClass === "content" ? "main" : "development";
6083
6223
  const checks = [];
6084
- const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
6224
+ const repoInfo = await restJson2(deps, `repos/${repo}`, {});
6085
6225
  checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
6086
6226
  checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
6087
6227
  checks.push({ ok: repoInfo.has_wiki === true, label: "wiki enabled", detail: repoInfo.has_wiki === true ? void 0 : "has_wiki is false or unavailable" });
6088
- const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
6228
+ const branchList = await restPagedJson2(deps, `repos/${repo}/branches`, []);
6089
6229
  const branchNames = new Set(branchList.map((b) => b.name));
6090
6230
  for (const branch of branchesWanted) {
6091
6231
  checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
@@ -6118,14 +6258,14 @@ async function verifyBootstrap(repo, repoClass, deps) {
6118
6258
  checks.push({ ok: await contentExists(deps, repo, baseBranch, trainScript), label: `train tooling script exists: ${trainScript}` });
6119
6259
  }
6120
6260
  checks.push({ ok: await contentExists(deps, repo, baseBranch, ".cursor/environment.json"), label: "Cursor environment committed" });
6121
- const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
6261
+ const labels = await restPagedJson2(deps, `repos/${repo}/labels`, []);
6122
6262
  const labelNames = new Set(labels.map((l) => l.name));
6123
6263
  for (const label of requiredLabels) {
6124
6264
  checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
6125
6265
  }
6126
6266
  const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
6127
6267
  checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
6128
- const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
6268
+ const actions = await restJson2(deps, `repos/${repo}/actions/permissions`, {});
6129
6269
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
6130
6270
  const config = deps.projectMeta ?? null;
6131
6271
  checks.push({
@@ -6133,12 +6273,18 @@ async function verifyBootstrap(repo, repoClass, deps) {
6133
6273
  label: "registry project board META exists"
6134
6274
  });
6135
6275
  if (config?.projectOwner && config.projectNumber != null) {
6136
- const project2 = await ghJson2(
6137
- deps,
6138
- ["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
6139
- {}
6140
- );
6141
- const fields = project2.fields || [];
6276
+ 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 } } } } } } }`;
6277
+ const fields = await (async () => {
6278
+ try {
6279
+ const data = await deps.client.graphql(fieldsQuery, {
6280
+ login: config.projectOwner,
6281
+ number: config.projectNumber
6282
+ });
6283
+ return (data.organization?.projectV2?.fields?.nodes ?? []).filter((f) => Boolean(f?.id && f?.name));
6284
+ } catch {
6285
+ return [];
6286
+ }
6287
+ })();
6142
6288
  const statusField = fields.find((field) => field.name === "Status");
6143
6289
  const labelField = fields.find((field) => field.name === "Labels");
6144
6290
  checks.push({
@@ -6195,12 +6341,17 @@ async function verifyBootstrap(repo, repoClass, deps) {
6195
6341
  }
6196
6342
  }
6197
6343
  const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
6198
- const workflowResponse = await ghJson2(
6199
- deps,
6200
- ["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
6201
- {}
6202
- );
6203
- const workflows = workflowResponse.data?.organization?.projectV2?.workflows?.nodes || [];
6344
+ const workflowResponse = await (async () => {
6345
+ try {
6346
+ return await deps.client.graphql(workflowQuery, {
6347
+ login: config.projectOwner,
6348
+ number: config.projectNumber
6349
+ });
6350
+ } catch {
6351
+ return {};
6352
+ }
6353
+ })();
6354
+ const workflows = workflowResponse.organization?.projectV2?.workflows?.nodes || [];
6204
6355
  for (const workflowName of requiredProjectWorkflows) {
6205
6356
  checks.push({
6206
6357
  ok: workflows.some((workflow) => workflow.name === workflowName && workflow.enabled === true),
@@ -6212,11 +6363,7 @@ async function verifyBootstrap(repo, repoClass, deps) {
6212
6363
  if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
6213
6364
  const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
6214
6365
  if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
6215
- const rulesetList = await ghJson2(
6216
- deps,
6217
- ["api", `repos/${repo}/rulesets?includes_parents=true`],
6218
- []
6219
- );
6366
+ const rulesetList = await restJson2(deps, `repos/${repo}/rulesets?includes_parents=true`, []);
6220
6367
  const rulesets = await rulesetDetails(deps, repo, rulesetList);
6221
6368
  const activeOrgRulesets = rulesets.filter(
6222
6369
  (r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
@@ -6402,17 +6549,22 @@ var ORG_CONFIG_PATH = "/org/config";
6402
6549
  var PROJECTS_ENVELOPE_KEY = "projects";
6403
6550
 
6404
6551
  // src/registry-client.ts
6405
- var DEFAULT_TIMEOUT_MS = 8e3;
6552
+ var DEFAULT_TIMEOUT_MS2 = 8e3;
6553
+ var RETRY_ATTEMPTS = 3;
6554
+ function retriedFetch(deps, url, init) {
6555
+ return fetchWithRetry(deps.fetch ?? fetch, url, init, {
6556
+ attempts: RETRY_ATTEMPTS,
6557
+ timeoutMs: deps.timeoutMs ?? DEFAULT_TIMEOUT_MS2
6558
+ });
6559
+ }
6406
6560
  async function fetchTrainAuthority(repo, deps) {
6407
6561
  if (!deps.baseUrl) return { ok: false, error: "Hub API URL not configured" };
6408
6562
  const token = await deps.token();
6409
6563
  if (!token) return { ok: false, error: "no GitHub token (gh auth login)" };
6410
- const doFetch = deps.fetch ?? fetch;
6411
6564
  try {
6412
- const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/train-authority?repo=${encodeURIComponent(repo)}`, {
6565
+ const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/train-authority?repo=${encodeURIComponent(repo)}`, {
6413
6566
  method: "GET",
6414
- headers: { Authorization: `Bearer ${token}` },
6415
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6567
+ headers: { Authorization: `Bearer ${token}` }
6416
6568
  });
6417
6569
  if (!res.ok) return { ok: false, error: `train-authority HTTP ${res.status}` };
6418
6570
  const body = await res.json();
@@ -6426,12 +6578,10 @@ async function fetchProjectsList(deps) {
6426
6578
  if (!deps.baseUrl) return null;
6427
6579
  const token = await deps.token();
6428
6580
  if (!token) return null;
6429
- const doFetch = deps.fetch ?? fetch;
6430
6581
  try {
6431
- const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}${PROJECTS_LIST_PATH}`, {
6582
+ const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}${PROJECTS_LIST_PATH}`, {
6432
6583
  method: "GET",
6433
- headers: { Authorization: `Bearer ${token}` },
6434
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6584
+ headers: { Authorization: `Bearer ${token}` }
6435
6585
  });
6436
6586
  if (!res.ok) return null;
6437
6587
  const body = await res.json();
@@ -6450,12 +6600,10 @@ async function fetchProjectBySlugChecked(slug, deps) {
6450
6600
  if (!slug) return { ok: false, error: "no slug" };
6451
6601
  const token = await deps.token();
6452
6602
  if (!token) return { ok: false, error: "no GitHub token (run `gh auth login`)" };
6453
- const doFetch = deps.fetch ?? fetch;
6454
6603
  try {
6455
- const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}`, {
6604
+ const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}`, {
6456
6605
  method: "GET",
6457
- headers: { Authorization: `Bearer ${token}` },
6458
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6606
+ headers: { Authorization: `Bearer ${token}` }
6459
6607
  });
6460
6608
  if (res.status === 404) return { ok: true, project: null };
6461
6609
  if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
@@ -6472,12 +6620,10 @@ async function fetchDeployStatusBySlug(slug, deps) {
6472
6620
  if (!deps.baseUrl || !slug) return null;
6473
6621
  const token = await deps.token();
6474
6622
  if (!token) return null;
6475
- const doFetch = deps.fetch ?? fetch;
6476
6623
  try {
6477
- const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}/deploy-status`, {
6624
+ const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}/deploy-status`, {
6478
6625
  method: "GET",
6479
- headers: { Authorization: `Bearer ${token}` },
6480
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6626
+ headers: { Authorization: `Bearer ${token}` }
6481
6627
  });
6482
6628
  if (!res.ok) return null;
6483
6629
  const body = await res.json();
@@ -6491,12 +6637,10 @@ async function fetchOrgConfig(deps) {
6491
6637
  if (!deps.baseUrl) return null;
6492
6638
  const token = await deps.token();
6493
6639
  if (!token) return null;
6494
- const doFetch = deps.fetch ?? fetch;
6495
6640
  try {
6496
- const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}${ORG_CONFIG_PATH}`, {
6641
+ const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}${ORG_CONFIG_PATH}`, {
6497
6642
  method: "GET",
6498
- headers: { Authorization: `Bearer ${token}` },
6499
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6643
+ headers: { Authorization: `Bearer ${token}` }
6500
6644
  });
6501
6645
  if (!res.ok) return null;
6502
6646
  return await res.json();
@@ -6508,13 +6652,11 @@ async function postJson(pathSuffix, payload, deps, method = "POST") {
6508
6652
  if (!deps.baseUrl) return { ok: false, status: 0, body: null, error: "no Hub API URL (set MMI_HUB_URL or use a current MMI CLI/plugin build)" };
6509
6653
  const token = await deps.token();
6510
6654
  if (!token) return { ok: false, status: 0, body: null, error: "no GitHub token (run `gh auth login`)" };
6511
- const doFetch = deps.fetch ?? fetch;
6512
6655
  try {
6513
- const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}${pathSuffix}`, {
6656
+ const res = await retriedFetch(deps, `${deps.baseUrl.replace(/\/$/, "")}${pathSuffix}`, {
6514
6657
  method,
6515
6658
  headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
6516
- body: JSON.stringify(payload),
6517
- signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
6659
+ body: JSON.stringify(payload)
6518
6660
  });
6519
6661
  let body = null;
6520
6662
  try {
@@ -6532,6 +6674,9 @@ async function registerProject(payload, deps) {
6532
6674
  async function upsertProject(slug, patch, deps) {
6533
6675
  return postJson(`/projects/${encodeURIComponent(slug)}`, patch, deps);
6534
6676
  }
6677
+ async function attestAppGaps(slug, repo, deps) {
6678
+ return postJson(`/projects/${encodeURIComponent(slug)}/attest-app`, { repo }, deps);
6679
+ }
6535
6680
  async function tenantControl(payload, deps) {
6536
6681
  return postJson("/tenant-control", payload, deps);
6537
6682
  }
@@ -6570,7 +6715,24 @@ function stageRequiredSecrets(stage2, meta) {
6570
6715
  function stageKey(stage2, key) {
6571
6716
  return key.includes("/") ? key : `${stage2}/${key}`;
6572
6717
  }
6718
+ function hasRuntimeSecretContract(contract) {
6719
+ return Boolean(contract) && !Array.isArray(contract);
6720
+ }
6721
+ function appAttestationOf(meta) {
6722
+ const a = meta?.appAttested;
6723
+ if (!a || typeof a !== "object" || Array.isArray(a)) return null;
6724
+ const { at, by } = a;
6725
+ return typeof at === "string" && at.length > 0 && typeof by === "string" && by.length > 0 ? { at, by } : null;
6726
+ }
6727
+ function attestedLine(att) {
6728
+ 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.`;
6729
+ }
6730
+ var CONTRACT_UNDECLARED_LINE = "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: [] }).";
6573
6731
  function appGapsFor(meta, model, slug, projectType) {
6732
+ const attested = appAttestationOf(meta);
6733
+ const isTenantWeb = !(projectType === "content" || model === "content") && projectType !== "desktop-game" && !(projectType === "non-deployable" || model === "none") && model !== "hub-serverless" && model !== "serverless";
6734
+ const contractUndeclared = isTenantWeb && Boolean(meta) && !hasRuntimeSecretContract(meta?.requiredRuntimeSecrets);
6735
+ if (attested) return contractUndeclared ? [attestedLine(attested), CONTRACT_UNDECLARED_LINE] : [attestedLine(attested)];
6574
6736
  if (projectType === "content" || model === "content") return ["Content/KB repo: keep app-owned work to docs/content changes; release train does not apply."];
6575
6737
  if (projectType === "desktop-game") {
6576
6738
  return [
@@ -6602,6 +6764,9 @@ function appGapsFor(meta, model, slug, projectType) {
6602
6764
  "Make app config fail clearly for missing required env in prod/rc instead of relying on hidden defaults.",
6603
6765
  "Keep app-owned README.md and architecture.md aligned with v2 central deploy/secrets reality."
6604
6766
  ];
6767
+ if (contractUndeclared) {
6768
+ gaps.unshift(CONTRACT_UNDECLARED_LINE);
6769
+ }
6605
6770
  if (slug === "mmi-katip") {
6606
6771
  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.");
6607
6772
  }
@@ -6680,7 +6845,29 @@ async function runV2Heal(repoOrSlug, opts, deps) {
6680
6845
  async function buildV2Doctor(repoOrSlug, deps) {
6681
6846
  const slug = slugOfRepo(repoOrSlug);
6682
6847
  const repo = repoFrom(repoOrSlug, slug);
6683
- const meta = await deps.getProject(slug);
6848
+ const read = await deps.getProject(slug);
6849
+ if (!read.ok) {
6850
+ const degradedSecrets = {
6851
+ dev: { required: [], present: [], missing: [] },
6852
+ rc: { required: [], present: [], missing: [] },
6853
+ main: { required: [], present: [], missing: [] }
6854
+ };
6855
+ const degradedStage = {
6856
+ dev: { ok: false, required: false },
6857
+ rc: { ok: false, required: false },
6858
+ main: { ok: false, required: false }
6859
+ };
6860
+ return {
6861
+ ok: false,
6862
+ repo,
6863
+ slug,
6864
+ registryError: read.error,
6865
+ hubOwned: { meta: { ok: false, missing: [] }, deployCoords: degradedStage, deployState: degradedStage, secrets: degradedSecrets },
6866
+ autoHealAvailable: [],
6867
+ appOwnedGaps: [`Hub registry read failed (${read.error}) \u2014 diagnosis degraded; nothing is known about META, coords, or gaps. Likely transient (cold start, network, or auth blip): retry \`mmi-cli project doctor\` shortly.`]
6868
+ };
6869
+ }
6870
+ const meta = read.project;
6684
6871
  const projectType = resolveProjectType(meta, repo);
6685
6872
  const model = resolveDeployModel(meta, repo);
6686
6873
  const autoHeal = buildV2HealPatch(repo, meta);
@@ -6741,7 +6928,8 @@ async function buildV2Doctor(repoOrSlug, deps) {
6741
6928
  hubOwned: { meta: { ok: metaMissing.length === 0, missing: metaMissing }, deployCoords, deployState, secrets: secrets2 },
6742
6929
  secretsError,
6743
6930
  autoHealAvailable: Object.keys(autoHeal.patch),
6744
- appOwnedGaps: autoHeal.appOwnedGaps
6931
+ appOwnedGaps: autoHeal.appOwnedGaps,
6932
+ appAttested: appAttestationOf(meta) ?? void 0
6745
6933
  };
6746
6934
  }
6747
6935
  function renderReadinessIssueBody(existingBody, report, opts = {}) {
@@ -6785,6 +6973,30 @@ ${section}`.trim();
6785
6973
  // src/project-set.ts
6786
6974
  var UNSET_KEYS = ["oauth", "requiredRuntimeSecrets", "edgeDomains", "requiredGcpApis", "publishRequired"];
6787
6975
  var UNSET_KEY_SET = new Set(UNSET_KEYS);
6976
+ var RUNTIME_SECRET_STAGES = ["dev", "rc", "main"];
6977
+ function parseRuntimeSecretsVar(raw) {
6978
+ let parsed;
6979
+ try {
6980
+ parsed = JSON.parse(raw);
6981
+ } catch {
6982
+ throw new Error('project set: requiredRuntimeSecrets must be JSON, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]}');
6983
+ }
6984
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
6985
+ throw new Error("project set: requiredRuntimeSecrets must be a stage map (a flat array is not box-loadable)");
6986
+ }
6987
+ const map = parsed;
6988
+ const out = {};
6989
+ for (const [stage2, names] of Object.entries(map)) {
6990
+ if (!RUNTIME_SECRET_STAGES.includes(stage2)) {
6991
+ throw new Error(`project set: requiredRuntimeSecrets stage "${stage2}" \u2014 expected only ${RUNTIME_SECRET_STAGES.join("/")}`);
6992
+ }
6993
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string" || !n.trim())) {
6994
+ throw new Error(`project set: requiredRuntimeSecrets.${stage2} must be an array of non-empty secret names`);
6995
+ }
6996
+ out[stage2] = names;
6997
+ }
6998
+ return out;
6999
+ }
6788
7000
  function buildProjectSetPatch(input) {
6789
7001
  const patch = {};
6790
7002
  if (input.class) {
@@ -6814,6 +7026,8 @@ function buildProjectSetPatch(input) {
6814
7026
  const n = Number(raw);
6815
7027
  if (!Number.isFinite(n)) throw new Error("project set: projectNumber must be numeric");
6816
7028
  patch[key] = n;
7029
+ } else if (key === "requiredRuntimeSecrets") {
7030
+ patch[key] = parseRuntimeSecretsVar(raw);
6817
7031
  } else {
6818
7032
  patch[key] = raw;
6819
7033
  }
@@ -6834,6 +7048,10 @@ function buildProjectSetPatch(input) {
6834
7048
  }
6835
7049
  return patch;
6836
7050
  }
7051
+ function repoFromRemoteUrl(remoteUrl) {
7052
+ const m = remoteUrl.trim().match(/^(?:[a-z][a-z0-9+.-]*:\/\/)?(?:[^@\s/]+@)?github\.com[:/]([^/\s:]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
7053
+ return m ? `${m[1]}/${m[2]}` : void 0;
7054
+ }
6837
7055
  function requireProjectTarget(commandName, explicitTarget, currentRepo) {
6838
7056
  const target = explicitTarget?.trim() || currentRepo?.trim();
6839
7057
  if (!target) {
@@ -6869,10 +7087,91 @@ function parseKbTree(stdout, prefix) {
6869
7087
  }
6870
7088
 
6871
7089
  // src/plan.ts
6872
- var import_node_path4 = require("node:path");
7090
+ var import_node_path5 = require("node:path");
7091
+
7092
+ // src/frontmatter.ts
7093
+ function splitFrontmatter(content) {
7094
+ const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(content);
7095
+ if (!match) return { entries: [], body: content };
7096
+ return { entries: match[1].split(/\r?\n/).filter((line) => line.trim()), body: content.slice(match[0].length) };
7097
+ }
7098
+ function entryKeyValue(line) {
7099
+ const i = line.indexOf(":");
7100
+ if (i < 0) return null;
7101
+ return { key: line.slice(0, i).trim().toLowerCase(), value: line.slice(i + 1).trim() };
7102
+ }
7103
+ function frontmatterValue(content, key) {
7104
+ const want = key.trim().toLowerCase();
7105
+ for (const line of splitFrontmatter(content).entries) {
7106
+ const kv = entryKeyValue(line);
7107
+ if (kv && kv.key === want) return kv.value || void 0;
7108
+ }
7109
+ return void 0;
7110
+ }
7111
+ function frontmatterList(content, key) {
7112
+ const raw = frontmatterValue(content, key);
7113
+ if (!raw) return [];
7114
+ return raw.replace(/^\[/, "").replace(/\]$/, "").split(",").map((s) => s.trim()).filter(Boolean);
7115
+ }
7116
+ function extractPlanMeta(content) {
7117
+ const meta = {};
7118
+ const status = frontmatterValue(content, "status");
7119
+ if (status) meta.status = status;
7120
+ const tags = frontmatterList(content, "topic-tags");
7121
+ if (tags.length) meta.topicTags = tags;
7122
+ const title = frontmatterValue(content, "title");
7123
+ if (title) meta.title = title;
7124
+ const supersedes = frontmatterValue(content, "supersedes");
7125
+ if (supersedes) meta.supersedes = supersedes;
7126
+ return meta;
7127
+ }
7128
+
7129
+ // src/plan-relevance.ts
7130
+ var STOP = /* @__PURE__ */ new Set(["the", "and", "for", "with", "plan", "issue", "fix", "feat", "add", "wip", "src", "tsx", "mjs"]);
7131
+ function tokenize(s) {
7132
+ return (s ?? "").replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase().split(/[^a-z0-9]+/).filter((t) => t.length >= 3 && !STOP.has(t));
7133
+ }
7134
+ var SUPPRESSED = /* @__PURE__ */ new Set(["superseded", "graduated"]);
7135
+ function signalTokens(s) {
7136
+ const all = [
7137
+ ...tokenize(s.branch),
7138
+ ...tokenize(s.issueTitle),
7139
+ ...(s.issueLabels ?? []).flatMap(tokenize),
7140
+ ...(s.changedFiles ?? []).flatMap(tokenize)
7141
+ ];
7142
+ return new Set(all);
7143
+ }
7144
+ function rankPlansByRelevance(plans, signals, opts = {}) {
7145
+ const wanted = signalTokens(signals);
7146
+ const eligible = opts.includeAll ? plans : plans.filter((p) => !p.status || !SUPPRESSED.has(p.status.toLowerCase()));
7147
+ const ranked = eligible.map((plan2) => {
7148
+ const tagTokens = new Set((plan2.topicTags ?? []).flatMap(tokenize));
7149
+ const titleTokens = new Set(tokenize(plan2.title));
7150
+ const slugTokens = new Set(tokenize(plan2.slug));
7151
+ const matched = /* @__PURE__ */ new Set();
7152
+ let score = 0;
7153
+ for (const t of wanted) {
7154
+ if (tagTokens.has(t)) {
7155
+ score += 3;
7156
+ matched.add(t);
7157
+ } else if (titleTokens.has(t)) {
7158
+ score += 2;
7159
+ matched.add(t);
7160
+ } else if (slugTokens.has(t)) {
7161
+ score += 1;
7162
+ matched.add(t);
7163
+ }
7164
+ }
7165
+ return { plan: plan2, score, matched: [...matched] };
7166
+ });
7167
+ ranked.sort((a, b) => b.score - a.score || (b.plan.updatedAt ?? "").localeCompare(a.plan.updatedAt ?? ""));
7168
+ return ranked;
7169
+ }
7170
+
7171
+ // src/plan.ts
6873
7172
  var PLANS_DIR = "plans";
6874
- var META_FILE = (0, import_node_path4.join)(PLANS_DIR, ".plan-meta.json");
6875
- var planPath = (slug) => (0, import_node_path4.join)(PLANS_DIR, `${slug}.md`);
7173
+ var META_FILE = (0, import_node_path5.join)(PLANS_DIR, ".plan-meta.json");
7174
+ var planPath = (slug) => (0, import_node_path5.join)(PLANS_DIR, `${slug}.md`);
6876
7175
  var metaKey = (project2, slug) => `${project2}/${slug}`;
6877
7176
  function parseMeta(raw) {
6878
7177
  if (!raw) return {};
@@ -6901,12 +7200,7 @@ function formatPlanList(plans) {
6901
7200
  return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
6902
7201
  }
6903
7202
  var TIMEOUT_MS = 8e3;
6904
- var GRADUATION_KEYS = /* @__PURE__ */ new Set(["northstar-graduation", "privacy", "merged-pr"]);
6905
- function splitFrontmatter(content) {
6906
- const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(content);
6907
- if (!match) return { entries: [], body: content };
6908
- return { entries: match[1].split(/\r?\n/).filter((line) => line.trim()), body: content.slice(match[0].length) };
6909
- }
7203
+ var GRADUATION_KEYS = /* @__PURE__ */ new Set(["northstar-graduation", "privacy", "merged-pr", "status"]);
6910
7204
  function markPlanGraduated(content, opts) {
6911
7205
  const { entries, body } = splitFrontmatter(normalizeEol(content));
6912
7206
  const preserved = entries.filter((line) => {
@@ -6916,6 +7210,7 @@ function markPlanGraduated(content, opts) {
6916
7210
  const next = [
6917
7211
  ...preserved,
6918
7212
  "northstar-graduation: built-and-merged",
7213
+ "status: graduated",
6919
7214
  "privacy: org",
6920
7215
  `merged-pr: ${opts.mergedPr}`
6921
7216
  ];
@@ -6935,6 +7230,8 @@ async function planPush(deps, slug, opts = {}) {
6935
7230
  const meta = parseMeta(deps.readMetaRaw());
6936
7231
  const entry = meta[metaKey(project2, slug)];
6937
7232
  const body = { project: project2, slug, content };
7233
+ const frontmatterMeta = extractPlanMeta(content);
7234
+ if (Object.keys(frontmatterMeta).length) body.meta = frontmatterMeta;
6938
7235
  if (opts.force) body.force = true;
6939
7236
  else if (entry?.etag) body.baseEtag = entry.etag;
6940
7237
  const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
@@ -6988,35 +7285,59 @@ async function planPull(deps, slug, opts = {}) {
6988
7285
  deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
6989
7286
  return true;
6990
7287
  }
7288
+ async function fetchPlanList(deps, project2) {
7289
+ const qs = project2 ? `?${new URLSearchParams({ project: project2 }).toString()}` : "";
7290
+ const res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
7291
+ method: "GET",
7292
+ headers: await deps.headers(),
7293
+ signal: AbortSignal.timeout(TIMEOUT_MS)
7294
+ });
7295
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
7296
+ const { plans } = await res.json();
7297
+ return plans ?? [];
7298
+ }
6991
7299
  async function planList(deps, opts = {}) {
6992
7300
  const project2 = opts.project ?? (opts.quiet ? await deps.project() : void 0);
6993
- const qs = project2 ? `?${new URLSearchParams({ project: project2 }).toString()}` : "";
6994
- let res;
7301
+ let plans;
6995
7302
  try {
6996
- res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
6997
- method: "GET",
6998
- headers: await deps.headers(),
6999
- signal: AbortSignal.timeout(TIMEOUT_MS)
7000
- });
7303
+ plans = await fetchPlanList(deps, project2);
7001
7304
  } catch (e) {
7002
7305
  if (!opts.quiet) deps.err(`plan list: ${e.message}`);
7003
7306
  return;
7004
7307
  }
7005
- if (!res.ok) {
7006
- if (!opts.quiet) deps.err(`plan list failed: HTTP ${res.status}`);
7007
- return;
7008
- }
7009
- const { plans } = await res.json();
7010
- if (opts.json) {
7011
- deps.log(JSON.stringify(plans));
7012
- return;
7013
- }
7308
+ if (opts.json) return deps.log(JSON.stringify(plans));
7014
7309
  if (!plans.length) {
7015
7310
  if (!opts.quiet) deps.log("no plans");
7016
7311
  return;
7017
7312
  }
7018
7313
  deps.log(formatPlanList(plans));
7019
7314
  }
7315
+ function formatRelevant(ranked) {
7316
+ return ranked.map((r) => {
7317
+ const why = r.matched.length ? `matches ${r.matched.join(", ")}` : "recent";
7318
+ return `${r.plan.slug} \xB7 ${why} \xB7 mmi-cli northstar pull ${r.plan.slug}`;
7319
+ }).join("\n");
7320
+ }
7321
+ async function relevantPlans(deps, signals, opts = {}) {
7322
+ const project2 = opts.project ?? await deps.project();
7323
+ let plans;
7324
+ try {
7325
+ const scoped = await fetchPlanList(deps, project2);
7326
+ const unprojected = project2 === "-" ? [] : await fetchPlanList(deps, "-").catch(() => []);
7327
+ const seen = new Set(scoped.map((p) => `${p.project}/${p.slug}`));
7328
+ plans = [...scoped, ...unprojected.filter((p) => !seen.has(`${p.project}/${p.slug}`))];
7329
+ } catch (e) {
7330
+ deps.err(`northstar relevant: ${e.message}`);
7331
+ return;
7332
+ }
7333
+ if (!plans.length) return deps.log("no North Stars for this repo yet");
7334
+ const ranked = rankPlansByRelevance(plans, signals, { includeAll: opts.includeAll });
7335
+ const top = (opts.includeAll ? ranked : ranked.filter((r) => r.score > 0)).slice(0, opts.limit ?? 5);
7336
+ if (!top.length) {
7337
+ return deps.log(`no task-relevant North Stars among ${plans.length} for this repo \u2014 \`mmi-cli northstar relevant --all\` lists recent ones`);
7338
+ }
7339
+ deps.log(formatRelevant(top));
7340
+ }
7020
7341
  async function planDelete(deps, slug, opts = {}) {
7021
7342
  const project2 = opts.project ?? await deps.project();
7022
7343
  const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
@@ -7113,12 +7434,14 @@ function formatVaultPointer(p) {
7113
7434
  }
7114
7435
  var TIMEOUT_MS2 = 8e3;
7115
7436
  var repoOf = (slug) => `${OWNER2}/${slug}`;
7437
+ async function vaultSlug(deps, opts) {
7438
+ return (opts.repo ? opts.repo.split("/").pop() : await deps.slug()).toLowerCase();
7439
+ }
7116
7440
  async function targetRepo(deps, opts) {
7117
- return opts.repo ?? repoOf(await deps.slug());
7441
+ return opts.repo ?? repoOf(await vaultSlug(deps, opts));
7118
7442
  }
7119
7443
  async function secretsWhere(deps, opts) {
7120
- const slug = opts.repo ? opts.repo.split("/").pop().toLowerCase() : await deps.slug();
7121
- deps.log(formatVaultPointer(vaultPointer(slug)));
7444
+ deps.log(formatVaultPointer(vaultPointer(await vaultSlug(deps, opts))));
7122
7445
  }
7123
7446
  async function readErr(res) {
7124
7447
  try {
@@ -7300,7 +7623,7 @@ async function secretsSet(deps, key, opts) {
7300
7623
  );
7301
7624
  return;
7302
7625
  }
7303
- deps.log(`set ${key} (${classifyTier(await deps.slug(), key)} tier)`);
7626
+ deps.log(`set ${key} (${classifyTier(await vaultSlug(deps, opts), key)} tier)`);
7304
7627
  }
7305
7628
  async function secretsEdit(deps, key, opts) {
7306
7629
  return secretsSet(deps, key, opts);
@@ -7352,8 +7675,8 @@ async function secretsRevoke(deps, repo, login, key, _opts) {
7352
7675
  }
7353
7676
  deps.log(`revoked @${login}'s access to ${key} in ${repo}`);
7354
7677
  }
7355
- async function secretsUse(deps, key, _opts) {
7356
- const slug = await deps.slug();
7678
+ async function secretsUse(deps, key, opts) {
7679
+ const slug = await vaultSlug(deps, opts);
7357
7680
  const tier = classifyTier(slug, key);
7358
7681
  const path2 = secretParamName(slug, key);
7359
7682
  deps.log(
@@ -7438,30 +7761,23 @@ function authorizeBodyHasMismatch(body) {
7438
7761
  }
7439
7762
 
7440
7763
  // src/index.ts
7441
- var rawExecFileP2 = (0, import_node_util5.promisify)(import_node_child_process5.execFile);
7764
+ var rawExecFileP3 = (0, import_node_util6.promisify)(import_node_child_process6.execFile);
7442
7765
  var DEFAULT_EXEC_TIMEOUT_MS = 1e4;
7443
- var execFileP3 = (file, args, options = {}) => (
7766
+ var execFileP4 = (file, args, options = {}) => (
7444
7767
  // encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
7445
7768
  // promisify(execFile)'s overloads widen to string|Buffer when options is spread in.
7446
- rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, timeout: DEFAULT_EXEC_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
7769
+ rawExecFileP3(file, args, { encoding: "utf8", windowsHide: true, timeout: DEFAULT_EXEC_TIMEOUT_MS, killSignal: "SIGTERM", ...options })
7447
7770
  );
7448
7771
  var GIT_TIMEOUT_MS = DEFAULT_EXEC_TIMEOUT_MS;
7449
7772
  var GC_GH_TIMEOUT_MS = 2e4;
7450
- var cachedGhCliToken;
7451
- async function githubToken() {
7452
- if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
7453
- if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
7454
- cachedGhCliToken ??= execFileP3("gh", ["auth", "token"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
7455
- return cachedGhCliToken;
7456
- }
7457
7773
  var cachedGithubLogin;
7458
7774
  async function githubLogin() {
7459
- cachedGithubLogin ??= execFileP3("gh", ["api", "user", "--jq", ".login"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
7775
+ cachedGithubLogin ??= execFileP4("gh", ["api", "user", "--jq", ".login"]).then(({ stdout }) => stdout.trim() || void 0).catch(() => void 0);
7460
7776
  return cachedGithubLogin;
7461
7777
  }
7462
7778
  async function awsCallerArn() {
7463
7779
  try {
7464
- const { stdout } = await execFileP3(
7780
+ const { stdout } = await execFileP4(
7465
7781
  "aws",
7466
7782
  ["sts", "get-caller-identity", "--query", "Arn", "--output", "text"],
7467
7783
  { timeout: GIT_TIMEOUT_MS }
@@ -7478,7 +7794,7 @@ async function sagaHeaders(extra = {}) {
7478
7794
  async function loadConfig() {
7479
7795
  let file = {};
7480
7796
  try {
7481
- file = JSON.parse(await (0, import_promises.readFile)(".mmi/config.json", "utf8"));
7797
+ file = JSON.parse(await (0, import_promises2.readFile)(".mmi/config.json", "utf8"));
7482
7798
  } catch {
7483
7799
  file = {};
7484
7800
  }
@@ -7486,12 +7802,16 @@ async function loadConfig() {
7486
7802
  return file;
7487
7803
  }
7488
7804
  var discoveredConfig = null;
7805
+ function registryDegradeError(error) {
7806
+ return new Error(`Hub registry read failed (${error}) \u2014 board coords could not be discovered; likely transient (cold start, network, or auth blip) \u2014 retry shortly`);
7807
+ }
7489
7808
  async function loadConfigOrDiscover() {
7490
7809
  if (discoveredConfig) return discoveredConfig;
7491
7810
  const floor = await loadConfig();
7492
7811
  if (!floor.sagaApiUrl) return stripMutableBoardConfig(floor);
7493
- const meta = await fetchProjectBySlug(await repoSlug(), registryClientDeps(floor));
7494
- discoveredConfig = meta ? boardConfigFromProject(meta, floor) : stripMutableBoardConfig(floor);
7812
+ const read = await fetchProjectBySlugChecked(await repoSlug(), registryClientDeps(floor));
7813
+ if (!read.ok) throw registryDegradeError(read.error);
7814
+ discoveredConfig = read.project ? boardConfigFromProject(read.project, floor) : stripMutableBoardConfig(floor);
7495
7815
  return discoveredConfig;
7496
7816
  }
7497
7817
  async function loadConfigForRepo(targetRepo2) {
@@ -7499,9 +7819,10 @@ async function loadConfigForRepo(targetRepo2) {
7499
7819
  const cwdRepo = await resolveRepo();
7500
7820
  if (cwdRepo && targetRepo2.toLowerCase() === cwdRepo.toLowerCase()) return loadConfigOrDiscover();
7501
7821
  const floor = await loadConfig();
7502
- const meta = await fetchProjectBySlug(slugOf(targetRepo2), registryClientDeps(floor));
7503
- if (!meta) return stripMutableBoardConfig(floor);
7504
- return boardConfigFromProject(meta, floor);
7822
+ const read = await fetchProjectBySlugChecked(slugOf(targetRepo2), registryClientDeps(floor));
7823
+ if (!read.ok) throw registryDegradeError(read.error);
7824
+ if (!read.project) return stripMutableBoardConfig(floor);
7825
+ return boardConfigFromProject(read.project, floor);
7505
7826
  }
7506
7827
  function repoFromSelector(selector) {
7507
7828
  const trimmed = selector.trim();
@@ -7521,7 +7842,7 @@ var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/d
7521
7842
  var SESSION_FILE = ".mmi/.session";
7522
7843
  var gitOut = async (args) => {
7523
7844
  try {
7524
- return (await execFileP3("git", [...args])).stdout.trim();
7845
+ return (await execFileP4("git", [...args])).stdout.trim();
7525
7846
  } catch {
7526
7847
  return "";
7527
7848
  }
@@ -7538,7 +7859,7 @@ function sessionDeps() {
7538
7859
  },
7539
7860
  writePersisted: (id) => persistSession(id),
7540
7861
  now: () => /* @__PURE__ */ new Date(),
7541
- rand: () => (0, import_node_crypto.randomBytes)(4).toString("hex")
7862
+ rand: () => (0, import_node_crypto2.randomBytes)(4).toString("hex")
7542
7863
  };
7543
7864
  }
7544
7865
  var resolveSessionId = () => resolveSession(sessionDeps());
@@ -7561,16 +7882,11 @@ async function postCapture(capture, quiet = false) {
7561
7882
  if (!quiet) console.error("mmi-cli saga: Hub API URL not configured");
7562
7883
  return;
7563
7884
  }
7564
- const res = await fetch(`${cfg.sagaApiUrl}/saga/capture`, {
7885
+ const res = await fetchWithRetry(fetch, `${cfg.sagaApiUrl}/saga/capture`, {
7565
7886
  method: "POST",
7566
7887
  headers: await sagaHeaders({ "content-type": "application/json" }),
7567
- body: JSON.stringify({ ...capture, ...await sagaKey(cfg) }),
7568
- // Capture latency is high + variable (server-side HEAD render); 8s dropped larger notes. Match the
7569
- // head-write timeout (20s) so a continuity note isn't lost to a slow/cold backend. No client retry:
7570
- // the capture isn't guaranteed idempotent, so a retry after a server-side-completed write could
7571
- // duplicate the note. Backend capture-latency root cause tracked in #255.
7572
- signal: AbortSignal.timeout(2e4)
7573
- });
7888
+ body: JSON.stringify({ ...capture, ...await sagaKey(cfg) })
7889
+ }, { attempts: 2, timeoutMs: 2e4, retryOn: () => false });
7574
7890
  let message = "";
7575
7891
  if (!res.ok) {
7576
7892
  try {
@@ -7593,18 +7909,18 @@ async function readStdin() {
7593
7909
  async function ghPrs(limit) {
7594
7910
  const args = (state) => ["pr", "list", "--state", state, "--limit", String(limit), "--json", "number,headRefName,state"];
7595
7911
  const [open, closed] = await Promise.all([
7596
- execFileP3("gh", args("open"), { timeout: GC_GH_TIMEOUT_MS }),
7597
- execFileP3("gh", args("closed"), { timeout: GC_GH_TIMEOUT_MS })
7912
+ execFileP4("gh", args("open"), { timeout: GC_GH_TIMEOUT_MS }),
7913
+ execFileP4("gh", args("closed"), { timeout: GC_GH_TIMEOUT_MS })
7598
7914
  ]);
7599
7915
  return [...JSON.parse(open.stdout || "[]"), ...JSON.parse(closed.stdout || "[]")];
7600
7916
  }
7601
7917
  async function worktreeBranches() {
7602
- const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7918
+ const { stdout } = await execFileP4("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7603
7919
  const parsed = parseWorktreePorcelain(stdout);
7604
7920
  return await Promise.all(parsed.map(async (w) => {
7605
7921
  let dirty = true;
7606
7922
  try {
7607
- const { stdout: status } = await execFileP3("git", ["-C", w.path, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7923
+ const { stdout: status } = await execFileP4("git", ["-C", w.path, "status", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
7608
7924
  dirty = status.trim().length > 0;
7609
7925
  } catch {
7610
7926
  dirty = true;
@@ -7616,7 +7932,7 @@ async function gcPlan(remote, limit) {
7616
7932
  const [branches, current, stale, prs, worktrees] = await Promise.all([
7617
7933
  gitOut(["branch", "--format=%(refname:short)"]),
7618
7934
  gitOut(["rev-parse", "--abbrev-ref", "HEAD"]),
7619
- execFileP3("git", ["remote", "prune", remote, "--dry-run"], { timeout: GIT_TIMEOUT_MS }).then((r) => parseRemotePruneDryRun(`${r.stdout}${r.stderr}`)).catch(() => []),
7935
+ execFileP4("git", ["remote", "prune", remote, "--dry-run"], { timeout: GIT_TIMEOUT_MS }).then((r) => parseRemotePruneDryRun(`${r.stdout}${r.stderr}`)).catch(() => []),
7620
7936
  ghPrs(limit),
7621
7937
  worktreeBranches()
7622
7938
  ]);
@@ -7644,8 +7960,8 @@ async function applyGcPlan(plan2, remote) {
7644
7960
  const result = { removedBranches: [], removedTrackingRefs: [], failed: [], pruned: false };
7645
7961
  for (const branch of plan2.branches) {
7646
7962
  try {
7647
- if (branch.worktreePath) await execFileP3("git", ["worktree", "remove", "--force", branch.worktreePath], { timeout: GIT_TIMEOUT_MS });
7648
- await execFileP3("git", ["branch", "-D", branch.branch], { timeout: GIT_TIMEOUT_MS });
7963
+ if (branch.worktreePath) await execFileP4("git", ["worktree", "remove", "--force", branch.worktreePath], { timeout: GIT_TIMEOUT_MS });
7964
+ await execFileP4("git", ["branch", "-D", branch.branch], { timeout: GIT_TIMEOUT_MS });
7649
7965
  result.removedBranches.push(branch.branch);
7650
7966
  } catch (e) {
7651
7967
  result.failed.push(`${branch.branch}: ${e.message.split("\n")[0]}`);
@@ -7653,7 +7969,7 @@ async function applyGcPlan(plan2, remote) {
7653
7969
  }
7654
7970
  for (const ref of plan2.trackingRefs) {
7655
7971
  try {
7656
- await execFileP3("git", ["update-ref", "-d", `refs/remotes/${remote}/${ref.branch}`], { timeout: GIT_TIMEOUT_MS });
7972
+ await execFileP4("git", ["update-ref", "-d", `refs/remotes/${remote}/${ref.branch}`], { timeout: GIT_TIMEOUT_MS });
7657
7973
  result.removedTrackingRefs.push(ref.branch);
7658
7974
  } catch (e) {
7659
7975
  result.failed.push(`${remote}/${ref.branch}: ${e.message.split("\n")[0]}`);
@@ -7661,7 +7977,7 @@ async function applyGcPlan(plan2, remote) {
7661
7977
  }
7662
7978
  if (plan2.branches.some((b) => b.worktreePath)) {
7663
7979
  try {
7664
- await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
7980
+ await execFileP4("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
7665
7981
  result.pruned = true;
7666
7982
  } catch (e) {
7667
7983
  result.failed.push(`worktree prune: ${e.message.split("\n")[0]}`);
@@ -7671,11 +7987,11 @@ async function applyGcPlan(plan2, remote) {
7671
7987
  }
7672
7988
  function resolveVersion() {
7673
7989
  try {
7674
- const manifest = (0, import_node_path5.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
7990
+ const manifest = (0, import_node_path6.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
7675
7991
  return JSON.parse((0, import_node_fs5.readFileSync)(manifest, "utf8")).version || "0.0.0";
7676
7992
  } catch {
7677
7993
  try {
7678
- const pkg = (0, import_node_path5.join)(__dirname, "..", "package.json");
7994
+ const pkg = (0, import_node_path6.join)(__dirname, "..", "package.json");
7679
7995
  return JSON.parse((0, import_node_fs5.readFileSync)(pkg, "utf8")).version || "0.0.0";
7680
7996
  } catch {
7681
7997
  return "0.0.0";
@@ -7684,14 +8000,14 @@ function resolveVersion() {
7684
8000
  }
7685
8001
  function readRepoVersion() {
7686
8002
  try {
7687
- return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path5.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
8003
+ return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path6.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
7688
8004
  } catch {
7689
8005
  return void 0;
7690
8006
  }
7691
8007
  }
7692
8008
  async function fetchReleasedVersion() {
7693
8009
  try {
7694
- const { stdout } = await execFileP3("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
8010
+ const { stdout } = await execFileP4("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
7695
8011
  return parseManifestVersion(stdout);
7696
8012
  } catch {
7697
8013
  return void 0;
@@ -7705,9 +8021,9 @@ async function applyVersionAutoUpdate(report, log) {
7705
8021
  const target = report.releasedVersion ?? "latest";
7706
8022
  if (action === "plugin-pull") {
7707
8023
  try {
7708
- const root = (await execFileP3("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
8024
+ const root = (await execFileP4("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
7709
8025
  log(` \u21BB refreshing MMI plugin ${report.currentVersion} \u2192 ${target} (effective next session)\u2026`);
7710
- await execFileP3("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
8026
+ await execFileP4("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
7711
8027
  return { ...report, ok: true };
7712
8028
  } catch {
7713
8029
  return report;
@@ -7734,7 +8050,7 @@ async function requireFreshTrainCli(commandName) {
7734
8050
  var consoleIo = { log: (m) => console.log(m), err: (m) => console.error(m) };
7735
8051
  var CLAUDE_PLUGIN_TIMEOUT_MS = 12e4;
7736
8052
  function runHostBin(bin, args, opts) {
7737
- return isWin ? execFileP3("cmd.exe", ["/c", bin, ...args], opts) : execFileP3(bin, args, opts);
8053
+ return isWin ? execFileP4("cmd.exe", ["/c", bin, ...args], opts) : execFileP4(bin, args, opts);
7738
8054
  }
7739
8055
  async function runClaudePlugin(args) {
7740
8056
  try {
@@ -7781,11 +8097,11 @@ async function runRulesSync(opts, io = consoleIo) {
7781
8097
  for (const entry of fetched) {
7782
8098
  if ("error" in entry) continue;
7783
8099
  const { file, source } = entry;
7784
- const current = (0, import_node_fs5.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
8100
+ const current = (0, import_node_fs5.existsSync)(file) ? await (0, import_promises2.readFile)(file, "utf8") : null;
7785
8101
  if (needsUpdate(source, current)) {
7786
8102
  const slash = file.lastIndexOf("/");
7787
8103
  if (slash > 0) (0, import_node_fs5.mkdirSync)(file.slice(0, slash), { recursive: true });
7788
- await (0, import_promises.writeFile)(file, normalizeEol(source), "utf8");
8104
+ await (0, import_promises2.writeFile)(file, normalizeEol(source), "utf8");
7789
8105
  changed++;
7790
8106
  if (!opts.quiet) io.log(`mmi-cli rules: updated ${file}`);
7791
8107
  }
@@ -7805,14 +8121,14 @@ async function runDocsSync(opts, io = consoleIo) {
7805
8121
  isDirty: async (f) => await gitOut(["status", "--porcelain", "--", f]) !== "",
7806
8122
  originContent: async (f) => {
7807
8123
  try {
7808
- return (await execFileP3("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
8124
+ return (await execFileP4("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
7809
8125
  } catch {
7810
8126
  return null;
7811
8127
  }
7812
8128
  },
7813
- localContent: async (f) => (0, import_node_fs5.existsSync)(f) ? await (0, import_promises.readFile)(f, "utf8") : null,
8129
+ localContent: async (f) => (0, import_node_fs5.existsSync)(f) ? await (0, import_promises2.readFile)(f, "utf8") : null,
7814
8130
  writeDoc: async (f, c) => {
7815
- await (0, import_promises.writeFile)(f, c, "utf8");
8131
+ await (0, import_promises2.writeFile)(f, c, "utf8");
7816
8132
  }
7817
8133
  });
7818
8134
  for (const f of result.updated) io.log(`mmi-cli docs: updated ${f} (from origin/${def})`);
@@ -7824,7 +8140,7 @@ docs.command("sync").option("--quiet", "stay silent unless something changed or
7824
8140
  var saga = program2.command("saga").description("per-session continuity");
7825
8141
  async function runNote(summary, o) {
7826
8142
  const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
7827
- const capture = buildNoteCapture(summary, o, (0, import_node_crypto.randomUUID)(), { sha: sha || void 0, branch: key.branch });
8143
+ const capture = buildNoteCapture(summary, o, (0, import_node_crypto2.randomUUID)(), { sha: sha || void 0, branch: key.branch });
7828
8144
  await postCapture(capture);
7829
8145
  }
7830
8146
  saga.command("note <summary>").description("record a one-line structured note into your saga (the per-turn capture)").option("--next <text>", 'set "where I left off" (NEXT)').option("--decision <text>", "append a verbatim decision").option("--queue-add <text>", "add a worklist item").option("--queue-done <n>", "mark worklist item N done").option("--verified", "mark this claim as checked against source (state: verified, else asserted)").option("--diagnostic", "isolate a probe write (state: diagnostic, source: probe) \u2014 never resume/LAST 5").option("--supersedes <key>", "retire prior decisions matching an evidence key (pr:N | file:path)").option("--anchor <intent>", "set the sprint North-Star (write-protected; needs --anchor-force to change)").option("--anchor-force", "overwrite an existing anchor").action((summary, o) => runNote(summary, o));
@@ -7838,21 +8154,24 @@ async function runSagaShow(opts, io = consoleIo) {
7838
8154
  try {
7839
8155
  const key = await sagaKey(cfg);
7840
8156
  const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
7841
- const res = await fetch(`${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
8157
+ const res = await fetchWithRetry(fetch, `${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders() }, { attempts: 2, timeoutMs: 3e3 });
7842
8158
  if (res.ok) {
7843
8159
  io.log(resumeCue());
7844
8160
  return io.log(await res.text());
7845
8161
  }
7846
8162
  if (!opts.quiet) io.log(`saga show failed: HTTP ${res.status}`);
7847
8163
  } catch (e) {
7848
- if (!opts.quiet) io.err(`saga show: ${e.message}`);
8164
+ if (!opts.quiet) {
8165
+ const reason = e.name === "TimeoutError" ? "backend unreachable (timed out after 2 attempts)" : e.message;
8166
+ io.err(`saga show: ${reason} \u2014 continuing without saga; diagnose with \`mmi-cli saga health --json\``);
8167
+ }
7849
8168
  }
7850
8169
  }
7851
8170
  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));
7852
8171
  saga.command("capture").option("--quiet", "capture silently (for the Stop hook)").description("per-turn deterministic capture (Stop hook): turn boundary + current sha").action(async (opts) => {
7853
8172
  const hook = parseHookInput(await readStdin());
7854
8173
  if (hook.session_id) persistSession(hook.session_id);
7855
- await postCapture({ event: "stop", id: (0, import_node_crypto.randomUUID)(), source: "hook", sha: await gitOut(["rev-parse", "--short", "HEAD"]), surface: agentSurface() }, opts.quiet ?? false);
8174
+ await postCapture({ event: "stop", id: (0, import_node_crypto2.randomUUID)(), source: "hook", sha: await gitOut(["rev-parse", "--short", "HEAD"]), surface: agentSurface() }, opts.quiet ?? false);
7856
8175
  });
7857
8176
  saga.command("session").option("--quiet", "silent (for the SessionStart hook)").description("persist the harness session id for this repo (SessionStart hook)").action(async () => {
7858
8177
  const hook = parseHookInput(await readStdin());
@@ -7865,7 +8184,7 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
7865
8184
  if (!headGateDue(tsPath)) return;
7866
8185
  markHeadRun(tsPath);
7867
8186
  try {
7868
- (0, import_node_child_process5.spawn)(process.execPath, [process.argv[1], "saga", "head-update", "--run"], {
8187
+ (0, import_node_child_process6.spawn)(process.execPath, [process.argv[1], "saga", "head-update", "--run"], {
7869
8188
  detached: true,
7870
8189
  stdio: "ignore",
7871
8190
  windowsHide: true
@@ -7904,7 +8223,7 @@ saga.command("key").option("--json", "machine-readable output").description("pri
7904
8223
  });
7905
8224
  async function probeBackend(url) {
7906
8225
  try {
7907
- const res = await fetch(`${url}/saga/head`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
8226
+ const res = await fetchWithRetry(fetch, `${url}/saga/head`, { headers: await sagaHeaders() }, { attempts: 3, timeoutMs: 4e3 });
7908
8227
  let message = "";
7909
8228
  try {
7910
8229
  const body = await res.clone().json();
@@ -7982,7 +8301,7 @@ var kb = program2.command("kb").description("org knowledgebase (read-only)");
7982
8301
  kb.command("get <path>").description("print a KB document by path").action(async (path2) => {
7983
8302
  const src = resolveKbSource((await loadConfig()).kbSource);
7984
8303
  try {
7985
- const { stdout } = await execFileP3("gh", buildKbGetArgs(src, path2), { timeout: 1e4 });
8304
+ const { stdout } = await execFileP4("gh", buildKbGetArgs(src, path2), { timeout: 1e4 });
7986
8305
  process.stdout.write(stdout);
7987
8306
  } catch (e) {
7988
8307
  const err = e;
@@ -7992,7 +8311,7 @@ kb.command("get <path>").description("print a KB document by path").action(async
7992
8311
  kb.command("list [prefix]").description("list KB document paths (optionally under a prefix)").action(async (prefix) => {
7993
8312
  const src = resolveKbSource((await loadConfig()).kbSource);
7994
8313
  try {
7995
- const { stdout } = await execFileP3("gh", buildKbTreeArgs(src), { timeout: 1e4 });
8314
+ const { stdout } = await execFileP4("gh", buildKbTreeArgs(src), { timeout: 1e4 });
7996
8315
  const paths = parseKbTree(stdout, prefix);
7997
8316
  if (!paths.length) return fail(`kb list: no documents${prefix ? ` under ${prefix}` : ""}`);
7998
8317
  console.log(paths.join("\n"));
@@ -8002,22 +8321,29 @@ kb.command("list [prefix]").description("list KB document paths (optionally unde
8002
8321
  }
8003
8322
  });
8004
8323
  async function ghCreate(args) {
8324
+ const swapped = await bodyArgsViaFile(args);
8005
8325
  try {
8006
- const { stdout } = await execFileP3("gh", args);
8326
+ const { stdout } = await execFileP4("gh", swapped.args, { timeout: GH_MUTATION_TIMEOUT_MS });
8007
8327
  return parseCreatedUrl(stdout);
8008
8328
  } catch (e) {
8329
+ await swapped.cleanup();
8009
8330
  const err = e;
8010
- fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
8331
+ const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
8332
+ fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}${note ? ` (${note})` : ""}`);
8333
+ } finally {
8334
+ await swapped.cleanup();
8011
8335
  }
8012
8336
  }
8013
- async function ghJson3(args, timeout = 1e4) {
8014
- const { stdout } = await execFileP3("gh", args, { timeout });
8337
+ async function ghJson(args, timeout = 1e4) {
8338
+ const { stdout } = await execFileP4("gh", args, { timeout });
8015
8339
  return JSON.parse(stdout);
8016
8340
  }
8017
8341
  async function resolveRepo(repo) {
8018
8342
  if (repo) return repo;
8343
+ const fromOrigin = repoFromRemoteUrl(await gitOut(["remote", "get-url", "origin"]));
8344
+ if (fromOrigin) return fromOrigin;
8019
8345
  try {
8020
- const { stdout } = await execFileP3("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], { timeout: 5e3 });
8346
+ const { stdout } = await execFileP4("gh", ["repo", "view", "--json", "nameWithOwner", "--jq", ".nameWithOwner"], { timeout: 5e3 });
8021
8347
  return stdout.trim() || void 0;
8022
8348
  } catch {
8023
8349
  return void 0;
@@ -8025,7 +8351,13 @@ async function resolveRepo(repo) {
8025
8351
  }
8026
8352
  async function attachToProject(issueNumber, repo, priority) {
8027
8353
  const targetRepo2 = await resolveRepo(repo);
8028
- const cfg = await loadConfigForRepo(targetRepo2);
8354
+ let cfg;
8355
+ try {
8356
+ cfg = await loadConfigForRepo(targetRepo2);
8357
+ } catch (e) {
8358
+ console.error(`issue create: board attach skipped \u2014 ${e.message}`);
8359
+ return void 0;
8360
+ }
8029
8361
  if (!cfg.projectId) {
8030
8362
  console.error(`issue create: board attach skipped \u2014 no Hub registry board META for ${targetRepo2 ?? "current repo"}; run \`mmi-cli project get ${targetRepo2 ?? "<owner/repo>"}\` and backfill board coords`);
8031
8363
  return void 0;
@@ -8033,19 +8365,14 @@ async function attachToProject(issueNumber, repo, priority) {
8033
8365
  try {
8034
8366
  const viewArgs = ["issue", "view", String(issueNumber), "--json", "id", "--jq", ".id"];
8035
8367
  if (targetRepo2) viewArgs.push("--repo", targetRepo2);
8036
- const { stdout: idOut } = await execFileP3("gh", viewArgs, { timeout: 1e4 });
8368
+ const { stdout: idOut } = await execFileP4("gh", viewArgs, { timeout: 1e4 });
8037
8369
  const contentId = idOut.trim();
8038
8370
  if (!contentId) throw new Error("could not resolve issue node id");
8039
- const { stdout } = await execFileP3("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: 1e4 });
8371
+ const { stdout } = await execFileP4("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: GH_MUTATION_TIMEOUT_MS });
8040
8372
  const projectItemId = parseAddedItemId(stdout);
8041
8373
  if (projectItemId && priority) {
8042
8374
  try {
8043
- await setBoardItemPriority(
8044
- async (args) => execFileP3("gh", args, { timeout: 1e4 }),
8045
- cfg,
8046
- projectItemId,
8047
- priority
8048
- );
8375
+ await setBoardItemPriority(defaultGitHubClient(), cfg, projectItemId, priority);
8049
8376
  } catch (e) {
8050
8377
  const err = e;
8051
8378
  process.stderr.write(`warning: issue #${issueNumber} board Priority not set: ${(err.stderr || err.message || String(e)).trim()}
@@ -8064,7 +8391,7 @@ function scheduleRelatedDiscovery(o) {
8064
8391
  try {
8065
8392
  const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
8066
8393
  if (o.repo) args.push("--repo", o.repo);
8067
- (0, import_node_child_process5.spawn)(process.execPath, [process.argv[1], ...args], {
8394
+ (0, import_node_child_process6.spawn)(process.execPath, [process.argv[1], ...args], {
8068
8395
  detached: true,
8069
8396
  stdio: "ignore",
8070
8397
  windowsHide: true,
@@ -8120,7 +8447,7 @@ function openInEditor(path2) {
8120
8447
  return;
8121
8448
  }
8122
8449
  try {
8123
- (0, import_node_child_process5.spawn)(editor, [path2], { stdio: "inherit" });
8450
+ (0, import_node_child_process6.spawn)(editor, [path2], { stdio: "inherit" });
8124
8451
  } catch {
8125
8452
  console.log(`open ${path2} manually`);
8126
8453
  }
@@ -8133,6 +8460,26 @@ async function withPlan(quiet, run, io = consoleIo) {
8133
8460
  }
8134
8461
  await run(makePlanDeps(cfg, io));
8135
8462
  }
8463
+ async function gatherRelevanceSignals() {
8464
+ const branch = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "") || void 0;
8465
+ const changed = (await gitOut(["diff", "--name-only", "HEAD"]).catch(() => "")).split("\n").map((s) => s.trim()).filter(Boolean);
8466
+ const signals = { branch, changedFiles: changed.length ? changed : void 0 };
8467
+ const issueNum = branch?.match(/\b(\d{2,})\b/)?.[1];
8468
+ if (issueNum) {
8469
+ try {
8470
+ const { stdout } = await execFileP4(
8471
+ "gh",
8472
+ ["issue", "view", issueNum, "--json", "title,labels", "--jq", "{title:.title,labels:[.labels[].name]}"],
8473
+ { timeout: 1e4 }
8474
+ );
8475
+ const j = JSON.parse(stdout);
8476
+ if (j.title) signals.issueTitle = j.title;
8477
+ if (j.labels?.length) signals.issueLabels = j.labels;
8478
+ } catch {
8479
+ }
8480
+ }
8481
+ return signals;
8482
+ }
8136
8483
  function registerNorthStarCommands(cmd) {
8137
8484
  cmd.command("push <slug>").description("push a local North Star plan (plans/<slug>.md) to the server").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action((slug, o) => withPlan(false, async (d) => {
8138
8485
  const ok = await planPush(d, slug, o);
@@ -8143,6 +8490,10 @@ function registerNorthStarCommands(cmd) {
8143
8490
  if (!ok) process.exitCode = 1;
8144
8491
  }));
8145
8492
  cmd.command("list").description("list your North Star plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
8493
+ cmd.command("relevant").description("list North Stars relevant to your current task (branch + claimed issue + changed files)").option("--project <name>", "override the project key").option("--all", "include superseded/graduated and recent non-matching plans").option("--limit <n>", "max results (default 5)").action((o) => withPlan(false, async (d) => {
8494
+ const signals = await gatherRelevanceSignals();
8495
+ await relevantPlans(d, signals, { project: o.project, includeAll: o.all, limit: o.limit ? Number(o.limit) : void 0 });
8496
+ }));
8146
8497
  cmd.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
8147
8498
  (slug, o) => withPlan(false, async (d) => {
8148
8499
  const ok = await planPull(d, slug, { project: o.project });
@@ -8178,7 +8529,9 @@ function makeSecretsDeps(cfg) {
8178
8529
  apiUrl: cfg.sagaApiUrl,
8179
8530
  fetch: (url, init = {}) => fetch(url, { ...init, signal: init.signal ?? AbortSignal.timeout(1e4) }),
8180
8531
  headers: (extra) => sagaHeaders(extra),
8181
- slug: async () => (await sagaKey(cfg)).project,
8532
+ // Vault paths are lowercase kebab (AGENTS.md naming); sagaKey carries the repo name's original
8533
+ // casing, which leaked mixed-case into `secrets where` output (#681).
8534
+ slug: async () => (await sagaKey(cfg)).project.toLowerCase(),
8182
8535
  readSecretValue: () => readSecretStdin(),
8183
8536
  log: (m) => console.log(m),
8184
8537
  err: (m) => console.error(m)
@@ -8251,7 +8604,8 @@ tenant.command("control <owner/repo> <stage> <action>").description("run bounded
8251
8604
  async function v2ReadinessDeps(cfg) {
8252
8605
  const reg = registryClientDeps(cfg);
8253
8606
  return {
8254
- getProject: (slug) => fetchProjectBySlug(slug, reg),
8607
+ // Checked read (#727/#733): the doctor distinguishes a FAILED read (degraded report) from a 404.
8608
+ getProject: (slug) => fetchProjectBySlugChecked(slug, reg),
8255
8609
  hasDeployCoords: async (slug, stage2) => {
8256
8610
  const status = await fetchDeployStatusBySlug(slug, reg);
8257
8611
  return Boolean(status?.stages[stage2]);
@@ -8264,11 +8618,10 @@ async function v2ReadinessDeps(cfg) {
8264
8618
  const apiUrl = cfg.sagaApiUrl;
8265
8619
  if (!apiUrl) throw new Error("Hub API URL not configured \u2014 cannot verify secret names (set sagaApiUrl)");
8266
8620
  const qs = new URLSearchParams({ repo: targetRepo2 }).toString();
8267
- const res = await fetch(`${apiUrl.replace(/\/$/, "")}/secrets/list?${qs}`, {
8621
+ const res = await fetchWithRetry(fetch, `${apiUrl.replace(/\/$/, "")}/secrets/list?${qs}`, {
8268
8622
  method: "GET",
8269
- headers: await sagaHeaders(),
8270
- signal: AbortSignal.timeout(8e3)
8271
- });
8623
+ headers: await sagaHeaders()
8624
+ }, { attempts: 2, timeoutMs: 5e3 });
8272
8625
  if (!res.ok) throw new Error(`secrets list failed for ${targetRepo2}: HTTP ${res.status}`);
8273
8626
  const body = await res.json();
8274
8627
  return (body.secrets ?? []).map((s) => s.key).filter(Boolean);
@@ -8278,22 +8631,32 @@ async function v2ReadinessDeps(cfg) {
8278
8631
  async function updateV2ReadinessIssue(repo, report, healed) {
8279
8632
  const title = "v2 readiness: central deploy + secrets alignment";
8280
8633
  const freshBody = renderReadinessIssueBody("", report, { healed });
8281
- const list = await execFileP3("gh", ["issue", "list", "--repo", repo, "--state", "open", "--search", "v2 readiness in:title", "--json", "number,title", "--limit", "20"], { timeout: 2e4 });
8634
+ const list = await execFileP4("gh", ["issue", "list", "--repo", repo, "--state", "open", "--search", "v2 readiness in:title", "--json", "number,title", "--limit", "20"], { timeout: 2e4 });
8282
8635
  const issues = JSON.parse(list.stdout || "[]");
8283
8636
  const existing = issues.find((i) => i.title.toLowerCase().includes("v2 readiness"));
8284
8637
  if (!existing) {
8285
- const created = await execFileP3("gh", ["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"], { timeout: 2e4 });
8286
- const url = created.stdout.trim();
8287
- const number = Number(url.match(/\/issues\/(\d+)$/)?.[1] ?? 0);
8288
- return { number, url, action: "created" };
8638
+ const create = await bodyArgsViaFile(["issue", "create", "--repo", repo, "--title", title, "--body", freshBody, "--label", "feature"]);
8639
+ try {
8640
+ const created = await execFileP4("gh", create.args, { timeout: GH_MUTATION_TIMEOUT_MS });
8641
+ const url = created.stdout.trim();
8642
+ const number = Number(url.match(/\/issues\/(\d+)$/)?.[1] ?? 0);
8643
+ return { number, url, action: "created" };
8644
+ } finally {
8645
+ await create.cleanup();
8646
+ }
8289
8647
  }
8290
- const view = await execFileP3("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
8648
+ const view = await execFileP4("gh", ["issue", "view", String(existing.number), "--repo", repo, "--json", "body,url", "--jq", "{body:.body,url:.url}"], { timeout: 2e4 });
8291
8649
  const current = JSON.parse(view.stdout || "{}");
8292
8650
  const nextBody = renderReadinessIssueBody(current.body ?? "", report, { healed });
8293
- await execFileP3("gh", ["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody], { timeout: 2e4 });
8651
+ const edit = await bodyArgsViaFile(["issue", "edit", String(existing.number), "--repo", repo, "--body", nextBody]);
8652
+ try {
8653
+ await execFileP4("gh", edit.args, { timeout: GH_MUTATION_TIMEOUT_MS });
8654
+ } finally {
8655
+ await edit.cleanup();
8656
+ }
8294
8657
  return { number: existing.number, url: current.url ?? `https://github.com/${repo}/issues/${existing.number}`, action: "updated" };
8295
8658
  }
8296
- var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
8659
+ var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); attest is project-admin; set is master-only");
8297
8660
  async function projectTarget(commandName, explicitTarget) {
8298
8661
  return requireProjectTarget(commandName, explicitTarget, explicitTarget ? void 0 : await resolveRepo());
8299
8662
  }
@@ -8330,7 +8693,7 @@ project.command("resolve <owner/repo>").description("deploy coords for a stage \
8330
8693
  }
8331
8694
  fail(msg);
8332
8695
  });
8333
- 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) => {
8696
+ 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) => {
8334
8697
  const cfg = await loadConfig();
8335
8698
  let target;
8336
8699
  try {
@@ -8380,7 +8743,19 @@ project.command("readiness [owner/repo]").description("update the repo v2 readin
8380
8743
  const issue2 = await updateV2ReadinessIssue(target, report, []);
8381
8744
  console.log(JSON.stringify({ ok: true, repo: target, issue: issue2, ready: report.ok }));
8382
8745
  });
8383
- 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) => {
8746
+ 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) => {
8747
+ const cfg = await loadConfig();
8748
+ let target;
8749
+ try {
8750
+ target = await projectTarget("project attest", repoOrSlug);
8751
+ } catch (e) {
8752
+ return fail(e.message);
8753
+ }
8754
+ const repo = target.includes("/") ? target : `mutmutco/${slugOf(target)}`;
8755
+ const res = await attestAppGaps(slugOf(target), repo, registryClientDeps(cfg));
8756
+ reportWrite("project attest", res);
8757
+ });
8758
+ 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, requiredRuntimeSecrets (JSON stage map, e.g. {"dev":["KEY"],"rc":["KEY"],"main":["KEY"]})').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) => {
8384
8759
  const cfg = await loadConfig();
8385
8760
  let target;
8386
8761
  try {
@@ -8484,7 +8859,7 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
8484
8859
  let priority;
8485
8860
  let body;
8486
8861
  try {
8487
- body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises.readFile, readStdin });
8862
+ body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises2.readFile, readStdin });
8488
8863
  priority = normalizePriority(o.priority);
8489
8864
  args = buildIssueArgs({ type: o.type, title: o.title, body, priority, repo: o.repo, labels: o.label });
8490
8865
  } catch (e) {
@@ -8494,7 +8869,7 @@ issue.command("create").description("create an issue (type \u2192 label) and pri
8494
8869
  const la = ["label", "create", label, "--color", "ededed"];
8495
8870
  if (o.repo) la.push("--repo", o.repo);
8496
8871
  try {
8497
- await execFileP3("gh", la, { timeout: 1e4 });
8872
+ await execFileP4("gh", la, { timeout: GH_MUTATION_TIMEOUT_MS });
8498
8873
  } catch {
8499
8874
  }
8500
8875
  }
@@ -8509,7 +8884,7 @@ issue.command("discover-related").description("find related issues for an existi
8509
8884
  const repo = await resolveRepo(o.repo);
8510
8885
  if (!repo) return fail("issue discover-related: could not resolve repo");
8511
8886
  try {
8512
- const issues = await ghJson3([
8887
+ const issues = await ghJson([
8513
8888
  "issue",
8514
8889
  "list",
8515
8890
  "--repo",
@@ -8524,7 +8899,7 @@ issue.command("discover-related").description("find related issues for an existi
8524
8899
  const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
8525
8900
  if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
8526
8901
  if (!candidates.length) return;
8527
- const viewed = await ghJson3([
8902
+ const viewed = await ghJson([
8528
8903
  "issue",
8529
8904
  "view",
8530
8905
  String(number),
@@ -8534,18 +8909,18 @@ issue.command("discover-related").description("find related issues for an existi
8534
8909
  "comments"
8535
8910
  ]);
8536
8911
  if (viewed.comments.some((comment) => comment.body.includes(relatedMarker(number)))) return;
8537
- await execFileP3("gh", ["issue", "comment", String(number), "--repo", repo, "--body", buildRelatedComment(number, candidates)], { timeout: 1e4 });
8912
+ await execFileP4("gh", ["issue", "comment", String(number), "--repo", repo, "--body", buildRelatedComment(number, candidates)], { timeout: GH_MUTATION_TIMEOUT_MS });
8538
8913
  } catch {
8539
8914
  }
8540
8915
  });
8541
- 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) => {
8916
+ 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) => {
8542
8917
  let body;
8543
8918
  let priority;
8544
8919
  let args;
8545
8920
  const targetRepo2 = o.repo ?? HUB_REPO;
8546
8921
  const sourceRepo = await resolveRepo(void 0);
8547
8922
  try {
8548
- body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises.readFile, readStdin });
8923
+ body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises2.readFile, readStdin });
8549
8924
  priority = normalizePriority(o.priority);
8550
8925
  args = buildIssueArgs({
8551
8926
  type: o.type,
@@ -8561,7 +8936,7 @@ program2.command("report").description("file a friction report on the Hub board
8561
8936
  if (!o.force) {
8562
8937
  let openReports = [];
8563
8938
  try {
8564
- openReports = await ghJson3([
8939
+ openReports = await ghJson([
8565
8940
  "issue",
8566
8941
  "list",
8567
8942
  "--repo",
@@ -8580,7 +8955,7 @@ program2.command("report").description("file a friction report on the Hub board
8580
8955
  const dup = findDuplicateReport({ title: o.title, body }, openReports);
8581
8956
  if (dup) {
8582
8957
  try {
8583
- await execFileP3("gh", ["issue", "comment", String(dup.number), "--repo", targetRepo2, "--body", buildDupComment(dup.number, body, sourceRepo)], { timeout: 1e4 });
8958
+ await execFileP4("gh", ["issue", "comment", String(dup.number), "--repo", targetRepo2, "--body", buildDupComment(dup.number, body, sourceRepo)], { timeout: GH_MUTATION_TIMEOUT_MS });
8584
8959
  } catch (e) {
8585
8960
  const err = e;
8586
8961
  return fail(`report: duplicate of #${dup.number} (${dup.url}) but the +1 comment failed: ${(err.stderr || err.message || String(e)).trim()}`);
@@ -8589,7 +8964,7 @@ program2.command("report").description("file a friction report on the Hub board
8589
8964
  }
8590
8965
  }
8591
8966
  try {
8592
- await execFileP3("gh", ["label", "create", REPORT_LABEL, "--color", "ededed", "--repo", targetRepo2], { timeout: 1e4 });
8967
+ await execFileP4("gh", ["label", "create", REPORT_LABEL, "--color", "ededed", "--repo", targetRepo2], { timeout: GH_MUTATION_TIMEOUT_MS });
8593
8968
  } catch {
8594
8969
  }
8595
8970
  const created = await ghCreate(args);
@@ -8597,14 +8972,20 @@ program2.command("report").description("file a friction report on the Hub board
8597
8972
  console.log(JSON.stringify({ ...created, deduped: false, label: REPORT_LABEL, priority, projectItemId }));
8598
8973
  });
8599
8974
  var pr = program2.command("pr").description("pull requests \u2014 reliable create with structured output");
8600
- pr.command("create").description("create a PR and print {number,url} JSON").requiredOption("--title <title>", "PR title").requiredOption("--body <body>", "PR body (markdown)").option("--base <branch>", "base branch (defaults to the repo default)").option("--head <branch>", "head branch (defaults to the current branch)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (o) => {
8601
- const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
8975
+ pr.command("create").description("create a PR and print {number,url} JSON").requiredOption("--title <title>", "PR title").option("--body <body>", "PR body (markdown)").option("--body-file <path|->", "read PR body from a UTF-8 file, or from stdin with -").option("--base <branch>", "base branch (defaults to the repo default)").option("--head <branch>", "head branch (defaults to the current branch)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (o) => {
8976
+ let body;
8977
+ try {
8978
+ body = await resolveIssueBody({ body: o.body, bodyFile: o.bodyFile }, { readFile: import_promises2.readFile, readStdin });
8979
+ } catch (e) {
8980
+ return fail(`pr create: ${e.message}`);
8981
+ }
8982
+ const created = await ghCreate(buildPrArgs({ title: o.title, body, base: o.base, head: o.head, repo: o.repo }));
8602
8983
  console.log(JSON.stringify(created));
8603
8984
  });
8604
8985
  async function remoteBranchExists(branch) {
8605
8986
  if (!branch) return void 0;
8606
8987
  try {
8607
- const { stdout } = await execFileP3("git", ["ls-remote", "--heads", "origin", branch], { timeout: GIT_TIMEOUT_MS });
8988
+ const { stdout } = await execFileP4("git", ["ls-remote", "--heads", "origin", branch], { timeout: GIT_TIMEOUT_MS });
8608
8989
  return stdout.trim().length > 0;
8609
8990
  } catch {
8610
8991
  return void 0;
@@ -8613,20 +8994,22 @@ async function remoteBranchExists(branch) {
8613
8994
  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) => {
8614
8995
  const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
8615
8996
  const repoArgs = o.repo ? ["--repo", o.repo] : [];
8616
- const headRef = (await execFileP3("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
8617
- const startingPath = (await execFileP3("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
8997
+ const headRef = (await execFileP4("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
8998
+ const startingPath = (await execFileP4("git", ["rev-parse", "--show-toplevel"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout.trim();
8618
8999
  const beforeWorktrees = parseWorktreePorcelain(
8619
- (await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
9000
+ (await execFileP4("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS }).catch(() => ({ stdout: "" }))).stdout
8620
9001
  );
8621
9002
  const remoteBefore = repoArgs.length ? void 0 : await remoteBranchExists(headRef);
8622
9003
  let remoteDeleteAttempted = false;
8623
9004
  let remoteNotAttemptedReason = repoArgs.length ? "repo-option" : void 0;
8624
- await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
9005
+ await execFileP4("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GH_MUTATION_TIMEOUT_MS }).catch((e) => {
8625
9006
  const message = String(e.message || "");
8626
9007
  if (/already been merged/i.test(message)) {
8627
9008
  remoteNotAttemptedReason = "pr-already-merged";
8628
9009
  return;
8629
9010
  }
9011
+ const note = timeoutKillNote(e, GH_MUTATION_TIMEOUT_MS);
9012
+ if (note) throw new Error(`gh pr merge ${number}: ${note}`);
8630
9013
  if (!/used by worktree|cannot delete branch/i.test(message)) throw e;
8631
9014
  });
8632
9015
  if (!remoteNotAttemptedReason) remoteDeleteAttempted = true;
@@ -8644,7 +9027,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
8644
9027
  } : await cleanupPrMergeLocalBranch(headRef, {
8645
9028
  beforeWorktrees,
8646
9029
  startingPath,
8647
- execGit: async (args) => (await execFileP3("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
9030
+ execGit: async (args) => (await execFileP4("git", args, { timeout: GIT_TIMEOUT_MS })).stdout
8648
9031
  });
8649
9032
  console.log(JSON.stringify({
8650
9033
  merged: number,
@@ -8767,9 +9150,9 @@ function stageKeepAlive() {
8767
9150
  return setTimeout(() => void 0, 5 * 60 * 1e3);
8768
9151
  }
8769
9152
  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) => {
8770
- const path2 = (0, import_node_path5.join)(process.cwd(), "infra", "port-ranges.json");
9153
+ const path2 = (0, import_node_path6.join)(process.cwd(), "infra", "port-ranges.json");
8771
9154
  const allocate = async (seed) => {
8772
- const { stdout } = await execFileP3("node", [(0, import_node_path5.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
9155
+ const { stdout } = await execFileP4("node", [(0, import_node_path6.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
8773
9156
  const parsed = JSON.parse(stdout);
8774
9157
  if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
8775
9158
  return parsed.range;
@@ -8875,8 +9258,8 @@ for (const commandName of ["rcand", "release", "hotfix"]) {
8875
9258
  if (commandName === "hotfix") return fail("hotfix: CLI apply is reserved; use the /hotfix skill PR path after explicit master-admin approval");
8876
9259
  try {
8877
9260
  const result = await runTrainApply(commandName, {
8878
- run: async (file, args) => (await execFileP3(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
8879
- runSelf: async (args) => (await execFileP3(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
9261
+ run: async (file, args) => (await execFileP4(file, args, { timeout: file === "gh" ? 3e4 : GIT_TIMEOUT_MS })).stdout,
9262
+ runSelf: async (args) => (await execFileP4(process.execPath, [process.argv[1], ...args], { timeout: 3e4 })).stdout,
8880
9263
  trainAuthority: async (repo) => {
8881
9264
  const verdict = await fetchTrainAuthority(repo, registryClientDeps(await loadConfig()));
8882
9265
  return verdict.ok ? { ok: true, role: verdict.authority.role, train: verdict.authority.train } : verdict;
@@ -8907,7 +9290,7 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
8907
9290
  const slug = (repo.includes("/") ? repo.split("/")[1] : repo).toLowerCase();
8908
9291
  const meta = await fetchProjectBySlug(slug, { baseUrl: cfg.sagaApiUrl, token: githubToken });
8909
9292
  const report = await verifyBootstrap(repo, o.class, {
8910
- gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
9293
+ client: defaultGitHubClient(),
8911
9294
  projectMeta: meta,
8912
9295
  readLocalFile: (path2) => path2 === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs5.existsSync)(path2) ? (0, import_node_fs5.readFileSync)(path2, "utf8") : null,
8913
9296
  // requiredGcpApis is stored as an array by a JSON write, but `project set --var KEY=VALUE` stores a raw
@@ -8920,7 +9303,7 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
8920
9303
  })(),
8921
9304
  listEnabledGcpApis: async (gcpProject) => {
8922
9305
  try {
8923
- const { stdout } = await execFileP3(
9306
+ const { stdout } = await execFileP4(
8924
9307
  "gcloud",
8925
9308
  ["services", "list", "--enabled", "--project", gcpProject, "--format", "value(config.name)"],
8926
9309
  { timeout: 3e4 }
@@ -8954,7 +9337,7 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
8954
9337
  const manifest = loadBootstrapSeeds((0, import_node_fs5.readFileSync)(manifestPath, "utf8"));
8955
9338
  const baseBranch = o.class === "content" ? "main" : "development";
8956
9339
  const slug = parsedRepo.slug;
8957
- const gh = async (args) => execFileP3("gh", args, { timeout: 2e4 });
9340
+ const gh = async (args) => execFileP4("gh", args, { timeout: 2e4 });
8958
9341
  const readFile2 = (p) => (0, import_node_fs5.existsSync)(p) ? (0, import_node_fs5.readFileSync)(p, "utf8") : null;
8959
9342
  const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
8960
9343
  const vars = {};
@@ -9071,7 +9454,7 @@ access.command("role [repo]").description("D14 train authority for a repo (serve
9071
9454
  });
9072
9455
  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 () => {
9073
9456
  const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
9074
- const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
9457
+ const deps = { client: defaultGitHubClient() };
9075
9458
  let targets;
9076
9459
  const cfg = await loadConfig();
9077
9460
  const registryProjects = await fetchProjectsList(registryClientDeps(cfg));
@@ -9097,7 +9480,7 @@ access.command("audit").description("audit collaborator roles + train-branch pus
9097
9480
  var isWin = process.platform === "win32";
9098
9481
  var installedPluginsPath = (surface = detectSurface(process.env)) => {
9099
9482
  const homeDir = surface === "codex" ? ".codex" : ".claude";
9100
- return (0, import_node_path5.join)((0, import_node_os.homedir)(), homeDir, "plugins", "installed_plugins.json");
9483
+ return (0, import_node_path6.join)((0, import_node_os2.homedir)(), homeDir, "plugins", "installed_plugins.json");
9101
9484
  };
9102
9485
  function readInstalledPlugins() {
9103
9486
  try {
@@ -9108,7 +9491,7 @@ function readInstalledPlugins() {
9108
9491
  }
9109
9492
  function readClaudeSettings() {
9110
9493
  try {
9111
- return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path5.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
9494
+ return JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path6.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
9112
9495
  } catch {
9113
9496
  return null;
9114
9497
  }
@@ -9154,14 +9537,14 @@ function backupAndWriteInstalledPlugins(records, pluginId) {
9154
9537
  }
9155
9538
  function mmiPluginCacheRootSnapshots() {
9156
9539
  const roots = [
9157
- { surface: "claude", root: (0, import_node_path5.join)((0, import_node_os.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
9158
- { surface: "codex", root: (0, import_node_path5.join)((0, import_node_os.homedir)(), ".codex", "plugins", "cache", "mmi", "mmi") }
9540
+ { surface: "claude", root: (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".claude", "plugins", "cache", "mmi", "mmi") },
9541
+ { surface: "codex", root: (0, import_node_path6.join)((0, import_node_os2.homedir)(), ".codex", "plugins", "cache", "mmi", "mmi") }
9159
9542
  ];
9160
9543
  return roots.flatMap(({ surface, root }) => {
9161
9544
  try {
9162
9545
  const entries = (0, import_node_fs5.readdirSync)(root, { withFileTypes: true }).map((entry) => ({
9163
9546
  name: entry.name,
9164
- path: (0, import_node_path5.join)(root, entry.name),
9547
+ path: (0, import_node_path6.join)(root, entry.name),
9165
9548
  isDirectory: entry.isDirectory()
9166
9549
  }));
9167
9550
  return [{ surface, root, entries }];
@@ -9184,7 +9567,7 @@ function quarantinePluginCacheDirs(plan2) {
9184
9567
  try {
9185
9568
  if (!(0, import_node_fs5.existsSync)(move.from)) continue;
9186
9569
  const target = uniqueQuarantineTarget(move.to);
9187
- (0, import_node_fs5.mkdirSync)((0, import_node_path5.dirname)(target), { recursive: true });
9570
+ (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)(target), { recursive: true });
9188
9571
  (0, import_node_fs5.renameSync)(move.from, target);
9189
9572
  moved += 1;
9190
9573
  } catch {
@@ -9192,7 +9575,7 @@ function quarantinePluginCacheDirs(plan2) {
9192
9575
  }
9193
9576
  return moved;
9194
9577
  }
9195
- var gitignorePath = () => (0, import_node_path5.join)(process.cwd(), ".gitignore");
9578
+ var gitignorePath = () => (0, import_node_path6.join)(process.cwd(), ".gitignore");
9196
9579
  function readGitignore() {
9197
9580
  try {
9198
9581
  return (0, import_node_fs5.readFileSync)(gitignorePath(), "utf8");
@@ -9219,17 +9602,17 @@ async function runDoctor(opts, io = consoleIo) {
9219
9602
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
9220
9603
  const [login, pathProbe, releasedVersion, cfg, callerArn, cloneProbe] = await Promise.all([
9221
9604
  githubLogin(),
9222
- execFileP3(isWin ? "where" : "which", ["mmi-cli"]).then(() => true).catch(() => false),
9605
+ execFileP4(isWin ? "where" : "which", ["mmi-cli"]).then(() => true).catch(() => false),
9223
9606
  fetchReleasedVersion(),
9224
9607
  loadConfig(),
9225
9608
  awsCallerArn(),
9226
- execFileP3("git", ["config", "--global", "--get-all", REWRITE_KEY]).then(({ stdout }) => stdout.split("\n").some((l) => l.trim() === "git@github.com:")).catch(() => false)
9609
+ execFileP4("git", ["config", "--global", "--get-all", REWRITE_KEY]).then(({ stdout }) => stdout.split("\n").some((l) => l.trim() === "git@github.com:")).catch(() => false)
9227
9610
  // unset → repair below
9228
9611
  ]);
9229
9612
  let ghInstalled = true;
9230
9613
  if (!login) {
9231
9614
  try {
9232
- await execFileP3("gh", ["--version"]);
9615
+ await execFileP4("gh", ["--version"]);
9233
9616
  } catch {
9234
9617
  ghInstalled = false;
9235
9618
  }
@@ -9256,7 +9639,7 @@ async function runDoctor(opts, io = consoleIo) {
9256
9639
  let cloneOk = cloneProbe;
9257
9640
  if (!cloneOk && !opts.banner && !opts.json) {
9258
9641
  try {
9259
- await execFileP3("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
9642
+ await execFileP4("git", ["config", "--global", "--add", REWRITE_KEY, "git@github.com:"]);
9260
9643
  cloneOk = true;
9261
9644
  io.err(" \u21BB repaired: git insteadOf git@github.com \u2192 https (plugin clone over HTTPS)");
9262
9645
  } catch {
@@ -9355,24 +9738,22 @@ async function runDoctor(opts, io = consoleIo) {
9355
9738
  ${gaps.length} item(s) need attention.` : "\nAll set \u2014 you are ready.");
9356
9739
  }
9357
9740
  program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, Hub API default, plugin git clone, plugin install record, .gitignore managed block, plugin config drift, installed plugin version) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--guide", "print the MMI Agentic Onboarding guide URL").option("--json", "machine-readable output").action((opts) => runDoctor(opts));
9358
- program2.command("session-start").description("run the SessionStart verbs (rules/docs sync, saga session+show, plan list, saga health, doctor) in one process").action(async () => {
9741
+ program2.command("session-start").description("run the SessionStart verbs (rules sync, saga session+show, saga health, doctor) in one process; docs sync runs detached").action(async () => {
9359
9742
  try {
9360
9743
  const hook = parseHookInput(await readStdin());
9361
9744
  if (hook.session_id) persistSession(hook.session_id);
9362
9745
  } catch (e) {
9363
9746
  console.error(`[mmi-hook] saga session failed: ${e.message}`);
9364
9747
  }
9365
- const parallel = [
9366
- { name: "rules sync", run: (io) => runRulesSync({ quiet: true }, io) },
9367
- { name: "docs sync", run: (io) => runDocsSync({ quiet: true }, io) },
9368
- { name: "saga show", run: (io) => runSagaShow({ quiet: true }, io) },
9369
- { name: "plan list", run: (io) => withPlan(true, (d) => planList(d, { quiet: true }), io) },
9370
- { name: "saga health", run: (io) => runSagaHealth({ banner: true, quiet: true }, io) }
9371
- ];
9372
- const sequential = [
9373
- { name: "doctor", run: (io) => runDoctor({ banner: true }, io) }
9374
- ];
9748
+ spawnDetachedSelf(["docs", "sync", "--quiet"], { spawn: import_node_child_process6.spawn, execPath: process.execPath, scriptPath: process.argv[1] });
9749
+ const { parallel, sequential } = buildSessionStartPlan({
9750
+ rulesSync: (io) => runRulesSync({ quiet: true }, io),
9751
+ sagaShow: (io) => runSagaShow({ quiet: true }, io),
9752
+ sagaHealth: (io) => runSagaHealth({ banner: true, quiet: true }, io),
9753
+ doctor: (io) => runDoctor({ banner: true }, io)
9754
+ });
9375
9755
  await runSessionStart(parallel, sequential, consoleIo);
9756
+ consoleIo.log(northstarPointer());
9376
9757
  });
9377
9758
  function fail(msg) {
9378
9759
  console.error(`mmi-cli ${msg}`);