@mutmutco/cli 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.cjs +1677 -116
  2. package/package.json +3 -3
package/dist/index.cjs CHANGED
@@ -3038,7 +3038,7 @@ var {
3038
3038
 
3039
3039
  // src/index.ts
3040
3040
  var import_promises = require("node:fs/promises");
3041
- var import_node_fs3 = require("node:fs");
3041
+ var import_node_fs4 = require("node:fs");
3042
3042
  var import_node_crypto = require("node:crypto");
3043
3043
 
3044
3044
  // src/rules-sync.ts
@@ -3052,6 +3052,39 @@ function needsUpdate(source, current) {
3052
3052
  function isRulesSource(orgRulesSource) {
3053
3053
  return orgRulesSource === "self";
3054
3054
  }
3055
+ function rulesSourceAuthHeaders(sourceUrl, token) {
3056
+ if (!token) return void 0;
3057
+ try {
3058
+ const host = new URL(sourceUrl).hostname.toLowerCase();
3059
+ if (host === "raw.githubusercontent.com" || host === "api.github.com") {
3060
+ return { Authorization: `Bearer ${token}` };
3061
+ }
3062
+ } catch {
3063
+ return void 0;
3064
+ }
3065
+ return void 0;
3066
+ }
3067
+
3068
+ // src/docs-sync.ts
3069
+ var SYNCED_DOCS = ["README.md", "architecture.md"];
3070
+ async function syncDocs(deps, docs2 = SYNCED_DOCS) {
3071
+ const updated = [];
3072
+ const skippedDirty = [];
3073
+ for (const file of docs2) {
3074
+ if (await deps.isDirty(file)) {
3075
+ skippedDirty.push(file);
3076
+ continue;
3077
+ }
3078
+ const origin = await deps.originContent(file);
3079
+ if (origin === null) continue;
3080
+ const local = await deps.localContent(file);
3081
+ if (needsUpdate(origin, local)) {
3082
+ await deps.writeDoc(file, normalizeEol(origin));
3083
+ updated.push(file);
3084
+ }
3085
+ }
3086
+ return { updated, skippedDirty };
3087
+ }
3055
3088
 
3056
3089
  // src/saga-capture.ts
3057
3090
  function parseHookInput(stdin) {
@@ -3066,7 +3099,7 @@ function parseHookInput(stdin) {
3066
3099
  // src/index.ts
3067
3100
  var import_node_child_process4 = require("node:child_process");
3068
3101
  var import_node_util3 = require("node:util");
3069
- var import_node_path3 = require("node:path");
3102
+ var import_node_path4 = require("node:path");
3070
3103
 
3071
3104
  // src/saga-head-maintainer.ts
3072
3105
  var import_node_child_process = require("node:child_process");
@@ -3099,13 +3132,18 @@ function markHeadRun(path, now = Date.now()) {
3099
3132
  }
3100
3133
  function headPrompt(state) {
3101
3134
  return [
3102
- "You maintain two durable slots of a work-session: GOAL (the current objective) and PINNED (things",
3103
- "worth remembering). Given the CURRENT HEAD and the recent TRANSCRIPT + DECISIONS, return an updated",
3104
- "GOAL and PINNED only. Keep them tight and concrete; keep anything the user pinned; never invent;",
3105
- "preserve Turkish characters (\xE7 \u011F \u0131 \u0130 \xF6 \u015F \xFC) exactly. Do NOT manage next or the checklist \u2014 the note",
3106
- "path owns those. Never restate an unverified artifact-claim (a named file, PR, flag, or board state)",
3135
+ "You maintain ONE durable slot of a work-session: PINNED (things worth remembering). Given the CURRENT",
3136
+ "HEAD and the recent TRANSCRIPT + DECISIONS, return an updated PINNED only. Keep it tight and concrete;",
3137
+ "keep anything the user pinned; never invent; preserve Turkish characters (\xE7 \u011F \u0131 \u0130 \xF6 \u015F \xFC) exactly. Do",
3138
+ "NOT manage next or the checklist \u2014 the note path owns those. The ANCHOR is the read-only North-Star \u2014",
3139
+ "NEVER change it. Never restate an unverified artifact-claim (a named file, PR, flag, or board state)",
3107
3140
  "as settled fact \u2014 keep it as the belief it was recorded as.",
3108
- 'Output ONLY a JSON object: {"goal":string,"pinned":[string]}.',
3141
+ "You MAY also propose supersessions: each DECISION is shown with a stable 0-based index. Propose a",
3142
+ "supersession ONLY for a NEWER decision that directly contradicts/replaces an OLDER one where neither",
3143
+ "already carries a supersededBy. HIGH PRECISION \u2014 propose ONLY when you are confident the older claim",
3144
+ "is now false or obsolete; the newer decision's timestamp MUST be later than the older's (newer-supersedes-",
3145
+ "older only, never the reverse). When unsure, propose nothing. NEVER touch the anchor, next, or checklist.",
3146
+ 'Output ONLY a JSON object: {"pinned":[string],"supersede":[{"older":int,"newer":int,"reason":string}]}.',
3109
3147
  "",
3110
3148
  "CURRENT HEAD:",
3111
3149
  JSON.stringify(state.head ?? {}, null, 2),
@@ -3114,7 +3152,7 @@ function headPrompt(state) {
3114
3152
  JSON.stringify(state.actionLog ?? []),
3115
3153
  "",
3116
3154
  "DECISIONS:",
3117
- (state.decisions ?? []).map((d) => `- ${d}`).join("\n") || "(none)"
3155
+ (state.decisions ?? []).map((d, i) => `[${i}] ${d}`).join("\n") || "(none)"
3118
3156
  ].join("\n");
3119
3157
  }
3120
3158
  function parseHeadUpdate(raw) {
@@ -3128,8 +3166,15 @@ function parseHeadUpdate(raw) {
3128
3166
  }
3129
3167
  if (!obj || typeof obj !== "object") return null;
3130
3168
  const u = {};
3131
- if (typeof obj.goal === "string") u.goal = obj.goal;
3132
3169
  if (Array.isArray(obj.pinned)) u.pinned = obj.pinned.filter((x) => typeof x === "string");
3170
+ if (Array.isArray(obj.supersede)) {
3171
+ const supersede = obj.supersede.filter((e) => {
3172
+ if (!e || typeof e !== "object") return false;
3173
+ const { older, newer, reason } = e;
3174
+ return Number.isInteger(older) && older >= 0 && Number.isInteger(newer) && newer >= 0 && older !== newer && typeof reason === "string" && reason.length > 0;
3175
+ }).map((e) => ({ older: e.older, newer: e.newer, reason: e.reason }));
3176
+ if (supersede.length) u.supersede = supersede;
3177
+ }
3133
3178
  return Object.keys(u).length ? u : null;
3134
3179
  }
3135
3180
  async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
@@ -3170,7 +3215,7 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
3170
3215
 
3171
3216
  // src/gh-create.ts
3172
3217
  var ISSUE_TYPES = ["bug", "feature", "task"];
3173
- var PRIORITIES = ["high", "medium", "low"];
3218
+ var PRIORITIES = ["urgent", "high", "medium", "low"];
3174
3219
  function parseCreatedUrl(stdout) {
3175
3220
  const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
3176
3221
  let match;
@@ -3182,7 +3227,7 @@ function parseCreatedUrl(stdout) {
3182
3227
  ${stdout.trim() || "(empty)"}`);
3183
3228
  return last;
3184
3229
  }
3185
- function buildIssueArgs({ type, title, body, priority, repo }) {
3230
+ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
3186
3231
  if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
3187
3232
  if (!PRIORITIES.includes(priority)) {
3188
3233
  throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
@@ -3191,8 +3236,36 @@ function buildIssueArgs({ type, title, body, priority, repo }) {
3191
3236
  if (repo) args.push("--repo", repo);
3192
3237
  args.push("--title", title, "--body", body, "--label", type);
3193
3238
  args.push("--label", `priority:${priority}`);
3239
+ for (const label of labels ?? []) args.push("--label", label);
3194
3240
  return args;
3195
3241
  }
3242
+ function boardAttachSkipReason(cwdRepo, targetRepo2) {
3243
+ if (targetRepo2 && cwdRepo && targetRepo2 !== cwdRepo) {
3244
+ return `issue was created in ${targetRepo2}, not the board's repo ${cwdRepo}`;
3245
+ }
3246
+ return null;
3247
+ }
3248
+ function buildAddToProjectArgs(projectId, contentId) {
3249
+ if (!projectId) throw new Error("addToProject: projectId is required");
3250
+ if (!contentId) throw new Error("addToProject: contentId is required");
3251
+ return [
3252
+ "api",
3253
+ "graphql",
3254
+ "-f",
3255
+ "query=mutation($p:ID!,$c:ID!){addProjectV2ItemById(input:{projectId:$p,contentId:$c}){item{id}}}",
3256
+ "-f",
3257
+ `p=${projectId}`,
3258
+ "-f",
3259
+ `c=${contentId}`
3260
+ ];
3261
+ }
3262
+ function parseAddedItemId(stdout) {
3263
+ try {
3264
+ return JSON.parse(stdout)?.data?.addProjectV2ItemById?.item?.id || void 0;
3265
+ } catch {
3266
+ return void 0;
3267
+ }
3268
+ }
3196
3269
  function buildPrArgs({ title, body, base, head, repo }) {
3197
3270
  const args = ["pr", "create"];
3198
3271
  if (repo) args.push("--repo", repo);
@@ -3246,11 +3319,12 @@ function buildNoteCapture(summary, o, id, evidence) {
3246
3319
  const queueOp = o.queueAdd ? { op: "add", text: o.queueAdd } : o.queueDone != null ? { op: "done", index: Number(o.queueDone) } : void 0;
3247
3320
  const state = o.diagnostic ? "diagnostic" : o.verified ? "verified" : "asserted";
3248
3321
  const source = o.diagnostic ? "probe" : "note";
3249
- const anchor = {};
3250
- if (evidence.sha) anchor.sha = evidence.sha;
3251
- if (evidence.branch) anchor.branch = evidence.branch;
3252
- if (evidence.pr) anchor.pr = evidence.pr;
3253
- if (evidence.file) anchor.file = evidence.file;
3322
+ const ev = {};
3323
+ if (evidence.sha) ev.sha = evidence.sha;
3324
+ if (evidence.branch) ev.branch = evidence.branch;
3325
+ if (evidence.pr) ev.pr = evidence.pr;
3326
+ if (evidence.file) ev.file = evidence.file;
3327
+ const anchor = o.anchor ? { intent: o.anchor, setAt: (/* @__PURE__ */ new Date()).toISOString() } : void 0;
3254
3328
  return {
3255
3329
  event: "note",
3256
3330
  id,
@@ -3260,14 +3334,17 @@ function buildNoteCapture(summary, o, id, evidence) {
3260
3334
  queueOp,
3261
3335
  state,
3262
3336
  source,
3263
- evidence: Object.keys(anchor).length ? anchor : void 0,
3264
- surface: process.env.MMI_AGENT_SURFACE || "claude"
3337
+ evidence: Object.keys(ev).length ? ev : void 0,
3338
+ surface: process.env.MMI_AGENT_SURFACE || "claude",
3339
+ supersedes: o.supersedes,
3340
+ anchor,
3341
+ anchorForce: o.anchorForce || void 0
3265
3342
  };
3266
3343
  }
3267
3344
 
3268
3345
  // src/version-lag.ts
3269
3346
  var VERSION_LABEL = "installed plugin/adapter cache freshness";
3270
- var VERSION_FIX = "refresh/update the MMI plugin, or run the repo-local CLI: node cli/dist/index.cjs doctor --json";
3347
+ var VERSION_FIX = "update the MMI plugin via /plugin; standalone npm CLI: npm install -g @mutmutco/cli@latest";
3271
3348
  function parseVersion(v) {
3272
3349
  return v.replace(/^v/, "").split(/[.-]/).slice(0, 3).map((part) => {
3273
3350
  const n = Number.parseInt(part, 10);
@@ -3315,6 +3392,10 @@ function buildVersionLagReport(input) {
3315
3392
  releasedVersion: input.releasedVersion
3316
3393
  };
3317
3394
  }
3395
+ function versionAutoUpdateAction(report, hasPluginRoot) {
3396
+ if (report.ok || report.staleAgainst !== "released") return "none";
3397
+ return hasPluginRoot ? "plugin-pull" : "npm";
3398
+ }
3318
3399
 
3319
3400
  // src/issue-related.ts
3320
3401
  var STOPWORDS = /* @__PURE__ */ new Set([
@@ -3373,7 +3454,50 @@ ${lines.join("\n")}`;
3373
3454
  // src/board.ts
3374
3455
  var import_node_child_process2 = require("node:child_process");
3375
3456
  var import_node_util = require("node:util");
3376
- var execFileP = (0, import_node_util.promisify)(import_node_child_process2.execFile);
3457
+
3458
+ // src/board-priority.ts
3459
+ var BOARD_PRIORITY_NAMES = ["Urgent", "High", "Medium", "Low"];
3460
+ var CLI_PRIORITIES = ["urgent", "high", "medium", "low"];
3461
+ var LABEL_PREFIX = "priority:";
3462
+ function cliPriorityToFieldName(priority) {
3463
+ if (!CLI_PRIORITIES.includes(priority)) {
3464
+ throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${CLI_PRIORITIES.join(", ")}`);
3465
+ }
3466
+ return priority.charAt(0).toUpperCase() + priority.slice(1);
3467
+ }
3468
+ function labelToFieldPriority(label) {
3469
+ if (!label.startsWith(LABEL_PREFIX)) return void 0;
3470
+ const slug = label.slice(LABEL_PREFIX.length).toLowerCase();
3471
+ if (!CLI_PRIORITIES.includes(slug)) return void 0;
3472
+ return cliPriorityToFieldName(slug);
3473
+ }
3474
+ function resolvePriorityOptionId(cfg, priority) {
3475
+ if (!cfg.priorityFieldId || !cfg.priorityOptions) return void 0;
3476
+ const name = cliPriorityToFieldName(priority);
3477
+ return cfg.priorityOptions[name];
3478
+ }
3479
+ function isPriorityFieldConfigured(cfg) {
3480
+ return Boolean(
3481
+ cfg.priorityFieldId && BOARD_PRIORITY_NAMES.every((name) => cfg.priorityOptions?.[name])
3482
+ );
3483
+ }
3484
+ function recoverPriorityFromEvents(events) {
3485
+ let found;
3486
+ for (const event of events) {
3487
+ if (event.event !== "labeled" || !event.label?.name) continue;
3488
+ const mapped = labelToFieldPriority(event.label.name);
3489
+ if (mapped) found = mapped;
3490
+ }
3491
+ return found;
3492
+ }
3493
+
3494
+ // src/board.ts
3495
+ var rawExecFileP = (0, import_node_util.promisify)(import_node_child_process2.execFile);
3496
+ var execFileP = (file, args, options = {}) => (
3497
+ // encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
3498
+ // overloads widen to string|Buffer when options is spread in).
3499
+ rawExecFileP(file, args, { encoding: "utf8", windowsHide: true, ...options })
3500
+ );
3377
3501
  var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
3378
3502
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
3379
3503
  var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
@@ -3391,7 +3515,7 @@ var defaultGit = async (args) => {
3391
3515
  }
3392
3516
  };
3393
3517
  var PROJECT_ITEMS_QUERY = `
3394
- query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
3518
+ query($owner: String!, $number: Int!, $after: String) {
3395
3519
  viewer { login }
3396
3520
  organization(login: $owner) {
3397
3521
  projectV2(number: $number) {
@@ -3401,8 +3525,14 @@ query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
3401
3525
  pageInfo { hasNextPage endCursor }
3402
3526
  nodes {
3403
3527
  id
3404
- fieldValueByName(name: $statusField) {
3405
- ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
3528
+ fieldValues(first: 8) {
3529
+ nodes {
3530
+ ... on ProjectV2ItemFieldSingleSelectValue {
3531
+ name
3532
+ optionId
3533
+ field { ... on ProjectV2SingleSelectField { name } }
3534
+ }
3535
+ }
3406
3536
  }
3407
3537
  content {
3408
3538
  __typename
@@ -3448,7 +3578,9 @@ function resolveBoardConfig(cfg) {
3448
3578
  projectNumber: cfg.projectNumber,
3449
3579
  projectId: cfg.projectId,
3450
3580
  statusFieldId: cfg.statusFieldId,
3451
- statusOptions: cfg.statusOptions
3581
+ statusOptions: cfg.statusOptions,
3582
+ priorityFieldId: cfg.priorityFieldId,
3583
+ priorityOptions: cfg.priorityOptions
3452
3584
  };
3453
3585
  }
3454
3586
  function repoFromGitRemote(remote) {
@@ -3521,6 +3653,22 @@ function findClaimableItem(report, selector) {
3521
3653
  }
3522
3654
  throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
3523
3655
  }
3656
+ function renderBoardItem(item) {
3657
+ const assignees = item.assignees.length ? `@${item.assignees.join(", @")}` : "unassigned";
3658
+ const lines = [
3659
+ `${item.ref} - ${item.title}`,
3660
+ `Status: ${item.status} \xB7 ${assignees}${item.priority ? ` \xB7 Priority: ${item.priority}` : ""}`,
3661
+ `Type: ${item.type ?? "item"}${item.labels.length ? ` \xB7 ${item.labels.join(", ")}` : ""}`,
3662
+ item.url
3663
+ ];
3664
+ if (item.details) {
3665
+ lines.push("", item.details.body.trim() || "_(no body)_");
3666
+ for (const comment of item.details.comments) {
3667
+ lines.push("", `\u2014 @${comment.author}:`, comment.body.trim());
3668
+ }
3669
+ }
3670
+ return lines.join("\n");
3671
+ }
3524
3672
  function renderBoardReport(report) {
3525
3673
  const lines = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`];
3526
3674
  renderScope(lines, "PRIMARY", report.repo, report.primary);
@@ -3531,8 +3679,7 @@ function renderBoardReport(report) {
3531
3679
  }
3532
3680
  return lines.join("\n");
3533
3681
  }
3534
- async function readBoard(options, deps = {}) {
3535
- const cfg = resolveBoardConfig(options.config);
3682
+ async function collectBoardItems(cfg, options, deps) {
3536
3683
  const gh = deps.gh ?? defaultGh;
3537
3684
  const git = deps.git ?? defaultGit;
3538
3685
  const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
@@ -3562,21 +3709,93 @@ async function readBoard(options, deps = {}) {
3562
3709
  after = void 0;
3563
3710
  }
3564
3711
  } while (after);
3565
- const items = nodesToItems(nodes, warnings);
3566
- const groups = partitionBoardItems(items, viewer, currentRepo);
3712
+ return { items: nodesToItems(nodes, warnings), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
3713
+ }
3714
+ async function readBoard(options, deps = {}) {
3715
+ const cfg = resolveBoardConfig(options.config);
3716
+ const gh = deps.gh ?? defaultGh;
3717
+ const collected = await collectBoardItems(cfg, options, deps);
3718
+ const groups = partitionBoardItems(collected.items, collected.viewer, collected.repo);
3567
3719
  const report = {
3568
- project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: projectId, title: projectTitle || String(cfg.projectNumber) },
3569
- viewer,
3570
- repo: currentRepo,
3720
+ project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
3721
+ viewer: collected.viewer,
3722
+ repo: collected.repo,
3571
3723
  ...groups,
3572
- warnings,
3573
- partial
3724
+ warnings: collected.warnings,
3725
+ partial: collected.partial
3574
3726
  };
3575
3727
  if (options.includeBundleDetails) {
3576
3728
  await attachBundleDetails(report, gh, options.allowPartial ?? false);
3577
3729
  }
3578
3730
  return report;
3579
3731
  }
3732
+ function findBoardItem(items, selector) {
3733
+ const found = items.find(
3734
+ (candidate) => candidate.repository.toLowerCase() === selector.repo.toLowerCase() && candidate.number === selector.number
3735
+ );
3736
+ if (!found) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
3737
+ return found;
3738
+ }
3739
+ async function moveBoardItem(options, deps = {}) {
3740
+ if (!BOARD_STATUSES.includes(options.status)) {
3741
+ throw new Error(`unknown status '${options.status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
3742
+ }
3743
+ const cfg = resolveBoardConfig(options.config);
3744
+ const gh = deps.gh ?? defaultGh;
3745
+ const collected = await collectBoardItems(cfg, options, deps);
3746
+ const selector = parseIssueSelector(options.selector, collected.repo);
3747
+ const item = findBoardItem(collected.items, selector);
3748
+ if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
3749
+ const optionId = cfg.statusOptions[options.status];
3750
+ try {
3751
+ await gh([
3752
+ "project",
3753
+ "item-edit",
3754
+ "--id",
3755
+ item.itemId,
3756
+ "--project-id",
3757
+ cfg.projectId,
3758
+ "--field-id",
3759
+ cfg.statusFieldId,
3760
+ "--single-select-option-id",
3761
+ optionId
3762
+ ]);
3763
+ } catch (e) {
3764
+ const warning = `partial move: ${item.ref} status was not changed to ${options.status} (${ghError(e)})`;
3765
+ if (!options.allowPartial) throw new Error(warning);
3766
+ return { item, viewer: collected.viewer, repo: collected.repo, status: item.status, partial: true, warning };
3767
+ }
3768
+ return {
3769
+ item: { ...item, status: options.status, statusOptionId: optionId },
3770
+ viewer: collected.viewer,
3771
+ repo: collected.repo,
3772
+ status: options.status,
3773
+ partial: false
3774
+ };
3775
+ }
3776
+ async function showBoardItem(options, deps = {}) {
3777
+ const cfg = resolveBoardConfig(options.config);
3778
+ const gh = deps.gh ?? defaultGh;
3779
+ const collected = await collectBoardItems(cfg, options, deps);
3780
+ const selector = parseIssueSelector(options.selector, collected.repo);
3781
+ const item = findBoardItem(collected.items, selector);
3782
+ if (item.contentType === "Issue") {
3783
+ try {
3784
+ const { stdout } = await gh(["issue", "view", String(item.number), "--repo", item.repository, "--json", "body,comments"]);
3785
+ const detail = JSON.parse(stdout);
3786
+ item.details = {
3787
+ body: detail.body ?? "",
3788
+ comments: (detail.comments ?? []).map((comment) => ({
3789
+ author: comment.author?.login ?? "",
3790
+ body: comment.body ?? ""
3791
+ }))
3792
+ };
3793
+ } catch (e) {
3794
+ if (!options.allowPartial) throw new Error(`detail read failed: ${item.ref}: ${ghError(e)}`);
3795
+ }
3796
+ }
3797
+ return item;
3798
+ }
3580
3799
  async function claimBoardIssue(options, deps = {}) {
3581
3800
  const cfg = resolveBoardConfig(options.config);
3582
3801
  const gh = deps.gh ?? defaultGh;
@@ -3584,8 +3803,10 @@ async function claimBoardIssue(options, deps = {}) {
3584
3803
  const selector = parseIssueSelector(options.selector, report.repo);
3585
3804
  const item = findClaimableItem(report, selector);
3586
3805
  if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
3806
+ const assignee = options.assignee ?? "@me";
3807
+ const assignedLogin = assignee === "@me" ? report.viewer : assignee.replace(/^@/, "");
3587
3808
  try {
3588
- await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", "@me"]);
3809
+ await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", assignee]);
3589
3810
  } catch (e) {
3590
3811
  throw new Error(`claim failed before board status changed: ${ghError(e)}`);
3591
3812
  }
@@ -3603,11 +3824,117 @@ async function claimBoardIssue(options, deps = {}) {
3603
3824
  cfg.statusOptions["In Progress"]
3604
3825
  ]);
3605
3826
  } catch (e) {
3606
- const warning = `partial claim: ${item.ref} was assigned to @${report.viewer}, but Status was not moved to In Progress (${ghError(e)})`;
3827
+ const warning = `partial claim: ${item.ref} was assigned to @${assignedLogin}, but Status was not moved to In Progress (${ghError(e)})`;
3607
3828
  if (!options.allowPartial) throw new Error(warning);
3608
3829
  return { item, viewer: report.viewer, repo: report.repo, status: "Todo", partial: true, warning };
3609
3830
  }
3610
- return { item: { ...item, assignees: [...item.assignees, report.viewer], status: "In Progress" }, viewer: report.viewer, repo: report.repo, status: "In Progress", partial: false };
3831
+ return {
3832
+ item: {
3833
+ ...item,
3834
+ assignees: item.assignees.includes(assignedLogin) ? item.assignees : [...item.assignees, assignedLogin],
3835
+ status: "In Progress",
3836
+ statusOptionId: cfg.statusOptions["In Progress"]
3837
+ },
3838
+ viewer: report.viewer,
3839
+ repo: report.repo,
3840
+ status: "In Progress",
3841
+ partial: false
3842
+ };
3843
+ }
3844
+ async function setBoardItemPriority(gh, cfg, itemId, priority) {
3845
+ if (!isPriorityFieldConfigured(cfg)) return void 0;
3846
+ const optionId = resolvePriorityOptionId(cfg, priority);
3847
+ if (!optionId || !cfg.priorityFieldId || !cfg.projectId) return void 0;
3848
+ await gh([
3849
+ "project",
3850
+ "item-edit",
3851
+ "--id",
3852
+ itemId,
3853
+ "--project-id",
3854
+ cfg.projectId,
3855
+ "--field-id",
3856
+ cfg.priorityFieldId,
3857
+ "--single-select-option-id",
3858
+ optionId
3859
+ ]);
3860
+ return cliPriorityToFieldName(priority);
3861
+ }
3862
+ async function backfillBoardPriorities(options, deps = {}) {
3863
+ const cfg = resolveBoardConfig(options.config);
3864
+ if (!isPriorityFieldConfigured(cfg)) {
3865
+ throw new Error("priority field is not configured in .mmi/config.json (priorityFieldId + priorityOptions)");
3866
+ }
3867
+ const gh = deps.gh ?? defaultGh;
3868
+ const collected = await collectBoardItems(cfg, { repo: options.repo }, deps);
3869
+ const issues = collected.items.filter((item) => item.contentType === "Issue");
3870
+ const concurrency = Math.max(1, options.concurrency ?? 8);
3871
+ const result = { scanned: issues.length, set: 0, skipped: 0, failed: 0, details: [] };
3872
+ async function work(item) {
3873
+ if (item.priority) {
3874
+ result.skipped += 1;
3875
+ return;
3876
+ }
3877
+ try {
3878
+ const priority = await recoverIssuePriority(gh, item);
3879
+ if (!priority) {
3880
+ result.skipped += 1;
3881
+ return;
3882
+ }
3883
+ if (options.dryRun) {
3884
+ result.set += 1;
3885
+ result.details.push(`${item.ref} \u2192 ${priority} (dry-run)`);
3886
+ return;
3887
+ }
3888
+ const optionId = cfg.priorityOptions?.[priority];
3889
+ if (!optionId) throw new Error(`no option id for ${priority}`);
3890
+ await gh([
3891
+ "project",
3892
+ "item-edit",
3893
+ "--id",
3894
+ item.itemId,
3895
+ "--project-id",
3896
+ cfg.projectId,
3897
+ "--field-id",
3898
+ cfg.priorityFieldId,
3899
+ "--single-select-option-id",
3900
+ optionId
3901
+ ]);
3902
+ result.set += 1;
3903
+ result.details.push(`${item.ref} \u2192 ${priority}`);
3904
+ } catch (e) {
3905
+ result.failed += 1;
3906
+ result.details.push(`${item.ref}: ${ghError(e)}`);
3907
+ }
3908
+ }
3909
+ for (let i = 0; i < issues.length; i += concurrency) {
3910
+ await Promise.all(issues.slice(i, i + concurrency).map(work));
3911
+ }
3912
+ return result;
3913
+ }
3914
+ async function recoverIssuePriority(gh, item) {
3915
+ for (const label of item.labels) {
3916
+ const fromLabel = labelToFieldPriority(label);
3917
+ if (fromLabel) return fromLabel;
3918
+ }
3919
+ const { stdout } = await gh(["api", `repos/${item.repository}/issues/${item.number}/events`, "--paginate"]);
3920
+ return recoverPriorityFromEvents(parsePaginatedEvents(stdout));
3921
+ }
3922
+ function parsePaginatedEvents(stdout) {
3923
+ const trimmed = stdout.trim();
3924
+ if (!trimmed) return [];
3925
+ try {
3926
+ const parsed = JSON.parse(trimmed);
3927
+ if (Array.isArray(parsed)) return parsed;
3928
+ } catch {
3929
+ }
3930
+ return trimmed.split(/\r?\n/).filter(Boolean).flatMap((line) => {
3931
+ try {
3932
+ const parsed = JSON.parse(line);
3933
+ return Array.isArray(parsed) ? parsed : [];
3934
+ } catch {
3935
+ return [];
3936
+ }
3937
+ });
3611
3938
  }
3612
3939
  async function fetchProjectPage(gh, cfg, after) {
3613
3940
  const args = [
@@ -3618,9 +3945,7 @@ async function fetchProjectPage(gh, cfg, after) {
3618
3945
  "-f",
3619
3946
  `owner=${cfg.projectOwner}`,
3620
3947
  "-F",
3621
- `number=${cfg.projectNumber}`,
3622
- "-f",
3623
- "statusField=Status"
3948
+ `number=${cfg.projectNumber}`
3624
3949
  ];
3625
3950
  if (after) args.push("-f", `after=${after}`);
3626
3951
  const { stdout } = await gh(args);
@@ -3637,10 +3962,27 @@ function nodesToItems(nodes, warnings) {
3637
3962
  }
3638
3963
  return items;
3639
3964
  }
3965
+ function parseSingleSelectFields(nodes) {
3966
+ const out = {};
3967
+ for (const node of nodes ?? []) {
3968
+ const fieldName = node.field?.name;
3969
+ if (fieldName === "Status") {
3970
+ const status = asBoardStatus(node.name);
3971
+ if (status) out.status = { name: status, optionId: node.optionId };
3972
+ } else if (fieldName === "Priority" && node.name) {
3973
+ const priority = node.name;
3974
+ if (["Urgent", "High", "Medium", "Low"].includes(priority)) {
3975
+ out.priority = { name: priority, optionId: node.optionId };
3976
+ }
3977
+ }
3978
+ }
3979
+ return out;
3980
+ }
3640
3981
  function nodeToItem(node) {
3641
3982
  const content = node.content;
3642
3983
  if (!node.id || !isSupportedContent(content)) return void 0;
3643
- const status = asBoardStatus(node.fieldValueByName?.name);
3984
+ const fields = parseSingleSelectFields(node.fieldValues?.nodes);
3985
+ const status = fields.status?.name;
3644
3986
  const repository = content.repository?.nameWithOwner;
3645
3987
  if (!status || !content.id || !content.number || !content.title || !content.url || !repository) return void 0;
3646
3988
  const labels = (content.labels?.nodes ?? []).map((l) => l.name).filter((name) => Boolean(name));
@@ -3656,7 +3998,9 @@ function nodeToItem(node) {
3656
3998
  title: content.title,
3657
3999
  state: content.state ?? "",
3658
4000
  status,
3659
- statusOptionId: node.fieldValueByName?.optionId,
4001
+ statusOptionId: fields.status?.optionId,
4002
+ priority: fields.priority?.name,
4003
+ priorityOptionId: fields.priority?.optionId,
3660
4004
  assignees,
3661
4005
  labels,
3662
4006
  type: labels.find((label) => TYPE_LABELS.includes(label)) ?? labels[0]
@@ -3715,7 +4059,8 @@ function renderTaken(lines, items) {
3715
4059
  for (const item of items) lines.push(` ${item.ref} \xB7 ${item.status} \xB7 @${item.assignees.join(", @")}`);
3716
4060
  }
3717
4061
  function renderTitledItem(item) {
3718
- return `${item.ref} - [${item.type ?? "item"}] ${item.title}`;
4062
+ const pri = item.priority ? ` \xB7 ${item.priority}` : "";
4063
+ return `${item.ref} - [${item.type ?? "item"}]${pri} ${item.title}`;
3719
4064
  }
3720
4065
  function hasItems(buckets) {
3721
4066
  return buckets.userOwned.length > 0 || buckets.claimable.length > 0 || buckets.taken.length > 0;
@@ -3824,31 +4169,31 @@ function parseWorktreePorcelain(stdout) {
3824
4169
  }
3825
4170
  return out;
3826
4171
  }
3827
- function formatGcPlan(plan, apply) {
4172
+ function formatGcPlan(plan2, apply) {
3828
4173
  const lines = [`mmi-cli gc: ${apply ? "apply" : "dry-run"}`];
3829
- if (!plan.branches.length && !plan.trackingRefs.length) lines.push("nothing to clean");
3830
- if (plan.branches.length) {
4174
+ if (!plan2.branches.length && !plan2.trackingRefs.length) lines.push("nothing to clean");
4175
+ if (plan2.branches.length) {
3831
4176
  lines.push("local branches:");
3832
- for (const b of plan.branches) {
4177
+ for (const b of plan2.branches) {
3833
4178
  const prs = b.prNumbers.length ? ` #${b.prNumbers.join(",#")}` : "";
3834
4179
  const wt = b.worktreePath ? ` (worktree: ${b.worktreePath})` : "";
3835
4180
  lines.push(` - ${b.branch} (${b.prState}${prs})${wt}`);
3836
4181
  }
3837
4182
  }
3838
- if (plan.trackingRefs.length) {
4183
+ if (plan2.trackingRefs.length) {
3839
4184
  lines.push("stale tracking refs:");
3840
- for (const r of plan.trackingRefs) {
4185
+ for (const r of plan2.trackingRefs) {
3841
4186
  const prs = r.prNumbers.length ? ` #${r.prNumbers.join(",#")}` : "";
3842
4187
  lines.push(` - ${r.ref} (${r.prState}${prs})`);
3843
4188
  }
3844
4189
  }
3845
- if (plan.skipped.length) {
4190
+ if (plan2.skipped.length) {
3846
4191
  lines.push("skipped:");
3847
- for (const s of plan.skipped) {
4192
+ for (const s of plan2.skipped) {
3848
4193
  lines.push(` - ${s.branch}: ${s.reason}${s.detail ? ` (${s.detail})` : ""}`);
3849
4194
  }
3850
4195
  }
3851
- if (!apply && (plan.branches.length || plan.trackingRefs.length)) lines.push("rerun with --apply to delete only the listed items");
4196
+ if (!apply && (plan2.branches.length || plan2.trackingRefs.length)) lines.push("rerun with --apply to delete only the listed items");
3852
4197
  return lines.join("\n");
3853
4198
  }
3854
4199
 
@@ -3914,6 +4259,7 @@ function buildGithubAuthCheck(input) {
3914
4259
  var import_node_child_process3 = require("node:child_process");
3915
4260
  var import_node_fs2 = require("node:fs");
3916
4261
  var import_node_path2 = require("node:path");
4262
+ var import_node_net = require("node:net");
3917
4263
  var import_node_util2 = require("node:util");
3918
4264
  var execFileP2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
3919
4265
  function stageStatePath(cwd = process.cwd()) {
@@ -3926,8 +4272,29 @@ function validateStageConfig(config = {}, action) {
3926
4272
  if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
3927
4273
  problems.push("stage.healthUrl must be an http(s) URL");
3928
4274
  }
4275
+ if (config.portRange != null) {
4276
+ const r = config.portRange;
4277
+ const ok = Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n) && n >= 1024 && n <= 65535) && r[0] <= r[1];
4278
+ if (!ok) problems.push("stage.portRange must be [start, end] within 1024-65535 with start <= end");
4279
+ }
3929
4280
  return problems;
3930
4281
  }
4282
+ function pickStagePort(range, isFree) {
4283
+ if (!range) return void 0;
4284
+ const [start, end] = range;
4285
+ for (let port = start; port <= end; port++) {
4286
+ if (isFree(port)) return port;
4287
+ }
4288
+ throw new Error(`no free stage port in range ${start}-${end} \u2014 every port is in use`);
4289
+ }
4290
+ function isPortFree(port) {
4291
+ return new Promise((resolve) => {
4292
+ const srv = (0, import_node_net.createServer)();
4293
+ srv.once("error", () => resolve(false));
4294
+ srv.once("listening", () => srv.close(() => resolve(true)));
4295
+ srv.listen(port, "127.0.0.1");
4296
+ });
4297
+ }
3931
4298
  async function shell(command, cwd, timeoutMs) {
3932
4299
  await execFileP2(command, [], {
3933
4300
  cwd,
@@ -4002,24 +4369,34 @@ async function startStage(config = {}, opts = {}) {
4002
4369
  const statePath = opts.statePath ?? stageStatePath(cwd);
4003
4370
  const dir = statePath.slice(0, Math.max(statePath.lastIndexOf("/"), statePath.lastIndexOf("\\")));
4004
4371
  (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
4005
- const up = config.up.trim();
4372
+ let stagePort;
4373
+ if (config.portRange) {
4374
+ const [s, e] = config.portRange;
4375
+ const free = /* @__PURE__ */ new Set();
4376
+ for (let p = s; p <= e; p++) if (await isPortFree(p)) free.add(p);
4377
+ stagePort = pickStagePort(config.portRange, (p) => free.has(p));
4378
+ }
4379
+ const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
4380
+ const up = sub(config.up.trim());
4006
4381
  const child = (0, import_node_child_process3.spawn)(up, {
4007
4382
  cwd,
4008
4383
  shell: true,
4009
4384
  detached: true,
4010
4385
  windowsHide: true,
4011
- stdio: "ignore"
4386
+ stdio: "ignore",
4387
+ env: stagePort != null ? { ...process.env, STAGE_PORT: String(stagePort) } : process.env
4012
4388
  });
4013
4389
  const state = {
4014
4390
  pid: child.pid ?? 0,
4015
4391
  command: up,
4016
4392
  cwd,
4017
4393
  startedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
4018
- healthUrl: config.healthUrl?.trim() || void 0
4394
+ healthUrl: sub(config.healthUrl?.trim()) || void 0,
4395
+ port: stagePort
4019
4396
  };
4020
4397
  (0, import_node_fs2.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
4021
4398
  if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4);
4022
- const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}` };
4399
+ const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
4023
4400
  opts.onReady?.(result);
4024
4401
  child.unref();
4025
4402
  return result;
@@ -4035,10 +4412,43 @@ async function runStage(config = {}, opts = {}) {
4035
4412
  return { ...started, action: "run", message: `built and ${started.message}` };
4036
4413
  }
4037
4414
 
4038
- // src/bootstrap-verify.ts
4039
- var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
4040
- var requiredLabels = ["bug", "feature", "task"];
4041
- function expectedBranches(repoClass) {
4415
+ // src/port-registry.ts
4416
+ var import_node_fs3 = require("node:fs");
4417
+ var BLOCK = 100;
4418
+ var SPAN = 10;
4419
+ var FIRST = 3e3;
4420
+ function nextPortBlock(registry) {
4421
+ const bases = Object.values(registry).map(([start]) => start);
4422
+ const base = bases.length ? Math.max(...bases) + BLOCK : FIRST;
4423
+ return [base, base + SPAN];
4424
+ }
4425
+ function loadPortRegistry(path) {
4426
+ if (!(0, import_node_fs3.existsSync)(path)) return {};
4427
+ const raw = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8"));
4428
+ const out = {};
4429
+ for (const [key, value] of Object.entries(raw)) {
4430
+ if (Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number")) {
4431
+ out[key] = [value[0], value[1]];
4432
+ }
4433
+ }
4434
+ return out;
4435
+ }
4436
+ function ensurePortRange(repo, path) {
4437
+ const registry = loadPortRegistry(path);
4438
+ const existing = registry[repo];
4439
+ if (existing) return existing;
4440
+ const range = nextPortBlock(registry);
4441
+ const raw = (0, import_node_fs3.existsSync)(path) ? JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8")) : {};
4442
+ raw[repo] = range;
4443
+ (0, import_node_fs3.writeFileSync)(path, JSON.stringify(raw, null, 2) + "\n", "utf8");
4444
+ return range;
4445
+ }
4446
+
4447
+ // src/access.ts
4448
+ var OWNER = "mutmutco";
4449
+ var LOCKED_APP = "mmi-github-app";
4450
+ var OVERGRANT_ROLES = /* @__PURE__ */ new Set(["admin", "maintain"]);
4451
+ function lockedBranches(repoClass) {
4042
4452
  return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4043
4453
  }
4044
4454
  function safeJson(text, fallback) {
@@ -4055,6 +4465,177 @@ async function ghJson(deps, args, fallback) {
4055
4465
  return fallback;
4056
4466
  }
4057
4467
  }
4468
+ async function resolveOwners(deps) {
4469
+ const members = await ghJson(deps, ["api", `orgs/${OWNER}/members?role=admin`, "--paginate"], []);
4470
+ return members.map((m) => m.login);
4471
+ }
4472
+ function collaboratorRole(c) {
4473
+ return c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : "write");
4474
+ }
4475
+ async function auditRepoCollaborators(repo, owners, deps) {
4476
+ const collabs = await ghJson(deps, ["api", `repos/${repo}/collaborators?affiliation=direct`, "--paginate"], []);
4477
+ const findings = [];
4478
+ for (const c of collabs) {
4479
+ if (owners.has(c.login)) continue;
4480
+ const role = collaboratorRole(c);
4481
+ if (OVERGRANT_ROLES.has(role)) {
4482
+ findings.push({
4483
+ repo,
4484
+ kind: "collaborator-overgrant",
4485
+ severity: "high",
4486
+ actor: c.login,
4487
+ detail: `direct collaborator @${c.login} holds role '${role}'; a developer must be 'write' (admin/maintain is master-only)`,
4488
+ remediation: `gh api -X PUT repos/${repo}/collaborators/${c.login} -f permission=push`
4489
+ });
4490
+ }
4491
+ }
4492
+ return findings;
4493
+ }
4494
+ async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
4495
+ let restrictions = null;
4496
+ try {
4497
+ restrictions = safeJson((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection/restrictions`])).stdout, null);
4498
+ } catch {
4499
+ restrictions = null;
4500
+ }
4501
+ if (!restrictions) {
4502
+ return [{
4503
+ repo,
4504
+ branch,
4505
+ kind: "unprotected-branch",
4506
+ severity: "medium",
4507
+ detail: `${branch} has no push restrictions (branch unprotected, or protection without a user/app allowlist)`,
4508
+ remediation: `initialize the lock \u2014 see docs/Guides/repo-access.md "Initialize the lock"; PUT repos/${repo}/branches/${branch}/protection with restrictions {users:[<owners>], apps:["${LOCKED_APP}"]}`
4509
+ }];
4510
+ }
4511
+ const findings = [];
4512
+ const users = (restrictions.users ?? []).map((u) => u.login);
4513
+ for (const login of users) {
4514
+ if (!owners.has(login) && !projectAdmins.has(login)) {
4515
+ findings.push({
4516
+ repo,
4517
+ branch,
4518
+ kind: "train-allowlist-extra",
4519
+ severity: "medium",
4520
+ actor: login,
4521
+ detail: `@${login} is on the ${branch} push allowlist \u2014 legitimate only if an intended full-write project-admin; confirm`,
4522
+ remediation: `# if NOT an intended full-write member: gh api -X DELETE repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${login}"]'`
4523
+ });
4524
+ }
4525
+ }
4526
+ for (const owner of owners) {
4527
+ if (!users.includes(owner)) {
4528
+ findings.push({
4529
+ repo,
4530
+ branch,
4531
+ kind: "train-allowlist-missing",
4532
+ severity: "medium",
4533
+ actor: owner,
4534
+ detail: `org owner @${owner} is missing from the ${branch} push allowlist`,
4535
+ remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${owner}"]'`
4536
+ });
4537
+ }
4538
+ }
4539
+ const apps = (restrictions.apps ?? []).map((a) => a.slug);
4540
+ if (!apps.includes(LOCKED_APP)) {
4541
+ findings.push({
4542
+ repo,
4543
+ branch,
4544
+ kind: "app-bypass-missing",
4545
+ severity: "high",
4546
+ detail: `the ${LOCKED_APP} App is missing from the ${branch} allowlist \u2014 fanout/promotions will break`,
4547
+ remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/apps --input - <<< '["${LOCKED_APP}"]'`
4548
+ });
4549
+ }
4550
+ return findings;
4551
+ }
4552
+ async function auditRepoAccess(repo, repoClass, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
4553
+ const findings = [];
4554
+ findings.push(...await auditRepoCollaborators(repo, owners, deps));
4555
+ for (const branch of lockedBranches(repoClass)) {
4556
+ findings.push(...await auditTrainBranch(repo, branch, owners, deps, projectAdmins));
4557
+ }
4558
+ return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
4559
+ }
4560
+ async function auditOrgAccess(targets, deps, matrix = {}) {
4561
+ const owners = new Set(await resolveOwners(deps));
4562
+ const repos = [];
4563
+ for (const target of targets) {
4564
+ repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? [])));
4565
+ }
4566
+ return { ok: repos.every((r) => r.ok), owners: [...owners], repos };
4567
+ }
4568
+ function loadAccessTargets(projectsJson, fanoutJson) {
4569
+ const projects = safeJson(projectsJson, {}).projects ?? [];
4570
+ const fanout = fanoutJson ? safeJson(fanoutJson, {}).repos ?? [] : [];
4571
+ const contentNames = new Set(fanout.filter((r) => r.class === "content").map((r) => r.repo));
4572
+ const seen = /* @__PURE__ */ new Set();
4573
+ const targets = [];
4574
+ for (const project of projects) {
4575
+ for (const repo of project.repos ?? []) {
4576
+ if (seen.has(repo)) continue;
4577
+ seen.add(repo);
4578
+ targets.push({ repo, class: contentNames.has(repo.split("/")[1]) ? "content" : "deployable" });
4579
+ }
4580
+ }
4581
+ return targets;
4582
+ }
4583
+ function loadAccessMatrix(matrixJson) {
4584
+ if (!matrixJson) return {};
4585
+ return safeJson(matrixJson, {}).projectAdmins ?? {};
4586
+ }
4587
+ function renderAccessReport(report) {
4588
+ const lines = [`mmi-cli access audit: ${report.ok ? "OK" : "CHECK"} (owners: ${report.owners.map((o) => "@" + o).join(", ") || "none"})`];
4589
+ for (const repo of report.repos) {
4590
+ lines.push(`${repo.ok ? "OK" : "FLAG"} ${repo.repo} (${repo.class})`);
4591
+ for (const finding of repo.findings) {
4592
+ lines.push(` [${finding.severity}] ${finding.kind}${finding.branch ? ` @${finding.branch}` : ""}: ${finding.detail}`);
4593
+ if (finding.remediation) lines.push(` ${finding.remediation}`);
4594
+ }
4595
+ }
4596
+ return lines.join("\n");
4597
+ }
4598
+
4599
+ // src/bootstrap-verify.ts
4600
+ var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
4601
+ var requiredIssueTemplates = [
4602
+ ".github/ISSUE_TEMPLATE/bug.yml",
4603
+ ".github/ISSUE_TEMPLATE/feature.yml",
4604
+ ".github/ISSUE_TEMPLATE/task.yml",
4605
+ ".github/ISSUE_TEMPLATE/config.yml"
4606
+ ];
4607
+ var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
4608
+ var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
4609
+ var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
4610
+ var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
4611
+ var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
4612
+ var requiredProjectWorkflows = [
4613
+ "Auto-add sub-issues to project",
4614
+ "Auto-close issue",
4615
+ "Item added to project",
4616
+ "Item closed",
4617
+ "Pull request linked to issue",
4618
+ "Pull request merged"
4619
+ ];
4620
+ var requiredActionsVariables = ["MMI_APP_ID"];
4621
+ var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
4622
+ function expectedBranches(repoClass) {
4623
+ return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4624
+ }
4625
+ function safeJson2(text, fallback) {
4626
+ try {
4627
+ return JSON.parse(text);
4628
+ } catch {
4629
+ return fallback;
4630
+ }
4631
+ }
4632
+ async function ghJson2(deps, args, fallback) {
4633
+ try {
4634
+ return safeJson2((await deps.gh(args)).stdout, fallback);
4635
+ } catch {
4636
+ return fallback;
4637
+ }
4638
+ }
4058
4639
  async function contentExists(deps, repo, branch, path) {
4059
4640
  try {
4060
4641
  const encodedPath = path.split("/").map(encodeURIComponent).join("/");
@@ -4064,60 +4645,629 @@ async function contentExists(deps, repo, branch, path) {
4064
4645
  return false;
4065
4646
  }
4066
4647
  }
4067
- async function protectedBranch(deps, repo, branch) {
4648
+ async function contentText(deps, repo, branch, path) {
4068
4649
  try {
4069
- await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`]);
4070
- return true;
4650
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
4651
+ const response = safeJson2(
4652
+ (await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`])).stdout,
4653
+ {}
4654
+ );
4655
+ if (response.content == null) return null;
4656
+ if (response.encoding != null && response.encoding !== "base64") return null;
4657
+ return Buffer.from(response.content.replace(/\s/g, ""), "base64").toString("utf8");
4071
4658
  } catch {
4072
- return false;
4659
+ return null;
4073
4660
  }
4074
4661
  }
4662
+ async function getProtection(deps, repo, branch) {
4663
+ try {
4664
+ return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
4665
+ } catch {
4666
+ return null;
4667
+ }
4668
+ }
4669
+ function hasPushAllowlist(p) {
4670
+ return Array.isArray(p?.restrictions?.users) && p.restrictions.users.length > 0;
4671
+ }
4672
+ function optionDetail(missing) {
4673
+ return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
4674
+ }
4075
4675
  function localRegistryCheck(deps, path, predicate) {
4076
4676
  const text = deps.readLocalFile?.(path);
4077
4677
  if (text == null) return null;
4078
- return predicate(safeJson(text, null));
4678
+ return predicate(safeJson2(text, null));
4079
4679
  }
4080
4680
  async function verifyBootstrap(repo, repoClass, deps) {
4081
4681
  const branchesWanted = expectedBranches(repoClass);
4082
4682
  const baseBranch = repoClass === "content" ? "main" : "development";
4083
4683
  const checks = [];
4084
- const repoInfo = await ghJson(deps, ["api", `repos/${repo}`], {});
4684
+ const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
4085
4685
  checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
4086
4686
  checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
4087
4687
  checks.push({ ok: repoInfo.has_wiki === true, label: "wiki enabled", detail: repoInfo.has_wiki === true ? void 0 : "has_wiki is false or unavailable" });
4088
- const branchList = await ghJson(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
4688
+ const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
4089
4689
  const branchNames = new Set(branchList.map((b) => b.name));
4090
4690
  for (const branch of branchesWanted) {
4091
4691
  checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
4092
- checks.push({ ok: await protectedBranch(deps, repo, branch), label: `branch protection exists: ${branch}` });
4692
+ const protection = await getProtection(deps, repo, branch);
4693
+ checks.push({ ok: protection != null, label: `branch protection exists: ${branch}` });
4694
+ checks.push({
4695
+ ok: hasPushAllowlist(protection),
4696
+ label: `push allowlist configured: ${branch}`,
4697
+ detail: hasPushAllowlist(protection) ? void 0 : "restrictions.users is empty or unset"
4698
+ });
4093
4699
  }
4700
+ const owners = new Set(await resolveOwners(deps));
4701
+ const overgrants = await auditRepoCollaborators(repo, owners, deps);
4702
+ checks.push({
4703
+ ok: overgrants.length === 0,
4704
+ label: "collaborator roles are master-only (no admin/maintain over-grant)",
4705
+ detail: overgrants.length ? `over-granted: ${overgrants.map((f) => f.actor).join(", ")}` : void 0
4706
+ });
4094
4707
  for (const path of requiredDocs) {
4095
4708
  checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `bootstrap artifact exists: ${path}` });
4096
4709
  }
4710
+ for (const path of requiredIssueTemplates) {
4711
+ checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `issue template exists: ${path}` });
4712
+ }
4713
+ for (const path of requiredWorkflows) {
4714
+ checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `automation workflow exists: ${path}` });
4715
+ }
4716
+ if (repoClass === "deployable") {
4717
+ const trainScript = "scripts/next-version.mjs";
4718
+ checks.push({ ok: await contentExists(deps, repo, baseBranch, trainScript), label: `train tooling script exists: ${trainScript}` });
4719
+ }
4097
4720
  checks.push({ ok: await contentExists(deps, repo, baseBranch, ".cursor/environment.json"), label: "Cursor environment committed" });
4098
- const labels = await ghJson(deps, ["label", "list", "--repo", repo, "--json", "name"], []);
4721
+ const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
4099
4722
  const labelNames = new Set(labels.map((l) => l.name));
4100
4723
  for (const label of requiredLabels) {
4101
4724
  checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
4102
4725
  }
4103
- const actions = await ghJson(deps, ["api", `repos/${repo}/actions/permissions`], {});
4726
+ const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
4727
+ checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: optionDetail(strays) });
4728
+ const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
4104
4729
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
4730
+ const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
4731
+ const variableNames = new Set(variables.map((v) => v.name));
4732
+ for (const variable of requiredActionsVariables) {
4733
+ checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
4734
+ }
4735
+ const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
4736
+ const secretNames = new Set(secrets2.map((s) => s.name));
4737
+ for (const secret of requiredActionsSecrets) {
4738
+ checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
4739
+ }
4740
+ const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
4741
+ checks.push({
4742
+ ok: Boolean(config?.projectOwner && config?.projectNumber),
4743
+ label: ".mmi project board config exists"
4744
+ });
4745
+ if (config?.projectOwner && config.projectNumber != null) {
4746
+ const project = await ghJson2(
4747
+ deps,
4748
+ ["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
4749
+ {}
4750
+ );
4751
+ const fields = project.fields || [];
4752
+ const statusField = fields.find((field) => field.name === "Status");
4753
+ const labelField = fields.find((field) => field.name === "Labels");
4754
+ checks.push({
4755
+ ok: Boolean(statusField),
4756
+ label: `Project Status field exists: ${config.projectOwner}#${config.projectNumber}`
4757
+ });
4758
+ checks.push({
4759
+ ok: Boolean(labelField),
4760
+ label: `Project Labels field exists: ${config.projectOwner}#${config.projectNumber}`
4761
+ });
4762
+ if (statusField != null) {
4763
+ const optionNames = new Set((statusField.options || []).map((option) => option.name));
4764
+ const missingOptions = requiredStatusOptions.filter((option) => !optionNames.has(option));
4765
+ checks.push({
4766
+ ok: missingOptions.length === 0,
4767
+ label: "Project Status lanes configured",
4768
+ detail: optionDetail(missingOptions)
4769
+ });
4770
+ checks.push({
4771
+ ok: config.statusFieldId === statusField.id,
4772
+ label: ".mmi statusFieldId matches project"
4773
+ });
4774
+ for (const optionName of requiredStatusOptions) {
4775
+ const projectOption = statusField.options?.find((option) => option.name === optionName);
4776
+ checks.push({
4777
+ ok: Boolean(projectOption?.id && config.statusOptions?.[optionName] === projectOption.id),
4778
+ label: `.mmi status option matches project: ${optionName}`
4779
+ });
4780
+ }
4781
+ }
4782
+ const priorityField = fields.find((field) => field.name === "Priority" && (field.options?.length ?? 0) > 0);
4783
+ checks.push({
4784
+ ok: Boolean(priorityField),
4785
+ label: `Project Priority field exists (API-writable): ${config.projectOwner}#${config.projectNumber}`
4786
+ });
4787
+ if (priorityField != null) {
4788
+ const priorityNames = new Set((priorityField.options || []).map((option) => option.name));
4789
+ const missingPriority = requiredPriorityOptions.filter((option) => !priorityNames.has(option));
4790
+ checks.push({
4791
+ ok: missingPriority.length === 0,
4792
+ label: "Project Priority options configured",
4793
+ detail: optionDetail(missingPriority)
4794
+ });
4795
+ checks.push({
4796
+ ok: config.priorityFieldId === priorityField.id,
4797
+ label: ".mmi priorityFieldId matches project"
4798
+ });
4799
+ for (const optionName of requiredPriorityOptions) {
4800
+ const projectOption = priorityField.options?.find((option) => option.name === optionName);
4801
+ checks.push({
4802
+ ok: Boolean(projectOption?.id && config.priorityOptions?.[optionName] === projectOption.id),
4803
+ label: `.mmi priority option matches project: ${optionName}`
4804
+ });
4805
+ }
4806
+ }
4807
+ const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
4808
+ const workflowResponse = await ghJson2(
4809
+ deps,
4810
+ ["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
4811
+ {}
4812
+ );
4813
+ const workflows = workflowResponse.data?.organization?.projectV2?.workflows?.nodes || [];
4814
+ for (const workflowName of requiredProjectWorkflows) {
4815
+ checks.push({
4816
+ ok: workflows.some((workflow) => workflow.name === workflowName && workflow.enabled === true),
4817
+ label: `Project workflow enabled: ${workflowName}`
4818
+ });
4819
+ }
4820
+ }
4105
4821
  const fanout = repo === "mutmutco/MMI-Hub" ? true : localRegistryCheck(deps, ".github/fanout-targets.json", (json) => Array.isArray(json?.repos) && json.repos.some((r) => r.repo === repo.split("/")[1] && r.branch === baseBranch));
4106
4822
  if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
4107
4823
  const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
4108
4824
  if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
4109
- return { ok: checks.every((c) => c.ok), repo, class: repoClass, baseBranch, checks };
4825
+ const rulesets = await ghJson2(
4826
+ deps,
4827
+ ["api", `repos/${repo}/rulesets?includes_parents=true`],
4828
+ []
4829
+ );
4830
+ const orgRuleset = rulesets.some(
4831
+ (r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
4832
+ );
4833
+ checks.push({
4834
+ ok: orgRuleset,
4835
+ label: "covered by an active org ruleset",
4836
+ detail: orgRuleset ? void 0 : "no active Organization-sourced branch ruleset targets this repo"
4837
+ });
4838
+ const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
4839
+ return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
4840
+ }
4841
+ function applyWaivers(checks, waivers) {
4842
+ if (!waivers?.length) return checks;
4843
+ const set = new Set(waivers);
4844
+ return checks.map((c) => !c.ok && set.has(c.label) ? { ...c, waived: true } : c);
4110
4845
  }
4111
4846
  function renderBootstrapVerifyReport(report) {
4112
4847
  const lines = [`mmi-cli bootstrap verify: ${report.ok ? "OK" : "CHECK"} ${report.repo} (${report.class}, ${report.baseBranch})`];
4113
4848
  for (const check of report.checks) {
4114
- lines.push(`${check.ok ? "\u2713" : "\u2717"} ${check.label}${check.detail ? ` \u2014 ${check.detail}` : ""}`);
4849
+ const status = check.ok ? "OK" : check.waived ? "WAIVE" : "FAIL";
4850
+ lines.push(`${status} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
4115
4851
  }
4116
4852
  return lines.join("\n");
4117
4853
  }
4118
4854
 
4855
+ // src/bootstrap-seeds.ts
4856
+ var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
4857
+ function loadBootstrapSeeds(manifestJson) {
4858
+ let parsed;
4859
+ try {
4860
+ parsed = JSON.parse(manifestJson);
4861
+ } catch {
4862
+ throw new Error("bootstrap seed manifest is not valid JSON");
4863
+ }
4864
+ const obj = parsed ?? {};
4865
+ const seeds = obj.seeds ?? [];
4866
+ for (const s of seeds) {
4867
+ if (!s || !s.target || !s.source || !Array.isArray(s.classes)) {
4868
+ throw new Error(`invalid seed entry (needs target, source, classes): ${JSON.stringify(s)}`);
4869
+ }
4870
+ if (s.ownership !== "org" && s.ownership !== "repo") {
4871
+ throw new Error(`invalid seed ownership '${s.ownership}' for ${s.target} (must be 'org' or 'repo')`);
4872
+ }
4873
+ }
4874
+ return {
4875
+ seeds,
4876
+ labels: obj.labels ?? [],
4877
+ placeholders: obj.placeholders ?? []
4878
+ };
4879
+ }
4880
+ function renderSeed(template, vars) {
4881
+ return template.replace(PLACEHOLDER_RE, (match, key) => key in vars ? vars[key] : match);
4882
+ }
4883
+ function missingPlaceholders(rendered) {
4884
+ const out = /* @__PURE__ */ new Set();
4885
+ for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
4886
+ return [...out];
4887
+ }
4888
+
4889
+ // src/bootstrap-apply.ts
4890
+ function planSeedAction(seed, exists) {
4891
+ if (seed.source === "fanout") {
4892
+ return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
4893
+ }
4894
+ if (seed.ownership === "repo") {
4895
+ return exists ? { target: seed.target, action: "skip", ownership: "repo", reason: "repo-owned, already present (never clobbered)" } : { target: seed.target, action: "create", ownership: "repo", reason: "repo-owned, missing" };
4896
+ }
4897
+ return exists ? { target: seed.target, action: "update", ownership: "org", reason: "org-owned, refresh to current" } : { target: seed.target, action: "create", ownership: "org", reason: "org-owned, missing" };
4898
+ }
4899
+ function renderSeedPlan(actions) {
4900
+ const lines = ["bootstrap apply \u2014 seed plan (dry-run; no mutations):"];
4901
+ for (const a of actions) {
4902
+ lines.push(` ${a.action.toUpperCase().padEnd(6)} ${a.target} (${a.ownership}: ${a.reason})`);
4903
+ }
4904
+ const order = ["create", "update", "skip"];
4905
+ lines.push(` \u2014 ${order.map((k) => `${actions.filter((a) => a.action === k).length} ${k}`).join(", ")}`);
4906
+ return lines.join("\n");
4907
+ }
4908
+ function resolveSeedContent(seed, vars, readFile2) {
4909
+ if (seed.source === "self") return readFile2(seed.target);
4910
+ if (seed.source.startsWith("seed:")) {
4911
+ const tmpl = readFile2(`skills/bootstrap/seeds/${seed.source.slice("seed:".length)}`);
4912
+ return tmpl == null ? null : renderSeed(tmpl, vars);
4913
+ }
4914
+ return null;
4915
+ }
4916
+ function contentPutArgs(repo, path, content, branch, sha) {
4917
+ const args = [
4918
+ "api",
4919
+ "-X",
4920
+ "PUT",
4921
+ `repos/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}`,
4922
+ "-f",
4923
+ `message=bootstrap: seed ${path}`,
4924
+ "-f",
4925
+ `content=${Buffer.from(content, "utf8").toString("base64")}`,
4926
+ "-f",
4927
+ `branch=${branch}`
4928
+ ];
4929
+ if (sha) args.push("-f", `sha=${sha}`);
4930
+ return args;
4931
+ }
4932
+
4933
+ // src/kb.ts
4934
+ var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
4935
+ function resolveKbSource(rawBase) {
4936
+ if (!rawBase) return DEFAULT_KB;
4937
+ const m = rawBase.match(/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/?#]+)/);
4938
+ if (!m) return DEFAULT_KB;
4939
+ return { owner: m[1], repo: m[2], ref: m[3] };
4940
+ }
4941
+ function buildKbGetArgs(src, path) {
4942
+ const clean = path.replace(/^\/+/, "");
4943
+ return ["api", `repos/${src.owner}/${src.repo}/contents/${clean}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
4944
+ }
4945
+ function buildKbTreeArgs(src) {
4946
+ return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
4947
+ }
4948
+ function parseKbTree(stdout, prefix) {
4949
+ let tree;
4950
+ try {
4951
+ tree = JSON.parse(stdout)?.tree ?? [];
4952
+ } catch {
4953
+ return [];
4954
+ }
4955
+ const pre = prefix ? prefix.replace(/^\/+/, "") : void 0;
4956
+ return tree.filter((t) => t.type === "blob" && typeof t.path === "string" && t.path.startsWith("kb/")).map((t) => t.path).filter((p) => pre ? p.startsWith(pre) : true).sort();
4957
+ }
4958
+
4959
+ // src/plan.ts
4960
+ var import_node_path3 = require("node:path");
4961
+ var PLANS_DIR = "plans";
4962
+ var META_FILE = (0, import_node_path3.join)(PLANS_DIR, ".plan-meta.json");
4963
+ var planPath = (slug) => (0, import_node_path3.join)(PLANS_DIR, `${slug}.md`);
4964
+ var metaKey = (project, slug) => `${project}/${slug}`;
4965
+ function parseMeta(raw) {
4966
+ if (!raw) return {};
4967
+ try {
4968
+ const o = JSON.parse(raw);
4969
+ return o && typeof o === "object" ? o : {};
4970
+ } catch {
4971
+ return {};
4972
+ }
4973
+ }
4974
+ function serializeMeta(meta) {
4975
+ return JSON.stringify(meta, null, 2) + "\n";
4976
+ }
4977
+ function hashContent(s) {
4978
+ let h = 2166136261;
4979
+ for (let i = 0; i < s.length; i++) {
4980
+ h ^= s.charCodeAt(i);
4981
+ h = Math.imul(h, 16777619);
4982
+ }
4983
+ return (h >>> 0).toString(16);
4984
+ }
4985
+ function staleHint(slug) {
4986
+ return `remote "${slug}" is newer \u2014 run \`mmi-cli plan pull ${slug}\` first (your local is based on an older version), or re-push with \`--force\` to overwrite`;
4987
+ }
4988
+ function formatPlanList(plans) {
4989
+ return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
4990
+ }
4991
+ var TIMEOUT_MS = 8e3;
4992
+ async function planPush(deps, slug, opts = {}) {
4993
+ const raw = deps.readLocal(slug);
4994
+ if (raw == null) {
4995
+ deps.err(`no local ${planPath(slug)} to push`);
4996
+ return;
4997
+ }
4998
+ const content = normalizeEol(raw);
4999
+ const project = opts.project ?? await deps.project();
5000
+ const meta = parseMeta(deps.readMetaRaw());
5001
+ const entry = meta[metaKey(project, slug)];
5002
+ const body = { project, slug, content };
5003
+ if (opts.force) body.force = true;
5004
+ else if (entry?.etag) body.baseEtag = entry.etag;
5005
+ const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
5006
+ method: "POST",
5007
+ headers: await deps.headers({ "content-type": "application/json" }),
5008
+ body: JSON.stringify(body),
5009
+ signal: AbortSignal.timeout(TIMEOUT_MS)
5010
+ });
5011
+ if (res.ok) {
5012
+ const out = await res.json();
5013
+ meta[metaKey(project, slug)] = { etag: out.etag, hash: hashContent(content), syncedAt: deps.now() };
5014
+ deps.writeMetaRaw(serializeMeta(meta));
5015
+ deps.log(`pushed ${slug}`);
5016
+ } else if (res.status === 409) {
5017
+ deps.err(staleHint(slug));
5018
+ } else {
5019
+ deps.err(`plan push failed: HTTP ${res.status}`);
5020
+ }
5021
+ }
5022
+ async function planPull(deps, slug, opts = {}) {
5023
+ const project = opts.project ?? await deps.project();
5024
+ const meta = parseMeta(deps.readMetaRaw());
5025
+ const entry = meta[metaKey(project, slug)];
5026
+ const local = deps.readLocal(slug);
5027
+ if (local != null && entry && !opts.force && hashContent(normalizeEol(local)) !== entry.hash) {
5028
+ deps.err(`local ${planPath(slug)} has unpushed edits \u2014 push it, or pull with --force to overwrite`);
5029
+ return;
5030
+ }
5031
+ const qs = new URLSearchParams({ project, slug }).toString();
5032
+ const res = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, {
5033
+ method: "GET",
5034
+ headers: await deps.headers(),
5035
+ signal: AbortSignal.timeout(TIMEOUT_MS)
5036
+ });
5037
+ if (res.status === 404) {
5038
+ deps.err(`no plan "${slug}" found on the server`);
5039
+ return;
5040
+ }
5041
+ if (!res.ok) {
5042
+ deps.err(`plan pull failed: HTTP ${res.status}`);
5043
+ return;
5044
+ }
5045
+ const doc = await res.json();
5046
+ const content = normalizeEol(doc.content ?? "");
5047
+ deps.writeLocal(slug, content);
5048
+ meta[metaKey(project, slug)] = { etag: doc.etag, hash: hashContent(content), syncedAt: deps.now() };
5049
+ deps.writeMetaRaw(serializeMeta(meta));
5050
+ deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
5051
+ }
5052
+ async function planList(deps, opts = {}) {
5053
+ const qs = opts.project ? `?${new URLSearchParams({ project: opts.project }).toString()}` : "";
5054
+ let res;
5055
+ try {
5056
+ res = await deps.fetch(`${deps.apiUrl}/plan/list${qs}`, {
5057
+ method: "GET",
5058
+ headers: await deps.headers(),
5059
+ signal: AbortSignal.timeout(TIMEOUT_MS)
5060
+ });
5061
+ } catch (e) {
5062
+ if (!opts.quiet) deps.err(`plan list: ${e.message}`);
5063
+ return;
5064
+ }
5065
+ if (!res.ok) {
5066
+ if (!opts.quiet) deps.err(`plan list failed: HTTP ${res.status}`);
5067
+ return;
5068
+ }
5069
+ const { plans } = await res.json();
5070
+ if (opts.json) {
5071
+ deps.log(JSON.stringify(plans));
5072
+ return;
5073
+ }
5074
+ if (!plans.length) {
5075
+ if (!opts.quiet) deps.log("no plans");
5076
+ return;
5077
+ }
5078
+ deps.log(formatPlanList(plans));
5079
+ }
5080
+ async function planDelete(deps, slug, opts = {}) {
5081
+ const project = opts.project ?? await deps.project();
5082
+ const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
5083
+ method: "POST",
5084
+ headers: await deps.headers({ "content-type": "application/json" }),
5085
+ body: JSON.stringify({ project, slug }),
5086
+ signal: AbortSignal.timeout(TIMEOUT_MS)
5087
+ });
5088
+ if (!res.ok) {
5089
+ deps.err(`plan delete failed: HTTP ${res.status}`);
5090
+ return;
5091
+ }
5092
+ deps.removeLocal(slug);
5093
+ const meta = parseMeta(deps.readMetaRaw());
5094
+ delete meta[metaKey(project, slug)];
5095
+ deps.writeMetaRaw(serializeMeta(meta));
5096
+ deps.log(`deleted ${slug}`);
5097
+ }
5098
+
5099
+ // src/secrets.ts
5100
+ var OWNER2 = "mutmutco";
5101
+ var SSM_ROOT = "/mmi-future";
5102
+ var PROJECT_TIER_SEGMENT = "dev";
5103
+ var KEY_RE = /^(?:[a-z][a-z0-9-]*\/)?[A-Za-z][A-Za-z0-9_]*$/;
5104
+ function isValidSecretKey(key) {
5105
+ if (!key || key.length > 256) return false;
5106
+ if (key.includes("..") || key.startsWith("/") || key.includes("*")) return false;
5107
+ return KEY_RE.test(key);
5108
+ }
5109
+ function classifyTier(_slug, key) {
5110
+ const slash = key.indexOf("/");
5111
+ if (slash === -1) return "project";
5112
+ return key.slice(0, slash) === PROJECT_TIER_SEGMENT ? "project" : "org";
5113
+ }
5114
+ function secretParamName(slug, key) {
5115
+ const rel = key.includes("/") ? key : `${PROJECT_TIER_SEGMENT}/${key}`;
5116
+ return `${SSM_ROOT}/${slug}/${rel}`;
5117
+ }
5118
+ function formatSecretList(items) {
5119
+ if (!items.length) return "no secrets";
5120
+ const width = Math.max(...items.map((i) => i.key.length));
5121
+ return items.map((i) => `${i.canManage ? "*" : " "} ${i.key.padEnd(width)} ${i.tier}`).join("\n").concat("\n\n* = you can manage (write/rotate) this secret. Values are never shown \u2014 `secrets get <KEY>` prints one.");
5122
+ }
5123
+ var TIMEOUT_MS2 = 8e3;
5124
+ var repoOf = (slug) => `${OWNER2}/${slug}`;
5125
+ async function targetRepo(deps, opts) {
5126
+ return opts.repo ?? repoOf(await deps.slug());
5127
+ }
5128
+ async function readErr(res) {
5129
+ try {
5130
+ const j = await res.json();
5131
+ return j?.error ? `: ${j.error}` : "";
5132
+ } catch {
5133
+ return "";
5134
+ }
5135
+ }
5136
+ async function secretsList(deps, opts) {
5137
+ const repo = await targetRepo(deps, opts);
5138
+ const qs = new URLSearchParams({ repo }).toString();
5139
+ let res;
5140
+ try {
5141
+ res = await deps.fetch(`${deps.apiUrl}/secrets/list?${qs}`, {
5142
+ method: "GET",
5143
+ headers: await deps.headers(),
5144
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5145
+ });
5146
+ } catch (e) {
5147
+ deps.err(`secrets list: ${e.message}`);
5148
+ return;
5149
+ }
5150
+ if (!res.ok) {
5151
+ deps.err(`secrets list failed: HTTP ${res.status}${await readErr(res)}`);
5152
+ return;
5153
+ }
5154
+ const { secrets: secrets2 } = await res.json();
5155
+ deps.log(formatSecretList(secrets2 ?? []));
5156
+ }
5157
+ async function secretsGet(deps, key, opts) {
5158
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5159
+ const repo = await targetRepo(deps, opts);
5160
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/get`, {
5161
+ method: "POST",
5162
+ headers: await deps.headers({ "content-type": "application/json" }),
5163
+ body: JSON.stringify({ repo, key }),
5164
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5165
+ });
5166
+ if (!res.ok) {
5167
+ deps.err(
5168
+ res.status === 403 ? `secrets get: not authorized for ${key} (HTTP 403)${await readErr(res)}` : `secrets get failed: HTTP ${res.status}${await readErr(res)}`
5169
+ );
5170
+ return;
5171
+ }
5172
+ const { value } = await res.json();
5173
+ deps.log(value ?? "");
5174
+ }
5175
+ async function secretsSet(deps, key, opts) {
5176
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5177
+ const repo = await targetRepo(deps, opts);
5178
+ const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
5179
+ if (!value) {
5180
+ deps.err("secrets set: empty value \u2014 aborted (nothing written)");
5181
+ return;
5182
+ }
5183
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/set`, {
5184
+ method: "POST",
5185
+ headers: await deps.headers({ "content-type": "application/json" }),
5186
+ body: JSON.stringify({ repo, key, value }),
5187
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5188
+ });
5189
+ if (!res.ok) {
5190
+ deps.err(
5191
+ res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`
5192
+ );
5193
+ return;
5194
+ }
5195
+ deps.log(`set ${key} (${classifyTier(await deps.slug(), key)} tier)`);
5196
+ }
5197
+ async function secretsEdit(deps, key, opts) {
5198
+ return secretsSet(deps, key, opts);
5199
+ }
5200
+ async function secretsRemove(deps, key, opts) {
5201
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5202
+ const repo = await targetRepo(deps, opts);
5203
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/rm`, {
5204
+ method: "POST",
5205
+ headers: await deps.headers({ "content-type": "application/json" }),
5206
+ body: JSON.stringify({ repo, key }),
5207
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5208
+ });
5209
+ if (!res.ok) {
5210
+ deps.err(
5211
+ res.status === 403 ? `secrets rm: not authorized to remove ${key} (HTTP 403)${await readErr(res)}` : `secrets rm failed: HTTP ${res.status}${await readErr(res)}`
5212
+ );
5213
+ return;
5214
+ }
5215
+ deps.log(`removed ${key}`);
5216
+ }
5217
+ async function secretsGrant(deps, repo, login, key, _opts) {
5218
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/grant`, {
5219
+ method: "POST",
5220
+ headers: await deps.headers({ "content-type": "application/json" }),
5221
+ body: JSON.stringify({ repo, login, key }),
5222
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5223
+ });
5224
+ if (!res.ok) {
5225
+ deps.err(
5226
+ res.status === 403 ? `secrets grant: master-admin only (HTTP 403)${await readErr(res)}` : `secrets grant failed: HTTP ${res.status}${await readErr(res)}`
5227
+ );
5228
+ return;
5229
+ }
5230
+ deps.log(`granted @${login} access to ${key} in ${repo}`);
5231
+ }
5232
+ async function secretsRevoke(deps, repo, login, key, _opts) {
5233
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/revoke`, {
5234
+ method: "POST",
5235
+ headers: await deps.headers({ "content-type": "application/json" }),
5236
+ body: JSON.stringify({ repo, login, key }),
5237
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5238
+ });
5239
+ if (!res.ok) {
5240
+ deps.err(
5241
+ res.status === 403 ? `secrets revoke: master-admin only (HTTP 403)${await readErr(res)}` : `secrets revoke failed: HTTP ${res.status}${await readErr(res)}`
5242
+ );
5243
+ return;
5244
+ }
5245
+ deps.log(`revoked @${login}'s access to ${key} in ${repo}`);
5246
+ }
5247
+ async function secretsUse(deps, key, _opts) {
5248
+ const slug = await deps.slug();
5249
+ const tier = classifyTier(slug, key);
5250
+ const path = secretParamName(slug, key);
5251
+ deps.log(
5252
+ [
5253
+ `${key} \u2192 ${path} (${tier} tier)`,
5254
+ "",
5255
+ "Consume it WITHOUT committing it:",
5256
+ ` \u2022 Runtime / agents: read it keylessly at runtime via the box's OIDC role (it can read its own ${tier} tier). Never bake it into an image or commit it.`,
5257
+ ` \u2022 CI (GitHub Actions): the workflow assumes its OIDC role and runs \`aws ssm get-parameter --with-decryption --name ${path}\` \u2014 no GitHub secret.`,
5258
+ " \u2022 Local dev: pull it into a gitignored .env from the vault \u2014 `mmi-cli secrets get " + key + " > /dev/null` to confirm access, then export it in your shell. Never paste it into tracked files or chat.",
5259
+ tier === "project" ? " \u2022 If this dev secret graduates to a real prod credential, ask the master to promote it to the org tier (rc/ or main/)." : " \u2022 This is an ORG-tier secret \u2014 master-gated. If you need standing access, ask the master for a `secrets grant`."
5260
+ ].join("\n")
5261
+ );
5262
+ }
5263
+
4119
5264
  // src/index.ts
4120
- var execFileP3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
5265
+ var rawExecFileP2 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
5266
+ var execFileP3 = (file, args, options = {}) => (
5267
+ // encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
5268
+ // promisify(execFile)'s overloads widen to string|Buffer when options is spread in.
5269
+ rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, ...options })
5270
+ );
4121
5271
  var GIT_TIMEOUT_MS = 1e4;
4122
5272
  var GC_GH_TIMEOUT_MS = 2e4;
4123
5273
  async function githubToken() {
@@ -4150,7 +5300,6 @@ async function loadConfig() {
4150
5300
  }
4151
5301
  }
4152
5302
  var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/development";
4153
- var DEFAULT_KB_SOURCE = "https://raw.githubusercontent.com/mutmutco/MM-KB/main";
4154
5303
  var SESSION_FILE = ".mmi/.session";
4155
5304
  var gitOut = async (args) => {
4156
5305
  try {
@@ -4164,7 +5313,7 @@ function sessionDeps() {
4164
5313
  env: process.env,
4165
5314
  readPersisted: () => {
4166
5315
  try {
4167
- return (0, import_node_fs3.readFileSync)(SESSION_FILE, "utf8");
5316
+ return (0, import_node_fs4.readFileSync)(SESSION_FILE, "utf8");
4168
5317
  } catch {
4169
5318
  return null;
4170
5319
  }
@@ -4177,8 +5326,8 @@ function sessionDeps() {
4177
5326
  var resolveSessionId = () => resolveSession(sessionDeps());
4178
5327
  function persistSession(id) {
4179
5328
  try {
4180
- (0, import_node_fs3.mkdirSync)(".mmi", { recursive: true });
4181
- (0, import_node_fs3.writeFileSync)(SESSION_FILE, id, "utf8");
5329
+ (0, import_node_fs4.mkdirSync)(".mmi", { recursive: true });
5330
+ (0, import_node_fs4.writeFileSync)(SESSION_FILE, id, "utf8");
4182
5331
  } catch {
4183
5332
  }
4184
5333
  }
@@ -4198,7 +5347,11 @@ async function postCapture(capture, quiet = false) {
4198
5347
  method: "POST",
4199
5348
  headers: await sagaHeaders({ "content-type": "application/json" }),
4200
5349
  body: JSON.stringify({ ...capture, ...await sagaKey(cfg) }),
4201
- signal: AbortSignal.timeout(8e3)
5350
+ // Capture latency is high + variable (server-side HEAD render); 8s dropped larger notes. Match the
5351
+ // head-write timeout (20s) so a continuity note isn't lost to a slow/cold backend. No client retry:
5352
+ // the capture isn't guaranteed idempotent, so a retry after a server-side-completed write could
5353
+ // duplicate the note. Backend capture-latency root cause tracked in #255.
5354
+ signal: AbortSignal.timeout(2e4)
4202
5355
  });
4203
5356
  if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
4204
5357
  } catch (e) {
@@ -4250,26 +5403,47 @@ async function gcPlan(remote, limit) {
4250
5403
  remote
4251
5404
  });
4252
5405
  }
4253
- async function applyGcPlan(plan, remote) {
4254
- for (const branch of plan.branches) {
5406
+ async function applyGcPlan(plan2, remote) {
5407
+ for (const branch of plan2.branches) {
4255
5408
  if (branch.worktreePath) await execFileP3("git", ["worktree", "remove", branch.worktreePath], { timeout: GIT_TIMEOUT_MS });
4256
5409
  await execFileP3("git", ["branch", "-D", branch.branch], { timeout: GIT_TIMEOUT_MS });
4257
5410
  }
4258
- for (const ref of plan.trackingRefs) {
5411
+ for (const ref of plan2.trackingRefs) {
4259
5412
  await execFileP3("git", ["update-ref", "-d", `refs/remotes/${remote}/${ref.branch}`], { timeout: GIT_TIMEOUT_MS });
4260
5413
  }
4261
- if (plan.branches.some((b) => b.worktreePath)) {
5414
+ if (plan2.branches.some((b) => b.worktreePath)) {
4262
5415
  await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
4263
5416
  }
4264
5417
  }
5418
+ async function cleanupLocalBranch(branch) {
5419
+ const result = { branchDeleted: false };
5420
+ if (!branch) return result;
5421
+ const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
5422
+ const wt = parseWorktreePorcelain(stdout).find((w) => w.branch === branch);
5423
+ if (wt) {
5424
+ await execFileP3("git", ["worktree", "remove", "--force", wt.path], { timeout: GIT_TIMEOUT_MS }).catch(() => {
5425
+ });
5426
+ result.worktreeRemoved = wt.path;
5427
+ }
5428
+ const current = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
5429
+ if (branch !== current) {
5430
+ await execFileP3("git", ["branch", "-D", branch], { timeout: GIT_TIMEOUT_MS }).then(() => {
5431
+ result.branchDeleted = true;
5432
+ }).catch(() => {
5433
+ });
5434
+ }
5435
+ if (wt) await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS }).catch(() => {
5436
+ });
5437
+ return result;
5438
+ }
4265
5439
  function resolveVersion() {
4266
5440
  try {
4267
- const manifest = (0, import_node_path3.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
4268
- return JSON.parse((0, import_node_fs3.readFileSync)(manifest, "utf8")).version || "0.0.0";
5441
+ const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
5442
+ return JSON.parse((0, import_node_fs4.readFileSync)(manifest, "utf8")).version || "0.0.0";
4269
5443
  } catch {
4270
5444
  try {
4271
- const pkg = (0, import_node_path3.join)(__dirname, "..", "package.json");
4272
- return JSON.parse((0, import_node_fs3.readFileSync)(pkg, "utf8")).version || "0.0.0";
5445
+ const pkg = (0, import_node_path4.join)(__dirname, "..", "package.json");
5446
+ return JSON.parse((0, import_node_fs4.readFileSync)(pkg, "utf8")).version || "0.0.0";
4273
5447
  } catch {
4274
5448
  return "0.0.0";
4275
5449
  }
@@ -4277,7 +5451,7 @@ function resolveVersion() {
4277
5451
  }
4278
5452
  function readRepoVersion() {
4279
5453
  try {
4280
- return JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path3.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
5454
+ return JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path4.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
4281
5455
  } catch {
4282
5456
  return void 0;
4283
5457
  }
@@ -4293,6 +5467,31 @@ async function fetchReleasedVersion() {
4293
5467
  return void 0;
4294
5468
  }
4295
5469
  }
5470
+ var NPM_UPDATE_TIMEOUT_MS = 12e4;
5471
+ var PLUGIN_PULL_TIMEOUT_MS = 3e4;
5472
+ async function applyVersionAutoUpdate(report, log) {
5473
+ const action = versionAutoUpdateAction(report, Boolean(process.env.CLAUDE_PLUGIN_ROOT));
5474
+ if (action === "none") return report;
5475
+ const target = report.releasedVersion ?? "latest";
5476
+ if (action === "plugin-pull") {
5477
+ try {
5478
+ const root = (await execFileP3("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
5479
+ log(` \u21BB refreshing MMI plugin ${report.currentVersion} \u2192 ${target} (effective next session)\u2026`);
5480
+ await execFileP3("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
5481
+ return { ...report, ok: true };
5482
+ } catch {
5483
+ return report;
5484
+ }
5485
+ }
5486
+ try {
5487
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
5488
+ log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
5489
+ await execFileP3(npm, ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
5490
+ return { ...report, ok: true };
5491
+ } catch {
5492
+ return report;
5493
+ }
5494
+ }
4296
5495
  var program2 = new Command();
4297
5496
  program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
4298
5497
  var rules = program2.command("rules").description("org rules delivery");
@@ -4303,21 +5502,23 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
4303
5502
  return;
4304
5503
  }
4305
5504
  const base = (cfg.orgRulesSource ?? DEFAULT_RULES_SOURCE).replace(/\/$/, "");
5505
+ const token = await githubToken();
4306
5506
  let changed = 0;
4307
5507
  for (const file of ["AGENTS.md", "CLAUDE.md", ".claude/settings.json"]) {
4308
5508
  let source;
4309
5509
  try {
4310
- const res = await fetch(`${base}/${file}`);
5510
+ const url = `${base}/${file}`;
5511
+ const res = await fetch(url, { headers: rulesSourceAuthHeaders(url, token) });
4311
5512
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
4312
5513
  source = await res.text();
4313
5514
  } catch (e) {
4314
5515
  if (!opts.quiet) console.error(`mmi-cli rules: could not fetch ${file} (${e.message}); left it untouched`);
4315
5516
  continue;
4316
5517
  }
4317
- const current = (0, import_node_fs3.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
5518
+ const current = (0, import_node_fs4.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
4318
5519
  if (needsUpdate(source, current)) {
4319
5520
  const slash = file.lastIndexOf("/");
4320
- if (slash > 0) (0, import_node_fs3.mkdirSync)(file.slice(0, slash), { recursive: true });
5521
+ if (slash > 0) (0, import_node_fs4.mkdirSync)(file.slice(0, slash), { recursive: true });
4321
5522
  await (0, import_promises.writeFile)(file, normalizeEol(source), "utf8");
4322
5523
  changed++;
4323
5524
  if (!opts.quiet) console.log(`mmi-cli rules: updated ${file}`);
@@ -4325,13 +5526,36 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
4325
5526
  }
4326
5527
  if (!opts.quiet && changed === 0) console.log("mmi-cli rules: up to date");
4327
5528
  });
5529
+ var docs = program2.command("docs").description("repo-owned authoritative docs");
5530
+ docs.command("sync").option("--quiet", "stay silent unless something changed or errored").description("refresh README.md / architecture.md from the repo default branch (keeper-authored); never clobbers uncommitted edits").action(async (opts) => {
5531
+ const ref = await gitOut(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
5532
+ const def = (ref.startsWith("origin/") ? ref.slice("origin/".length) : ref) || "development";
5533
+ await gitOut(["fetch", "origin", def, "--quiet"]);
5534
+ const result = await syncDocs({
5535
+ isDirty: async (f) => await gitOut(["status", "--porcelain", "--", f]) !== "",
5536
+ originContent: async (f) => {
5537
+ try {
5538
+ return (await execFileP3("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
5539
+ } catch {
5540
+ return null;
5541
+ }
5542
+ },
5543
+ localContent: async (f) => (0, import_node_fs4.existsSync)(f) ? await (0, import_promises.readFile)(f, "utf8") : null,
5544
+ writeDoc: async (f, c) => {
5545
+ await (0, import_promises.writeFile)(f, c, "utf8");
5546
+ }
5547
+ });
5548
+ for (const f of result.updated) console.log(`mmi-cli docs: updated ${f} (from origin/${def})`);
5549
+ if (!opts.quiet && result.skippedDirty.length) console.log(`mmi-cli docs: kept local edits in ${result.skippedDirty.join(", ")}`);
5550
+ if (!opts.quiet && result.updated.length === 0 && result.skippedDirty.length === 0) console.log("mmi-cli docs: up to date");
5551
+ });
4328
5552
  var saga = program2.command("saga").description("per-session continuity");
4329
5553
  async function runNote(summary, o) {
4330
5554
  const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
4331
5555
  const capture = buildNoteCapture(summary, o, (0, import_node_crypto.randomUUID)(), { sha: sha || void 0, branch: key.branch });
4332
5556
  await postCapture(capture);
4333
5557
  }
4334
- 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").action((summary, o) => runNote(summary, o));
5558
+ 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));
4335
5559
  saga.command("probe <summary>").description("record a diagnostic probe note (alias for `saga note --diagnostic`)").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").action((summary, o) => runNote(summary, { ...o, diagnostic: true }));
4336
5560
  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(async (opts) => {
4337
5561
  const cfg = await loadConfig();
@@ -4383,7 +5607,8 @@ saga.command("head-update").option("--run", "detached worker: fetch state, run t
4383
5607
  if (!res.ok) return;
4384
5608
  const state = await res.json();
4385
5609
  if (!state.actionLog?.length) return;
4386
- const update = parseHeadUpdate(await runHeadEngine(headPrompt(state)));
5610
+ const decisionTexts = (state.decisions ?? []).map((d) => typeof d === "string" ? d : d.text);
5611
+ const update = parseHeadUpdate(await runHeadEngine(headPrompt({ ...state, decisions: decisionTexts })));
4387
5612
  if (!update) return;
4388
5613
  await fetch(`${cfg.sagaApiUrl}/saga/head`, {
4389
5614
  method: "POST",
@@ -4433,19 +5658,36 @@ program2.command("gc").description("dry-run cleanup for merged/closed PR branche
4433
5658
  const limit = Number.parseInt(o.limit, 10);
4434
5659
  if (!Number.isFinite(limit) || limit < 1) return fail("gc: --limit must be a positive integer");
4435
5660
  try {
4436
- const plan = await gcPlan(o.remote, limit);
4437
- if (o.apply) await applyGcPlan(plan, o.remote);
4438
- if (o.json) return console.log(JSON.stringify({ dryRun: !o.apply, remote: o.remote, plan }, null, 2));
4439
- console.log(formatGcPlan(plan, Boolean(o.apply)));
5661
+ const plan2 = await gcPlan(o.remote, limit);
5662
+ if (o.apply) await applyGcPlan(plan2, o.remote);
5663
+ if (o.json) return console.log(JSON.stringify({ dryRun: !o.apply, remote: o.remote, plan: plan2 }, null, 2));
5664
+ console.log(formatGcPlan(plan2, Boolean(o.apply)));
4440
5665
  } catch (e) {
4441
5666
  fail(`gc: ${e.message}`);
4442
5667
  }
4443
5668
  });
4444
- program2.command("kb").description("org knowledgebase (read-only)").command("get <path>").description("print a KB document by path").action(async (path) => {
4445
- const cfg = await loadConfig();
4446
- const base = (cfg.kbSource ?? DEFAULT_KB_SOURCE).replace(/\/$/, "");
4447
- const res = await fetch(`${base}/${path.replace(/^\//, "")}`);
4448
- console.log(res.ok ? await res.text() : `kb get failed: HTTP ${res.status}`);
5669
+ var kb = program2.command("kb").description("org knowledgebase (read-only)");
5670
+ kb.command("get <path>").description("print a KB document by path").action(async (path) => {
5671
+ const src = resolveKbSource((await loadConfig()).kbSource);
5672
+ try {
5673
+ const { stdout } = await execFileP3("gh", buildKbGetArgs(src, path), { timeout: 1e4 });
5674
+ process.stdout.write(stdout);
5675
+ } catch (e) {
5676
+ const err = e;
5677
+ fail(`kb get failed: ${(err.stderr || err.message || String(e)).trim()}`);
5678
+ }
5679
+ });
5680
+ kb.command("list [prefix]").description("list KB document paths (optionally under a prefix)").action(async (prefix) => {
5681
+ const src = resolveKbSource((await loadConfig()).kbSource);
5682
+ try {
5683
+ const { stdout } = await execFileP3("gh", buildKbTreeArgs(src), { timeout: 1e4 });
5684
+ const paths = parseKbTree(stdout, prefix);
5685
+ if (!paths.length) return fail(`kb list: no documents${prefix ? ` under ${prefix}` : ""}`);
5686
+ console.log(paths.join("\n"));
5687
+ } catch (e) {
5688
+ const err = e;
5689
+ fail(`kb list failed: ${(err.stderr || err.message || String(e)).trim()}`);
5690
+ }
4449
5691
  });
4450
5692
  async function ghCreate(args) {
4451
5693
  try {
@@ -4456,7 +5698,7 @@ async function ghCreate(args) {
4456
5698
  fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
4457
5699
  }
4458
5700
  }
4459
- async function ghJson2(args, timeout = 1e4) {
5701
+ async function ghJson3(args, timeout = 1e4) {
4460
5702
  const { stdout } = await execFileP3("gh", args, { timeout });
4461
5703
  return JSON.parse(stdout);
4462
5704
  }
@@ -4469,6 +5711,47 @@ async function resolveRepo(repo) {
4469
5711
  return void 0;
4470
5712
  }
4471
5713
  }
5714
+ async function attachToProject(issueNumber, repo, priority) {
5715
+ const cfg = await loadConfig();
5716
+ if (!cfg.projectId) return void 0;
5717
+ if (repo) {
5718
+ const skip = boardAttachSkipReason(await resolveRepo(), repo);
5719
+ if (skip) {
5720
+ process.stderr.write(`warning: issue #${issueNumber} NOT added to this board \u2014 ${skip}
5721
+ `);
5722
+ return void 0;
5723
+ }
5724
+ }
5725
+ try {
5726
+ const viewArgs = ["issue", "view", String(issueNumber), "--json", "id", "--jq", ".id"];
5727
+ if (repo) viewArgs.push("--repo", repo);
5728
+ const { stdout: idOut } = await execFileP3("gh", viewArgs, { timeout: 1e4 });
5729
+ const contentId = idOut.trim();
5730
+ if (!contentId) throw new Error("could not resolve issue node id");
5731
+ const { stdout } = await execFileP3("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: 1e4 });
5732
+ const projectItemId = parseAddedItemId(stdout);
5733
+ if (projectItemId && priority) {
5734
+ try {
5735
+ await setBoardItemPriority(
5736
+ async (args) => execFileP3("gh", args, { timeout: 1e4 }),
5737
+ cfg,
5738
+ projectItemId,
5739
+ priority
5740
+ );
5741
+ } catch (e) {
5742
+ const err = e;
5743
+ process.stderr.write(`warning: issue #${issueNumber} board Priority not set: ${(err.stderr || err.message || String(e)).trim()}
5744
+ `);
5745
+ }
5746
+ }
5747
+ return projectItemId;
5748
+ } catch (e) {
5749
+ const err = e;
5750
+ process.stderr.write(`warning: issue #${issueNumber} created but NOT added to the project board: ${(err.stderr || err.message || String(e)).trim()}
5751
+ `);
5752
+ return void 0;
5753
+ }
5754
+ }
4472
5755
  function scheduleRelatedDiscovery(o) {
4473
5756
  try {
4474
5757
  const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
@@ -4482,17 +5765,136 @@ function scheduleRelatedDiscovery(o) {
4482
5765
  } catch {
4483
5766
  }
4484
5767
  }
5768
+ function makePlanDeps(cfg) {
5769
+ const ensureDir = () => (0, import_node_fs4.mkdirSync)(PLANS_DIR, { recursive: true });
5770
+ return {
5771
+ apiUrl: cfg.sagaApiUrl,
5772
+ fetch: (url, init) => fetch(url, init),
5773
+ headers: (extra) => sagaHeaders(extra),
5774
+ project: async () => (await sagaKey(cfg)).project,
5775
+ readLocal: (slug) => {
5776
+ try {
5777
+ return (0, import_node_fs4.readFileSync)(planPath(slug), "utf8");
5778
+ } catch {
5779
+ return null;
5780
+ }
5781
+ },
5782
+ writeLocal: (slug, content) => {
5783
+ ensureDir();
5784
+ (0, import_node_fs4.writeFileSync)(planPath(slug), content, "utf8");
5785
+ },
5786
+ removeLocal: (slug) => {
5787
+ try {
5788
+ (0, import_node_fs4.rmSync)(planPath(slug));
5789
+ } catch {
5790
+ }
5791
+ },
5792
+ readMetaRaw: () => {
5793
+ try {
5794
+ return (0, import_node_fs4.readFileSync)(META_FILE, "utf8");
5795
+ } catch {
5796
+ return null;
5797
+ }
5798
+ },
5799
+ writeMetaRaw: (raw) => {
5800
+ ensureDir();
5801
+ (0, import_node_fs4.writeFileSync)(META_FILE, raw, "utf8");
5802
+ },
5803
+ log: (m) => console.log(m),
5804
+ err: (m) => console.error(m),
5805
+ now: () => (/* @__PURE__ */ new Date()).toISOString()
5806
+ };
5807
+ }
5808
+ function openInEditor(path) {
5809
+ const editor = process.env.EDITOR || process.env.VISUAL;
5810
+ if (!editor) {
5811
+ console.log(`plan at ${path} (set $EDITOR to open it automatically)`);
5812
+ return;
5813
+ }
5814
+ try {
5815
+ (0, import_node_child_process4.spawn)(editor, [path], { stdio: "inherit" });
5816
+ } catch {
5817
+ console.log(`open ${path} manually`);
5818
+ }
5819
+ }
5820
+ async function withPlan(quiet, run) {
5821
+ const cfg = await loadConfig();
5822
+ if (!cfg.sagaApiUrl) {
5823
+ if (!quiet) fail("plan: sagaApiUrl not configured in .mmi/config.json");
5824
+ return;
5825
+ }
5826
+ await run(makePlanDeps(cfg));
5827
+ }
5828
+ var plan = program2.command("plan").description("your cross-device plans/SSOTs (S3-backed, git-clean)");
5829
+ plan.command("push <slug>").description("push a local 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, (d) => planPush(d, slug, o)));
5830
+ plan.command("pull <slug>").description("pull a plan from the server into plans/<slug>.md").option("--project <name>", "override the project key").option("--force", "overwrite local even if it has unpushed edits").action((slug, o) => withPlan(false, (d) => planPull(d, slug, o)));
5831
+ plan.command("list").description("list your 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)));
5832
+ plan.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
5833
+ (slug, o) => withPlan(false, async (d) => {
5834
+ await planPull(d, slug, { project: o.project });
5835
+ openInEditor(planPath(slug));
5836
+ })
5837
+ );
5838
+ plan.command("delete <slug>").description("delete a plan from the server and the local copy").option("--project <name>", "override the project key").action((slug, o) => withPlan(false, (d) => planDelete(d, slug, o)));
5839
+ async function readSecretStdin() {
5840
+ if (process.stdin.isTTY) {
5841
+ process.stderr.write(
5842
+ 'secrets set: pipe the value on stdin (it is never an argument) \u2014 e.g.\n printf %s "$VALUE" | mmi-cli secrets set <KEY>\n'
5843
+ );
5844
+ return "";
5845
+ }
5846
+ const chunks = [];
5847
+ for await (const chunk of process.stdin) chunks.push(chunk);
5848
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
5849
+ }
5850
+ function makeSecretsDeps(cfg) {
5851
+ return {
5852
+ apiUrl: cfg.sagaApiUrl,
5853
+ fetch: (url, init) => fetch(url, init),
5854
+ headers: (extra) => sagaHeaders(extra),
5855
+ slug: async () => (await sagaKey(cfg)).project,
5856
+ readSecretValue: () => readSecretStdin(),
5857
+ log: (m) => console.log(m),
5858
+ err: (m) => console.error(m)
5859
+ };
5860
+ }
5861
+ async function withSecrets(run) {
5862
+ const cfg = await loadConfig();
5863
+ if (!cfg.sagaApiUrl) {
5864
+ fail("secrets: sagaApiUrl not configured in .mmi/config.json (this repo is not bootstrapped)");
5865
+ return;
5866
+ }
5867
+ await run(makeSecretsDeps(cfg));
5868
+ }
5869
+ var secrets = program2.command("secrets").description("two-tier project secrets \u2014 self-serve your repo dev/* tier; org tier is master-gated");
5870
+ secrets.command("list").description("list secret NAMES + tier for this repo (never values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsList(d, o)));
5871
+ secrets.command("get <key>").description("print one secret value over TLS (prints once, raw \u2014 do not log/paste it)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsGet(d, key, o)));
5872
+ secrets.command("set <key>").description("write/rotate a secret; value is read from stdin (never an argument)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsSet(d, key, o)));
5873
+ secrets.command("edit <key>").description("alias for set \u2014 replace a secret value (read from stdin)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsEdit(d, key, o)));
5874
+ secrets.command("rm <key>").description("remove a secret (project tier self-serve; org tier needs a grant)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsRemove(d, key, o)));
5875
+ secrets.command("use <key>").description("print guidance on consuming a secret without committing it (no value)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((key, o) => withSecrets((d) => secretsUse(d, key, o)));
5876
+ secrets.command("grant <repo> <login> <key>").description("MASTER-ONLY: grant a project-admin standing access to a specific org-tier secret").action((repo, login, key) => withSecrets((d) => secretsGrant(d, repo, login, key, {})));
5877
+ secrets.command("revoke <repo> <login> <key>").description("MASTER-ONLY: withdraw a previously granted org-tier secret access").action((repo, login, key) => withSecrets((d) => secretsRevoke(d, repo, login, key, {})));
4485
5878
  var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
4486
- issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").requiredOption("--type <type>", "bug | feature | task (sets the matching label)").requiredOption("--title <title>", "issue title").requiredOption("--body <body>", "issue body (markdown)").requiredOption("--priority <priority>", "high | medium | low (adds a priority:<p> label)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action(async (o) => {
5879
+ issue.command("create").description("create an issue (type \u2192 label) and print {number,url,label} JSON").requiredOption("--type <type>", "bug | feature | task (sets the matching label)").requiredOption("--title <title>", "issue title").requiredOption("--body <body>", "issue body (markdown)").requiredOption("--priority <priority>", "urgent | high | medium | low (label + board Priority field when configured)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--label <label...>", "extra label(s) to attach (repeatable; auto-created if missing)").option("--no-related", "skip the auto related-issues comment").action(async (o) => {
4487
5880
  let args;
4488
5881
  try {
4489
- args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo });
5882
+ args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo, labels: o.label });
4490
5883
  } catch (e) {
4491
5884
  return fail(`issue create: ${e.message}`);
4492
5885
  }
5886
+ for (const label of o.label ?? []) {
5887
+ const la = ["label", "create", label, "--color", "ededed"];
5888
+ if (o.repo) la.push("--repo", o.repo);
5889
+ try {
5890
+ await execFileP3("gh", la, { timeout: 1e4 });
5891
+ } catch {
5892
+ }
5893
+ }
4493
5894
  const created = await ghCreate(args);
4494
- scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
4495
- console.log(JSON.stringify({ ...created, label: o.type, priority: o.priority }));
5895
+ const projectItemId = await attachToProject(created.number, o.repo, o.priority);
5896
+ if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
5897
+ console.log(JSON.stringify({ ...created, label: o.type, priority: o.priority, projectItemId }));
4496
5898
  });
4497
5899
  issue.command("discover-related").description("find related issues for an existing issue and post only high-confidence links").requiredOption("--number <number>", "created issue number").requiredOption("--title <title>", "created issue title").requiredOption("--body <body>", "created issue body").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--json", "print candidates instead of posting").action(async (o) => {
4498
5900
  const number = Number(o.number);
@@ -4500,7 +5902,7 @@ issue.command("discover-related").description("find related issues for an existi
4500
5902
  const repo = await resolveRepo(o.repo);
4501
5903
  if (!repo) return fail("issue discover-related: could not resolve repo");
4502
5904
  try {
4503
- const issues = await ghJson2([
5905
+ const issues = await ghJson3([
4504
5906
  "issue",
4505
5907
  "list",
4506
5908
  "--repo",
@@ -4515,7 +5917,7 @@ issue.command("discover-related").description("find related issues for an existi
4515
5917
  const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
4516
5918
  if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
4517
5919
  if (!candidates.length) return;
4518
- const viewed = await ghJson2([
5920
+ const viewed = await ghJson3([
4519
5921
  "issue",
4520
5922
  "view",
4521
5923
  String(number),
@@ -4534,6 +5936,16 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
4534
5936
  const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
4535
5937
  console.log(JSON.stringify(created));
4536
5938
  });
5939
+ 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) => {
5940
+ const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
5941
+ const repoArgs = o.repo ? ["--repo", o.repo] : [];
5942
+ const headRef = (await execFileP3("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
5943
+ await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
5944
+ if (!/used by worktree|cannot delete branch|already been merged/i.test(String(e.message || ""))) throw e;
5945
+ });
5946
+ const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef);
5947
+ console.log(JSON.stringify({ merged: number, branch: headRef, method: method.slice(2), ...cleaned }));
5948
+ });
4537
5949
  async function runBoardRead(o) {
4538
5950
  try {
4539
5951
  const report = await readBoard({
@@ -4547,17 +5959,69 @@ async function runBoardRead(o) {
4547
5959
  fail(`board read failed: ${e.message}`);
4548
5960
  }
4549
5961
  }
4550
- var board = program2.command("board").description("read and claim Project v2 work items for the current repo");
5962
+ var board = program2.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
4551
5963
  board.command("read", { isDefault: true }).description("read the board and print user-owned, claimable, and taken items").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--bundle-details", "fetch body/comments only for user-owned and claimable issues").option("--allow-partial", "return partial board results when later page/detail reads fail").action((o) => runBoardRead(o));
4552
- board.command("claim <issue>").description("assign a Todo issue to you and move its Project v2 Status to In Progress").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails").action(async (issueRef, o) => {
5964
+ board.command("claim <issue>").description("assign a Todo issue and move its Project v2 Status to In Progress").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--for <login>", "assign to this login instead of @me \u2014 agent claims on behalf of the master").option("--allow-partial", "return success JSON if assignment succeeds but the status move fails").action(async (issueRef, o) => {
4553
5965
  try {
4554
- const result = await claimBoardIssue({ config: await loadConfig(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
5966
+ const result = await claimBoardIssue({
5967
+ config: await loadConfig(),
5968
+ selector: issueRef,
5969
+ repo: o.repo,
5970
+ assignee: o.for,
5971
+ allowPartial: o.allowPartial
5972
+ });
4555
5973
  if (o.json) return console.log(JSON.stringify(result));
4556
5974
  console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
4557
5975
  } catch (e) {
4558
5976
  fail(`board claim failed: ${e.message}`);
4559
5977
  }
4560
5978
  });
5979
+ board.command("show <issue>").alias("open").description("print one board item (status, assignees, type, url) with its body and comments").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return the item even if its body/comments fetch fails").action(async (issueRef, o) => {
5980
+ try {
5981
+ const item = await showBoardItem({ config: await loadConfig(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
5982
+ console.log(o.json ? JSON.stringify(item) : renderBoardItem(item));
5983
+ } catch (e) {
5984
+ fail(`board show failed: ${e.message}`);
5985
+ }
5986
+ });
5987
+ board.command("move <issue> <status>").description(`move a board item's Status to one of: ${BOARD_STATUSES.join(", ")} (quote multi-word statuses)`).option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return success JSON if the item resolves but the status move fails").action(async (issueRef, status, o) => {
5988
+ if (!BOARD_STATUSES.includes(status)) {
5989
+ return fail(`board move failed: unknown status '${status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
5990
+ }
5991
+ try {
5992
+ const result = await moveBoardItem({ config: await loadConfig(), selector: issueRef, status, repo: o.repo, allowPartial: o.allowPartial });
5993
+ if (o.json) return console.log(JSON.stringify(result));
5994
+ console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> ${result.status}`);
5995
+ } catch (e) {
5996
+ fail(`board move failed: ${e.message}`);
5997
+ }
5998
+ });
5999
+ board.command("backfill-priority").description("set board Priority from priority:* labels or issue timeline for items missing the field").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo (defaults to git origin)").option("--dry-run", "report what would be set without writing").option("--concurrency <n>", "parallel timeline reads (default 8)", "8").action(async (o) => {
6000
+ try {
6001
+ const result = await backfillBoardPriorities({
6002
+ config: await loadConfig(),
6003
+ repo: o.repo,
6004
+ dryRun: o.dryRun,
6005
+ concurrency: Number(o.concurrency) || 8
6006
+ });
6007
+ if (o.json) return console.log(JSON.stringify(result));
6008
+ console.log(`backfill-priority: scanned ${result.scanned}, set ${result.set}, skipped ${result.skipped}, failed ${result.failed}`);
6009
+ for (const line of result.details.slice(0, 30)) console.log(` ${line}`);
6010
+ if (result.details.length > 30) console.log(` ... +${result.details.length - 30} more`);
6011
+ if (result.failed) process.exitCode = 1;
6012
+ } catch (e) {
6013
+ fail(`board backfill-priority failed: ${e.message}`);
6014
+ }
6015
+ });
6016
+ board.command("done <issue>").description("set a board item's Status to Done (does not close the GitHub issue; use `gh issue close`)").option("--json", "machine-readable output").option("--repo <owner/repo>", "current repo for local issue numbers (defaults to git origin)").option("--allow-partial", "return success JSON if the item resolves but the status move fails").action(async (issueRef, o) => {
6017
+ try {
6018
+ const result = await moveBoardItem({ config: await loadConfig(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
6019
+ if (o.json) return console.log(JSON.stringify(result));
6020
+ console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> Done`);
6021
+ } catch (e) {
6022
+ fail(`board done failed: ${e.message}`);
6023
+ }
6024
+ });
4561
6025
  function renderSteps(title, steps) {
4562
6026
  return [
4563
6027
  title,
@@ -4572,12 +6036,17 @@ function rawValue(flag, fallback) {
4572
6036
  return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
4573
6037
  }
4574
6038
  function printLine(value) {
4575
- (0, import_node_fs3.writeSync)(1, `${value}
6039
+ (0, import_node_fs4.writeSync)(1, `${value}
4576
6040
  `);
4577
6041
  }
4578
6042
  function stageKeepAlive() {
4579
6043
  return setTimeout(() => void 0, 5 * 60 * 1e3);
4580
6044
  }
6045
+ program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block from infra/port-ranges.json").option("--json", "machine-readable output").action((repo, o) => {
6046
+ const path = (0, import_node_path4.join)(process.cwd(), "infra", "port-ranges.json");
6047
+ const [start, end] = ensurePortRange(repo, path);
6048
+ printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
6049
+ });
4581
6050
  var stage = program2.command("stage").description("plan or run the repo local stage environment").option("--json", "machine-readable output").option("--apply", "run the full local stage: stop previous, build, start, health-check").option("--timeout-ms <ms>", "bounded build/health timeout", "60000").action(async (o) => {
4582
6051
  const cfg = (await loadConfig()).stage;
4583
6052
  if (o.apply) {
@@ -4679,11 +6148,101 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
4679
6148
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
4680
6149
  const report = await verifyBootstrap(repo, o.class, {
4681
6150
  gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
4682
- readLocalFile: (path) => (0, import_node_fs3.existsSync)(path) ? (0, import_node_fs3.readFileSync)(path, "utf8") : null
6151
+ readLocalFile: (path) => (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
4683
6152
  });
4684
6153
  console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
4685
6154
  if (!report.ok) process.exitCode = 1;
4686
6155
  });
6156
+ bootstrap.command("apply <repo>").description("idempotent seed apply from skills/bootstrap/seeds/manifest.json; dry-run unless --execute (live, master-gated)").option("--class <class>", "deployable | content", "deployable").option("--execute", "LIVE apply via gh (master-gated) \u2014 stamps seed files + labels into the repo").option("--var <KEY=VALUE...>", "placeholder values for repo-owned templates (repeatable)").option("--json", "machine-readable output").action(async (repo) => {
6157
+ const o = { class: rawValue("--class", "deployable"), execute: rawFlag("--execute"), json: rawFlag("--json") };
6158
+ if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap apply: --class must be deployable or content");
6159
+ const manifestPath = "skills/bootstrap/seeds/manifest.json";
6160
+ if (!(0, import_node_fs4.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
6161
+ const manifest = loadBootstrapSeeds((0, import_node_fs4.readFileSync)(manifestPath, "utf8"));
6162
+ const baseBranch = o.class === "content" ? "main" : "development";
6163
+ const slug = repo.split("/")[1].toLowerCase();
6164
+ const gh = async (args) => execFileP3("gh", args, { timeout: 2e4 });
6165
+ const readFile2 = (p) => (0, import_node_fs4.existsSync)(p) ? (0, import_node_fs4.readFileSync)(p, "utf8") : null;
6166
+ const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
6167
+ const vars = {};
6168
+ for (let i = 0; i < process.argv.length - 1; i++) {
6169
+ if (process.argv[i] === "--var") {
6170
+ const eq = process.argv[i + 1].indexOf("=");
6171
+ if (eq > 0) vars[process.argv[i + 1].slice(0, eq)] = process.argv[i + 1].slice(eq + 1);
6172
+ }
6173
+ }
6174
+ const actions = [];
6175
+ const applied = [];
6176
+ for (const seed of manifest.seeds) {
6177
+ if (!seed.classes.includes(o.class)) continue;
6178
+ const resolved = { ...seed, target: seed.target.replace("{{REPO_SLUG}}", slug) };
6179
+ let exists = false;
6180
+ let sha;
6181
+ if (resolved.source !== "fanout") {
6182
+ try {
6183
+ const r = await gh(["api", `repos/${repo}/contents/${enc(resolved.target)}?ref=${baseBranch}`]);
6184
+ exists = true;
6185
+ try {
6186
+ sha = JSON.parse(r.stdout).sha;
6187
+ } catch {
6188
+ }
6189
+ } catch {
6190
+ exists = false;
6191
+ }
6192
+ }
6193
+ const action = planSeedAction(resolved, exists);
6194
+ actions.push(action);
6195
+ if (o.execute && (action.action === "create" || action.action === "update")) {
6196
+ const content = resolveSeedContent(resolved, vars, readFile2);
6197
+ if (content == null) {
6198
+ applied.push(`skip ${resolved.target} (no resolvable content)`);
6199
+ continue;
6200
+ }
6201
+ const missing = missingPlaceholders(content);
6202
+ if (missing.length) {
6203
+ applied.push(`skip ${resolved.target} (unfilled: ${missing.join(", ")} \u2014 pass --var)`);
6204
+ continue;
6205
+ }
6206
+ await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
6207
+ applied.push(`${action.action} ${resolved.target}`);
6208
+ }
6209
+ }
6210
+ if (o.execute) {
6211
+ for (const l of manifest.labels) {
6212
+ try {
6213
+ await gh(["label", "create", l.name, "--color", l.color, "--description", l.description, "--force", "-R", repo]);
6214
+ applied.push(`label ${l.name}`);
6215
+ } catch {
6216
+ applied.push(`label ${l.name} (failed)`);
6217
+ }
6218
+ }
6219
+ }
6220
+ if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, actions, applied }, null, 2));
6221
+ else {
6222
+ console.log(renderSeedPlan(actions));
6223
+ if (o.execute) console.log(`
6224
+ LIVE apply to ${repo}:
6225
+ ${applied.join("\n ")}`);
6226
+ }
6227
+ });
6228
+ var access = program2.command("access").description("org access audit (read-only)");
6229
+ 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 () => {
6230
+ const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
6231
+ const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
6232
+ let targets;
6233
+ if (o.repo) {
6234
+ if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
6235
+ targets = [{ repo: o.repo, class: o.class }];
6236
+ } else {
6237
+ if (!(0, import_node_fs4.existsSync)("projects.json")) return fail("access audit: projects.json not found; run from the MMI-Hub repo root or pass --repo <owner/repo>");
6238
+ const fanoutJson = (0, import_node_fs4.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs4.readFileSync)(".github/fanout-targets.json", "utf8") : null;
6239
+ targets = loadAccessTargets((0, import_node_fs4.readFileSync)("projects.json", "utf8"), fanoutJson);
6240
+ }
6241
+ const matrix = (0, import_node_fs4.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs4.readFileSync)("access-matrix.json", "utf8")) : {};
6242
+ const report = await auditOrgAccess(targets, deps, matrix);
6243
+ console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
6244
+ if (!report.ok) process.exitCode = 1;
6245
+ });
4687
6246
  var isWin = process.platform === "win32";
4688
6247
  program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--json", "machine-readable output").action(async (opts) => {
4689
6248
  const checks = [];
@@ -4705,14 +6264,16 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
4705
6264
  }
4706
6265
  if (!onPath) {
4707
6266
  const root = process.env.CLAUDE_PLUGIN_ROOT;
4708
- if (root && (0, import_node_fs3.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
6267
+ if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
4709
6268
  }
4710
6269
  checks.push({ ok: onPath, label: "mmi-cli on PATH", fix: "auto-provisioned at session start \u2014 reopen the session, or install the MMI plugin" });
4711
- checks.push(buildVersionLagReport({
6270
+ let versionReport = buildVersionLagReport({
4712
6271
  currentVersion: resolveVersion(),
4713
6272
  repoVersion: readRepoVersion(),
4714
6273
  releasedVersion: await fetchReleasedVersion()
4715
- }));
6274
+ });
6275
+ if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
6276
+ checks.push(versionReport);
4716
6277
  const cfg = await loadConfig();
4717
6278
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
4718
6279
  const REWRITE_KEY = "url.https://github.com/.insteadOf";