@mutmutco/cli 0.10.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 +1302 -90
  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
@@ -3065,6 +3065,27 @@ function rulesSourceAuthHeaders(sourceUrl, token) {
3065
3065
  return void 0;
3066
3066
  }
3067
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
+ }
3088
+
3068
3089
  // src/saga-capture.ts
3069
3090
  function parseHookInput(stdin) {
3070
3091
  try {
@@ -3194,7 +3215,7 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
3194
3215
 
3195
3216
  // src/gh-create.ts
3196
3217
  var ISSUE_TYPES = ["bug", "feature", "task"];
3197
- var PRIORITIES = ["high", "medium", "low"];
3218
+ var PRIORITIES = ["urgent", "high", "medium", "low"];
3198
3219
  function parseCreatedUrl(stdout) {
3199
3220
  const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
3200
3221
  let match;
@@ -3206,7 +3227,7 @@ function parseCreatedUrl(stdout) {
3206
3227
  ${stdout.trim() || "(empty)"}`);
3207
3228
  return last;
3208
3229
  }
3209
- function buildIssueArgs({ type, title, body, priority, repo }) {
3230
+ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
3210
3231
  if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
3211
3232
  if (!PRIORITIES.includes(priority)) {
3212
3233
  throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
@@ -3215,8 +3236,36 @@ function buildIssueArgs({ type, title, body, priority, repo }) {
3215
3236
  if (repo) args.push("--repo", repo);
3216
3237
  args.push("--title", title, "--body", body, "--label", type);
3217
3238
  args.push("--label", `priority:${priority}`);
3239
+ for (const label of labels ?? []) args.push("--label", label);
3218
3240
  return args;
3219
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
+ }
3220
3269
  function buildPrArgs({ title, body, base, head, repo }) {
3221
3270
  const args = ["pr", "create"];
3222
3271
  if (repo) args.push("--repo", repo);
@@ -3343,6 +3392,10 @@ function buildVersionLagReport(input) {
3343
3392
  releasedVersion: input.releasedVersion
3344
3393
  };
3345
3394
  }
3395
+ function versionAutoUpdateAction(report, hasPluginRoot) {
3396
+ if (report.ok || report.staleAgainst !== "released") return "none";
3397
+ return hasPluginRoot ? "plugin-pull" : "npm";
3398
+ }
3346
3399
 
3347
3400
  // src/issue-related.ts
3348
3401
  var STOPWORDS = /* @__PURE__ */ new Set([
@@ -3401,7 +3454,50 @@ ${lines.join("\n")}`;
3401
3454
  // src/board.ts
3402
3455
  var import_node_child_process2 = require("node:child_process");
3403
3456
  var import_node_util = require("node:util");
3404
- 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
+ );
3405
3501
  var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
3406
3502
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
3407
3503
  var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
@@ -3419,7 +3515,7 @@ var defaultGit = async (args) => {
3419
3515
  }
3420
3516
  };
3421
3517
  var PROJECT_ITEMS_QUERY = `
3422
- query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
3518
+ query($owner: String!, $number: Int!, $after: String) {
3423
3519
  viewer { login }
3424
3520
  organization(login: $owner) {
3425
3521
  projectV2(number: $number) {
@@ -3429,8 +3525,14 @@ query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
3429
3525
  pageInfo { hasNextPage endCursor }
3430
3526
  nodes {
3431
3527
  id
3432
- fieldValueByName(name: $statusField) {
3433
- ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
3528
+ fieldValues(first: 8) {
3529
+ nodes {
3530
+ ... on ProjectV2ItemFieldSingleSelectValue {
3531
+ name
3532
+ optionId
3533
+ field { ... on ProjectV2SingleSelectField { name } }
3534
+ }
3535
+ }
3434
3536
  }
3435
3537
  content {
3436
3538
  __typename
@@ -3476,7 +3578,9 @@ function resolveBoardConfig(cfg) {
3476
3578
  projectNumber: cfg.projectNumber,
3477
3579
  projectId: cfg.projectId,
3478
3580
  statusFieldId: cfg.statusFieldId,
3479
- statusOptions: cfg.statusOptions
3581
+ statusOptions: cfg.statusOptions,
3582
+ priorityFieldId: cfg.priorityFieldId,
3583
+ priorityOptions: cfg.priorityOptions
3480
3584
  };
3481
3585
  }
3482
3586
  function repoFromGitRemote(remote) {
@@ -3549,6 +3653,22 @@ function findClaimableItem(report, selector) {
3549
3653
  }
3550
3654
  throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
3551
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
+ }
3552
3672
  function renderBoardReport(report) {
3553
3673
  const lines = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`];
3554
3674
  renderScope(lines, "PRIMARY", report.repo, report.primary);
@@ -3559,8 +3679,7 @@ function renderBoardReport(report) {
3559
3679
  }
3560
3680
  return lines.join("\n");
3561
3681
  }
3562
- async function readBoard(options, deps = {}) {
3563
- const cfg = resolveBoardConfig(options.config);
3682
+ async function collectBoardItems(cfg, options, deps) {
3564
3683
  const gh = deps.gh ?? defaultGh;
3565
3684
  const git = deps.git ?? defaultGit;
3566
3685
  const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
@@ -3590,21 +3709,93 @@ async function readBoard(options, deps = {}) {
3590
3709
  after = void 0;
3591
3710
  }
3592
3711
  } while (after);
3593
- const items = nodesToItems(nodes, warnings);
3594
- 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);
3595
3719
  const report = {
3596
- project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: projectId, title: projectTitle || String(cfg.projectNumber) },
3597
- viewer,
3598
- 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,
3599
3723
  ...groups,
3600
- warnings,
3601
- partial
3724
+ warnings: collected.warnings,
3725
+ partial: collected.partial
3602
3726
  };
3603
3727
  if (options.includeBundleDetails) {
3604
3728
  await attachBundleDetails(report, gh, options.allowPartial ?? false);
3605
3729
  }
3606
3730
  return report;
3607
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
+ }
3608
3799
  async function claimBoardIssue(options, deps = {}) {
3609
3800
  const cfg = resolveBoardConfig(options.config);
3610
3801
  const gh = deps.gh ?? defaultGh;
@@ -3612,8 +3803,10 @@ async function claimBoardIssue(options, deps = {}) {
3612
3803
  const selector = parseIssueSelector(options.selector, report.repo);
3613
3804
  const item = findClaimableItem(report, selector);
3614
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(/^@/, "");
3615
3808
  try {
3616
- 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]);
3617
3810
  } catch (e) {
3618
3811
  throw new Error(`claim failed before board status changed: ${ghError(e)}`);
3619
3812
  }
@@ -3631,11 +3824,117 @@ async function claimBoardIssue(options, deps = {}) {
3631
3824
  cfg.statusOptions["In Progress"]
3632
3825
  ]);
3633
3826
  } catch (e) {
3634
- 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)})`;
3635
3828
  if (!options.allowPartial) throw new Error(warning);
3636
3829
  return { item, viewer: report.viewer, repo: report.repo, status: "Todo", partial: true, warning };
3637
3830
  }
3638
- 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
+ });
3639
3938
  }
3640
3939
  async function fetchProjectPage(gh, cfg, after) {
3641
3940
  const args = [
@@ -3646,9 +3945,7 @@ async function fetchProjectPage(gh, cfg, after) {
3646
3945
  "-f",
3647
3946
  `owner=${cfg.projectOwner}`,
3648
3947
  "-F",
3649
- `number=${cfg.projectNumber}`,
3650
- "-f",
3651
- "statusField=Status"
3948
+ `number=${cfg.projectNumber}`
3652
3949
  ];
3653
3950
  if (after) args.push("-f", `after=${after}`);
3654
3951
  const { stdout } = await gh(args);
@@ -3665,10 +3962,27 @@ function nodesToItems(nodes, warnings) {
3665
3962
  }
3666
3963
  return items;
3667
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
+ }
3668
3981
  function nodeToItem(node) {
3669
3982
  const content = node.content;
3670
3983
  if (!node.id || !isSupportedContent(content)) return void 0;
3671
- const status = asBoardStatus(node.fieldValueByName?.name);
3984
+ const fields = parseSingleSelectFields(node.fieldValues?.nodes);
3985
+ const status = fields.status?.name;
3672
3986
  const repository = content.repository?.nameWithOwner;
3673
3987
  if (!status || !content.id || !content.number || !content.title || !content.url || !repository) return void 0;
3674
3988
  const labels = (content.labels?.nodes ?? []).map((l) => l.name).filter((name) => Boolean(name));
@@ -3684,7 +3998,9 @@ function nodeToItem(node) {
3684
3998
  title: content.title,
3685
3999
  state: content.state ?? "",
3686
4000
  status,
3687
- statusOptionId: node.fieldValueByName?.optionId,
4001
+ statusOptionId: fields.status?.optionId,
4002
+ priority: fields.priority?.name,
4003
+ priorityOptionId: fields.priority?.optionId,
3688
4004
  assignees,
3689
4005
  labels,
3690
4006
  type: labels.find((label) => TYPE_LABELS.includes(label)) ?? labels[0]
@@ -3743,7 +4059,8 @@ function renderTaken(lines, items) {
3743
4059
  for (const item of items) lines.push(` ${item.ref} \xB7 ${item.status} \xB7 @${item.assignees.join(", @")}`);
3744
4060
  }
3745
4061
  function renderTitledItem(item) {
3746
- 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}`;
3747
4064
  }
3748
4065
  function hasItems(buckets) {
3749
4066
  return buckets.userOwned.length > 0 || buckets.claimable.length > 0 || buckets.taken.length > 0;
@@ -3942,6 +4259,7 @@ function buildGithubAuthCheck(input) {
3942
4259
  var import_node_child_process3 = require("node:child_process");
3943
4260
  var import_node_fs2 = require("node:fs");
3944
4261
  var import_node_path2 = require("node:path");
4262
+ var import_node_net = require("node:net");
3945
4263
  var import_node_util2 = require("node:util");
3946
4264
  var execFileP2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
3947
4265
  function stageStatePath(cwd = process.cwd()) {
@@ -3954,8 +4272,29 @@ function validateStageConfig(config = {}, action) {
3954
4272
  if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
3955
4273
  problems.push("stage.healthUrl must be an http(s) URL");
3956
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
+ }
3957
4280
  return problems;
3958
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
+ }
3959
4298
  async function shell(command, cwd, timeoutMs) {
3960
4299
  await execFileP2(command, [], {
3961
4300
  cwd,
@@ -4030,24 +4369,34 @@ async function startStage(config = {}, opts = {}) {
4030
4369
  const statePath = opts.statePath ?? stageStatePath(cwd);
4031
4370
  const dir = statePath.slice(0, Math.max(statePath.lastIndexOf("/"), statePath.lastIndexOf("\\")));
4032
4371
  (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
4033
- 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());
4034
4381
  const child = (0, import_node_child_process3.spawn)(up, {
4035
4382
  cwd,
4036
4383
  shell: true,
4037
4384
  detached: true,
4038
4385
  windowsHide: true,
4039
- stdio: "ignore"
4386
+ stdio: "ignore",
4387
+ env: stagePort != null ? { ...process.env, STAGE_PORT: String(stagePort) } : process.env
4040
4388
  });
4041
4389
  const state = {
4042
4390
  pid: child.pid ?? 0,
4043
4391
  command: up,
4044
4392
  cwd,
4045
4393
  startedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
4046
- healthUrl: config.healthUrl?.trim() || void 0
4394
+ healthUrl: sub(config.healthUrl?.trim()) || void 0,
4395
+ port: stagePort
4047
4396
  };
4048
4397
  (0, import_node_fs2.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
4049
4398
  if (state.healthUrl) await waitForHealth(state.healthUrl, opts.timeoutMs ?? 6e4);
4050
- 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}` : ""}` };
4051
4400
  opts.onReady?.(result);
4052
4401
  child.unref();
4053
4402
  return result;
@@ -4063,6 +4412,190 @@ async function runStage(config = {}, opts = {}) {
4063
4412
  return { ...started, action: "run", message: `built and ${started.message}` };
4064
4413
  }
4065
4414
 
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) {
4452
+ return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4453
+ }
4454
+ function safeJson(text, fallback) {
4455
+ try {
4456
+ return JSON.parse(text);
4457
+ } catch {
4458
+ return fallback;
4459
+ }
4460
+ }
4461
+ async function ghJson(deps, args, fallback) {
4462
+ try {
4463
+ return safeJson((await deps.gh(args)).stdout, fallback);
4464
+ } catch {
4465
+ return fallback;
4466
+ }
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
+
4066
4599
  // src/bootstrap-verify.ts
4067
4600
  var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
4068
4601
  var requiredIssueTemplates = [
@@ -4072,7 +4605,9 @@ var requiredIssueTemplates = [
4072
4605
  ".github/ISSUE_TEMPLATE/config.yml"
4073
4606
  ];
4074
4607
  var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
4075
- var requiredLabels = ["bug", "feature", "task", "priority:high", "priority:medium", "priority:low"];
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"];
4076
4611
  var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
4077
4612
  var requiredProjectWorkflows = [
4078
4613
  "Auto-add sub-issues to project",
@@ -4087,16 +4622,16 @@ var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
4087
4622
  function expectedBranches(repoClass) {
4088
4623
  return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4089
4624
  }
4090
- function safeJson(text, fallback) {
4625
+ function safeJson2(text, fallback) {
4091
4626
  try {
4092
4627
  return JSON.parse(text);
4093
4628
  } catch {
4094
4629
  return fallback;
4095
4630
  }
4096
4631
  }
4097
- async function ghJson(deps, args, fallback) {
4632
+ async function ghJson2(deps, args, fallback) {
4098
4633
  try {
4099
- return safeJson((await deps.gh(args)).stdout, fallback);
4634
+ return safeJson2((await deps.gh(args)).stdout, fallback);
4100
4635
  } catch {
4101
4636
  return fallback;
4102
4637
  }
@@ -4113,7 +4648,7 @@ async function contentExists(deps, repo, branch, path) {
4113
4648
  async function contentText(deps, repo, branch, path) {
4114
4649
  try {
4115
4650
  const encodedPath = path.split("/").map(encodeURIComponent).join("/");
4116
- const response = safeJson(
4651
+ const response = safeJson2(
4117
4652
  (await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`])).stdout,
4118
4653
  {}
4119
4654
  );
@@ -4124,36 +4659,51 @@ async function contentText(deps, repo, branch, path) {
4124
4659
  return null;
4125
4660
  }
4126
4661
  }
4127
- async function protectedBranch(deps, repo, branch) {
4662
+ async function getProtection(deps, repo, branch) {
4128
4663
  try {
4129
- await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`]);
4130
- return true;
4664
+ return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
4131
4665
  } catch {
4132
- return false;
4666
+ return null;
4133
4667
  }
4134
4668
  }
4669
+ function hasPushAllowlist(p) {
4670
+ return Array.isArray(p?.restrictions?.users) && p.restrictions.users.length > 0;
4671
+ }
4135
4672
  function optionDetail(missing) {
4136
4673
  return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
4137
4674
  }
4138
4675
  function localRegistryCheck(deps, path, predicate) {
4139
4676
  const text = deps.readLocalFile?.(path);
4140
4677
  if (text == null) return null;
4141
- return predicate(safeJson(text, null));
4678
+ return predicate(safeJson2(text, null));
4142
4679
  }
4143
4680
  async function verifyBootstrap(repo, repoClass, deps) {
4144
4681
  const branchesWanted = expectedBranches(repoClass);
4145
4682
  const baseBranch = repoClass === "content" ? "main" : "development";
4146
4683
  const checks = [];
4147
- const repoInfo = await ghJson(deps, ["api", `repos/${repo}`], {});
4684
+ const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
4148
4685
  checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
4149
4686
  checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
4150
4687
  checks.push({ ok: repoInfo.has_wiki === true, label: "wiki enabled", detail: repoInfo.has_wiki === true ? void 0 : "has_wiki is false or unavailable" });
4151
- const branchList = await ghJson(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
4688
+ const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
4152
4689
  const branchNames = new Set(branchList.map((b) => b.name));
4153
4690
  for (const branch of branchesWanted) {
4154
4691
  checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
4155
- 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
+ });
4156
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
+ });
4157
4707
  for (const path of requiredDocs) {
4158
4708
  checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `bootstrap artifact exists: ${path}` });
4159
4709
  }
@@ -4163,31 +4713,37 @@ async function verifyBootstrap(repo, repoClass, deps) {
4163
4713
  for (const path of requiredWorkflows) {
4164
4714
  checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `automation workflow exists: ${path}` });
4165
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
+ }
4166
4720
  checks.push({ ok: await contentExists(deps, repo, baseBranch, ".cursor/environment.json"), label: "Cursor environment committed" });
4167
- const labels = await ghJson(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
4721
+ const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
4168
4722
  const labelNames = new Set(labels.map((l) => l.name));
4169
4723
  for (const label of requiredLabels) {
4170
4724
  checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
4171
4725
  }
4172
- 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`], {});
4173
4729
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
4174
- const variables = await ghJson(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
4730
+ const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
4175
4731
  const variableNames = new Set(variables.map((v) => v.name));
4176
4732
  for (const variable of requiredActionsVariables) {
4177
4733
  checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
4178
4734
  }
4179
- const secrets = await ghJson(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
4180
- const secretNames = new Set(secrets.map((s) => s.name));
4735
+ const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
4736
+ const secretNames = new Set(secrets2.map((s) => s.name));
4181
4737
  for (const secret of requiredActionsSecrets) {
4182
4738
  checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
4183
4739
  }
4184
- const config = safeJson(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
4740
+ const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
4185
4741
  checks.push({
4186
4742
  ok: Boolean(config?.projectOwner && config?.projectNumber),
4187
4743
  label: ".mmi project board config exists"
4188
4744
  });
4189
4745
  if (config?.projectOwner && config.projectNumber != null) {
4190
- const project = await ghJson(
4746
+ const project = await ghJson2(
4191
4747
  deps,
4192
4748
  ["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
4193
4749
  {}
@@ -4223,8 +4779,33 @@ async function verifyBootstrap(repo, repoClass, deps) {
4223
4779
  });
4224
4780
  }
4225
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
+ }
4226
4807
  const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
4227
- const workflowResponse = await ghJson(
4808
+ const workflowResponse = await ghJson2(
4228
4809
  deps,
4229
4810
  ["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
4230
4811
  {}
@@ -4241,15 +4822,139 @@ async function verifyBootstrap(repo, repoClass, deps) {
4241
4822
  if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
4242
4823
  const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
4243
4824
  if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
4244
- 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);
4245
4845
  }
4246
4846
  function renderBootstrapVerifyReport(report) {
4247
4847
  const lines = [`mmi-cli bootstrap verify: ${report.ok ? "OK" : "CHECK"} ${report.repo} (${report.class}, ${report.baseBranch})`];
4248
4848
  for (const check of report.checks) {
4249
- lines.push(`${check.ok ? "OK" : "FAIL"} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
4849
+ const status = check.ok ? "OK" : check.waived ? "WAIVE" : "FAIL";
4850
+ lines.push(`${status} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
4851
+ }
4852
+ return lines.join("\n");
4853
+ }
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" };
4250
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(", ")}`);
4251
4906
  return lines.join("\n");
4252
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
+ }
4253
4958
 
4254
4959
  // src/plan.ts
4255
4960
  var import_node_path3 = require("node:path");
@@ -4391,8 +5096,178 @@ async function planDelete(deps, slug, opts = {}) {
4391
5096
  deps.log(`deleted ${slug}`);
4392
5097
  }
4393
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
+
4394
5264
  // src/index.ts
4395
- 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
+ );
4396
5271
  var GIT_TIMEOUT_MS = 1e4;
4397
5272
  var GC_GH_TIMEOUT_MS = 2e4;
4398
5273
  async function githubToken() {
@@ -4425,7 +5300,6 @@ async function loadConfig() {
4425
5300
  }
4426
5301
  }
4427
5302
  var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/development";
4428
- var DEFAULT_KB_SOURCE = "https://raw.githubusercontent.com/mutmutco/MM-KB/main";
4429
5303
  var SESSION_FILE = ".mmi/.session";
4430
5304
  var gitOut = async (args) => {
4431
5305
  try {
@@ -4439,7 +5313,7 @@ function sessionDeps() {
4439
5313
  env: process.env,
4440
5314
  readPersisted: () => {
4441
5315
  try {
4442
- return (0, import_node_fs3.readFileSync)(SESSION_FILE, "utf8");
5316
+ return (0, import_node_fs4.readFileSync)(SESSION_FILE, "utf8");
4443
5317
  } catch {
4444
5318
  return null;
4445
5319
  }
@@ -4452,8 +5326,8 @@ function sessionDeps() {
4452
5326
  var resolveSessionId = () => resolveSession(sessionDeps());
4453
5327
  function persistSession(id) {
4454
5328
  try {
4455
- (0, import_node_fs3.mkdirSync)(".mmi", { recursive: true });
4456
- (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");
4457
5331
  } catch {
4458
5332
  }
4459
5333
  }
@@ -4473,7 +5347,11 @@ async function postCapture(capture, quiet = false) {
4473
5347
  method: "POST",
4474
5348
  headers: await sagaHeaders({ "content-type": "application/json" }),
4475
5349
  body: JSON.stringify({ ...capture, ...await sagaKey(cfg) }),
4476
- 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)
4477
5355
  });
4478
5356
  if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
4479
5357
  } catch (e) {
@@ -4537,14 +5415,35 @@ async function applyGcPlan(plan2, remote) {
4537
5415
  await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
4538
5416
  }
4539
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
+ }
4540
5439
  function resolveVersion() {
4541
5440
  try {
4542
5441
  const manifest = (0, import_node_path4.join)(__dirname, "..", "..", ".claude-plugin", "plugin.json");
4543
- return JSON.parse((0, import_node_fs3.readFileSync)(manifest, "utf8")).version || "0.0.0";
5442
+ return JSON.parse((0, import_node_fs4.readFileSync)(manifest, "utf8")).version || "0.0.0";
4544
5443
  } catch {
4545
5444
  try {
4546
5445
  const pkg = (0, import_node_path4.join)(__dirname, "..", "package.json");
4547
- return JSON.parse((0, import_node_fs3.readFileSync)(pkg, "utf8")).version || "0.0.0";
5446
+ return JSON.parse((0, import_node_fs4.readFileSync)(pkg, "utf8")).version || "0.0.0";
4548
5447
  } catch {
4549
5448
  return "0.0.0";
4550
5449
  }
@@ -4552,7 +5451,7 @@ function resolveVersion() {
4552
5451
  }
4553
5452
  function readRepoVersion() {
4554
5453
  try {
4555
- return JSON.parse((0, import_node_fs3.readFileSync)((0, import_node_path4.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;
4556
5455
  } catch {
4557
5456
  return void 0;
4558
5457
  }
@@ -4568,6 +5467,31 @@ async function fetchReleasedVersion() {
4568
5467
  return void 0;
4569
5468
  }
4570
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
+ }
4571
5495
  var program2 = new Command();
4572
5496
  program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
4573
5497
  var rules = program2.command("rules").description("org rules delivery");
@@ -4591,10 +5515,10 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
4591
5515
  if (!opts.quiet) console.error(`mmi-cli rules: could not fetch ${file} (${e.message}); left it untouched`);
4592
5516
  continue;
4593
5517
  }
4594
- 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;
4595
5519
  if (needsUpdate(source, current)) {
4596
5520
  const slash = file.lastIndexOf("/");
4597
- 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 });
4598
5522
  await (0, import_promises.writeFile)(file, normalizeEol(source), "utf8");
4599
5523
  changed++;
4600
5524
  if (!opts.quiet) console.log(`mmi-cli rules: updated ${file}`);
@@ -4602,6 +5526,29 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
4602
5526
  }
4603
5527
  if (!opts.quiet && changed === 0) console.log("mmi-cli rules: up to date");
4604
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
+ });
4605
5552
  var saga = program2.command("saga").description("per-session continuity");
4606
5553
  async function runNote(summary, o) {
4607
5554
  const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
@@ -4719,11 +5666,28 @@ program2.command("gc").description("dry-run cleanup for merged/closed PR branche
4719
5666
  fail(`gc: ${e.message}`);
4720
5667
  }
4721
5668
  });
4722
- program2.command("kb").description("org knowledgebase (read-only)").command("get <path>").description("print a KB document by path").action(async (path) => {
4723
- const cfg = await loadConfig();
4724
- const base = (cfg.kbSource ?? DEFAULT_KB_SOURCE).replace(/\/$/, "");
4725
- const res = await fetch(`${base}/${path.replace(/^\//, "")}`);
4726
- 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
+ }
4727
5691
  });
4728
5692
  async function ghCreate(args) {
4729
5693
  try {
@@ -4734,7 +5698,7 @@ async function ghCreate(args) {
4734
5698
  fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
4735
5699
  }
4736
5700
  }
4737
- async function ghJson2(args, timeout = 1e4) {
5701
+ async function ghJson3(args, timeout = 1e4) {
4738
5702
  const { stdout } = await execFileP3("gh", args, { timeout });
4739
5703
  return JSON.parse(stdout);
4740
5704
  }
@@ -4747,6 +5711,47 @@ async function resolveRepo(repo) {
4747
5711
  return void 0;
4748
5712
  }
4749
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
+ }
4750
5755
  function scheduleRelatedDiscovery(o) {
4751
5756
  try {
4752
5757
  const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
@@ -4761,7 +5766,7 @@ function scheduleRelatedDiscovery(o) {
4761
5766
  }
4762
5767
  }
4763
5768
  function makePlanDeps(cfg) {
4764
- const ensureDir = () => (0, import_node_fs3.mkdirSync)(PLANS_DIR, { recursive: true });
5769
+ const ensureDir = () => (0, import_node_fs4.mkdirSync)(PLANS_DIR, { recursive: true });
4765
5770
  return {
4766
5771
  apiUrl: cfg.sagaApiUrl,
4767
5772
  fetch: (url, init) => fetch(url, init),
@@ -4769,31 +5774,31 @@ function makePlanDeps(cfg) {
4769
5774
  project: async () => (await sagaKey(cfg)).project,
4770
5775
  readLocal: (slug) => {
4771
5776
  try {
4772
- return (0, import_node_fs3.readFileSync)(planPath(slug), "utf8");
5777
+ return (0, import_node_fs4.readFileSync)(planPath(slug), "utf8");
4773
5778
  } catch {
4774
5779
  return null;
4775
5780
  }
4776
5781
  },
4777
5782
  writeLocal: (slug, content) => {
4778
5783
  ensureDir();
4779
- (0, import_node_fs3.writeFileSync)(planPath(slug), content, "utf8");
5784
+ (0, import_node_fs4.writeFileSync)(planPath(slug), content, "utf8");
4780
5785
  },
4781
5786
  removeLocal: (slug) => {
4782
5787
  try {
4783
- (0, import_node_fs3.rmSync)(planPath(slug));
5788
+ (0, import_node_fs4.rmSync)(planPath(slug));
4784
5789
  } catch {
4785
5790
  }
4786
5791
  },
4787
5792
  readMetaRaw: () => {
4788
5793
  try {
4789
- return (0, import_node_fs3.readFileSync)(META_FILE, "utf8");
5794
+ return (0, import_node_fs4.readFileSync)(META_FILE, "utf8");
4790
5795
  } catch {
4791
5796
  return null;
4792
5797
  }
4793
5798
  },
4794
5799
  writeMetaRaw: (raw) => {
4795
5800
  ensureDir();
4796
- (0, import_node_fs3.writeFileSync)(META_FILE, raw, "utf8");
5801
+ (0, import_node_fs4.writeFileSync)(META_FILE, raw, "utf8");
4797
5802
  },
4798
5803
  log: (m) => console.log(m),
4799
5804
  err: (m) => console.error(m),
@@ -4831,17 +5836,65 @@ plan.command("open <slug>").description("pull if needed, then open plans/<slug>.
4831
5836
  })
4832
5837
  );
4833
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, {})));
4834
5878
  var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
4835
- 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) => {
4836
5880
  let args;
4837
5881
  try {
4838
- 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 });
4839
5883
  } catch (e) {
4840
5884
  return fail(`issue create: ${e.message}`);
4841
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
+ }
4842
5894
  const created = await ghCreate(args);
4843
- scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
4844
- 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 }));
4845
5898
  });
4846
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) => {
4847
5900
  const number = Number(o.number);
@@ -4849,7 +5902,7 @@ issue.command("discover-related").description("find related issues for an existi
4849
5902
  const repo = await resolveRepo(o.repo);
4850
5903
  if (!repo) return fail("issue discover-related: could not resolve repo");
4851
5904
  try {
4852
- const issues = await ghJson2([
5905
+ const issues = await ghJson3([
4853
5906
  "issue",
4854
5907
  "list",
4855
5908
  "--repo",
@@ -4864,7 +5917,7 @@ issue.command("discover-related").description("find related issues for an existi
4864
5917
  const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
4865
5918
  if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
4866
5919
  if (!candidates.length) return;
4867
- const viewed = await ghJson2([
5920
+ const viewed = await ghJson3([
4868
5921
  "issue",
4869
5922
  "view",
4870
5923
  String(number),
@@ -4883,6 +5936,16 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
4883
5936
  const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
4884
5937
  console.log(JSON.stringify(created));
4885
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
+ });
4886
5949
  async function runBoardRead(o) {
4887
5950
  try {
4888
5951
  const report = await readBoard({
@@ -4896,17 +5959,69 @@ async function runBoardRead(o) {
4896
5959
  fail(`board read failed: ${e.message}`);
4897
5960
  }
4898
5961
  }
4899
- 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");
4900
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));
4901
- 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) => {
4902
5965
  try {
4903
- 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
+ });
4904
5973
  if (o.json) return console.log(JSON.stringify(result));
4905
5974
  console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
4906
5975
  } catch (e) {
4907
5976
  fail(`board claim failed: ${e.message}`);
4908
5977
  }
4909
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
+ });
4910
6025
  function renderSteps(title, steps) {
4911
6026
  return [
4912
6027
  title,
@@ -4921,12 +6036,17 @@ function rawValue(flag, fallback) {
4921
6036
  return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
4922
6037
  }
4923
6038
  function printLine(value) {
4924
- (0, import_node_fs3.writeSync)(1, `${value}
6039
+ (0, import_node_fs4.writeSync)(1, `${value}
4925
6040
  `);
4926
6041
  }
4927
6042
  function stageKeepAlive() {
4928
6043
  return setTimeout(() => void 0, 5 * 60 * 1e3);
4929
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
+ });
4930
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) => {
4931
6051
  const cfg = (await loadConfig()).stage;
4932
6052
  if (o.apply) {
@@ -5028,11 +6148,101 @@ bootstrap.command("verify <repo>").description("audit whether an existing repo i
5028
6148
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
5029
6149
  const report = await verifyBootstrap(repo, o.class, {
5030
6150
  gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
5031
- 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
5032
6152
  });
5033
6153
  console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
5034
6154
  if (!report.ok) process.exitCode = 1;
5035
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
+ });
5036
6246
  var isWin = process.platform === "win32";
5037
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) => {
5038
6248
  const checks = [];
@@ -5054,14 +6264,16 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
5054
6264
  }
5055
6265
  if (!onPath) {
5056
6266
  const root = process.env.CLAUDE_PLUGIN_ROOT;
5057
- 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;
5058
6268
  }
5059
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" });
5060
- checks.push(buildVersionLagReport({
6270
+ let versionReport = buildVersionLagReport({
5061
6271
  currentVersion: resolveVersion(),
5062
6272
  repoVersion: readRepoVersion(),
5063
6273
  releasedVersion: await fetchReleasedVersion()
5064
- }));
6274
+ });
6275
+ if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
6276
+ checks.push(versionReport);
5065
6277
  const cfg = await loadConfig();
5066
6278
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
5067
6279
  const REWRITE_KEY = "url.https://github.com/.insteadOf";