@mutmutco/cli 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +18 -7
  2. package/dist/index.cjs +2291 -183
  3. package/package.json +4 -4
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
@@ -3064,6 +3064,31 @@ function rulesSourceAuthHeaders(sourceUrl, token) {
3064
3064
  }
3065
3065
  return void 0;
3066
3066
  }
3067
+ function resolveRulesBase(orgRulesSource, defaultBase) {
3068
+ const isUrl = typeof orgRulesSource === "string" && /^https?:\/\//i.test(orgRulesSource);
3069
+ return (isUrl ? orgRulesSource : defaultBase).replace(/\/$/, "");
3070
+ }
3071
+
3072
+ // src/docs-sync.ts
3073
+ var SYNCED_DOCS = ["README.md", "architecture.md"];
3074
+ async function syncDocs(deps, docs2 = SYNCED_DOCS) {
3075
+ const updated = [];
3076
+ const skippedDirty = [];
3077
+ for (const file of docs2) {
3078
+ if (await deps.isDirty(file)) {
3079
+ skippedDirty.push(file);
3080
+ continue;
3081
+ }
3082
+ const origin = await deps.originContent(file);
3083
+ if (origin === null) continue;
3084
+ const local = await deps.localContent(file);
3085
+ if (needsUpdate(origin, local)) {
3086
+ await deps.writeDoc(file, normalizeEol(origin));
3087
+ updated.push(file);
3088
+ }
3089
+ }
3090
+ return { updated, skippedDirty };
3091
+ }
3067
3092
 
3068
3093
  // src/saga-capture.ts
3069
3094
  function parseHookInput(stdin) {
@@ -3079,6 +3104,7 @@ function parseHookInput(stdin) {
3079
3104
  var import_node_child_process4 = require("node:child_process");
3080
3105
  var import_node_util3 = require("node:util");
3081
3106
  var import_node_path4 = require("node:path");
3107
+ var import_node_os = require("node:os");
3082
3108
 
3083
3109
  // src/saga-head-maintainer.ts
3084
3110
  var import_node_child_process = require("node:child_process");
@@ -3194,7 +3220,7 @@ async function runHeadEngine(prompt, timeoutMs = HEAD_ENGINE_TIMEOUT_MS) {
3194
3220
 
3195
3221
  // src/gh-create.ts
3196
3222
  var ISSUE_TYPES = ["bug", "feature", "task"];
3197
- var PRIORITIES = ["high", "medium", "low"];
3223
+ var PRIORITIES = ["urgent", "high", "medium", "low"];
3198
3224
  function parseCreatedUrl(stdout) {
3199
3225
  const re = /https:\/\/github\.com\/[^\s]+\/(?:issues|pull)\/(\d+)/g;
3200
3226
  let match;
@@ -3206,7 +3232,7 @@ function parseCreatedUrl(stdout) {
3206
3232
  ${stdout.trim() || "(empty)"}`);
3207
3233
  return last;
3208
3234
  }
3209
- function buildIssueArgs({ type, title, body, priority, repo }) {
3235
+ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
3210
3236
  if (!ISSUE_TYPES.includes(type)) throw new Error(`unknown issue type "${type}" \u2014 expected one of: ${ISSUE_TYPES.join(", ")}`);
3211
3237
  if (!PRIORITIES.includes(priority)) {
3212
3238
  throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${PRIORITIES.join(", ")}`);
@@ -3214,9 +3240,36 @@ function buildIssueArgs({ type, title, body, priority, repo }) {
3214
3240
  const args = ["issue", "create"];
3215
3241
  if (repo) args.push("--repo", repo);
3216
3242
  args.push("--title", title, "--body", body, "--label", type);
3217
- args.push("--label", `priority:${priority}`);
3243
+ for (const label of labels ?? []) args.push("--label", label);
3218
3244
  return args;
3219
3245
  }
3246
+ function boardAttachSkipReason(cwdRepo, targetRepo2) {
3247
+ if (targetRepo2 && cwdRepo && targetRepo2 !== cwdRepo) {
3248
+ return `issue was created in ${targetRepo2}, not the board's repo ${cwdRepo}`;
3249
+ }
3250
+ return null;
3251
+ }
3252
+ function buildAddToProjectArgs(projectId, contentId) {
3253
+ if (!projectId) throw new Error("addToProject: projectId is required");
3254
+ if (!contentId) throw new Error("addToProject: contentId is required");
3255
+ return [
3256
+ "api",
3257
+ "graphql",
3258
+ "-f",
3259
+ "query=mutation($p:ID!,$c:ID!){addProjectV2ItemById(input:{projectId:$p,contentId:$c}){item{id}}}",
3260
+ "-f",
3261
+ `p=${projectId}`,
3262
+ "-f",
3263
+ `c=${contentId}`
3264
+ ];
3265
+ }
3266
+ function parseAddedItemId(stdout) {
3267
+ try {
3268
+ return JSON.parse(stdout)?.data?.addProjectV2ItemById?.item?.id || void 0;
3269
+ } catch {
3270
+ return void 0;
3271
+ }
3272
+ }
3220
3273
  function buildPrArgs({ title, body, base, head, repo }) {
3221
3274
  const args = ["pr", "create"];
3222
3275
  if (repo) args.push("--repo", repo);
@@ -3239,6 +3292,12 @@ function resolveSession(d) {
3239
3292
  return { id, source: "generated" };
3240
3293
  }
3241
3294
 
3295
+ // src/hub-url.ts
3296
+ var DEFAULT_HUB_URL = "https://tqxxwzftic.execute-api.eu-central-1.amazonaws.com";
3297
+ function defaultHubUrl() {
3298
+ return process.env.MMI_HUB_URL || DEFAULT_HUB_URL;
3299
+ }
3300
+
3242
3301
  // src/saga-health.ts
3243
3302
  function buildHealth(i) {
3244
3303
  const problems = [];
@@ -3264,8 +3323,17 @@ function healthBanner(report) {
3264
3323
  const suffix = report.problems.length > 2 ? ` (+${report.problems.length - 2} more)` : "";
3265
3324
  return `saga health: CHECK - ${summary}${suffix}`;
3266
3325
  }
3326
+ function resumeCue() {
3327
+ return '> STATUS/RESUME CUE \u2014 For any status, resume, or "where do I stand" report: read THIS saga HEAD first (`mmi-cli saga show`), then reconcile its NEXT / LAST 5 / DECISIONS against the live board + git/gh before reporting. Do not rebuild the picture from board/issues/memory while skipping the HEAD.';
3328
+ }
3267
3329
 
3268
3330
  // src/saga-note.ts
3331
+ var AGENT_SURFACE_TOKENS = ["claude", "codex", "cursor", "gemini"];
3332
+ function agentSurface() {
3333
+ const surface = process.env.MMI_AGENT_SURFACE || "claude";
3334
+ if (AGENT_SURFACE_TOKENS.includes(surface)) return surface;
3335
+ throw new Error(`MMI_AGENT_SURFACE must be one of: ${AGENT_SURFACE_TOKENS.join(", ")}`);
3336
+ }
3269
3337
  function buildNoteCapture(summary, o, id, evidence) {
3270
3338
  const queueOp = o.queueAdd ? { op: "add", text: o.queueAdd } : o.queueDone != null ? { op: "done", index: Number(o.queueDone) } : void 0;
3271
3339
  const state = o.diagnostic ? "diagnostic" : o.verified ? "verified" : "asserted";
@@ -3286,7 +3354,7 @@ function buildNoteCapture(summary, o, id, evidence) {
3286
3354
  state,
3287
3355
  source,
3288
3356
  evidence: Object.keys(ev).length ? ev : void 0,
3289
- surface: process.env.MMI_AGENT_SURFACE || "claude",
3357
+ surface: agentSurface(),
3290
3358
  supersedes: o.supersedes,
3291
3359
  anchor,
3292
3360
  anchorForce: o.anchorForce || void 0
@@ -3343,6 +3411,20 @@ function buildVersionLagReport(input) {
3343
3411
  releasedVersion: input.releasedVersion
3344
3412
  };
3345
3413
  }
3414
+ function pluginManifestVersionArgs() {
3415
+ return ["api", "repos/mutmutco/MMI-Hub/contents/.claude-plugin/plugin.json?ref=main", "-H", "Accept: application/vnd.github.raw"];
3416
+ }
3417
+ function parseManifestVersion(stdout) {
3418
+ try {
3419
+ return JSON.parse(stdout).version || void 0;
3420
+ } catch {
3421
+ return void 0;
3422
+ }
3423
+ }
3424
+ function versionAutoUpdateAction(report, hasPluginRoot) {
3425
+ if (report.ok || report.staleAgainst !== "released") return "none";
3426
+ return hasPluginRoot ? "plugin-pull" : "npm";
3427
+ }
3346
3428
 
3347
3429
  // src/issue-related.ts
3348
3430
  var STOPWORDS = /* @__PURE__ */ new Set([
@@ -3401,7 +3483,50 @@ ${lines.join("\n")}`;
3401
3483
  // src/board.ts
3402
3484
  var import_node_child_process2 = require("node:child_process");
3403
3485
  var import_node_util = require("node:util");
3404
- var execFileP = (0, import_node_util.promisify)(import_node_child_process2.execFile);
3486
+
3487
+ // src/board-priority.ts
3488
+ var BOARD_PRIORITY_NAMES = ["Urgent", "High", "Medium", "Low"];
3489
+ var CLI_PRIORITIES = ["urgent", "high", "medium", "low"];
3490
+ var LABEL_PREFIX = "priority:";
3491
+ function cliPriorityToFieldName(priority) {
3492
+ if (!CLI_PRIORITIES.includes(priority)) {
3493
+ throw new Error(`unknown priority "${priority}" \u2014 expected one of: ${CLI_PRIORITIES.join(", ")}`);
3494
+ }
3495
+ return priority.charAt(0).toUpperCase() + priority.slice(1);
3496
+ }
3497
+ function labelToFieldPriority(label) {
3498
+ if (!label.startsWith(LABEL_PREFIX)) return void 0;
3499
+ const slug = label.slice(LABEL_PREFIX.length).toLowerCase();
3500
+ if (!CLI_PRIORITIES.includes(slug)) return void 0;
3501
+ return cliPriorityToFieldName(slug);
3502
+ }
3503
+ function resolvePriorityOptionId(cfg, priority) {
3504
+ if (!cfg.priorityFieldId || !cfg.priorityOptions) return void 0;
3505
+ const name = cliPriorityToFieldName(priority);
3506
+ return cfg.priorityOptions[name];
3507
+ }
3508
+ function isPriorityFieldConfigured(cfg) {
3509
+ return Boolean(
3510
+ cfg.priorityFieldId && BOARD_PRIORITY_NAMES.every((name) => cfg.priorityOptions?.[name])
3511
+ );
3512
+ }
3513
+ function recoverPriorityFromEvents(events) {
3514
+ let found;
3515
+ for (const event of events) {
3516
+ if (event.event !== "labeled" || !event.label?.name) continue;
3517
+ const mapped = labelToFieldPriority(event.label.name);
3518
+ if (mapped) found = mapped;
3519
+ }
3520
+ return found;
3521
+ }
3522
+
3523
+ // src/board.ts
3524
+ var rawExecFileP = (0, import_node_util.promisify)(import_node_child_process2.execFile);
3525
+ var execFileP = (file, args, options = {}) => (
3526
+ // encoding 'utf8' guarantees string output at runtime; the cast pins the type (promisify's
3527
+ // overloads widen to string|Buffer when options is spread in).
3528
+ rawExecFileP(file, args, { encoding: "utf8", windowsHide: true, ...options })
3529
+ );
3405
3530
  var BOARD_STATUSES = ["Todo", "In Progress", "In Review", "Done"];
3406
3531
  var ACTIVE_STATUSES = /* @__PURE__ */ new Set(["Todo", "In Progress", "In Review"]);
3407
3532
  var STATUS_ORDER = new Map(BOARD_STATUSES.map((s, i) => [s, i]));
@@ -3419,7 +3544,7 @@ var defaultGit = async (args) => {
3419
3544
  }
3420
3545
  };
3421
3546
  var PROJECT_ITEMS_QUERY = `
3422
- query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
3547
+ query($owner: String!, $number: Int!, $after: String) {
3423
3548
  viewer { login }
3424
3549
  organization(login: $owner) {
3425
3550
  projectV2(number: $number) {
@@ -3429,8 +3554,14 @@ query($owner: String!, $number: Int!, $statusField: String!, $after: String) {
3429
3554
  pageInfo { hasNextPage endCursor }
3430
3555
  nodes {
3431
3556
  id
3432
- fieldValueByName(name: $statusField) {
3433
- ... on ProjectV2ItemFieldSingleSelectValue { name optionId }
3557
+ fieldValues(first: 8) {
3558
+ nodes {
3559
+ ... on ProjectV2ItemFieldSingleSelectValue {
3560
+ name
3561
+ optionId
3562
+ field { ... on ProjectV2SingleSelectField { name } }
3563
+ }
3564
+ }
3434
3565
  }
3435
3566
  content {
3436
3567
  __typename
@@ -3476,7 +3607,9 @@ function resolveBoardConfig(cfg) {
3476
3607
  projectNumber: cfg.projectNumber,
3477
3608
  projectId: cfg.projectId,
3478
3609
  statusFieldId: cfg.statusFieldId,
3479
- statusOptions: cfg.statusOptions
3610
+ statusOptions: cfg.statusOptions,
3611
+ priorityFieldId: cfg.priorityFieldId,
3612
+ priorityOptions: cfg.priorityOptions
3480
3613
  };
3481
3614
  }
3482
3615
  function repoFromGitRemote(remote) {
@@ -3499,7 +3632,7 @@ function parseIssueSelector(selector, defaultRepo) {
3499
3632
  if (local) return { repo: defaultRepo, number: Number(local[1]) };
3500
3633
  throw new Error(`expected an issue selector like 123, #123, owner/repo#123, or a GitHub issue URL`);
3501
3634
  }
3502
- function partitionBoardItems(items, viewer, currentRepo) {
3635
+ function partitionBoardItems(items, viewer, currentRepo, writableRepos) {
3503
3636
  const empty = () => ({ userOwned: [], claimable: [], taken: [] });
3504
3637
  const groups = { primary: empty(), secondary: empty() };
3505
3638
  const viewerKey = viewer.toLowerCase();
@@ -3511,7 +3644,7 @@ function partitionBoardItems(items, viewer, currentRepo) {
3511
3644
  const assignedToViewer = assignees.includes(viewerKey);
3512
3645
  if (assignedToViewer) {
3513
3646
  groups[scope].userOwned.push(item);
3514
- } else if (item.status === "Todo" && item.assignees.length === 0) {
3647
+ } else if (item.status === "Todo" && item.assignees.length === 0 && (!writableRepos || writableRepos.has(item.repository.toLowerCase()))) {
3515
3648
  groups[scope].claimable.push(item);
3516
3649
  } else if (item.assignees.length > 0) {
3517
3650
  groups[scope].taken.push(item);
@@ -3549,6 +3682,22 @@ function findClaimableItem(report, selector) {
3549
3682
  }
3550
3683
  throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
3551
3684
  }
3685
+ function renderBoardItem(item) {
3686
+ const assignees = item.assignees.length ? `@${item.assignees.join(", @")}` : "unassigned";
3687
+ const lines = [
3688
+ `${item.ref} - ${item.title}`,
3689
+ `Status: ${item.status} \xB7 ${assignees}${item.priority ? ` \xB7 Priority: ${item.priority}` : ""}`,
3690
+ `Type: ${item.type ?? "item"}${item.labels.length ? ` \xB7 ${item.labels.join(", ")}` : ""}`,
3691
+ item.url
3692
+ ];
3693
+ if (item.details) {
3694
+ lines.push("", item.details.body.trim() || "_(no body)_");
3695
+ for (const comment of item.details.comments) {
3696
+ lines.push("", `\u2014 @${comment.author}:`, comment.body.trim());
3697
+ }
3698
+ }
3699
+ return lines.join("\n");
3700
+ }
3552
3701
  function renderBoardReport(report) {
3553
3702
  const lines = [`Board \xB7 ${report.project.title} \xB7 @${report.viewer}`];
3554
3703
  renderScope(lines, "PRIMARY", report.repo, report.primary);
@@ -3559,8 +3708,7 @@ function renderBoardReport(report) {
3559
3708
  }
3560
3709
  return lines.join("\n");
3561
3710
  }
3562
- async function readBoard(options, deps = {}) {
3563
- const cfg = resolveBoardConfig(options.config);
3711
+ async function collectBoardItems(cfg, options, deps) {
3564
3712
  const gh = deps.gh ?? defaultGh;
3565
3713
  const git = deps.git ?? defaultGit;
3566
3714
  const currentRepo = options.repo ?? repoFromGitRemote(await git(["remote", "get-url", "origin"])) ?? "";
@@ -3576,12 +3724,12 @@ async function readBoard(options, deps = {}) {
3576
3724
  try {
3577
3725
  const page = await fetchProjectPage(gh, cfg, after);
3578
3726
  viewer ||= page.viewer.login;
3579
- const project = page.organization?.projectV2;
3580
- if (!project) throw new Error(`project ${cfg.projectOwner}#${cfg.projectNumber} not found`);
3581
- projectId = project.id;
3582
- projectTitle ||= project.title;
3583
- nodes.push(...project.items.nodes ?? []);
3584
- after = project.items.pageInfo.hasNextPage ? project.items.pageInfo.endCursor ?? void 0 : void 0;
3727
+ const project2 = page.organization?.projectV2;
3728
+ if (!project2) throw new Error(`project ${cfg.projectOwner}#${cfg.projectNumber} not found`);
3729
+ projectId = project2.id;
3730
+ projectTitle ||= project2.title;
3731
+ nodes.push(...project2.items.nodes ?? []);
3732
+ after = project2.items.pageInfo.hasNextPage ? project2.items.pageInfo.endCursor ?? void 0 : void 0;
3585
3733
  } catch (e) {
3586
3734
  const message = `partial board read: ${e.message}`;
3587
3735
  if (!nodes.length || !options.allowPartial) throw new Error(message);
@@ -3590,30 +3738,154 @@ async function readBoard(options, deps = {}) {
3590
3738
  after = void 0;
3591
3739
  }
3592
3740
  } while (after);
3593
- const items = nodesToItems(nodes, warnings);
3594
- const groups = partitionBoardItems(items, viewer, currentRepo);
3741
+ return { items: nodesToItems(nodes, warnings), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
3742
+ }
3743
+ async function repoCanPush(repo, gh) {
3744
+ try {
3745
+ const { stdout } = await gh(["api", `repos/${repo}`, "--jq", ".permissions.push"]);
3746
+ const value = stdout.trim();
3747
+ if (value === "true") return true;
3748
+ if (value === "false") return false;
3749
+ const parsed = JSON.parse(value);
3750
+ return typeof parsed.permissions?.push === "boolean" ? parsed.permissions.push : void 0;
3751
+ } catch {
3752
+ return void 0;
3753
+ }
3754
+ }
3755
+ async function resolveWritableReposForClaimables(items, gh, allowPartial) {
3756
+ const candidateRepos = [...new Set(items.filter((item) => item.status === "Todo" && item.assignees.length === 0).map((item) => item.repository))];
3757
+ const repos = /* @__PURE__ */ new Set();
3758
+ const warnings = [];
3759
+ let partial = false;
3760
+ for (const repo of candidateRepos) {
3761
+ const canPush = await repoCanPush(repo, gh);
3762
+ if (canPush === true) {
3763
+ repos.add(repo.toLowerCase());
3764
+ continue;
3765
+ }
3766
+ if (canPush === void 0) {
3767
+ const warning = `partial claimable access read: ${repo}: could not verify viewer write access`;
3768
+ if (!allowPartial) throw new Error(warning);
3769
+ warnings.push(warning);
3770
+ partial = true;
3771
+ }
3772
+ }
3773
+ return { repos, warnings, partial };
3774
+ }
3775
+ async function readBoard(options, deps = {}) {
3776
+ const cfg = resolveBoardConfig(options.config);
3777
+ const gh = deps.gh ?? defaultGh;
3778
+ const collected = await collectBoardItems(cfg, options, deps);
3779
+ const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
3780
+ collected.warnings.push(...writable.warnings);
3781
+ collected.partial = collected.partial || writable.partial;
3782
+ const groups = partitionBoardItems(collected.items, collected.viewer, collected.repo, writable.repos);
3595
3783
  const report = {
3596
- project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: projectId, title: projectTitle || String(cfg.projectNumber) },
3597
- viewer,
3598
- repo: currentRepo,
3784
+ project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
3785
+ viewer: collected.viewer,
3786
+ repo: collected.repo,
3599
3787
  ...groups,
3600
- warnings,
3601
- partial
3788
+ warnings: collected.warnings,
3789
+ partial: collected.partial
3602
3790
  };
3603
3791
  if (options.includeBundleDetails) {
3604
3792
  await attachBundleDetails(report, gh, options.allowPartial ?? false);
3605
3793
  }
3606
3794
  return report;
3607
3795
  }
3796
+ function findBoardItem(items, selector) {
3797
+ const found = items.find(
3798
+ (candidate) => candidate.repository.toLowerCase() === selector.repo.toLowerCase() && candidate.number === selector.number
3799
+ );
3800
+ if (!found) throw new Error(`${selector.repo}#${selector.number} is not on this project board`);
3801
+ return found;
3802
+ }
3803
+ async function moveBoardItem(options, deps = {}) {
3804
+ if (!BOARD_STATUSES.includes(options.status)) {
3805
+ throw new Error(`unknown status '${options.status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
3806
+ }
3807
+ const cfg = resolveBoardConfig(options.config);
3808
+ const gh = deps.gh ?? defaultGh;
3809
+ const collected = await collectBoardItems(cfg, options, deps);
3810
+ const selector = parseIssueSelector(options.selector, collected.repo);
3811
+ const item = findBoardItem(collected.items, selector);
3812
+ if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
3813
+ const optionId = cfg.statusOptions[options.status];
3814
+ try {
3815
+ await gh([
3816
+ "project",
3817
+ "item-edit",
3818
+ "--id",
3819
+ item.itemId,
3820
+ "--project-id",
3821
+ cfg.projectId,
3822
+ "--field-id",
3823
+ cfg.statusFieldId,
3824
+ "--single-select-option-id",
3825
+ optionId
3826
+ ]);
3827
+ } catch (e) {
3828
+ const warning = `partial move: ${item.ref} status was not changed to ${options.status} (${ghError(e)})`;
3829
+ if (!options.allowPartial) throw new Error(warning);
3830
+ return { item, viewer: collected.viewer, repo: collected.repo, status: item.status, partial: true, warning };
3831
+ }
3832
+ return {
3833
+ item: { ...item, status: options.status, statusOptionId: optionId },
3834
+ viewer: collected.viewer,
3835
+ repo: collected.repo,
3836
+ status: options.status,
3837
+ partial: false
3838
+ };
3839
+ }
3840
+ async function showBoardItem(options, deps = {}) {
3841
+ const cfg = resolveBoardConfig(options.config);
3842
+ const gh = deps.gh ?? defaultGh;
3843
+ const collected = await collectBoardItems(cfg, options, deps);
3844
+ const selector = parseIssueSelector(options.selector, collected.repo);
3845
+ const item = findBoardItem(collected.items, selector);
3846
+ if (item.contentType === "Issue") {
3847
+ try {
3848
+ const { stdout } = await gh(["issue", "view", String(item.number), "--repo", item.repository, "--json", "body,comments"]);
3849
+ const detail = JSON.parse(stdout);
3850
+ item.details = {
3851
+ body: detail.body ?? "",
3852
+ comments: (detail.comments ?? []).map((comment) => ({
3853
+ author: comment.author?.login ?? "",
3854
+ body: comment.body ?? ""
3855
+ }))
3856
+ };
3857
+ } catch (e) {
3858
+ if (!options.allowPartial) throw new Error(`detail read failed: ${item.ref}: ${ghError(e)}`);
3859
+ }
3860
+ }
3861
+ return item;
3862
+ }
3608
3863
  async function claimBoardIssue(options, deps = {}) {
3609
3864
  const cfg = resolveBoardConfig(options.config);
3610
3865
  const gh = deps.gh ?? defaultGh;
3611
- const report = await readBoard({ config: cfg, repo: options.repo, allowPartial: options.allowPartial }, deps);
3612
- const selector = parseIssueSelector(options.selector, report.repo);
3866
+ const collected = await collectBoardItems(cfg, { repo: options.repo, allowPartial: options.allowPartial }, deps);
3867
+ const writable = await resolveWritableReposForClaimables(collected.items, gh, options.allowPartial ?? false);
3868
+ collected.warnings.push(...writable.warnings);
3869
+ collected.partial = collected.partial || writable.partial;
3870
+ const report = {
3871
+ project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
3872
+ viewer: collected.viewer,
3873
+ repo: collected.repo,
3874
+ ...partitionBoardItems(collected.items, collected.viewer, collected.repo, writable.repos),
3875
+ warnings: collected.warnings,
3876
+ partial: collected.partial
3877
+ };
3878
+ const selector = parseIssueSelector(options.selector, collected.repo);
3879
+ const flatItem = findBoardItem(collected.items, selector);
3880
+ if (flatItem.status === "Todo" && flatItem.assignees.length === 0 && !writable.repos.has(flatItem.repository.toLowerCase())) {
3881
+ throw new Error(`${flatItem.ref} is not claimable: viewer does not have write access to ${flatItem.repository}`);
3882
+ }
3613
3883
  const item = findClaimableItem(report, selector);
3614
3884
  if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
3885
+ const assignee = options.assignee ?? "@me";
3886
+ const assignedLogin = assignee === "@me" ? report.viewer : assignee.replace(/^@/, "");
3615
3887
  try {
3616
- await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", "@me"]);
3888
+ await gh(["issue", "edit", String(item.number), "--repo", item.repository, "--add-assignee", assignee]);
3617
3889
  } catch (e) {
3618
3890
  throw new Error(`claim failed before board status changed: ${ghError(e)}`);
3619
3891
  }
@@ -3631,11 +3903,117 @@ async function claimBoardIssue(options, deps = {}) {
3631
3903
  cfg.statusOptions["In Progress"]
3632
3904
  ]);
3633
3905
  } catch (e) {
3634
- const warning = `partial claim: ${item.ref} was assigned to @${report.viewer}, but Status was not moved to In Progress (${ghError(e)})`;
3906
+ const warning = `partial claim: ${item.ref} was assigned to @${assignedLogin}, but Status was not moved to In Progress (${ghError(e)})`;
3635
3907
  if (!options.allowPartial) throw new Error(warning);
3636
3908
  return { item, viewer: report.viewer, repo: report.repo, status: "Todo", partial: true, warning };
3637
3909
  }
3638
- return { item: { ...item, assignees: [...item.assignees, report.viewer], status: "In Progress" }, viewer: report.viewer, repo: report.repo, status: "In Progress", partial: false };
3910
+ return {
3911
+ item: {
3912
+ ...item,
3913
+ assignees: item.assignees.includes(assignedLogin) ? item.assignees : [...item.assignees, assignedLogin],
3914
+ status: "In Progress",
3915
+ statusOptionId: cfg.statusOptions["In Progress"]
3916
+ },
3917
+ viewer: report.viewer,
3918
+ repo: report.repo,
3919
+ status: "In Progress",
3920
+ partial: false
3921
+ };
3922
+ }
3923
+ async function setBoardItemPriority(gh, cfg, itemId, priority) {
3924
+ if (!isPriorityFieldConfigured(cfg)) return void 0;
3925
+ const optionId = resolvePriorityOptionId(cfg, priority);
3926
+ if (!optionId || !cfg.priorityFieldId || !cfg.projectId) return void 0;
3927
+ await gh([
3928
+ "project",
3929
+ "item-edit",
3930
+ "--id",
3931
+ itemId,
3932
+ "--project-id",
3933
+ cfg.projectId,
3934
+ "--field-id",
3935
+ cfg.priorityFieldId,
3936
+ "--single-select-option-id",
3937
+ optionId
3938
+ ]);
3939
+ return cliPriorityToFieldName(priority);
3940
+ }
3941
+ async function backfillBoardPriorities(options, deps = {}) {
3942
+ const cfg = resolveBoardConfig(options.config);
3943
+ if (!isPriorityFieldConfigured(cfg)) {
3944
+ throw new Error("priority field is not configured in .mmi/config.json (priorityFieldId + priorityOptions)");
3945
+ }
3946
+ const gh = deps.gh ?? defaultGh;
3947
+ const collected = await collectBoardItems(cfg, { repo: options.repo }, deps);
3948
+ const issues = collected.items.filter((item) => item.contentType === "Issue");
3949
+ const concurrency = Math.max(1, options.concurrency ?? 8);
3950
+ const result = { scanned: issues.length, set: 0, skipped: 0, failed: 0, details: [] };
3951
+ async function work(item) {
3952
+ if (item.priority) {
3953
+ result.skipped += 1;
3954
+ return;
3955
+ }
3956
+ try {
3957
+ const priority = await recoverIssuePriority(gh, item);
3958
+ if (!priority) {
3959
+ result.skipped += 1;
3960
+ return;
3961
+ }
3962
+ if (options.dryRun) {
3963
+ result.set += 1;
3964
+ result.details.push(`${item.ref} \u2192 ${priority} (dry-run)`);
3965
+ return;
3966
+ }
3967
+ const optionId = cfg.priorityOptions?.[priority];
3968
+ if (!optionId) throw new Error(`no option id for ${priority}`);
3969
+ await gh([
3970
+ "project",
3971
+ "item-edit",
3972
+ "--id",
3973
+ item.itemId,
3974
+ "--project-id",
3975
+ cfg.projectId,
3976
+ "--field-id",
3977
+ cfg.priorityFieldId,
3978
+ "--single-select-option-id",
3979
+ optionId
3980
+ ]);
3981
+ result.set += 1;
3982
+ result.details.push(`${item.ref} \u2192 ${priority}`);
3983
+ } catch (e) {
3984
+ result.failed += 1;
3985
+ result.details.push(`${item.ref}: ${ghError(e)}`);
3986
+ }
3987
+ }
3988
+ for (let i = 0; i < issues.length; i += concurrency) {
3989
+ await Promise.all(issues.slice(i, i + concurrency).map(work));
3990
+ }
3991
+ return result;
3992
+ }
3993
+ async function recoverIssuePriority(gh, item) {
3994
+ for (const label of item.labels) {
3995
+ const fromLabel = labelToFieldPriority(label);
3996
+ if (fromLabel) return fromLabel;
3997
+ }
3998
+ const { stdout } = await gh(["api", `repos/${item.repository}/issues/${item.number}/events`, "--paginate"]);
3999
+ return recoverPriorityFromEvents(parsePaginatedEvents(stdout));
4000
+ }
4001
+ function parsePaginatedEvents(stdout) {
4002
+ const trimmed = stdout.trim();
4003
+ if (!trimmed) return [];
4004
+ try {
4005
+ const parsed = JSON.parse(trimmed);
4006
+ if (Array.isArray(parsed)) return parsed;
4007
+ } catch {
4008
+ }
4009
+ return trimmed.split(/\r?\n/).filter(Boolean).flatMap((line) => {
4010
+ try {
4011
+ const parsed = JSON.parse(line);
4012
+ return Array.isArray(parsed) ? parsed : [];
4013
+ } catch {
4014
+ return [];
4015
+ }
4016
+ });
3639
4017
  }
3640
4018
  async function fetchProjectPage(gh, cfg, after) {
3641
4019
  const args = [
@@ -3646,9 +4024,7 @@ async function fetchProjectPage(gh, cfg, after) {
3646
4024
  "-f",
3647
4025
  `owner=${cfg.projectOwner}`,
3648
4026
  "-F",
3649
- `number=${cfg.projectNumber}`,
3650
- "-f",
3651
- "statusField=Status"
4027
+ `number=${cfg.projectNumber}`
3652
4028
  ];
3653
4029
  if (after) args.push("-f", `after=${after}`);
3654
4030
  const { stdout } = await gh(args);
@@ -3665,10 +4041,27 @@ function nodesToItems(nodes, warnings) {
3665
4041
  }
3666
4042
  return items;
3667
4043
  }
4044
+ function parseSingleSelectFields(nodes) {
4045
+ const out = {};
4046
+ for (const node of nodes ?? []) {
4047
+ const fieldName = node.field?.name;
4048
+ if (fieldName === "Status") {
4049
+ const status = asBoardStatus(node.name);
4050
+ if (status) out.status = { name: status, optionId: node.optionId };
4051
+ } else if (fieldName === "Priority" && node.name) {
4052
+ const priority = node.name;
4053
+ if (["Urgent", "High", "Medium", "Low"].includes(priority)) {
4054
+ out.priority = { name: priority, optionId: node.optionId };
4055
+ }
4056
+ }
4057
+ }
4058
+ return out;
4059
+ }
3668
4060
  function nodeToItem(node) {
3669
4061
  const content = node.content;
3670
4062
  if (!node.id || !isSupportedContent(content)) return void 0;
3671
- const status = asBoardStatus(node.fieldValueByName?.name);
4063
+ const fields = parseSingleSelectFields(node.fieldValues?.nodes);
4064
+ const status = fields.status?.name;
3672
4065
  const repository = content.repository?.nameWithOwner;
3673
4066
  if (!status || !content.id || !content.number || !content.title || !content.url || !repository) return void 0;
3674
4067
  const labels = (content.labels?.nodes ?? []).map((l) => l.name).filter((name) => Boolean(name));
@@ -3684,7 +4077,9 @@ function nodeToItem(node) {
3684
4077
  title: content.title,
3685
4078
  state: content.state ?? "",
3686
4079
  status,
3687
- statusOptionId: node.fieldValueByName?.optionId,
4080
+ statusOptionId: fields.status?.optionId,
4081
+ priority: fields.priority?.name,
4082
+ priorityOptionId: fields.priority?.optionId,
3688
4083
  assignees,
3689
4084
  labels,
3690
4085
  type: labels.find((label) => TYPE_LABELS.includes(label)) ?? labels[0]
@@ -3743,7 +4138,8 @@ function renderTaken(lines, items) {
3743
4138
  for (const item of items) lines.push(` ${item.ref} \xB7 ${item.status} \xB7 @${item.assignees.join(", @")}`);
3744
4139
  }
3745
4140
  function renderTitledItem(item) {
3746
- return `${item.ref} - [${item.type ?? "item"}] ${item.title}`;
4141
+ const pri = item.priority ? ` \xB7 ${item.priority}` : "";
4142
+ return `${item.ref} - [${item.type ?? "item"}]${pri} ${item.title}`;
3747
4143
  }
3748
4144
  function hasItems(buckets) {
3749
4145
  return buckets.userOwned.length > 0 || buckets.claimable.length > 0 || buckets.taken.length > 0;
@@ -3889,6 +4285,12 @@ function stagePlan(stage2 = {}) {
3889
4285
  { label: "check health", command: stage2.healthUrl ? `curl --fail ${stage2.healthUrl}` : "(no stage.healthUrl configured)" }
3890
4286
  ];
3891
4287
  }
4288
+ function stageLivePlan() {
4289
+ return [
4290
+ { label: "stage-live is not an org command; /stage is local only", command: "mmi-cli stage run --apply" },
4291
+ { label: "remote rc/live environments move through the gated promotion train", command: "mmi-cli rc && mmi-cli release && mmi-cli hotfix", gated: true }
4292
+ ];
4293
+ }
3892
4294
  function trainPlan(command) {
3893
4295
  if (command === "rc") {
3894
4296
  return [
@@ -3927,8 +4329,83 @@ function bootstrapPlan(repo, repoClass) {
3927
4329
  ];
3928
4330
  }
3929
4331
 
4332
+ // src/bootstrap-seeds.ts
4333
+ var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
4334
+ function loadBootstrapSeeds(manifestJson) {
4335
+ let parsed;
4336
+ try {
4337
+ parsed = JSON.parse(manifestJson);
4338
+ } catch {
4339
+ throw new Error("bootstrap seed manifest is not valid JSON");
4340
+ }
4341
+ const obj = parsed ?? {};
4342
+ const seeds = obj.seeds ?? [];
4343
+ for (const s of seeds) {
4344
+ if (!s || !s.target || !s.source || !Array.isArray(s.classes)) {
4345
+ throw new Error(`invalid seed entry (needs target, source, classes): ${JSON.stringify(s)}`);
4346
+ }
4347
+ if (s.ownership !== "org" && s.ownership !== "repo") {
4348
+ throw new Error(`invalid seed ownership '${s.ownership}' for ${s.target} (must be 'org' or 'repo')`);
4349
+ }
4350
+ }
4351
+ return {
4352
+ seeds,
4353
+ labels: obj.labels ?? [],
4354
+ placeholders: obj.placeholders ?? []
4355
+ };
4356
+ }
4357
+ function renderSeed(template, vars) {
4358
+ return template.replace(PLACEHOLDER_RE, (match, key) => key in vars ? vars[key] : match);
4359
+ }
4360
+ function missingPlaceholders(rendered) {
4361
+ const out = /* @__PURE__ */ new Set();
4362
+ for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
4363
+ return [...out];
4364
+ }
4365
+ var GITIGNORE_MANAGED_BEGIN = "# >>> mmi-managed >>>";
4366
+ var GITIGNORE_MANAGED_END = "# <<< mmi-managed <<<";
4367
+ var MANAGED_GITIGNORE_LINES = [
4368
+ '# Org-wide cleanliness (AGENTS.md "Repo cleanliness") \u2014 enforced by `mmi-cli doctor`.',
4369
+ "# Do not edit inside these markers; this block is regenerated on the next doctor run.",
4370
+ ".playwright-mcp/",
4371
+ ".claude/worktrees/",
4372
+ "/*.png"
4373
+ ];
4374
+ function renderManagedGitignoreBlock() {
4375
+ return [GITIGNORE_MANAGED_BEGIN, ...MANAGED_GITIGNORE_LINES, GITIGNORE_MANAGED_END].join("\n");
4376
+ }
4377
+ function upsertManagedGitignoreBlock(current) {
4378
+ const block = renderManagedGitignoreBlock();
4379
+ const src = (current ?? "").replace(/\r\n/g, "\n");
4380
+ if (src.trim() === "") {
4381
+ const next2 = `${block}
4382
+ `;
4383
+ return { content: next2, changed: src !== next2 };
4384
+ }
4385
+ const managed = /* @__PURE__ */ new Set([GITIGNORE_MANAGED_BEGIN, GITIGNORE_MANAGED_END, ...MANAGED_GITIGNORE_LINES]);
4386
+ const lines = src.split("\n");
4387
+ const beginAt = lines.findIndex((l) => l === GITIGNORE_MANAGED_BEGIN);
4388
+ const endAt = beginAt === -1 ? -1 : lines.findIndex((l, i) => i > beginAt && l === GITIGNORE_MANAGED_END);
4389
+ let next;
4390
+ if (beginAt !== -1 && endAt !== -1) {
4391
+ const before = lines.slice(0, beginAt).filter((l) => !managed.has(l.trim()));
4392
+ const after = lines.slice(endAt + 1).filter((l) => !managed.has(l.trim()));
4393
+ next = `${[...before, ...block.split("\n"), ...after].join("\n").replace(/\n+$/, "")}
4394
+ `;
4395
+ } else {
4396
+ const kept = lines.filter((l) => !managed.has(l.trim())).join("\n").replace(/\n+$/, "");
4397
+ next = kept === "" ? `${block}
4398
+ ` : `${kept}
4399
+
4400
+ ${block}
4401
+ `;
4402
+ }
4403
+ return { content: next, changed: src !== next };
4404
+ }
4405
+
3930
4406
  // src/doctor.ts
3931
4407
  var GH_PROJECT_LOGIN_FIX = 'run: gh auth login --hostname github.com --git-protocol https --web --scopes "project"';
4408
+ var AWS_CROSS_ACCOUNT_FIX = "use a non-root IAM user/session profile for master-agent AWS checks; set AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY (plus AWS_SESSION_TOKEN for temporary credentials), then verify `aws sts get-caller-identity` does not end in :root";
3932
4409
  function buildGithubAuthCheck(input) {
3933
4410
  const ok = Boolean(input.login?.trim());
3934
4411
  return {
@@ -3937,11 +4414,68 @@ function buildGithubAuthCheck(input) {
3937
4414
  fix: input.ghInstalled ? GH_PROJECT_LOGIN_FIX : `install GitHub CLI (https://cli.github.com), then: ${GH_PROJECT_LOGIN_FIX.replace(/^run: /, "")}`
3938
4415
  };
3939
4416
  }
4417
+ function buildAwsCrossAccountCheck(input) {
4418
+ const callerArn = input.callerArn?.trim();
4419
+ return {
4420
+ ok: !callerArn || !callerArn.endsWith(":root"),
4421
+ label: "AWS cross-account identity (master-agent audits)",
4422
+ fix: AWS_CROSS_ACCOUNT_FIX
4423
+ };
4424
+ }
4425
+ var MMI_PLUGIN_ID = "mmi@mmi";
4426
+ var PLUGIN_LABEL = "plugin install record (mmi@mmi for this project)";
4427
+ function pluginInstallManualFix(projectPath) {
4428
+ return `run \`/plugin install ${MMI_PLUGIN_ID}\` then \`/reload-plugins\` (VS Code extension: reopen the workspace) to register the project install record for ${projectPath}`;
4429
+ }
4430
+ function isMmiPluginEnabled(settings) {
4431
+ return Boolean(settings?.enabledPlugins?.[MMI_PLUGIN_ID]);
4432
+ }
4433
+ function hasProjectInstallRecord(file, pluginId, projectPath) {
4434
+ const records = file?.plugins?.[pluginId];
4435
+ if (!Array.isArray(records)) return false;
4436
+ return records.some((r) => r.scope === "project" && r.projectPath === projectPath);
4437
+ }
4438
+ function buildPluginInstallRecordCheck(input) {
4439
+ const pluginId = input.pluginId ?? MMI_PLUGIN_ID;
4440
+ const base = {
4441
+ ok: true,
4442
+ label: PLUGIN_LABEL,
4443
+ fix: pluginInstallManualFix(input.projectPath),
4444
+ pluginId
4445
+ };
4446
+ if (!input.isOrgRepo || !isMmiPluginEnabled(input.settings)) return base;
4447
+ if (hasProjectInstallRecord(input.installed, pluginId, input.projectPath)) return base;
4448
+ const now = input.now ?? (/* @__PURE__ */ new Date()).toISOString();
4449
+ const recordToInsert = input.mirrorFrom ? {
4450
+ ...input.mirrorFrom,
4451
+ scope: "project",
4452
+ projectPath: input.projectPath,
4453
+ ...input.mirrorFrom.installedAt !== void 0 ? { installedAt: now } : {},
4454
+ ...input.mirrorFrom.lastUpdated !== void 0 ? { lastUpdated: now } : {}
4455
+ } : { scope: "project", projectPath: input.projectPath, installedAt: now, lastUpdated: now };
4456
+ return {
4457
+ ok: false,
4458
+ label: PLUGIN_LABEL,
4459
+ fix: pluginInstallManualFix(input.projectPath),
4460
+ pluginId,
4461
+ recordToInsert
4462
+ };
4463
+ }
4464
+ var GITIGNORE_BLOCK_LABEL = "org .gitignore managed block (.playwright-mcp/, .claude/worktrees/, scratch *.png)";
4465
+ var GITIGNORE_BLOCK_FIX = "run `mmi-cli doctor` to auto-insert the `# >>> mmi-managed >>>` block (or copy it from MMI-Hub's .gitignore)";
4466
+ function buildGitignoreManagedBlockCheck(input) {
4467
+ const base = { ok: true, label: GITIGNORE_BLOCK_LABEL, fix: GITIGNORE_BLOCK_FIX };
4468
+ if (!input.isOrgRepo) return base;
4469
+ const { content, changed } = upsertManagedGitignoreBlock(input.content);
4470
+ if (!changed) return base;
4471
+ return { ...base, ok: false, contentToWrite: content };
4472
+ }
3940
4473
 
3941
4474
  // src/stage-runner.ts
3942
4475
  var import_node_child_process3 = require("node:child_process");
3943
4476
  var import_node_fs2 = require("node:fs");
3944
4477
  var import_node_path2 = require("node:path");
4478
+ var import_node_net = require("node:net");
3945
4479
  var import_node_util2 = require("node:util");
3946
4480
  var execFileP2 = (0, import_node_util2.promisify)(import_node_child_process3.execFile);
3947
4481
  function stageStatePath(cwd = process.cwd()) {
@@ -3954,8 +4488,29 @@ function validateStageConfig(config = {}, action) {
3954
4488
  if (config.healthUrl != null && config.healthUrl.trim() && !/^https?:\/\//.test(config.healthUrl.trim())) {
3955
4489
  problems.push("stage.healthUrl must be an http(s) URL");
3956
4490
  }
4491
+ if (config.portRange != null) {
4492
+ const r = config.portRange;
4493
+ const ok = Array.isArray(r) && r.length === 2 && r.every((n) => Number.isInteger(n) && n >= 1024 && n <= 65535) && r[0] <= r[1];
4494
+ if (!ok) problems.push("stage.portRange must be [start, end] within 1024-65535 with start <= end");
4495
+ }
3957
4496
  return problems;
3958
4497
  }
4498
+ function pickStagePort(range, isFree) {
4499
+ if (!range) return void 0;
4500
+ const [start, end] = range;
4501
+ for (let port = start; port <= end; port++) {
4502
+ if (isFree(port)) return port;
4503
+ }
4504
+ throw new Error(`no free stage port in range ${start}-${end} \u2014 every port is in use`);
4505
+ }
4506
+ function isPortFree(port) {
4507
+ return new Promise((resolve) => {
4508
+ const srv = (0, import_node_net.createServer)();
4509
+ srv.once("error", () => resolve(false));
4510
+ srv.once("listening", () => srv.close(() => resolve(true)));
4511
+ srv.listen(port, "127.0.0.1");
4512
+ });
4513
+ }
3959
4514
  async function shell(command, cwd, timeoutMs) {
3960
4515
  await execFileP2(command, [], {
3961
4516
  cwd,
@@ -4030,24 +4585,34 @@ async function startStage(config = {}, opts = {}) {
4030
4585
  const statePath = opts.statePath ?? stageStatePath(cwd);
4031
4586
  const dir = statePath.slice(0, Math.max(statePath.lastIndexOf("/"), statePath.lastIndexOf("\\")));
4032
4587
  (0, import_node_fs2.mkdirSync)(dir, { recursive: true });
4033
- const up = config.up.trim();
4588
+ let stagePort;
4589
+ if (config.portRange) {
4590
+ const [s, e] = config.portRange;
4591
+ const free = /* @__PURE__ */ new Set();
4592
+ for (let p = s; p <= e; p++) if (await isPortFree(p)) free.add(p);
4593
+ stagePort = pickStagePort(config.portRange, (p) => free.has(p));
4594
+ }
4595
+ const sub = (s) => s != null && stagePort != null ? s.replace(/\$\{?STAGE_PORT\}?/g, String(stagePort)) : s;
4596
+ const up = sub(config.up.trim());
4034
4597
  const child = (0, import_node_child_process3.spawn)(up, {
4035
4598
  cwd,
4036
4599
  shell: true,
4037
4600
  detached: true,
4038
4601
  windowsHide: true,
4039
- stdio: "ignore"
4602
+ stdio: "ignore",
4603
+ env: stagePort != null ? { ...process.env, STAGE_PORT: String(stagePort) } : process.env
4040
4604
  });
4041
4605
  const state = {
4042
4606
  pid: child.pid ?? 0,
4043
4607
  command: up,
4044
4608
  cwd,
4045
4609
  startedAt: (opts.now ?? (() => /* @__PURE__ */ new Date()))().toISOString(),
4046
- healthUrl: config.healthUrl?.trim() || void 0
4610
+ healthUrl: sub(config.healthUrl?.trim()) || void 0,
4611
+ port: stagePort
4047
4612
  };
4048
4613
  (0, import_node_fs2.writeFileSync)(statePath, JSON.stringify(state, null, 2), "utf8");
4049
4614
  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}` };
4615
+ const result = { ok: true, action: "start", statePath, pid: state.pid, message: `started stage pid ${state.pid}${stagePort != null ? ` on port ${stagePort}` : ""}` };
4051
4616
  opts.onReady?.(result);
4052
4617
  child.unref();
4053
4618
  return result;
@@ -4063,28 +4628,65 @@ async function runStage(config = {}, opts = {}) {
4063
4628
  return { ...started, action: "run", message: `built and ${started.message}` };
4064
4629
  }
4065
4630
 
4066
- // src/bootstrap-verify.ts
4067
- var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
4068
- var requiredIssueTemplates = [
4069
- ".github/ISSUE_TEMPLATE/bug.yml",
4070
- ".github/ISSUE_TEMPLATE/feature.yml",
4071
- ".github/ISSUE_TEMPLATE/task.yml",
4072
- ".github/ISSUE_TEMPLATE/config.yml"
4073
- ];
4074
- var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
4075
- var requiredLabels = ["bug", "feature", "task", "priority:high", "priority:medium", "priority:low"];
4076
- var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
4077
- var requiredProjectWorkflows = [
4078
- "Auto-add sub-issues to project",
4079
- "Auto-close issue",
4080
- "Item added to project",
4081
- "Item closed",
4082
- "Pull request linked to issue",
4083
- "Pull request merged"
4084
- ];
4085
- var requiredActionsVariables = ["MMI_APP_ID"];
4086
- var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
4087
- function expectedBranches(repoClass) {
4631
+ // src/port-registry.ts
4632
+ var import_node_fs3 = require("node:fs");
4633
+ var BLOCK = 100;
4634
+ var SPAN = 10;
4635
+ var FIRST = 3e3;
4636
+ function nextPortBlock(registry2) {
4637
+ const bases = Object.values(registry2).map(([start]) => start);
4638
+ const base = bases.length ? Math.max(...bases) + BLOCK : FIRST;
4639
+ return [base, base + SPAN];
4640
+ }
4641
+ function loadPortRegistry(path) {
4642
+ if (!(0, import_node_fs3.existsSync)(path)) return {};
4643
+ const raw = JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8"));
4644
+ const out = {};
4645
+ for (const [key, value] of Object.entries(raw)) {
4646
+ if (Array.isArray(value) && value.length === 2 && value.every((n) => typeof n === "number")) {
4647
+ out[key] = [value[0], value[1]];
4648
+ }
4649
+ }
4650
+ return out;
4651
+ }
4652
+ function ensurePortRange(repo, path) {
4653
+ const registry2 = loadPortRegistry(path);
4654
+ const existing = registry2[repo];
4655
+ if (existing) return existing;
4656
+ const range = nextPortBlock(registry2);
4657
+ const raw = (0, import_node_fs3.existsSync)(path) ? JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8")) : {};
4658
+ raw[repo] = range;
4659
+ (0, import_node_fs3.writeFileSync)(path, JSON.stringify(raw, null, 2) + "\n", "utf8");
4660
+ return range;
4661
+ }
4662
+ function portCursorSeed(registry2) {
4663
+ return nextPortBlock(registry2)[0];
4664
+ }
4665
+ function existingPortRange(repo, registry2) {
4666
+ return registry2[repo] ?? null;
4667
+ }
4668
+ async function ensurePortRangeAtomic(repo, path, allocate, opts = {}) {
4669
+ const registry2 = loadPortRegistry(path);
4670
+ const existing = existingPortRange(repo, registry2);
4671
+ if (existing) return { range: existing, source: "existing" };
4672
+ const seed = portCursorSeed(registry2);
4673
+ try {
4674
+ const range = await allocate(seed);
4675
+ return { range, source: "ddb" };
4676
+ } catch (e) {
4677
+ if (!opts.quiet) console.warn(`port-registry: DDB allocator unreachable, falling back to committed file (${e.message})`);
4678
+ return { range: ensurePortRange(repo, path), source: "file" };
4679
+ }
4680
+ }
4681
+
4682
+ // src/access.ts
4683
+ var OWNER = "mutmutco";
4684
+ var LOCKED_APP = "mmi-github-app";
4685
+ var OVERGRANT_ROLES = /* @__PURE__ */ new Set(["admin", "maintain"]);
4686
+ var REQUIRED_DATA_ACCESS = {
4687
+ "mutmutco/MM-Chat": [{ name: "kb-projection-reader", dbRole: "kb_reader", vaultParamNeedle: "KB_READ_DB_URL" }]
4688
+ };
4689
+ function lockedBranches(repoClass) {
4088
4690
  return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4089
4691
  }
4090
4692
  function safeJson(text, fallback) {
@@ -4101,59 +4703,330 @@ async function ghJson(deps, args, fallback) {
4101
4703
  return fallback;
4102
4704
  }
4103
4705
  }
4104
- async function contentExists(deps, repo, branch, path) {
4105
- try {
4106
- const encodedPath = path.split("/").map(encodeURIComponent).join("/");
4107
- await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`]);
4108
- return true;
4109
- } catch {
4110
- return false;
4111
- }
4706
+ async function resolveOwners(deps) {
4707
+ const members = await ghJson(deps, ["api", `orgs/${OWNER}/members?role=admin`, "--paginate"], []);
4708
+ return members.map((m) => m.login);
4112
4709
  }
4113
- async function contentText(deps, repo, branch, path) {
4114
- try {
4115
- const encodedPath = path.split("/").map(encodeURIComponent).join("/");
4116
- const response = safeJson(
4117
- (await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`])).stdout,
4118
- {}
4119
- );
4120
- if (response.content == null) return null;
4121
- if (response.encoding != null && response.encoding !== "base64") return null;
4122
- return Buffer.from(response.content.replace(/\s/g, ""), "base64").toString("utf8");
4123
- } catch {
4124
- return null;
4710
+ function collaboratorRole(c) {
4711
+ return c.role_name ?? (c.permissions?.admin ? "admin" : c.permissions?.maintain ? "maintain" : "write");
4712
+ }
4713
+ async function auditRepoCollaborators(repo, owners, deps) {
4714
+ const collabs = await ghJson(deps, ["api", `repos/${repo}/collaborators?affiliation=direct`, "--paginate"], []);
4715
+ const findings = [];
4716
+ for (const c of collabs) {
4717
+ if (owners.has(c.login)) continue;
4718
+ const role = collaboratorRole(c);
4719
+ if (OVERGRANT_ROLES.has(role)) {
4720
+ findings.push({
4721
+ repo,
4722
+ kind: "collaborator-overgrant",
4723
+ severity: "high",
4724
+ actor: c.login,
4725
+ detail: `direct collaborator @${c.login} holds role '${role}'; a developer must be 'write' (admin/maintain is master-only)`,
4726
+ remediation: `gh api -X PUT repos/${repo}/collaborators/${c.login} -f permission=push`
4727
+ });
4728
+ }
4125
4729
  }
4730
+ return findings;
4126
4731
  }
4127
- async function protectedBranch(deps, repo, branch) {
4732
+ async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
4733
+ let restrictions = null;
4128
4734
  try {
4129
- await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`]);
4130
- return true;
4735
+ restrictions = safeJson((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection/restrictions`])).stdout, null);
4131
4736
  } catch {
4132
- return false;
4737
+ restrictions = null;
4738
+ }
4739
+ if (!restrictions) {
4740
+ return [{
4741
+ repo,
4742
+ branch,
4743
+ kind: "unprotected-branch",
4744
+ severity: "medium",
4745
+ detail: `${branch} has no push restrictions (branch unprotected, or protection without a user/app allowlist)`,
4746
+ 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}"]}`
4747
+ }];
4748
+ }
4749
+ const findings = [];
4750
+ const users = (restrictions.users ?? []).map((u) => u.login);
4751
+ for (const login of users) {
4752
+ if (!owners.has(login) && !projectAdmins.has(login)) {
4753
+ findings.push({
4754
+ repo,
4755
+ branch,
4756
+ kind: "train-allowlist-extra",
4757
+ severity: "medium",
4758
+ actor: login,
4759
+ detail: `@${login} is on the ${branch} push allowlist \u2014 legitimate only if an intended full-write project-admin; confirm`,
4760
+ remediation: `# if NOT an intended full-write member: gh api -X DELETE repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${login}"]'`
4761
+ });
4762
+ }
4763
+ }
4764
+ for (const owner of owners) {
4765
+ if (!users.includes(owner)) {
4766
+ findings.push({
4767
+ repo,
4768
+ branch,
4769
+ kind: "train-allowlist-missing",
4770
+ severity: "medium",
4771
+ actor: owner,
4772
+ detail: `org owner @${owner} is missing from the ${branch} push allowlist`,
4773
+ remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/users --input - <<< '["${owner}"]'`
4774
+ });
4775
+ }
4776
+ }
4777
+ const apps = (restrictions.apps ?? []).map((a) => a.slug);
4778
+ if (!apps.includes(LOCKED_APP)) {
4779
+ findings.push({
4780
+ repo,
4781
+ branch,
4782
+ kind: "app-bypass-missing",
4783
+ severity: "high",
4784
+ detail: `the ${LOCKED_APP} App is missing from the ${branch} allowlist \u2014 fanout/promotions will break`,
4785
+ remediation: `gh api -X POST repos/${repo}/branches/${branch}/protection/restrictions/apps --input - <<< '["${LOCKED_APP}"]'`
4786
+ });
4133
4787
  }
4788
+ return findings;
4789
+ }
4790
+ function auditDataAccessContracts(repo, contracts = { consumers: {} }) {
4791
+ const required = REQUIRED_DATA_ACCESS[repo] ?? [];
4792
+ const configured = (contracts.consumers ?? {})[repo] ?? [];
4793
+ const findings = [];
4794
+ for (const req of required) {
4795
+ const matched = configured.some((grant) => grant.name === req.name && grant.dbRole === req.dbRole && (grant.vaultParams ?? []).some((param) => param.includes(req.vaultParamNeedle)));
4796
+ if (!matched) {
4797
+ findings.push({
4798
+ repo,
4799
+ kind: "data-access-missing",
4800
+ severity: "high",
4801
+ detail: `${repo} must have auditable data-access contract '${req.name}' with DB role '${req.dbRole}' and vault parameter name containing '${req.vaultParamNeedle}'`,
4802
+ remediation: `add or fix data-access-contracts.json for ${repo}; record parameter names only, never DSNs or secret values`
4803
+ });
4804
+ }
4805
+ }
4806
+ return findings;
4807
+ }
4808
+ async function auditRepoAccess(repo, repoClass, owners, deps, projectAdmins = /* @__PURE__ */ new Set(), dataAccess) {
4809
+ const findings = [];
4810
+ findings.push(...await auditRepoCollaborators(repo, owners, deps));
4811
+ if (dataAccess) findings.push(...auditDataAccessContracts(repo, dataAccess));
4812
+ for (const branch of lockedBranches(repoClass)) {
4813
+ findings.push(...await auditTrainBranch(repo, branch, owners, deps, projectAdmins));
4814
+ }
4815
+ return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
4816
+ }
4817
+ async function auditOrgBasePermission(deps) {
4818
+ const org = await ghJson(deps, ["api", `orgs/${OWNER}`], {});
4819
+ const perm = org.default_repository_permission;
4820
+ if (perm && perm !== "read" && perm !== "none") {
4821
+ return [{
4822
+ repo: OWNER,
4823
+ kind: "org-base-permission",
4824
+ severity: "high",
4825
+ detail: `org default_repository_permission is '${perm}' \u2014 every member gets '${perm}' on every repo; D25 requires 'read'`,
4826
+ remediation: `gh api -X PATCH orgs/${OWNER} -f default_repository_permission=read`
4827
+ }];
4828
+ }
4829
+ return [];
4830
+ }
4831
+ async function auditOrgAccess(targets, deps, matrix = {}, dataAccess) {
4832
+ const owners = new Set(await resolveOwners(deps));
4833
+ const orgFindings = await auditOrgBasePermission(deps);
4834
+ const repos = [];
4835
+ for (const target of targets) {
4836
+ repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? []), dataAccess));
4837
+ }
4838
+ const ok = orgFindings.every((f) => f.severity !== "high") && repos.every((r) => r.ok);
4839
+ return { ok, owners: [...owners], orgFindings, repos };
4840
+ }
4841
+ function loadAccessTargets(projectsJson, fanoutJson) {
4842
+ const projects = safeJson(projectsJson, {}).projects ?? [];
4843
+ const fanout = fanoutJson ? safeJson(fanoutJson, {}).repos ?? [] : [];
4844
+ const contentNames = new Set(fanout.filter((r) => r.class === "content").map((r) => r.repo));
4845
+ const seen = /* @__PURE__ */ new Set();
4846
+ const targets = [];
4847
+ for (const project2 of projects) {
4848
+ for (const repo of project2.repos ?? []) {
4849
+ if (seen.has(repo)) continue;
4850
+ seen.add(repo);
4851
+ targets.push({ repo, class: contentNames.has(repo.split("/")[1]) ? "content" : "deployable" });
4852
+ }
4853
+ }
4854
+ return targets;
4855
+ }
4856
+ function loadAccessMatrix(matrixJson) {
4857
+ if (!matrixJson) return {};
4858
+ return safeJson(matrixJson, {}).projectAdmins ?? {};
4859
+ }
4860
+ function loadDataAccessContracts(dataAccessJson) {
4861
+ if (!dataAccessJson) return { consumers: {} };
4862
+ const parsed = safeJson(dataAccessJson, { consumers: {} });
4863
+ return { consumers: parsed.consumers ?? {} };
4864
+ }
4865
+ function canonAccessRepo(repo) {
4866
+ const name = repo.includes("/") ? repo.split("/").pop() : repo;
4867
+ return `mutmutco/${name.toLowerCase()}`;
4868
+ }
4869
+ function accessMatrixFromProjects(projects) {
4870
+ const matrix = {};
4871
+ for (const p of projects) {
4872
+ if (!Array.isArray(p.projectAdmins) || p.projectAdmins.length === 0) continue;
4873
+ for (const repo of p.repos ?? []) matrix[canonAccessRepo(repo)] = p.projectAdmins;
4874
+ }
4875
+ return matrix;
4876
+ }
4877
+ function dataAccessContractsFromProjects(projects) {
4878
+ const consumers = {};
4879
+ for (const p of projects) {
4880
+ if (!Array.isArray(p.consumers) || p.consumers.length === 0) continue;
4881
+ for (const repo of p.repos ?? []) consumers[canonAccessRepo(repo)] = p.consumers;
4882
+ }
4883
+ return { consumers };
4884
+ }
4885
+ function renderAccessReport(report) {
4886
+ const lines = [`mmi-cli access audit: ${report.ok ? "OK" : "CHECK"} (owners: ${report.owners.map((o) => "@" + o).join(", ") || "none"})`];
4887
+ for (const finding of report.orgFindings ?? []) {
4888
+ lines.push(` [${finding.severity}] ${finding.kind}: ${finding.detail}`);
4889
+ if (finding.remediation) lines.push(` ${finding.remediation}`);
4890
+ }
4891
+ for (const repo of report.repos) {
4892
+ lines.push(`${repo.ok ? "OK" : "FLAG"} ${repo.repo} (${repo.class})`);
4893
+ for (const finding of repo.findings) {
4894
+ lines.push(` [${finding.severity}] ${finding.kind}${finding.branch ? ` @${finding.branch}` : ""}: ${finding.detail}`);
4895
+ if (finding.remediation) lines.push(` ${finding.remediation}`);
4896
+ }
4897
+ }
4898
+ return lines.join("\n");
4899
+ }
4900
+
4901
+ // src/bootstrap-verify.ts
4902
+ var requiredDocs = ["README.md", "architecture.md", "AGENTS.md", "CLAUDE.md", ".claude/settings.json", ".mmi/config.json"];
4903
+ var requiredIssueTemplates = [
4904
+ ".github/ISSUE_TEMPLATE/bug.yml",
4905
+ ".github/ISSUE_TEMPLATE/feature.yml",
4906
+ ".github/ISSUE_TEMPLATE/task.yml",
4907
+ ".github/ISSUE_TEMPLATE/config.yml"
4908
+ ];
4909
+ var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
4910
+ var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
4911
+ var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
4912
+ var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
4913
+ var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
4914
+ var requiredProjectWorkflows = [
4915
+ "Auto-add sub-issues to project",
4916
+ "Auto-archive items",
4917
+ "Item added to project",
4918
+ "Item closed"
4919
+ ];
4920
+ var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
4921
+ var requiredHubStatusChecks = ["cli", "infra", "docs"];
4922
+ var requiredActionsVariables = ["MMI_APP_ID"];
4923
+ var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
4924
+ function expectedBranches(repoClass) {
4925
+ return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4926
+ }
4927
+ function safeJson2(text, fallback) {
4928
+ try {
4929
+ return JSON.parse(text);
4930
+ } catch {
4931
+ return fallback;
4932
+ }
4933
+ }
4934
+ async function ghJson2(deps, args, fallback) {
4935
+ try {
4936
+ return safeJson2((await deps.gh(args)).stdout, fallback);
4937
+ } catch {
4938
+ return fallback;
4939
+ }
4940
+ }
4941
+ async function contentExists(deps, repo, branch, path) {
4942
+ try {
4943
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
4944
+ await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`]);
4945
+ return true;
4946
+ } catch {
4947
+ return false;
4948
+ }
4949
+ }
4950
+ async function contentText(deps, repo, branch, path) {
4951
+ try {
4952
+ const encodedPath = path.split("/").map(encodeURIComponent).join("/");
4953
+ const response = safeJson2(
4954
+ (await deps.gh(["api", `repos/${repo}/contents/${encodedPath}?ref=${encodeURIComponent(branch)}`])).stdout,
4955
+ {}
4956
+ );
4957
+ if (response.content == null) return null;
4958
+ if (response.encoding != null && response.encoding !== "base64") return null;
4959
+ return Buffer.from(response.content.replace(/\s/g, ""), "base64").toString("utf8");
4960
+ } catch {
4961
+ return null;
4962
+ }
4963
+ }
4964
+ async function getProtection(deps, repo, branch) {
4965
+ try {
4966
+ return safeJson2((await deps.gh(["api", `repos/${repo}/branches/${branch}/protection`])).stdout, {});
4967
+ } catch {
4968
+ return null;
4969
+ }
4970
+ }
4971
+ function hasPushAllowlist(p) {
4972
+ return Array.isArray(p?.restrictions?.users) && p.restrictions.users.length > 0;
4134
4973
  }
4135
4974
  function optionDetail(missing) {
4136
4975
  return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
4137
4976
  }
4977
+ function presentDetail(present) {
4978
+ return present.length === 0 ? void 0 : `present: ${present.join(", ")}`;
4979
+ }
4980
+ function missingRuleTypes(ruleset, required) {
4981
+ const types = new Set((ruleset.rules || []).map((rule) => rule.type).filter(Boolean));
4982
+ return required.filter((type) => !types.has(type));
4983
+ }
4984
+ function rulesetStatusChecks(rulesets) {
4985
+ return new Set(rulesets.flatMap((ruleset) => (ruleset.rules || []).filter((rule) => rule.type === "required_status_checks").flatMap((rule) => rule.parameters?.required_status_checks || []).map((check) => check.context).filter((context) => Boolean(context))));
4986
+ }
4987
+ async function rulesetDetails(deps, repo, list) {
4988
+ const details = [];
4989
+ for (const ruleset of list) {
4990
+ if (ruleset.id == null || ruleset.rules != null) {
4991
+ details.push(ruleset);
4992
+ continue;
4993
+ }
4994
+ details.push(await ghJson2(deps, ["api", `repos/${repo}/rulesets/${ruleset.id}`], ruleset));
4995
+ }
4996
+ return details;
4997
+ }
4138
4998
  function localRegistryCheck(deps, path, predicate) {
4139
4999
  const text = deps.readLocalFile?.(path);
4140
5000
  if (text == null) return null;
4141
- return predicate(safeJson(text, null));
5001
+ return predicate(safeJson2(text, null));
4142
5002
  }
4143
5003
  async function verifyBootstrap(repo, repoClass, deps) {
4144
5004
  const branchesWanted = expectedBranches(repoClass);
4145
5005
  const baseBranch = repoClass === "content" ? "main" : "development";
4146
5006
  const checks = [];
4147
- const repoInfo = await ghJson(deps, ["api", `repos/${repo}`], {});
5007
+ const repoInfo = await ghJson2(deps, ["api", `repos/${repo}`], {});
4148
5008
  checks.push({ ok: Boolean(repoInfo.default_branch), label: "repo exists" });
4149
5009
  checks.push({ ok: repoInfo.default_branch === baseBranch, label: `default branch is ${baseBranch}`, detail: repoInfo.default_branch || "missing" });
4150
5010
  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"], []);
5011
+ const branchList = await ghJson2(deps, ["api", `repos/${repo}/branches`, "--paginate"], []);
4152
5012
  const branchNames = new Set(branchList.map((b) => b.name));
4153
5013
  for (const branch of branchesWanted) {
4154
5014
  checks.push({ ok: branchNames.has(branch), label: `branch exists: ${branch}` });
4155
- checks.push({ ok: await protectedBranch(deps, repo, branch), label: `branch protection exists: ${branch}` });
5015
+ const protection = await getProtection(deps, repo, branch);
5016
+ checks.push({ ok: protection != null, label: `branch protection exists: ${branch}` });
5017
+ checks.push({
5018
+ ok: hasPushAllowlist(protection),
5019
+ label: `push allowlist configured: ${branch}`,
5020
+ detail: hasPushAllowlist(protection) ? void 0 : "restrictions.users is empty or unset"
5021
+ });
4156
5022
  }
5023
+ const owners = new Set(await resolveOwners(deps));
5024
+ const overgrants = await auditRepoCollaborators(repo, owners, deps);
5025
+ checks.push({
5026
+ ok: overgrants.length === 0,
5027
+ label: "collaborator roles are master-only (no admin/maintain over-grant)",
5028
+ detail: overgrants.length ? `over-granted: ${overgrants.map((f) => f.actor).join(", ")}` : void 0
5029
+ });
4157
5030
  for (const path of requiredDocs) {
4158
5031
  checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `bootstrap artifact exists: ${path}` });
4159
5032
  }
@@ -4163,36 +5036,42 @@ async function verifyBootstrap(repo, repoClass, deps) {
4163
5036
  for (const path of requiredWorkflows) {
4164
5037
  checks.push({ ok: await contentExists(deps, repo, baseBranch, path), label: `automation workflow exists: ${path}` });
4165
5038
  }
5039
+ if (repoClass === "deployable") {
5040
+ const trainScript = "scripts/next-version.mjs";
5041
+ checks.push({ ok: await contentExists(deps, repo, baseBranch, trainScript), label: `train tooling script exists: ${trainScript}` });
5042
+ }
4166
5043
  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"], []);
5044
+ const labels = await ghJson2(deps, ["label", "list", "--repo", repo, "--limit", "200", "--json", "name"], []);
4168
5045
  const labelNames = new Set(labels.map((l) => l.name));
4169
5046
  for (const label of requiredLabels) {
4170
5047
  checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
4171
5048
  }
4172
- const actions = await ghJson(deps, ["api", `repos/${repo}/actions/permissions`], {});
5049
+ const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
5050
+ checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
5051
+ const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
4173
5052
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
4174
- const variables = await ghJson(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
5053
+ const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
4175
5054
  const variableNames = new Set(variables.map((v) => v.name));
4176
5055
  for (const variable of requiredActionsVariables) {
4177
5056
  checks.push({ ok: variableNames.has(variable), label: `Actions variable exists: ${variable}` });
4178
5057
  }
4179
- const secrets = await ghJson(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
4180
- const secretNames = new Set(secrets.map((s) => s.name));
5058
+ const secrets2 = await ghJson2(deps, ["secret", "list", "--repo", repo, "--json", "name"], []);
5059
+ const secretNames = new Set(secrets2.map((s) => s.name));
4181
5060
  for (const secret of requiredActionsSecrets) {
4182
5061
  checks.push({ ok: secretNames.has(secret), label: `Actions secret exists: ${secret}` });
4183
5062
  }
4184
- const config = safeJson(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
5063
+ const config = safeJson2(await contentText(deps, repo, baseBranch, ".mmi/config.json") || "", null);
4185
5064
  checks.push({
4186
5065
  ok: Boolean(config?.projectOwner && config?.projectNumber),
4187
5066
  label: ".mmi project board config exists"
4188
5067
  });
4189
5068
  if (config?.projectOwner && config.projectNumber != null) {
4190
- const project = await ghJson(
5069
+ const project2 = await ghJson2(
4191
5070
  deps,
4192
5071
  ["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
4193
5072
  {}
4194
5073
  );
4195
- const fields = project.fields || [];
5074
+ const fields = project2.fields || [];
4196
5075
  const statusField = fields.find((field) => field.name === "Status");
4197
5076
  const labelField = fields.find((field) => field.name === "Labels");
4198
5077
  checks.push({
@@ -4223,8 +5102,33 @@ async function verifyBootstrap(repo, repoClass, deps) {
4223
5102
  });
4224
5103
  }
4225
5104
  }
5105
+ const priorityField = fields.find((field) => field.name === "Priority" && (field.options?.length ?? 0) > 0);
5106
+ checks.push({
5107
+ ok: Boolean(priorityField),
5108
+ label: `Project Priority field exists (API-writable): ${config.projectOwner}#${config.projectNumber}`
5109
+ });
5110
+ if (priorityField != null) {
5111
+ const priorityNames = new Set((priorityField.options || []).map((option) => option.name));
5112
+ const missingPriority = requiredPriorityOptions.filter((option) => !priorityNames.has(option));
5113
+ checks.push({
5114
+ ok: missingPriority.length === 0,
5115
+ label: "Project Priority options configured",
5116
+ detail: optionDetail(missingPriority)
5117
+ });
5118
+ checks.push({
5119
+ ok: config.priorityFieldId === priorityField.id,
5120
+ label: ".mmi priorityFieldId matches project"
5121
+ });
5122
+ for (const optionName of requiredPriorityOptions) {
5123
+ const projectOption = priorityField.options?.find((option) => option.name === optionName);
5124
+ checks.push({
5125
+ ok: Boolean(projectOption?.id && config.priorityOptions?.[optionName] === projectOption.id),
5126
+ label: `.mmi priority option matches project: ${optionName}`
5127
+ });
5128
+ }
5129
+ }
4226
5130
  const workflowQuery = "query($login: String!, $number: Int!) { organization(login: $login) { projectV2(number: $number) { workflows(first: 30) { nodes { name enabled } } } } }";
4227
- const workflowResponse = await ghJson(
5131
+ const workflowResponse = await ghJson2(
4228
5132
  deps,
4229
5133
  ["api", "graphql", "-f", `query=${workflowQuery}`, "-f", `login=${config.projectOwner}`, "-F", `number=${config.projectNumber}`],
4230
5134
  {}
@@ -4241,22 +5145,257 @@ async function verifyBootstrap(repo, repoClass, deps) {
4241
5145
  if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
4242
5146
  const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
4243
5147
  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 };
5148
+ const rulesetList = await ghJson2(
5149
+ deps,
5150
+ ["api", `repos/${repo}/rulesets?includes_parents=true`],
5151
+ []
5152
+ );
5153
+ const rulesets = await rulesetDetails(deps, repo, rulesetList);
5154
+ const activeOrgRulesets = rulesets.filter(
5155
+ (r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
5156
+ );
5157
+ const orgRuleset = activeOrgRulesets.find((ruleset) => missingRuleTypes(ruleset, requiredOrgRulesetTypes).length === 0);
5158
+ const missingOrgRuleTypes = activeOrgRulesets.length === 0 ? requiredOrgRulesetTypes : missingRuleTypes(activeOrgRulesets[0], requiredOrgRulesetTypes);
5159
+ checks.push({
5160
+ ok: Boolean(orgRuleset),
5161
+ label: "covered by an active org ruleset",
5162
+ detail: orgRuleset ? void 0 : activeOrgRulesets.length === 0 ? "no active Organization-sourced branch ruleset targets this repo" : `missing rule types: ${missingOrgRuleTypes.join(", ")}`
5163
+ });
5164
+ if (repo === "mutmutco/MMI-Hub") {
5165
+ const statusChecks = rulesetStatusChecks(rulesets.filter((r) => r.target === "branch" && r.enforcement === "active"));
5166
+ const missing = requiredHubStatusChecks.filter((check) => !statusChecks.has(check));
5167
+ checks.push({
5168
+ ok: missing.length === 0,
5169
+ label: "Hub required status checks configured",
5170
+ detail: optionDetail(missing)
5171
+ });
5172
+ }
5173
+ const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
5174
+ return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
5175
+ }
5176
+ function applyWaivers(checks, waivers) {
5177
+ if (!waivers?.length) return checks;
5178
+ const set = new Set(waivers);
5179
+ return checks.map((c) => !c.ok && set.has(c.label) ? { ...c, waived: true } : c);
4245
5180
  }
4246
5181
  function renderBootstrapVerifyReport(report) {
4247
5182
  const lines = [`mmi-cli bootstrap verify: ${report.ok ? "OK" : "CHECK"} ${report.repo} (${report.class}, ${report.baseBranch})`];
4248
5183
  for (const check of report.checks) {
4249
- lines.push(`${check.ok ? "OK" : "FAIL"} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
5184
+ const status = check.ok ? "OK" : check.waived ? "WAIVE" : "FAIL";
5185
+ lines.push(`${status} ${check.label}${check.detail ? ` - ${check.detail}` : ""}`);
5186
+ }
5187
+ return lines.join("\n");
5188
+ }
5189
+
5190
+ // src/bootstrap-apply.ts
5191
+ function planSeedAction(seed, exists) {
5192
+ if (seed.source === "fanout") {
5193
+ return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
5194
+ }
5195
+ if (seed.source === "managed-block") {
5196
+ return exists ? { target: seed.target, action: "update", ownership: "org", reason: "org-managed block merged in-place (repo-owned lines preserved)" } : { target: seed.target, action: "create", ownership: "org", reason: "org-managed block; .gitignore absent, created" };
5197
+ }
5198
+ if (seed.ownership === "repo") {
5199
+ 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
5200
  }
5201
+ 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" };
5202
+ }
5203
+ function renderSeedPlan(actions) {
5204
+ const lines = ["bootstrap apply \u2014 seed plan (dry-run; no mutations):"];
5205
+ for (const a of actions) {
5206
+ lines.push(` ${a.action.toUpperCase().padEnd(6)} ${a.target} (${a.ownership}: ${a.reason})`);
5207
+ }
5208
+ const order = ["create", "update", "skip"];
5209
+ lines.push(` \u2014 ${order.map((k) => `${actions.filter((a) => a.action === k).length} ${k}`).join(", ")}`);
4251
5210
  return lines.join("\n");
4252
5211
  }
5212
+ function resolveSeedContent(seed, vars, readFile2) {
5213
+ if (seed.source === "self") return readFile2(seed.target);
5214
+ if (seed.source.startsWith("seed:")) {
5215
+ const tmpl = readFile2(`skills/bootstrap/seeds/${seed.source.slice("seed:".length)}`);
5216
+ return tmpl == null ? null : renderSeed(tmpl, vars);
5217
+ }
5218
+ return null;
5219
+ }
5220
+ function buildRegisterPayload(repo, cls, vars) {
5221
+ const slug = (repo.split("/")[1] ?? repo).toLowerCase();
5222
+ const num = (v) => {
5223
+ if (v == null || v === "") return void 0;
5224
+ const n = Number(v);
5225
+ return Number.isFinite(n) ? n : void 0;
5226
+ };
5227
+ const statusOptions = vars.STATUS_TODO || vars.STATUS_IN_PROGRESS || vars.STATUS_IN_REVIEW || vars.STATUS_DONE ? {
5228
+ Todo: vars.STATUS_TODO,
5229
+ "In Progress": vars.STATUS_IN_PROGRESS,
5230
+ "In Review": vars.STATUS_IN_REVIEW,
5231
+ Done: vars.STATUS_DONE
5232
+ } : void 0;
5233
+ const priorityOptions = vars.PRIORITY_URGENT || vars.PRIORITY_HIGH || vars.PRIORITY_MEDIUM || vars.PRIORITY_LOW ? {
5234
+ Urgent: vars.PRIORITY_URGENT,
5235
+ High: vars.PRIORITY_HIGH,
5236
+ Medium: vars.PRIORITY_MEDIUM,
5237
+ Low: vars.PRIORITY_LOW
5238
+ } : void 0;
5239
+ const payload = {
5240
+ slug,
5241
+ // Identity. name/division default off the repo name when the skill didn't pass them.
5242
+ name: vars.NAME || repo.split("/")[1] || slug,
5243
+ division: vars.DIVISION || (repo.split("/")[1] ?? "").split("-")[0] || void 0,
5244
+ repos: [`mutmutco/${slug}`],
5245
+ wikiRepo: vars.WIKI_REPO || `mutmutco/${repo.split("/")[1] ?? slug}`,
5246
+ branch: vars.BRANCH || (cls === "content" ? "main" : "development"),
5247
+ class: cls,
5248
+ // Board coords (from GraphQL at bootstrap, passed as --var by the skill).
5249
+ projectOwner: vars.PROJECT_OWNER || void 0,
5250
+ projectNumber: num(vars.PROJECT_NUMBER),
5251
+ projectId: vars.PROJECT_ID || void 0,
5252
+ statusFieldId: vars.STATUS_FIELD_ID || void 0,
5253
+ statusOptions,
5254
+ priorityFieldId: vars.PRIORITY_FIELD_ID || void 0,
5255
+ priorityOptions,
5256
+ // Pointers. vaultPath is explicit + canonical; kbPointer is the per-project KB doc path.
5257
+ vaultPath: `/mmi-future/${slug}`,
5258
+ kbPointer: `kb/projects/${slug}.md`
5259
+ };
5260
+ for (const k of Object.keys(payload)) if (payload[k] === void 0) delete payload[k];
5261
+ return payload;
5262
+ }
5263
+ function contentPutArgs(repo, path, content, branch, sha) {
5264
+ const args = [
5265
+ "api",
5266
+ "-X",
5267
+ "PUT",
5268
+ `repos/${repo}/contents/${path.split("/").map(encodeURIComponent).join("/")}`,
5269
+ "-f",
5270
+ `message=bootstrap: seed ${path}`,
5271
+ "-f",
5272
+ `content=${Buffer.from(content, "utf8").toString("base64")}`,
5273
+ "-f",
5274
+ `branch=${branch}`
5275
+ ];
5276
+ if (sha) args.push("-f", `sha=${sha}`);
5277
+ return args;
5278
+ }
5279
+
5280
+ // src/registry-client.ts
5281
+ var DEFAULT_TIMEOUT_MS = 8e3;
5282
+ async function fetchProjectsList(deps) {
5283
+ if (!deps.baseUrl) return null;
5284
+ const token = await deps.token();
5285
+ if (!token) return null;
5286
+ const doFetch = deps.fetch ?? fetch;
5287
+ try {
5288
+ const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/list`, {
5289
+ method: "GET",
5290
+ headers: { Authorization: `Bearer ${token}` },
5291
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
5292
+ });
5293
+ if (!res.ok) return null;
5294
+ const body = await res.json();
5295
+ return Array.isArray(body?.projects) ? body.projects : null;
5296
+ } catch {
5297
+ return null;
5298
+ }
5299
+ }
5300
+ async function fetchProjectsJson(deps) {
5301
+ const projects = await fetchProjectsList(deps);
5302
+ return projects ? JSON.stringify({ projects }) : null;
5303
+ }
5304
+ async function fetchProjectBySlug(slug, deps) {
5305
+ if (!deps.baseUrl || !slug) return null;
5306
+ const token = await deps.token();
5307
+ if (!token) return null;
5308
+ const doFetch = deps.fetch ?? fetch;
5309
+ try {
5310
+ const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/projects/${encodeURIComponent(slug)}`, {
5311
+ method: "GET",
5312
+ headers: { Authorization: `Bearer ${token}` },
5313
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
5314
+ });
5315
+ if (!res.ok) return null;
5316
+ return await res.json();
5317
+ } catch {
5318
+ return null;
5319
+ }
5320
+ }
5321
+ async function fetchOrgConfig(deps) {
5322
+ if (!deps.baseUrl) return null;
5323
+ const token = await deps.token();
5324
+ if (!token) return null;
5325
+ const doFetch = deps.fetch ?? fetch;
5326
+ try {
5327
+ const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}/org/config`, {
5328
+ method: "GET",
5329
+ headers: { Authorization: `Bearer ${token}` },
5330
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
5331
+ });
5332
+ if (!res.ok) return null;
5333
+ return await res.json();
5334
+ } catch {
5335
+ return null;
5336
+ }
5337
+ }
5338
+ async function postJson(pathSuffix, payload, deps, method = "POST") {
5339
+ if (!deps.baseUrl) return { ok: false, status: 0, body: null, error: "no Hub API URL (this repo is not bootstrapped)" };
5340
+ const token = await deps.token();
5341
+ if (!token) return { ok: false, status: 0, body: null, error: "no GitHub token (run `gh auth login`)" };
5342
+ const doFetch = deps.fetch ?? fetch;
5343
+ try {
5344
+ const res = await doFetch(`${deps.baseUrl.replace(/\/$/, "")}${pathSuffix}`, {
5345
+ method,
5346
+ headers: { Authorization: `Bearer ${token}`, "content-type": "application/json" },
5347
+ body: JSON.stringify(payload),
5348
+ signal: AbortSignal.timeout(deps.timeoutMs ?? DEFAULT_TIMEOUT_MS)
5349
+ });
5350
+ let body = null;
5351
+ try {
5352
+ body = await res.json();
5353
+ } catch {
5354
+ }
5355
+ return { ok: res.ok, status: res.status, body };
5356
+ } catch (e) {
5357
+ return { ok: false, status: 0, body: null, error: e.message };
5358
+ }
5359
+ }
5360
+ async function registerProject(payload, deps) {
5361
+ return postJson("/projects/register", payload, deps);
5362
+ }
5363
+ async function upsertProject(slug, patch, deps) {
5364
+ return postJson(`/projects/${encodeURIComponent(slug)}`, patch, deps);
5365
+ }
5366
+
5367
+ // src/kb.ts
5368
+ var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
5369
+ function resolveKbSource(rawBase) {
5370
+ if (!rawBase) return DEFAULT_KB;
5371
+ const m = rawBase.match(/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/?#]+)/);
5372
+ if (!m) return DEFAULT_KB;
5373
+ return { owner: m[1], repo: m[2], ref: m[3] };
5374
+ }
5375
+ function buildKbGetArgs(src, path) {
5376
+ const clean = path.replace(/^\/+/, "");
5377
+ return ["api", `repos/${src.owner}/${src.repo}/contents/${clean}?ref=${src.ref}`, "-H", "Accept: application/vnd.github.raw"];
5378
+ }
5379
+ function buildKbTreeArgs(src) {
5380
+ return ["api", `repos/${src.owner}/${src.repo}/git/trees/${src.ref}?recursive=1`];
5381
+ }
5382
+ function parseKbTree(stdout, prefix) {
5383
+ let tree;
5384
+ try {
5385
+ tree = JSON.parse(stdout)?.tree ?? [];
5386
+ } catch {
5387
+ return [];
5388
+ }
5389
+ const pre = prefix ? prefix.replace(/^\/+/, "") : void 0;
5390
+ 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();
5391
+ }
4253
5392
 
4254
5393
  // src/plan.ts
4255
5394
  var import_node_path3 = require("node:path");
4256
5395
  var PLANS_DIR = "plans";
4257
5396
  var META_FILE = (0, import_node_path3.join)(PLANS_DIR, ".plan-meta.json");
4258
5397
  var planPath = (slug) => (0, import_node_path3.join)(PLANS_DIR, `${slug}.md`);
4259
- var metaKey = (project, slug) => `${project}/${slug}`;
5398
+ var metaKey = (project2, slug) => `${project2}/${slug}`;
4260
5399
  function parseMeta(raw) {
4261
5400
  if (!raw) return {};
4262
5401
  try {
@@ -4278,23 +5417,46 @@ function hashContent(s) {
4278
5417
  return (h >>> 0).toString(16);
4279
5418
  }
4280
5419
  function staleHint(slug) {
4281
- return `remote "${slug}" is newer \u2014 run \`mmi-cli plan pull ${slug}\` first (your local is based on an older version), or re-push with \`--force\` to overwrite`;
5420
+ return `remote "${slug}" is newer \u2014 run \`mmi-cli northstar pull ${slug}\` first (your local is based on an older version), or re-push with \`--force\` to overwrite`;
4282
5421
  }
4283
5422
  function formatPlanList(plans) {
4284
5423
  return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
4285
5424
  }
4286
5425
  var TIMEOUT_MS = 8e3;
5426
+ var GRADUATION_KEYS = /* @__PURE__ */ new Set(["northstar-graduation", "privacy", "merged-pr"]);
5427
+ function splitFrontmatter(content) {
5428
+ const match = /^---\n([\s\S]*?)\n---(?:\n|$)/.exec(content);
5429
+ if (!match) return { entries: [], body: content };
5430
+ return { entries: match[1].split(/\r?\n/).filter((line) => line.trim()), body: content.slice(match[0].length) };
5431
+ }
5432
+ function markPlanGraduated(content, opts) {
5433
+ const { entries, body } = splitFrontmatter(normalizeEol(content));
5434
+ const preserved = entries.filter((line) => {
5435
+ const key = /^([A-Za-z0-9_-]+):/.exec(line)?.[1]?.toLowerCase();
5436
+ return !key || !GRADUATION_KEYS.has(key);
5437
+ });
5438
+ const next = [
5439
+ ...preserved,
5440
+ "northstar-graduation: built-and-merged",
5441
+ "privacy: org",
5442
+ `merged-pr: ${opts.mergedPr}`
5443
+ ];
5444
+ return `---
5445
+ ${next.join("\n")}
5446
+ ---
5447
+ ${body.replace(/^\n+/, "")}`;
5448
+ }
4287
5449
  async function planPush(deps, slug, opts = {}) {
4288
5450
  const raw = deps.readLocal(slug);
4289
5451
  if (raw == null) {
4290
5452
  deps.err(`no local ${planPath(slug)} to push`);
4291
- return;
5453
+ return false;
4292
5454
  }
4293
5455
  const content = normalizeEol(raw);
4294
- const project = opts.project ?? await deps.project();
5456
+ const project2 = opts.project ?? await deps.project();
4295
5457
  const meta = parseMeta(deps.readMetaRaw());
4296
- const entry = meta[metaKey(project, slug)];
4297
- const body = { project, slug, content };
5458
+ const entry = meta[metaKey(project2, slug)];
5459
+ const body = { project: project2, slug, content };
4298
5460
  if (opts.force) body.force = true;
4299
5461
  else if (entry?.etag) body.baseEtag = entry.etag;
4300
5462
  const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
@@ -4305,25 +5467,28 @@ async function planPush(deps, slug, opts = {}) {
4305
5467
  });
4306
5468
  if (res.ok) {
4307
5469
  const out = await res.json();
4308
- meta[metaKey(project, slug)] = { etag: out.etag, hash: hashContent(content), syncedAt: deps.now() };
5470
+ meta[metaKey(project2, slug)] = { etag: out.etag, hash: hashContent(content), syncedAt: deps.now() };
4309
5471
  deps.writeMetaRaw(serializeMeta(meta));
4310
5472
  deps.log(`pushed ${slug}`);
5473
+ return true;
4311
5474
  } else if (res.status === 409) {
4312
5475
  deps.err(staleHint(slug));
5476
+ return false;
4313
5477
  } else {
4314
5478
  deps.err(`plan push failed: HTTP ${res.status}`);
5479
+ return false;
4315
5480
  }
4316
5481
  }
4317
5482
  async function planPull(deps, slug, opts = {}) {
4318
- const project = opts.project ?? await deps.project();
5483
+ const project2 = opts.project ?? await deps.project();
4319
5484
  const meta = parseMeta(deps.readMetaRaw());
4320
- const entry = meta[metaKey(project, slug)];
5485
+ const entry = meta[metaKey(project2, slug)];
4321
5486
  const local = deps.readLocal(slug);
4322
5487
  if (local != null && entry && !opts.force && hashContent(normalizeEol(local)) !== entry.hash) {
4323
5488
  deps.err(`local ${planPath(slug)} has unpushed edits \u2014 push it, or pull with --force to overwrite`);
4324
5489
  return;
4325
5490
  }
4326
- const qs = new URLSearchParams({ project, slug }).toString();
5491
+ const qs = new URLSearchParams({ project: project2, slug }).toString();
4327
5492
  const res = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, {
4328
5493
  method: "GET",
4329
5494
  headers: await deps.headers(),
@@ -4340,7 +5505,7 @@ async function planPull(deps, slug, opts = {}) {
4340
5505
  const doc = await res.json();
4341
5506
  const content = normalizeEol(doc.content ?? "");
4342
5507
  deps.writeLocal(slug, content);
4343
- meta[metaKey(project, slug)] = { etag: doc.etag, hash: hashContent(content), syncedAt: deps.now() };
5508
+ meta[metaKey(project2, slug)] = { etag: doc.etag, hash: hashContent(content), syncedAt: deps.now() };
4344
5509
  deps.writeMetaRaw(serializeMeta(meta));
4345
5510
  deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
4346
5511
  }
@@ -4373,26 +5538,331 @@ async function planList(deps, opts = {}) {
4373
5538
  deps.log(formatPlanList(plans));
4374
5539
  }
4375
5540
  async function planDelete(deps, slug, opts = {}) {
4376
- const project = opts.project ?? await deps.project();
5541
+ const project2 = opts.project ?? await deps.project();
4377
5542
  const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
4378
5543
  method: "POST",
4379
5544
  headers: await deps.headers({ "content-type": "application/json" }),
4380
- body: JSON.stringify({ project, slug }),
4381
- signal: AbortSignal.timeout(TIMEOUT_MS)
5545
+ body: JSON.stringify({ project: project2, slug }),
5546
+ signal: AbortSignal.timeout(TIMEOUT_MS)
5547
+ });
5548
+ if (!res.ok) {
5549
+ deps.err(`plan delete failed: HTTP ${res.status}`);
5550
+ return;
5551
+ }
5552
+ deps.removeLocal(slug);
5553
+ const meta = parseMeta(deps.readMetaRaw());
5554
+ delete meta[metaKey(project2, slug)];
5555
+ deps.writeMetaRaw(serializeMeta(meta));
5556
+ deps.log(`deleted ${slug}`);
5557
+ }
5558
+ async function planGraduate(deps, slug, opts = {}) {
5559
+ if (!opts.orgVisible) {
5560
+ deps.err("refusing to mark an org-visible graduation without --org-visible");
5561
+ return;
5562
+ }
5563
+ if (!opts.mergedPr) {
5564
+ deps.err("missing --merged-pr <url|number>");
5565
+ return;
5566
+ }
5567
+ const raw = deps.readLocal(slug);
5568
+ if (raw == null) {
5569
+ deps.err(`no local ${planPath(slug)} to graduate`);
5570
+ return;
5571
+ }
5572
+ const content = markPlanGraduated(raw, { mergedPr: opts.mergedPr });
5573
+ deps.writeLocal(slug, content);
5574
+ const pushed = await planPush(deps, slug, { project: opts.project, force: opts.force });
5575
+ if (pushed) deps.log(`graduated ${slug}`);
5576
+ }
5577
+
5578
+ // src/secrets.ts
5579
+ var OWNER2 = "mutmutco";
5580
+ var SSM_ROOT = "/mmi-future";
5581
+ var PROJECT_TIER_SEGMENT = "dev";
5582
+ var KEY_RE = /^(?:[a-z][a-z0-9-]*\/)?[A-Za-z][A-Za-z0-9_]*$/;
5583
+ function isValidSecretKey(key) {
5584
+ if (!key || key.length > 256) return false;
5585
+ if (key.includes("..") || key.startsWith("/") || key.includes("*")) return false;
5586
+ return KEY_RE.test(key);
5587
+ }
5588
+ function classifyTier(_slug, key) {
5589
+ const slash = key.indexOf("/");
5590
+ if (slash === -1) return "project";
5591
+ return key.slice(0, slash) === PROJECT_TIER_SEGMENT ? "project" : "org";
5592
+ }
5593
+ function secretParamName(slug, key) {
5594
+ const rel = key.includes("/") ? key : `${PROJECT_TIER_SEGMENT}/${key}`;
5595
+ return `${SSM_ROOT}/${slug}/${rel}`;
5596
+ }
5597
+ function formatSecretList(items) {
5598
+ if (!items.length) return "no secrets";
5599
+ const width = Math.max(...items.map((i) => i.key.length));
5600
+ 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.");
5601
+ }
5602
+ function vaultPointer(slug) {
5603
+ const root = `${SSM_ROOT}/${slug}`;
5604
+ return {
5605
+ slug,
5606
+ root,
5607
+ tiers: {
5608
+ project: `${root}/${PROJECT_TIER_SEGMENT}/* (project-admin self-serve)`,
5609
+ org: [`${root}/rc/*`, `${root}/main/*`].map((p) => `${p} (master-gated)`)
5610
+ },
5611
+ stages: ["dev", "rc", "main"],
5612
+ // Google OAuth is one client per repo; creds live at every stage under the standard key names
5613
+ // (local is port-agnostic and reuses the dev tier). See the oauth-everywhere convention.
5614
+ wellKnown: {
5615
+ googleOAuth: ["dev/GOOGLE_CLIENT_ID", "dev/GOOGLE_CLIENT_SECRET", "rc/GOOGLE_CLIENT_ID", "rc/GOOGLE_CLIENT_SECRET", "main/GOOGLE_CLIENT_ID", "main/GOOGLE_CLIENT_SECRET"]
5616
+ }
5617
+ };
5618
+ }
5619
+ function formatVaultPointer(p) {
5620
+ const lines = [
5621
+ `vault root: ${p.root}`,
5622
+ ` project tier (self-serve): ${p.tiers.project}`,
5623
+ ` org tier (master-gated): ${p.tiers.org.join(" \xB7 ")}`,
5624
+ `stages: ${p.stages.join(", ")} (local is port-agnostic, reuses dev)`,
5625
+ `well-known keys:`,
5626
+ ...Object.entries(p.wellKnown).map(([k, keys]) => ` ${k}: ${keys.join(", ")}`),
5627
+ ``,
5628
+ `enumerate actual keys: mmi-cli secrets list`,
5629
+ `read one: mmi-cli secrets get <stage>/<KEY> (e.g. main/GOOGLE_CLIENT_ID)`,
5630
+ `set a project-tier key: mmi-cli secrets set <KEY> (value via stdin; org tier needs a master grant)`
5631
+ ];
5632
+ return lines.join("\n");
5633
+ }
5634
+ var TIMEOUT_MS2 = 8e3;
5635
+ var repoOf = (slug) => `${OWNER2}/${slug}`;
5636
+ async function targetRepo(deps, opts) {
5637
+ return opts.repo ?? repoOf(await deps.slug());
5638
+ }
5639
+ async function secretsWhere(deps, opts) {
5640
+ const slug = opts.repo ? opts.repo.split("/").pop().toLowerCase() : await deps.slug();
5641
+ deps.log(formatVaultPointer(vaultPointer(slug)));
5642
+ }
5643
+ async function readErr(res) {
5644
+ try {
5645
+ const j = await res.json();
5646
+ return j?.error ? `: ${j.error}` : "";
5647
+ } catch {
5648
+ return "";
5649
+ }
5650
+ }
5651
+ async function fetchSecretValue(deps, key, opts) {
5652
+ if (!isValidSecretKey(key)) return null;
5653
+ const repo = await targetRepo(deps, opts);
5654
+ try {
5655
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/get`, {
5656
+ method: "POST",
5657
+ headers: await deps.headers({ "content-type": "application/json" }),
5658
+ body: JSON.stringify({ repo, key }),
5659
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5660
+ });
5661
+ if (!res.ok) return null;
5662
+ const { value } = await res.json();
5663
+ return value ?? null;
5664
+ } catch {
5665
+ return null;
5666
+ }
5667
+ }
5668
+ async function secretsList(deps, opts) {
5669
+ const repo = await targetRepo(deps, opts);
5670
+ const qs = new URLSearchParams({ repo }).toString();
5671
+ let res;
5672
+ try {
5673
+ res = await deps.fetch(`${deps.apiUrl}/secrets/list?${qs}`, {
5674
+ method: "GET",
5675
+ headers: await deps.headers(),
5676
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5677
+ });
5678
+ } catch (e) {
5679
+ deps.err(`secrets list: ${e.message}`);
5680
+ return;
5681
+ }
5682
+ if (!res.ok) {
5683
+ deps.err(`secrets list failed: HTTP ${res.status}${await readErr(res)}`);
5684
+ return;
5685
+ }
5686
+ const { secrets: secrets2 } = await res.json();
5687
+ deps.log(formatSecretList(secrets2 ?? []));
5688
+ }
5689
+ async function secretsGet(deps, key, opts) {
5690
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5691
+ const repo = await targetRepo(deps, opts);
5692
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/get`, {
5693
+ method: "POST",
5694
+ headers: await deps.headers({ "content-type": "application/json" }),
5695
+ body: JSON.stringify({ repo, key }),
5696
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5697
+ });
5698
+ if (!res.ok) {
5699
+ deps.err(
5700
+ res.status === 403 ? `secrets get: not authorized for ${key} (HTTP 403)${await readErr(res)}` : `secrets get failed: HTTP ${res.status}${await readErr(res)}`
5701
+ );
5702
+ return;
5703
+ }
5704
+ const { value } = await res.json();
5705
+ deps.log(value ?? "");
5706
+ }
5707
+ async function secretsSet(deps, key, opts) {
5708
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5709
+ const repo = await targetRepo(deps, opts);
5710
+ const value = await deps.readSecretValue(`value for ${key} (input hidden; will not be echoed): `);
5711
+ if (!value) {
5712
+ deps.err("secrets set: empty value \u2014 aborted (nothing written)");
5713
+ return;
5714
+ }
5715
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/set`, {
5716
+ method: "POST",
5717
+ headers: await deps.headers({ "content-type": "application/json" }),
5718
+ body: JSON.stringify({ repo, key, value }),
5719
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5720
+ });
5721
+ if (!res.ok) {
5722
+ deps.err(
5723
+ res.status === 403 ? `secrets set: not authorized to write ${key} (HTTP 403)${await readErr(res)}` : `secrets set failed: HTTP ${res.status}${await readErr(res)}`
5724
+ );
5725
+ return;
5726
+ }
5727
+ deps.log(`set ${key} (${classifyTier(await deps.slug(), key)} tier)`);
5728
+ }
5729
+ async function secretsEdit(deps, key, opts) {
5730
+ return secretsSet(deps, key, opts);
5731
+ }
5732
+ async function secretsRemove(deps, key, opts) {
5733
+ if (!isValidSecretKey(key)) return deps.err(`invalid secret key ${JSON.stringify(key)}`);
5734
+ const repo = await targetRepo(deps, opts);
5735
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/rm`, {
5736
+ method: "POST",
5737
+ headers: await deps.headers({ "content-type": "application/json" }),
5738
+ body: JSON.stringify({ repo, key }),
5739
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5740
+ });
5741
+ if (!res.ok) {
5742
+ deps.err(
5743
+ res.status === 403 ? `secrets rm: not authorized to remove ${key} (HTTP 403)${await readErr(res)}` : `secrets rm failed: HTTP ${res.status}${await readErr(res)}`
5744
+ );
5745
+ return;
5746
+ }
5747
+ deps.log(`removed ${key}`);
5748
+ }
5749
+ async function secretsGrant(deps, repo, login, key, _opts) {
5750
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/grant`, {
5751
+ method: "POST",
5752
+ headers: await deps.headers({ "content-type": "application/json" }),
5753
+ body: JSON.stringify({ repo, login, key }),
5754
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
5755
+ });
5756
+ if (!res.ok) {
5757
+ deps.err(
5758
+ res.status === 403 ? `secrets grant: master-admin only (HTTP 403)${await readErr(res)}` : `secrets grant failed: HTTP ${res.status}${await readErr(res)}`
5759
+ );
5760
+ return;
5761
+ }
5762
+ deps.log(`granted @${login} access to ${key} in ${repo}`);
5763
+ }
5764
+ async function secretsRevoke(deps, repo, login, key, _opts) {
5765
+ const res = await deps.fetch(`${deps.apiUrl}/secrets/revoke`, {
5766
+ method: "POST",
5767
+ headers: await deps.headers({ "content-type": "application/json" }),
5768
+ body: JSON.stringify({ repo, login, key }),
5769
+ signal: AbortSignal.timeout(TIMEOUT_MS2)
4382
5770
  });
4383
5771
  if (!res.ok) {
4384
- deps.err(`plan delete failed: HTTP ${res.status}`);
5772
+ deps.err(
5773
+ res.status === 403 ? `secrets revoke: master-admin only (HTTP 403)${await readErr(res)}` : `secrets revoke failed: HTTP ${res.status}${await readErr(res)}`
5774
+ );
4385
5775
  return;
4386
5776
  }
4387
- deps.removeLocal(slug);
4388
- const meta = parseMeta(deps.readMetaRaw());
4389
- delete meta[metaKey(project, slug)];
4390
- deps.writeMetaRaw(serializeMeta(meta));
4391
- deps.log(`deleted ${slug}`);
5777
+ deps.log(`revoked @${login}'s access to ${key} in ${repo}`);
5778
+ }
5779
+ async function secretsUse(deps, key, _opts) {
5780
+ const slug = await deps.slug();
5781
+ const tier = classifyTier(slug, key);
5782
+ const path = secretParamName(slug, key);
5783
+ deps.log(
5784
+ [
5785
+ `${key} \u2192 ${path} (${tier} tier)`,
5786
+ "",
5787
+ "Consume it WITHOUT committing it:",
5788
+ ` \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.`,
5789
+ ` \u2022 CI (GitHub Actions): the workflow assumes its OIDC role and runs \`aws ssm get-parameter --with-decryption --name ${path}\` \u2014 no GitHub secret.`,
5790
+ " \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.",
5791
+ 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`."
5792
+ ].join("\n")
5793
+ );
5794
+ }
5795
+
5796
+ // src/oauth.ts
5797
+ var DEFAULT_DOMAINS = ["mutatismutandis.co", "mutmut.co"];
5798
+ var DEFAULT_CALLBACK_PATH = "/api/auth/callback";
5799
+ var ENV_PREFIXES = ["", "dev", "rc"];
5800
+ var LOOPBACK = ["http://localhost", "http://127.0.0.1"];
5801
+ var SSM_ENVS = ["dev", "rc", "main"];
5802
+ var SSM_NAMES = ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"];
5803
+ var uniq = (xs) => [...new Set(xs)];
5804
+ function defaultSubdomain(slug) {
5805
+ const i = slug.indexOf("-");
5806
+ return i === -1 ? slug : slug.slice(i + 1);
5807
+ }
5808
+ function expectedHosts(cfg) {
5809
+ const out = [];
5810
+ for (const sub of cfg.subdomains) {
5811
+ for (const domain of cfg.domains) {
5812
+ const base = sub ? `${sub}.${domain}` : domain;
5813
+ for (const env of ENV_PREFIXES) out.push(env ? `${env}.${base}` : base);
5814
+ }
5815
+ }
5816
+ return uniq(out);
5817
+ }
5818
+ function expectedJsOrigins(cfg) {
5819
+ return uniq([...expectedHosts(cfg).map((h) => `https://${h}`), ...LOOPBACK]);
5820
+ }
5821
+ function expectedRedirectUris(cfg) {
5822
+ const { callbackPath } = cfg;
5823
+ return uniq([
5824
+ ...expectedHosts(cfg).map((h) => `https://${h}${callbackPath}`),
5825
+ ...LOOPBACK.map((l) => `${l}${callbackPath}`)
5826
+ ]);
5827
+ }
5828
+ function oauthSsmKeys() {
5829
+ return SSM_ENVS.flatMap((env) => SSM_NAMES.map((name) => `${env}/${name}`));
5830
+ }
5831
+ function parseOauthConfig(mmiConfig, slug) {
5832
+ const raw = mmiConfig?.oauth ?? {};
5833
+ const subdomains = Array.isArray(raw.subdomains) && raw.subdomains.length > 0 ? raw.subdomains.map(String) : [defaultSubdomain(slug)];
5834
+ const domains = Array.isArray(raw.domains) && raw.domains.length > 0 ? raw.domains.map(String) : [...DEFAULT_DOMAINS];
5835
+ const callbackPath = typeof raw.callbackPath === "string" && raw.callbackPath ? raw.callbackPath : DEFAULT_CALLBACK_PATH;
5836
+ if (!callbackPath.startsWith("/")) {
5837
+ throw new Error(`oauth.callbackPath must start with "/" (got ${JSON.stringify(callbackPath)})`);
5838
+ }
5839
+ return { subdomains, domains, callbackPath };
5840
+ }
5841
+ function probeRedirectUri(callbackPath, port = 9123) {
5842
+ return `http://localhost:${port}${callbackPath}`;
5843
+ }
5844
+ function buildAuthorizeProbeUrl(clientId, redirectUri) {
5845
+ const qs = new URLSearchParams({
5846
+ client_id: clientId,
5847
+ redirect_uri: redirectUri,
5848
+ response_type: "code",
5849
+ scope: "openid email",
5850
+ access_type: "offline",
5851
+ prompt: "consent"
5852
+ });
5853
+ return `https://accounts.google.com/o/oauth2/v2/auth?${qs.toString()}`;
5854
+ }
5855
+ function authorizeBodyHasMismatch(body) {
5856
+ return /redirect_uri_mismatch/i.test(body);
4392
5857
  }
4393
5858
 
4394
5859
  // src/index.ts
4395
- var execFileP3 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
5860
+ var rawExecFileP2 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
5861
+ var execFileP3 = (file, args, options = {}) => (
5862
+ // encoding 'utf8' guarantees string stdout/stderr at runtime; the cast pins the type because
5863
+ // promisify(execFile)'s overloads widen to string|Buffer when options is spread in.
5864
+ rawExecFileP2(file, args, { encoding: "utf8", windowsHide: true, ...options })
5865
+ );
4396
5866
  var GIT_TIMEOUT_MS = 1e4;
4397
5867
  var GC_GH_TIMEOUT_MS = 2e4;
4398
5868
  async function githubToken() {
@@ -4413,19 +5883,60 @@ async function githubLogin() {
4413
5883
  return void 0;
4414
5884
  }
4415
5885
  }
5886
+ async function awsCallerArn() {
5887
+ try {
5888
+ const { stdout } = await execFileP3(
5889
+ "aws",
5890
+ ["sts", "get-caller-identity", "--query", "Arn", "--output", "text"],
5891
+ { timeout: GIT_TIMEOUT_MS }
5892
+ );
5893
+ return stdout.trim() || void 0;
5894
+ } catch {
5895
+ return void 0;
5896
+ }
5897
+ }
4416
5898
  async function sagaHeaders(extra = {}) {
4417
5899
  const t = await githubToken();
4418
5900
  return t ? { ...extra, Authorization: `Bearer ${t}` } : extra;
4419
5901
  }
4420
5902
  async function loadConfig() {
5903
+ let file = {};
4421
5904
  try {
4422
- return JSON.parse(await (0, import_promises.readFile)(".mmi/config.json", "utf8"));
5905
+ file = JSON.parse(await (0, import_promises.readFile)(".mmi/config.json", "utf8"));
4423
5906
  } catch {
4424
- return {};
5907
+ file = {};
5908
+ }
5909
+ if (!file.sagaApiUrl) file.sagaApiUrl = defaultHubUrl();
5910
+ return file;
5911
+ }
5912
+ var discoveredConfig = null;
5913
+ async function loadConfigOrDiscover() {
5914
+ if (discoveredConfig) return discoveredConfig;
5915
+ const floor = await loadConfig();
5916
+ if (floor.projectId && floor.statusFieldId && floor.statusOptions) {
5917
+ discoveredConfig = floor;
5918
+ return floor;
4425
5919
  }
5920
+ if (!floor.sagaApiUrl) return floor;
5921
+ const meta = await fetchProjectBySlug(await repoSlug(), { baseUrl: floor.sagaApiUrl, token: githubToken });
5922
+ if (!meta) return floor;
5923
+ discoveredConfig = {
5924
+ projectOwner: meta.projectOwner,
5925
+ projectNumber: meta.projectNumber,
5926
+ projectId: meta.projectId,
5927
+ statusFieldId: meta.statusFieldId,
5928
+ statusOptions: meta.statusOptions,
5929
+ priorityFieldId: meta.priorityFieldId,
5930
+ priorityOptions: meta.priorityOptions,
5931
+ ...floor
5932
+ };
5933
+ return discoveredConfig;
5934
+ }
5935
+ async function repoSlug() {
5936
+ const remote = await gitOut(["remote", "get-url", "origin"]);
5937
+ return (remote.replace(/\.git$/, "").split("/").pop() || "-").toLowerCase();
4426
5938
  }
4427
5939
  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
5940
  var SESSION_FILE = ".mmi/.session";
4430
5941
  var gitOut = async (args) => {
4431
5942
  try {
@@ -4439,7 +5950,7 @@ function sessionDeps() {
4439
5950
  env: process.env,
4440
5951
  readPersisted: () => {
4441
5952
  try {
4442
- return (0, import_node_fs3.readFileSync)(SESSION_FILE, "utf8");
5953
+ return (0, import_node_fs4.readFileSync)(SESSION_FILE, "utf8");
4443
5954
  } catch {
4444
5955
  return null;
4445
5956
  }
@@ -4452,8 +5963,8 @@ function sessionDeps() {
4452
5963
  var resolveSessionId = () => resolveSession(sessionDeps());
4453
5964
  function persistSession(id) {
4454
5965
  try {
4455
- (0, import_node_fs3.mkdirSync)(".mmi", { recursive: true });
4456
- (0, import_node_fs3.writeFileSync)(SESSION_FILE, id, "utf8");
5966
+ (0, import_node_fs4.mkdirSync)(".mmi", { recursive: true });
5967
+ (0, import_node_fs4.writeFileSync)(SESSION_FILE, id, "utf8");
4457
5968
  } catch {
4458
5969
  }
4459
5970
  }
@@ -4473,7 +5984,11 @@ async function postCapture(capture, quiet = false) {
4473
5984
  method: "POST",
4474
5985
  headers: await sagaHeaders({ "content-type": "application/json" }),
4475
5986
  body: JSON.stringify({ ...capture, ...await sagaKey(cfg) }),
4476
- signal: AbortSignal.timeout(8e3)
5987
+ // Capture latency is high + variable (server-side HEAD render); 8s dropped larger notes. Match the
5988
+ // head-write timeout (20s) so a continuity note isn't lost to a slow/cold backend. No client retry:
5989
+ // the capture isn't guaranteed idempotent, so a retry after a server-side-completed write could
5990
+ // duplicate the note. Backend capture-latency root cause tracked in #255.
5991
+ signal: AbortSignal.timeout(2e4)
4477
5992
  });
4478
5993
  if (!quiet) console.log(res.ok ? "noted" : `saga: HTTP ${res.status}`);
4479
5994
  } catch (e) {
@@ -4537,14 +6052,35 @@ async function applyGcPlan(plan2, remote) {
4537
6052
  await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS });
4538
6053
  }
4539
6054
  }
6055
+ async function cleanupLocalBranch(branch) {
6056
+ const result = { branchDeleted: false };
6057
+ if (!branch) return result;
6058
+ const { stdout } = await execFileP3("git", ["worktree", "list", "--porcelain"], { timeout: GIT_TIMEOUT_MS });
6059
+ const wt = parseWorktreePorcelain(stdout).find((w) => w.branch === branch);
6060
+ if (wt) {
6061
+ await execFileP3("git", ["worktree", "remove", "--force", wt.path], { timeout: GIT_TIMEOUT_MS }).catch(() => {
6062
+ });
6063
+ result.worktreeRemoved = wt.path;
6064
+ }
6065
+ const current = await gitOut(["rev-parse", "--abbrev-ref", "HEAD"]) || "";
6066
+ if (branch !== current) {
6067
+ await execFileP3("git", ["branch", "-D", branch], { timeout: GIT_TIMEOUT_MS }).then(() => {
6068
+ result.branchDeleted = true;
6069
+ }).catch(() => {
6070
+ });
6071
+ }
6072
+ if (wt) await execFileP3("git", ["worktree", "prune"], { timeout: GIT_TIMEOUT_MS }).catch(() => {
6073
+ });
6074
+ return result;
6075
+ }
4540
6076
  function resolveVersion() {
4541
6077
  try {
4542
6078
  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";
6079
+ return JSON.parse((0, import_node_fs4.readFileSync)(manifest, "utf8")).version || "0.0.0";
4544
6080
  } catch {
4545
6081
  try {
4546
6082
  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";
6083
+ return JSON.parse((0, import_node_fs4.readFileSync)(pkg, "utf8")).version || "0.0.0";
4548
6084
  } catch {
4549
6085
  return "0.0.0";
4550
6086
  }
@@ -4552,22 +6088,44 @@ function resolveVersion() {
4552
6088
  }
4553
6089
  function readRepoVersion() {
4554
6090
  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;
6091
+ return JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path4.join)(process.cwd(), ".claude-plugin", "plugin.json"), "utf8")).version || void 0;
4556
6092
  } catch {
4557
6093
  return void 0;
4558
6094
  }
4559
6095
  }
4560
6096
  async function fetchReleasedVersion() {
4561
6097
  try {
4562
- const res = await fetch("https://raw.githubusercontent.com/mutmutco/MMI-Hub/main/.claude-plugin/plugin.json", {
4563
- signal: AbortSignal.timeout(5e3)
4564
- });
4565
- if (!res.ok) return void 0;
4566
- return (await res.json()).version;
6098
+ const { stdout } = await execFileP3("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
6099
+ return parseManifestVersion(stdout);
4567
6100
  } catch {
4568
6101
  return void 0;
4569
6102
  }
4570
6103
  }
6104
+ var NPM_UPDATE_TIMEOUT_MS = 12e4;
6105
+ var PLUGIN_PULL_TIMEOUT_MS = 3e4;
6106
+ async function applyVersionAutoUpdate(report, log) {
6107
+ const action = versionAutoUpdateAction(report, Boolean(process.env.CLAUDE_PLUGIN_ROOT));
6108
+ if (action === "none") return report;
6109
+ const target = report.releasedVersion ?? "latest";
6110
+ if (action === "plugin-pull") {
6111
+ try {
6112
+ const root = (await execFileP3("git", ["-C", process.env.CLAUDE_PLUGIN_ROOT, "rev-parse", "--show-toplevel"], { timeout: PLUGIN_PULL_TIMEOUT_MS })).stdout.trim();
6113
+ log(` \u21BB refreshing MMI plugin ${report.currentVersion} \u2192 ${target} (effective next session)\u2026`);
6114
+ await execFileP3("git", ["-C", root, "pull", "--ff-only"], { timeout: PLUGIN_PULL_TIMEOUT_MS });
6115
+ return { ...report, ok: true };
6116
+ } catch {
6117
+ return report;
6118
+ }
6119
+ }
6120
+ try {
6121
+ const npm = process.platform === "win32" ? "npm.cmd" : "npm";
6122
+ log(` \u21BB updating mmi-cli ${report.currentVersion} \u2192 ${target}\u2026`);
6123
+ await execFileP3(npm, ["install", "-g", "@mutmutco/cli@latest"], { timeout: NPM_UPDATE_TIMEOUT_MS });
6124
+ return { ...report, ok: true };
6125
+ } catch {
6126
+ return report;
6127
+ }
6128
+ }
4571
6129
  var program2 = new Command();
4572
6130
  program2.name("mmi-cli").description("MMI Future CLI \u2014 org rules delivery, saga, KB. The engine the plugin SessionStart hook drives.").version(resolveVersion());
4573
6131
  var rules = program2.command("rules").description("org rules delivery");
@@ -4577,7 +6135,7 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
4577
6135
  if (!opts.quiet) console.log('mmi-cli rules: source repo (orgRulesSource: "self") \u2014 skipping self-sync');
4578
6136
  return;
4579
6137
  }
4580
- const base = (cfg.orgRulesSource ?? DEFAULT_RULES_SOURCE).replace(/\/$/, "");
6138
+ const base = resolveRulesBase(cfg.orgRulesSource, DEFAULT_RULES_SOURCE);
4581
6139
  const token = await githubToken();
4582
6140
  let changed = 0;
4583
6141
  for (const file of ["AGENTS.md", "CLAUDE.md", ".claude/settings.json"]) {
@@ -4591,10 +6149,10 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
4591
6149
  if (!opts.quiet) console.error(`mmi-cli rules: could not fetch ${file} (${e.message}); left it untouched`);
4592
6150
  continue;
4593
6151
  }
4594
- const current = (0, import_node_fs3.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
6152
+ const current = (0, import_node_fs4.existsSync)(file) ? await (0, import_promises.readFile)(file, "utf8") : null;
4595
6153
  if (needsUpdate(source, current)) {
4596
6154
  const slash = file.lastIndexOf("/");
4597
- if (slash > 0) (0, import_node_fs3.mkdirSync)(file.slice(0, slash), { recursive: true });
6155
+ if (slash > 0) (0, import_node_fs4.mkdirSync)(file.slice(0, slash), { recursive: true });
4598
6156
  await (0, import_promises.writeFile)(file, normalizeEol(source), "utf8");
4599
6157
  changed++;
4600
6158
  if (!opts.quiet) console.log(`mmi-cli rules: updated ${file}`);
@@ -4602,6 +6160,29 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
4602
6160
  }
4603
6161
  if (!opts.quiet && changed === 0) console.log("mmi-cli rules: up to date");
4604
6162
  });
6163
+ var docs = program2.command("docs").description("repo-owned authoritative docs");
6164
+ 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) => {
6165
+ const ref = await gitOut(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]);
6166
+ const def = (ref.startsWith("origin/") ? ref.slice("origin/".length) : ref) || "development";
6167
+ await gitOut(["fetch", "origin", def, "--quiet"]);
6168
+ const result = await syncDocs({
6169
+ isDirty: async (f) => await gitOut(["status", "--porcelain", "--", f]) !== "",
6170
+ originContent: async (f) => {
6171
+ try {
6172
+ return (await execFileP3("git", ["show", `origin/${def}:${f}`], { maxBuffer: 10 * 1024 * 1024 })).stdout;
6173
+ } catch {
6174
+ return null;
6175
+ }
6176
+ },
6177
+ localContent: async (f) => (0, import_node_fs4.existsSync)(f) ? await (0, import_promises.readFile)(f, "utf8") : null,
6178
+ writeDoc: async (f, c) => {
6179
+ await (0, import_promises.writeFile)(f, c, "utf8");
6180
+ }
6181
+ });
6182
+ for (const f of result.updated) console.log(`mmi-cli docs: updated ${f} (from origin/${def})`);
6183
+ if (!opts.quiet && result.skippedDirty.length) console.log(`mmi-cli docs: kept local edits in ${result.skippedDirty.join(", ")}`);
6184
+ if (!opts.quiet && result.updated.length === 0 && result.skippedDirty.length === 0) console.log("mmi-cli docs: up to date");
6185
+ });
4605
6186
  var saga = program2.command("saga").description("per-session continuity");
4606
6187
  async function runNote(summary, o) {
4607
6188
  const [sha, key] = await Promise.all([gitOut(["rev-parse", "--short", "HEAD"]), sagaKey(await loadConfig())]);
@@ -4620,7 +6201,10 @@ saga.command("show").option("--quiet", "no-op silently when unconfigured/unreach
4620
6201
  const key = await sagaKey(cfg);
4621
6202
  const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
4622
6203
  const res = await fetch(`${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
4623
- if (res.ok) return console.log(await res.text());
6204
+ if (res.ok) {
6205
+ console.log(resumeCue());
6206
+ return console.log(await res.text());
6207
+ }
4624
6208
  if (!opts.quiet) console.log(`saga show failed: HTTP ${res.status}`);
4625
6209
  } catch (e) {
4626
6210
  if (!opts.quiet) console.error(`saga show: ${e.message}`);
@@ -4719,11 +6303,28 @@ program2.command("gc").description("dry-run cleanup for merged/closed PR branche
4719
6303
  fail(`gc: ${e.message}`);
4720
6304
  }
4721
6305
  });
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}`);
6306
+ var kb = program2.command("kb").description("org knowledgebase (read-only)");
6307
+ kb.command("get <path>").description("print a KB document by path").action(async (path) => {
6308
+ const src = resolveKbSource((await loadConfig()).kbSource);
6309
+ try {
6310
+ const { stdout } = await execFileP3("gh", buildKbGetArgs(src, path), { timeout: 1e4 });
6311
+ process.stdout.write(stdout);
6312
+ } catch (e) {
6313
+ const err = e;
6314
+ fail(`kb get failed: ${(err.stderr || err.message || String(e)).trim()}`);
6315
+ }
6316
+ });
6317
+ kb.command("list [prefix]").description("list KB document paths (optionally under a prefix)").action(async (prefix) => {
6318
+ const src = resolveKbSource((await loadConfig()).kbSource);
6319
+ try {
6320
+ const { stdout } = await execFileP3("gh", buildKbTreeArgs(src), { timeout: 1e4 });
6321
+ const paths = parseKbTree(stdout, prefix);
6322
+ if (!paths.length) return fail(`kb list: no documents${prefix ? ` under ${prefix}` : ""}`);
6323
+ console.log(paths.join("\n"));
6324
+ } catch (e) {
6325
+ const err = e;
6326
+ fail(`kb list failed: ${(err.stderr || err.message || String(e)).trim()}`);
6327
+ }
4727
6328
  });
4728
6329
  async function ghCreate(args) {
4729
6330
  try {
@@ -4734,7 +6335,7 @@ async function ghCreate(args) {
4734
6335
  fail(`gh ${args[0]} create failed: ${(err.stderr || err.message || String(e)).trim()}`);
4735
6336
  }
4736
6337
  }
4737
- async function ghJson2(args, timeout = 1e4) {
6338
+ async function ghJson3(args, timeout = 1e4) {
4738
6339
  const { stdout } = await execFileP3("gh", args, { timeout });
4739
6340
  return JSON.parse(stdout);
4740
6341
  }
@@ -4747,6 +6348,47 @@ async function resolveRepo(repo) {
4747
6348
  return void 0;
4748
6349
  }
4749
6350
  }
6351
+ async function attachToProject(issueNumber, repo, priority) {
6352
+ const cfg = await loadConfigOrDiscover();
6353
+ if (!cfg.projectId) return void 0;
6354
+ if (repo) {
6355
+ const skip = boardAttachSkipReason(await resolveRepo(), repo);
6356
+ if (skip) {
6357
+ process.stderr.write(`warning: issue #${issueNumber} NOT added to this board \u2014 ${skip}
6358
+ `);
6359
+ return void 0;
6360
+ }
6361
+ }
6362
+ try {
6363
+ const viewArgs = ["issue", "view", String(issueNumber), "--json", "id", "--jq", ".id"];
6364
+ if (repo) viewArgs.push("--repo", repo);
6365
+ const { stdout: idOut } = await execFileP3("gh", viewArgs, { timeout: 1e4 });
6366
+ const contentId = idOut.trim();
6367
+ if (!contentId) throw new Error("could not resolve issue node id");
6368
+ const { stdout } = await execFileP3("gh", buildAddToProjectArgs(cfg.projectId, contentId), { timeout: 1e4 });
6369
+ const projectItemId = parseAddedItemId(stdout);
6370
+ if (projectItemId && priority) {
6371
+ try {
6372
+ await setBoardItemPriority(
6373
+ async (args) => execFileP3("gh", args, { timeout: 1e4 }),
6374
+ cfg,
6375
+ projectItemId,
6376
+ priority
6377
+ );
6378
+ } catch (e) {
6379
+ const err = e;
6380
+ process.stderr.write(`warning: issue #${issueNumber} board Priority not set: ${(err.stderr || err.message || String(e)).trim()}
6381
+ `);
6382
+ }
6383
+ }
6384
+ return projectItemId;
6385
+ } catch (e) {
6386
+ const err = e;
6387
+ process.stderr.write(`warning: issue #${issueNumber} created but NOT added to the project board: ${(err.stderr || err.message || String(e)).trim()}
6388
+ `);
6389
+ return void 0;
6390
+ }
6391
+ }
4750
6392
  function scheduleRelatedDiscovery(o) {
4751
6393
  try {
4752
6394
  const args = ["issue", "discover-related", "--number", String(o.number), "--title", o.title, "--body", o.body];
@@ -4761,7 +6403,7 @@ function scheduleRelatedDiscovery(o) {
4761
6403
  }
4762
6404
  }
4763
6405
  function makePlanDeps(cfg) {
4764
- const ensureDir = () => (0, import_node_fs3.mkdirSync)(PLANS_DIR, { recursive: true });
6406
+ const ensureDir = () => (0, import_node_fs4.mkdirSync)(PLANS_DIR, { recursive: true });
4765
6407
  return {
4766
6408
  apiUrl: cfg.sagaApiUrl,
4767
6409
  fetch: (url, init) => fetch(url, init),
@@ -4769,31 +6411,31 @@ function makePlanDeps(cfg) {
4769
6411
  project: async () => (await sagaKey(cfg)).project,
4770
6412
  readLocal: (slug) => {
4771
6413
  try {
4772
- return (0, import_node_fs3.readFileSync)(planPath(slug), "utf8");
6414
+ return (0, import_node_fs4.readFileSync)(planPath(slug), "utf8");
4773
6415
  } catch {
4774
6416
  return null;
4775
6417
  }
4776
6418
  },
4777
6419
  writeLocal: (slug, content) => {
4778
6420
  ensureDir();
4779
- (0, import_node_fs3.writeFileSync)(planPath(slug), content, "utf8");
6421
+ (0, import_node_fs4.writeFileSync)(planPath(slug), content, "utf8");
4780
6422
  },
4781
6423
  removeLocal: (slug) => {
4782
6424
  try {
4783
- (0, import_node_fs3.rmSync)(planPath(slug));
6425
+ (0, import_node_fs4.rmSync)(planPath(slug));
4784
6426
  } catch {
4785
6427
  }
4786
6428
  },
4787
6429
  readMetaRaw: () => {
4788
6430
  try {
4789
- return (0, import_node_fs3.readFileSync)(META_FILE, "utf8");
6431
+ return (0, import_node_fs4.readFileSync)(META_FILE, "utf8");
4790
6432
  } catch {
4791
6433
  return null;
4792
6434
  }
4793
6435
  },
4794
6436
  writeMetaRaw: (raw) => {
4795
6437
  ensureDir();
4796
- (0, import_node_fs3.writeFileSync)(META_FILE, raw, "utf8");
6438
+ (0, import_node_fs4.writeFileSync)(META_FILE, raw, "utf8");
4797
6439
  },
4798
6440
  log: (m) => console.log(m),
4799
6441
  err: (m) => console.error(m),
@@ -4820,28 +6462,221 @@ async function withPlan(quiet, run) {
4820
6462
  }
4821
6463
  await run(makePlanDeps(cfg));
4822
6464
  }
4823
- var plan = program2.command("plan").description("your cross-device plans/SSOTs (S3-backed, git-clean)");
4824
- plan.command("push <slug>").description("push a local plan (plans/<slug>.md) to the server").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action((slug, o) => withPlan(false, (d) => planPush(d, slug, o)));
4825
- plan.command("pull <slug>").description("pull a plan from the server into plans/<slug>.md").option("--project <name>", "override the project key").option("--force", "overwrite local even if it has unpushed edits").action((slug, o) => withPlan(false, (d) => planPull(d, slug, o)));
4826
- plan.command("list").description("list your plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
4827
- plan.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
4828
- (slug, o) => withPlan(false, async (d) => {
4829
- await planPull(d, slug, { project: o.project });
4830
- openInEditor(planPath(slug));
4831
- })
4832
- );
4833
- 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)));
6465
+ function registerNorthStarCommands(cmd) {
6466
+ cmd.command("push <slug>").description("push a local North Star plan (plans/<slug>.md) to the server").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action((slug, o) => withPlan(false, async (d) => {
6467
+ await planPush(d, slug, o);
6468
+ }));
6469
+ cmd.command("pull <slug>").description("pull a North Star plan from the server into plans/<slug>.md").option("--project <name>", "override the project key").option("--force", "overwrite local even if it has unpushed edits").action((slug, o) => withPlan(false, (d) => planPull(d, slug, o)));
6470
+ cmd.command("list").description("list your North Star plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
6471
+ cmd.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
6472
+ (slug, o) => withPlan(false, async (d) => {
6473
+ await planPull(d, slug, { project: o.project });
6474
+ openInEditor(planPath(slug));
6475
+ })
6476
+ );
6477
+ cmd.command("delete <slug>").description("delete a North Star 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)));
6478
+ cmd.command("graduate <slug>").description("mark a built-and-merged North Star plan as org-visible and push it").requiredOption("--merged-pr <url|number>", "merged PR URL or number proving the plan shipped").option("--org-visible", "confirm this plan is safe to queue for org KB curation").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action(
6479
+ (slug, o) => withPlan(false, (d) => planGraduate(d, slug, o))
6480
+ );
6481
+ }
6482
+ var northstar = program2.command("northstar").description("North Star \u2014 your cross-device plans/SSOTs (S3-backed, git-clean)");
6483
+ registerNorthStarCommands(northstar);
6484
+ var plan = program2.command("plan").description("Alias for `northstar` (kept for compatibility)");
6485
+ registerNorthStarCommands(plan);
6486
+ async function readSecretStdin() {
6487
+ if (process.stdin.isTTY) {
6488
+ process.stderr.write(
6489
+ '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'
6490
+ );
6491
+ return "";
6492
+ }
6493
+ const chunks = [];
6494
+ for await (const chunk of process.stdin) chunks.push(chunk);
6495
+ return Buffer.concat(chunks).toString("utf8").replace(/\r?\n$/, "");
6496
+ }
6497
+ function makeSecretsDeps(cfg) {
6498
+ return {
6499
+ apiUrl: cfg.sagaApiUrl,
6500
+ fetch: (url, init) => fetch(url, init),
6501
+ headers: (extra) => sagaHeaders(extra),
6502
+ slug: async () => (await sagaKey(cfg)).project,
6503
+ readSecretValue: () => readSecretStdin(),
6504
+ log: (m) => console.log(m),
6505
+ err: (m) => console.error(m)
6506
+ };
6507
+ }
6508
+ async function withSecrets(run) {
6509
+ const cfg = await loadConfig();
6510
+ if (!cfg.sagaApiUrl) {
6511
+ fail("secrets: sagaApiUrl not configured in .mmi/config.json (this repo is not bootstrapped)");
6512
+ return;
6513
+ }
6514
+ await run(makeSecretsDeps(cfg));
6515
+ }
6516
+ var secrets = program2.command("secrets").description("two-tier project secrets \u2014 self-serve your repo dev/* tier; org tier is master-gated");
6517
+ secrets.command("where").description("print where this repo\u2019s secrets live \u2014 the two-tier vault layout + well-known keys (no values)").option("--repo <owner/repo>", "target repo (defaults to the current repo)").action((o) => withSecrets((d) => secretsWhere(d, o)));
6518
+ 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)));
6519
+ 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)));
6520
+ 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)));
6521
+ 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)));
6522
+ 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)));
6523
+ 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)));
6524
+ 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, {})));
6525
+ 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, {})));
6526
+ function registryClientDeps(cfg) {
6527
+ return { baseUrl: cfg.sagaApiUrl, token: githubToken };
6528
+ }
6529
+ function slugOf(repoOrSlug) {
6530
+ return (repoOrSlug.includes("/") ? repoOrSlug.split("/").pop() : repoOrSlug).toLowerCase();
6531
+ }
6532
+ function reportWrite(label, res) {
6533
+ if (res.ok) {
6534
+ console.log(JSON.stringify(res.body));
6535
+ return;
6536
+ }
6537
+ if (res.error) return fail(`${label}: ${res.error}`);
6538
+ const detail = res.body?.error ?? "";
6539
+ fail(`${label}: HTTP ${res.status}${detail ? ` \u2014 ${detail}` : ""}`);
6540
+ }
6541
+ var project = program2.command("project").description("the DDB org registry \u2014 list/get projects (any member); set is master-only");
6542
+ project.command("list").description("list all projects (identity + board, never deploy coords)").option("--json", "machine-readable output").action(async (o) => {
6543
+ const cfg = await loadConfig();
6544
+ const projects = await fetchProjectsList(registryClientDeps(cfg));
6545
+ if (!projects) return fail("project list: Hub API unreachable or this repo is not bootstrapped");
6546
+ if (o.json) {
6547
+ console.log(JSON.stringify(projects));
6548
+ return;
6549
+ }
6550
+ for (const p of projects) {
6551
+ console.log(`${p.slug ?? "?"} - ${p.name ?? ""}${p.division ? ` [${p.division}]` : ""}${p.class ? ` (${p.class})` : ""}`);
6552
+ }
6553
+ });
6554
+ project.command("get <owner/repo>").description("a project's META (board ids + pointers) by repo or slug \u2014 identity, NOT deploy coords").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
6555
+ const cfg = await loadConfig();
6556
+ const meta = await fetchProjectBySlug(slugOf(repoOrSlug), registryClientDeps(cfg));
6557
+ if (!meta) return fail(`project get: no registry META for ${repoOrSlug} (unknown, unbootstrapped, or Hub unreachable)`);
6558
+ console.log(JSON.stringify(meta));
6559
+ });
6560
+ project.command("resolve <owner/repo>").description("deploy coords for a stage \u2014 for diagnosis. NOTE: /deploy-coords is OIDC-gated (a deploy job\u2019s id-token), so a gh-token CLI cannot read it from a dev machine").option("--stage <main|rc>", "deploy stage", "main").option("--json", "machine-readable output").action((_repoOrRepo, o) => {
6561
+ const msg = "project resolve: deploy coords are served only to a deploy workflow (GitHub OIDC id-token, repo-scoped). A gh-token CLI on a dev machine cannot read /deploy-coords; inspect the DEPLOY# item via the AWS console / a master DDB read instead.";
6562
+ if (o.json) {
6563
+ console.log(JSON.stringify({ ok: false, stage: o.stage, error: msg }));
6564
+ process.exitCode = 1;
6565
+ return;
6566
+ }
6567
+ fail(msg);
6568
+ });
6569
+ project.command("set <owner/repo>").description("MASTER-ONLY: upsert a project META (idempotent merge; no clobber of unspecified fields)").option("--class <class>", "deployable | content").option("--var <KEY=VALUE...>", "META field to set (repeatable): name, division, projectId, branch, wikiRepo, vaultPath, kbPointer").option("--json", "machine-readable output").action(async (repoOrSlug, o) => {
6570
+ const cfg = await loadConfig();
6571
+ const slug = slugOf(repoOrSlug);
6572
+ const patch = {};
6573
+ if (o.class) {
6574
+ if (o.class !== "deployable" && o.class !== "content") return fail("project set: --class must be deployable or content");
6575
+ patch.class = o.class;
6576
+ }
6577
+ for (let i = 0; i < process.argv.length - 1; i++) {
6578
+ if (process.argv[i] === "--var") {
6579
+ const eq = process.argv[i + 1].indexOf("=");
6580
+ if (eq > 0) patch[process.argv[i + 1].slice(0, eq)] = process.argv[i + 1].slice(eq + 1);
6581
+ }
6582
+ }
6583
+ if (Object.keys(patch).length === 0) return fail("project set: nothing to set \u2014 pass --class and/or --var KEY=VALUE");
6584
+ const res = await upsertProject(slug, patch, registryClientDeps(cfg));
6585
+ reportWrite("project set", res);
6586
+ });
6587
+ var registry = program2.command("registry").description("the DDB org registry \u2014 org-level constants");
6588
+ registry.command("org").description("the org config (account id, region, orgProjectId, sagaApiUrl)").option("--json", "machine-readable output").action(async (_o) => {
6589
+ const cfg = await loadConfig();
6590
+ const org = await fetchOrgConfig(registryClientDeps(cfg));
6591
+ if (!org) return fail("registry org: Hub API unreachable, unseeded, or this repo is not bootstrapped");
6592
+ console.log(JSON.stringify(org));
6593
+ });
6594
+ var oauth = program2.command("oauth").description("per-repo Google OAuth \u2014 plan the canonical URI set, verify the client is port-agnostic");
6595
+ oauth.command("plan", { isDefault: true }).description("print the canonical JS origins + redirect URIs + SSM cred param names for this repo").option("--repo <owner/repo>", "slug source (defaults to the current repo)").option("--json", "machine-readable output").action(async (o) => {
6596
+ const cfg = await loadConfig();
6597
+ const slug = (o.repo ? o.repo.split("/").pop() : cfg.project ?? await repoSlug()).toLowerCase();
6598
+ const meta = await fetchProjectBySlug(slug, registryClientDeps(cfg));
6599
+ let oc;
6600
+ try {
6601
+ oc = parseOauthConfig(meta ?? {}, slug);
6602
+ } catch (e) {
6603
+ return fail(`oauth plan: ${e.message}`);
6604
+ }
6605
+ const origins = expectedJsOrigins(oc);
6606
+ const redirects = expectedRedirectUris(oc);
6607
+ const ssm = oauthSsmKeys();
6608
+ if (o.json) {
6609
+ console.log(JSON.stringify({ slug, oauth: oc, jsOrigins: origins, redirectUris: redirects, ssmKeys: ssm }, null, 2));
6610
+ return;
6611
+ }
6612
+ console.log(`OAuth plan for ${slug} (callback ${oc.callbackPath}):
6613
+ `);
6614
+ console.log("Authorized JavaScript origins:");
6615
+ origins.forEach((u) => console.log(` ${u}`));
6616
+ console.log("\nAuthorized redirect URIs:");
6617
+ redirects.forEach((u) => console.log(` ${u}`));
6618
+ console.log(`
6619
+ SSM cred params (under /mmi-future/${slug}/):`);
6620
+ ssm.forEach((k) => console.log(` ${k}`));
6621
+ console.log("\nProvision/repair the Console client per docs/Guides/oauth-provision.md; creds via `mmi-cli secrets set`.");
6622
+ });
6623
+ oauth.command("verify").description("probe Google authorize with an arbitrary port (:9123) to confirm the client is port-agnostic").option("--repo <owner/repo>", "target repo (defaults to the current repo)").option("--client-id <id>", "OAuth client_id (else read dev/GOOGLE_CLIENT_ID from SSM)").option("--json", "machine-readable output").action(async (o) => {
6624
+ const cfg = await loadConfig();
6625
+ const slug = (o.repo ? o.repo.split("/").pop() : cfg.project ?? await repoSlug()).toLowerCase();
6626
+ const meta = await fetchProjectBySlug(slug, registryClientDeps(cfg));
6627
+ let oc;
6628
+ try {
6629
+ oc = parseOauthConfig(meta ?? {}, slug);
6630
+ } catch (e) {
6631
+ return fail(`oauth verify: ${e.message}`);
6632
+ }
6633
+ let clientId = o.clientId;
6634
+ if (!clientId) {
6635
+ await withSecrets(async (d) => {
6636
+ clientId = await fetchSecretValue(d, "dev/GOOGLE_CLIENT_ID", { repo: o.repo }) ?? void 0;
6637
+ });
6638
+ }
6639
+ if (!clientId) {
6640
+ return fail("oauth verify: no client_id (pass --client-id, or provision the repo so dev/GOOGLE_CLIENT_ID exists)");
6641
+ }
6642
+ const redirectUri = probeRedirectUri(oc.callbackPath);
6643
+ let body = "";
6644
+ try {
6645
+ const res = await fetch(buildAuthorizeProbeUrl(clientId, redirectUri), { redirect: "follow" });
6646
+ body = await res.text();
6647
+ } catch (e) {
6648
+ return fail(`oauth verify: probe request failed: ${e.message}`);
6649
+ }
6650
+ const mismatch = authorizeBodyHasMismatch(body);
6651
+ if (o.json) {
6652
+ console.log(JSON.stringify({ slug, redirectUri, portAgnostic: !mismatch }));
6653
+ } else if (mismatch) {
6654
+ console.error(`FAIL ${slug}: redirect_uri_mismatch for ${redirectUri} \u2014 client is not port-agnostic (run /oauth-provision)`);
6655
+ } else {
6656
+ console.log(`PASS ${slug}: ${redirectUri} accepted \u2014 port-agnostic OAuth is live`);
6657
+ }
6658
+ if (mismatch) process.exitCode = 1;
6659
+ });
4834
6660
  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) => {
6661
+ 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
6662
  let args;
4837
6663
  try {
4838
- args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo });
6664
+ args = buildIssueArgs({ type: o.type, title: o.title, body: o.body, priority: o.priority, repo: o.repo, labels: o.label });
4839
6665
  } catch (e) {
4840
6666
  return fail(`issue create: ${e.message}`);
4841
6667
  }
6668
+ for (const label of o.label ?? []) {
6669
+ const la = ["label", "create", label, "--color", "ededed"];
6670
+ if (o.repo) la.push("--repo", o.repo);
6671
+ try {
6672
+ await execFileP3("gh", la, { timeout: 1e4 });
6673
+ } catch {
6674
+ }
6675
+ }
4842
6676
  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 }));
6677
+ const projectItemId = await attachToProject(created.number, o.repo, o.priority);
6678
+ if (o.related !== false) scheduleRelatedDiscovery({ repo: o.repo, number: created.number, title: o.title, body: o.body });
6679
+ console.log(JSON.stringify({ ...created, label: o.type, priority: o.priority, projectItemId }));
4845
6680
  });
4846
6681
  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
6682
  const number = Number(o.number);
@@ -4849,7 +6684,7 @@ issue.command("discover-related").description("find related issues for an existi
4849
6684
  const repo = await resolveRepo(o.repo);
4850
6685
  if (!repo) return fail("issue discover-related: could not resolve repo");
4851
6686
  try {
4852
- const issues = await ghJson2([
6687
+ const issues = await ghJson3([
4853
6688
  "issue",
4854
6689
  "list",
4855
6690
  "--repo",
@@ -4864,7 +6699,7 @@ issue.command("discover-related").description("find related issues for an existi
4864
6699
  const candidates = findRelatedIssues({ number, title: o.title, body: o.body }, issues);
4865
6700
  if (o.json) return console.log(JSON.stringify({ number, repo, candidates }, null, 2));
4866
6701
  if (!candidates.length) return;
4867
- const viewed = await ghJson2([
6702
+ const viewed = await ghJson3([
4868
6703
  "issue",
4869
6704
  "view",
4870
6705
  String(number),
@@ -4883,10 +6718,20 @@ pr.command("create").description("create a PR and print {number,url} JSON").requ
4883
6718
  const created = await ghCreate(buildPrArgs({ title: o.title, body: o.body, base: o.base, head: o.head, repo: o.repo }));
4884
6719
  console.log(JSON.stringify(created));
4885
6720
  });
6721
+ 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) => {
6722
+ const method = o.rebase ? "--rebase" : o.merge ? "--merge" : "--squash";
6723
+ const repoArgs = o.repo ? ["--repo", o.repo] : [];
6724
+ const headRef = (await execFileP3("gh", ["pr", "view", number, ...repoArgs, "--json", "headRefName", "--jq", ".headRefName"], { timeout: GC_GH_TIMEOUT_MS })).stdout.trim();
6725
+ await execFileP3("gh", ["pr", "merge", number, ...repoArgs, method, "--delete-branch"], { timeout: GC_GH_TIMEOUT_MS }).catch((e) => {
6726
+ if (!/used by worktree|cannot delete branch|already been merged/i.test(String(e.message || ""))) throw e;
6727
+ });
6728
+ const cleaned = repoArgs.length ? { branchDeleted: false } : await cleanupLocalBranch(headRef);
6729
+ console.log(JSON.stringify({ merged: number, branch: headRef, method: method.slice(2), ...cleaned }));
6730
+ });
4886
6731
  async function runBoardRead(o) {
4887
6732
  try {
4888
6733
  const report = await readBoard({
4889
- config: await loadConfig(),
6734
+ config: await loadConfigOrDiscover(),
4890
6735
  repo: o.repo,
4891
6736
  includeBundleDetails: o.bundleDetails,
4892
6737
  allowPartial: o.allowPartial
@@ -4896,17 +6741,69 @@ async function runBoardRead(o) {
4896
6741
  fail(`board read failed: ${e.message}`);
4897
6742
  }
4898
6743
  }
4899
- var board = program2.command("board").description("read and claim Project v2 work items for the current repo");
6744
+ var board = program2.command("board").description("read, claim, show, and move Project v2 work items for the current repo");
4900
6745
  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) => {
6746
+ 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
6747
  try {
4903
- const result = await claimBoardIssue({ config: await loadConfig(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
6748
+ const result = await claimBoardIssue({
6749
+ config: await loadConfigOrDiscover(),
6750
+ selector: issueRef,
6751
+ repo: o.repo,
6752
+ assignee: o.for,
6753
+ allowPartial: o.allowPartial
6754
+ });
4904
6755
  if (o.json) return console.log(JSON.stringify(result));
4905
6756
  console.log(result.partial ? `Partially claimed ${result.item.ref}: ${result.warning}` : `Claimed ${result.item.ref} - In Progress`);
4906
6757
  } catch (e) {
4907
6758
  fail(`board claim failed: ${e.message}`);
4908
6759
  }
4909
6760
  });
6761
+ 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) => {
6762
+ try {
6763
+ const item = await showBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
6764
+ console.log(o.json ? JSON.stringify(item) : renderBoardItem(item));
6765
+ } catch (e) {
6766
+ fail(`board show failed: ${e.message}`);
6767
+ }
6768
+ });
6769
+ 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) => {
6770
+ if (!BOARD_STATUSES.includes(status)) {
6771
+ return fail(`board move failed: unknown status '${status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
6772
+ }
6773
+ try {
6774
+ const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status, repo: o.repo, allowPartial: o.allowPartial });
6775
+ if (o.json) return console.log(JSON.stringify(result));
6776
+ console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> ${result.status}`);
6777
+ } catch (e) {
6778
+ fail(`board move failed: ${e.message}`);
6779
+ }
6780
+ });
6781
+ 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) => {
6782
+ try {
6783
+ const result = await backfillBoardPriorities({
6784
+ config: await loadConfigOrDiscover(),
6785
+ repo: o.repo,
6786
+ dryRun: o.dryRun,
6787
+ concurrency: Number(o.concurrency) || 8
6788
+ });
6789
+ if (o.json) return console.log(JSON.stringify(result));
6790
+ console.log(`backfill-priority: scanned ${result.scanned}, set ${result.set}, skipped ${result.skipped}, failed ${result.failed}`);
6791
+ for (const line of result.details.slice(0, 30)) console.log(` ${line}`);
6792
+ if (result.details.length > 30) console.log(` ... +${result.details.length - 30} more`);
6793
+ if (result.failed) process.exitCode = 1;
6794
+ } catch (e) {
6795
+ fail(`board backfill-priority failed: ${e.message}`);
6796
+ }
6797
+ });
6798
+ 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) => {
6799
+ try {
6800
+ const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
6801
+ if (o.json) return console.log(JSON.stringify(result));
6802
+ console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> Done`);
6803
+ } catch (e) {
6804
+ fail(`board done failed: ${e.message}`);
6805
+ }
6806
+ });
4910
6807
  function renderSteps(title, steps) {
4911
6808
  return [
4912
6809
  title,
@@ -4921,12 +6818,23 @@ function rawValue(flag, fallback) {
4921
6818
  return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
4922
6819
  }
4923
6820
  function printLine(value) {
4924
- (0, import_node_fs3.writeSync)(1, `${value}
6821
+ (0, import_node_fs4.writeSync)(1, `${value}
4925
6822
  `);
4926
6823
  }
4927
6824
  function stageKeepAlive() {
4928
6825
  return setTimeout(() => void 0, 5 * 60 * 1e3);
4929
6826
  }
6827
+ program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block via the atomic ORG#config.portCursor allocator (committed-file fallback)").option("--json", "machine-readable output").action(async (repo, o) => {
6828
+ const path = (0, import_node_path4.join)(process.cwd(), "infra", "port-ranges.json");
6829
+ const allocate = async (seed) => {
6830
+ const { stdout } = await execFileP3("node", [(0, import_node_path4.join)(process.cwd(), "infra", "port-ddb.mjs"), String(seed)], { timeout: 15e3 });
6831
+ const parsed = JSON.parse(stdout);
6832
+ if (!Array.isArray(parsed.range) || parsed.range.length !== 2) throw new Error("port-ddb: no range in output");
6833
+ return parsed.range;
6834
+ };
6835
+ const { range: [start, end] } = await ensurePortRangeAtomic(repo, path, allocate);
6836
+ printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
6837
+ });
4930
6838
  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
6839
  const cfg = (await loadConfig()).stage;
4932
6840
  if (o.apply) {
@@ -5009,9 +6917,14 @@ stage.command("run").description("force-stop previous stage, build, start, and h
5009
6917
  fail(`stage run: ${e.message}`);
5010
6918
  }
5011
6919
  });
6920
+ program2.command("stage-live").description("explain that remote rc/live environments use /rcand, /release, and /hotfix; /stage is local only").option("--json", "machine-readable output").option("--apply", "always refused; there is no stage-live mutation path").action((o) => {
6921
+ if (o.apply) return fail("stage-live: not an org command; use mmi-cli stage for local tests, or the gated rc/release/hotfix train for remote environments");
6922
+ const steps = stageLivePlan();
6923
+ console.log(o.json ? JSON.stringify({ command: "stage-live", steps }, null, 2) : renderSteps("mmi-cli stage-live: not an org command", steps));
6924
+ });
5012
6925
  for (const commandName of ["rc", "release", "hotfix"]) {
5013
6926
  program2.command(commandName).description(`plan ${commandName} train operations; mutations require explicit approval`).option("--json", "machine-readable output").option("--apply", "reserved for future train execution after explicit admin approval").action((o) => {
5014
- if (o.apply) return fail(`${commandName}: execution is not implemented yet; use the dry-run plan and the existing /${commandName} skill`);
6927
+ if (o.apply) return fail(`${commandName}: execution is not implemented yet; use the dry-run plan and the existing /${commandName === "rc" ? "rcand" : commandName} skill`);
5015
6928
  const steps = trainPlan(commandName);
5016
6929
  console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
5017
6930
  });
@@ -5026,15 +6939,184 @@ var bootstrap = program2.command("bootstrap").description("plan repo bootstrap o
5026
6939
  bootstrap.command("verify <repo>").description("audit whether an existing repo is bootstrapped correctly; no mutations").option("--class <class>", "deployable | content", "deployable").option("--json", "machine-readable output").action(async (repo) => {
5027
6940
  const o = { class: rawValue("--class", "deployable"), json: rawFlag("--json") };
5028
6941
  if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap verify: --class must be deployable or content");
6942
+ const cfg = await loadConfig();
6943
+ const apiProjects = await fetchProjectsJson({ baseUrl: cfg.sagaApiUrl, token: githubToken });
5029
6944
  const report = await verifyBootstrap(repo, o.class, {
5030
6945
  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
6946
+ readLocalFile: (path) => path === "projects.json" && apiProjects != null ? apiProjects : (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.readFileSync)(path, "utf8") : null
5032
6947
  });
5033
6948
  console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
5034
6949
  if (!report.ok) process.exitCode = 1;
5035
6950
  });
6951
+ 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) => {
6952
+ const o = { class: rawValue("--class", "deployable"), execute: rawFlag("--execute"), json: rawFlag("--json") };
6953
+ if (o.class !== "deployable" && o.class !== "content") return fail("bootstrap apply: --class must be deployable or content");
6954
+ const manifestPath = "skills/bootstrap/seeds/manifest.json";
6955
+ if (!(0, import_node_fs4.existsSync)(manifestPath)) return fail(`bootstrap apply: ${manifestPath} not found; run from the MMI-Hub repo root`);
6956
+ const manifest = loadBootstrapSeeds((0, import_node_fs4.readFileSync)(manifestPath, "utf8"));
6957
+ const baseBranch = o.class === "content" ? "main" : "development";
6958
+ const slug = repo.split("/")[1].toLowerCase();
6959
+ const gh = async (args) => execFileP3("gh", args, { timeout: 2e4 });
6960
+ const readFile2 = (p) => (0, import_node_fs4.existsSync)(p) ? (0, import_node_fs4.readFileSync)(p, "utf8") : null;
6961
+ const enc = (p) => p.split("/").map(encodeURIComponent).join("/");
6962
+ const vars = {};
6963
+ for (let i = 0; i < process.argv.length - 1; i++) {
6964
+ if (process.argv[i] === "--var") {
6965
+ const eq = process.argv[i + 1].indexOf("=");
6966
+ if (eq > 0) vars[process.argv[i + 1].slice(0, eq)] = process.argv[i + 1].slice(eq + 1);
6967
+ }
6968
+ }
6969
+ const actions = [];
6970
+ const applied = [];
6971
+ for (const seed of manifest.seeds) {
6972
+ if (!seed.classes.includes(o.class)) continue;
6973
+ const resolved = { ...seed, target: seed.target.replace("{{REPO_SLUG}}", slug) };
6974
+ let exists = false;
6975
+ let sha;
6976
+ let remoteContent = null;
6977
+ if (resolved.source !== "fanout") {
6978
+ try {
6979
+ const r = await gh(["api", `repos/${repo}/contents/${enc(resolved.target)}?ref=${baseBranch}`]);
6980
+ exists = true;
6981
+ try {
6982
+ const parsed = JSON.parse(r.stdout);
6983
+ sha = parsed.sha;
6984
+ if (parsed.encoding === "base64" && typeof parsed.content === "string") {
6985
+ remoteContent = Buffer.from(parsed.content, "base64").toString("utf8");
6986
+ }
6987
+ } catch {
6988
+ }
6989
+ } catch {
6990
+ exists = false;
6991
+ }
6992
+ }
6993
+ const action = planSeedAction(resolved, exists);
6994
+ actions.push(action);
6995
+ if (o.execute && (action.action === "create" || action.action === "update")) {
6996
+ const isBlock = resolved.source === "managed-block";
6997
+ const content = isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2);
6998
+ if (content == null) {
6999
+ applied.push(`skip ${resolved.target} (no resolvable content)`);
7000
+ continue;
7001
+ }
7002
+ if (!isBlock) {
7003
+ const missing = missingPlaceholders(content);
7004
+ if (missing.length) {
7005
+ applied.push(`skip ${resolved.target} (unfilled: ${missing.join(", ")} \u2014 pass --var)`);
7006
+ continue;
7007
+ }
7008
+ }
7009
+ await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
7010
+ applied.push(`${action.action} ${resolved.target}`);
7011
+ }
7012
+ }
7013
+ if (o.execute) {
7014
+ for (const l of manifest.labels) {
7015
+ try {
7016
+ await gh(["label", "create", l.name, "--color", l.color, "--description", l.description, "--force", "-R", repo]);
7017
+ applied.push(`label ${l.name}`);
7018
+ } catch {
7019
+ applied.push(`label ${l.name} (failed)`);
7020
+ }
7021
+ }
7022
+ }
7023
+ const ddbWrites = [];
7024
+ const registerPayload = buildRegisterPayload(repo, o.class, vars);
7025
+ if (o.execute) {
7026
+ const cfg = await loadConfig();
7027
+ const res = await registerProject(registerPayload, { baseUrl: cfg.sagaApiUrl, token: githubToken });
7028
+ if (res.ok) {
7029
+ ddbWrites.push({ slug: registerPayload.slug, action: "register", record: registerPayload });
7030
+ applied.push(`ddb register ${registerPayload.slug}`);
7031
+ } else {
7032
+ const why = res.error ?? `HTTP ${res.status}${res.body?.error ? ` \u2014 ${res.body.error}` : ""}`;
7033
+ applied.push(`ddb register ${registerPayload.slug} (failed: ${why})`);
7034
+ }
7035
+ }
7036
+ if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, actions, applied, ddbWrites }, null, 2));
7037
+ else {
7038
+ console.log(renderSeedPlan(actions));
7039
+ if (o.execute) console.log(`
7040
+ LIVE apply to ${repo}:
7041
+ ${applied.join("\n ")}`);
7042
+ }
7043
+ });
7044
+ var access = program2.command("access").description("org access audit (read-only)");
7045
+ 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 () => {
7046
+ const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
7047
+ const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
7048
+ let targets;
7049
+ const cfg = await loadConfig();
7050
+ const registryProjects = await fetchProjectsList(registryClientDeps(cfg));
7051
+ if (o.repo) {
7052
+ if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
7053
+ targets = [{ repo: o.repo, class: o.class }];
7054
+ } else {
7055
+ const projectsJson = registryProjects ? JSON.stringify({ projects: registryProjects }) : (0, import_node_fs4.existsSync)("projects.json") ? (0, import_node_fs4.readFileSync)("projects.json", "utf8") : null;
7056
+ if (!projectsJson) return fail("access audit: no project registry \u2014 Hub API unreachable and projects.json not found; run from the MMI-Hub repo root or pass --repo <owner/repo>");
7057
+ const fanoutJson = (0, import_node_fs4.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs4.readFileSync)(".github/fanout-targets.json", "utf8") : null;
7058
+ targets = loadAccessTargets(projectsJson, fanoutJson);
7059
+ }
7060
+ const derivedMatrix = registryProjects ? accessMatrixFromProjects(registryProjects) : {};
7061
+ const matrix = Object.keys(derivedMatrix).length ? derivedMatrix : (0, import_node_fs4.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs4.readFileSync)("access-matrix.json", "utf8")) : {};
7062
+ const derivedContracts = registryProjects ? dataAccessContractsFromProjects(registryProjects) : { consumers: {} };
7063
+ const dataAccess = Object.keys(derivedContracts.consumers).length ? derivedContracts : (0, import_node_fs4.existsSync)("data-access-contracts.json") ? loadDataAccessContracts((0, import_node_fs4.readFileSync)("data-access-contracts.json", "utf8")) : void 0;
7064
+ const report = await auditOrgAccess(targets, deps, matrix, dataAccess);
7065
+ console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
7066
+ if (!report.ok) process.exitCode = 1;
7067
+ });
5036
7068
  var isWin = process.platform === "win32";
5037
- 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) => {
7069
+ var installedPluginsPath = () => (0, import_node_path4.join)((0, import_node_os.homedir)(), ".claude", "plugins", "installed_plugins.json");
7070
+ function readInstalledPlugins() {
7071
+ try {
7072
+ return JSON.parse((0, import_node_fs4.readFileSync)(installedPluginsPath(), "utf8"));
7073
+ } catch {
7074
+ return null;
7075
+ }
7076
+ }
7077
+ function readClaudeSettings() {
7078
+ try {
7079
+ return JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path4.join)(process.cwd(), ".claude", "settings.json"), "utf8"));
7080
+ } catch {
7081
+ return null;
7082
+ }
7083
+ }
7084
+ function existingMirrorRecord(file) {
7085
+ const records = file?.plugins?.[MMI_PLUGIN_ID];
7086
+ if (!Array.isArray(records) || records.length === 0) return void 0;
7087
+ return records.find((r) => r.scope === "user") ?? records[0];
7088
+ }
7089
+ function writeProjectInstallRecord(record) {
7090
+ try {
7091
+ const file = readInstalledPlugins() ?? { version: 2, plugins: {} };
7092
+ if (!file.plugins) file.plugins = {};
7093
+ const list = file.plugins[MMI_PLUGIN_ID] ?? [];
7094
+ list.push(record);
7095
+ file.plugins[MMI_PLUGIN_ID] = list;
7096
+ (0, import_node_fs4.writeFileSync)(installedPluginsPath(), `${JSON.stringify(file, null, 2)}
7097
+ `, "utf8");
7098
+ return true;
7099
+ } catch {
7100
+ return false;
7101
+ }
7102
+ }
7103
+ var gitignorePath = () => (0, import_node_path4.join)(process.cwd(), ".gitignore");
7104
+ function readGitignore() {
7105
+ try {
7106
+ return (0, import_node_fs4.readFileSync)(gitignorePath(), "utf8");
7107
+ } catch {
7108
+ return null;
7109
+ }
7110
+ }
7111
+ function writeGitignore(content) {
7112
+ try {
7113
+ (0, import_node_fs4.writeFileSync)(gitignorePath(), content, "utf8");
7114
+ return true;
7115
+ } catch {
7116
+ return false;
7117
+ }
7118
+ }
7119
+ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone, plugin install record, .gitignore managed block) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--json", "machine-readable output").action(async (opts) => {
5038
7120
  const checks = [];
5039
7121
  const login = await githubLogin();
5040
7122
  let ghInstalled = true;
@@ -5054,16 +7136,19 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
5054
7136
  }
5055
7137
  if (!onPath) {
5056
7138
  const root = process.env.CLAUDE_PLUGIN_ROOT;
5057
- if (root && (0, import_node_fs3.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
7139
+ if (root && (0, import_node_fs4.existsSync)(`${root}/bin/mmi-cli${isWin ? ".cmd" : ""}`)) onPath = true;
5058
7140
  }
5059
7141
  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({
7142
+ let versionReport = buildVersionLagReport({
5061
7143
  currentVersion: resolveVersion(),
5062
7144
  repoVersion: readRepoVersion(),
5063
7145
  releasedVersion: await fetchReleasedVersion()
5064
- }));
7146
+ });
7147
+ if (!opts.json) versionReport = await applyVersionAutoUpdate(versionReport, (m) => console.error(m));
7148
+ checks.push(versionReport);
5065
7149
  const cfg = await loadConfig();
5066
7150
  checks.push({ ok: Boolean(cfg.sagaApiUrl), label: "repo config (.mmi/config.json)", fix: "ask a master-admin to run /bootstrap on this repo" });
7151
+ checks.push(buildAwsCrossAccountCheck({ callerArn: await awsCallerArn() }));
5067
7152
  const REWRITE_KEY = "url.https://github.com/.insteadOf";
5068
7153
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
5069
7154
  let cloneOk = false;
@@ -5081,6 +7166,29 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
5081
7166
  }
5082
7167
  }
5083
7168
  checks.push({ ok: cloneOk, label: "plugin git clone (SSH\u2192HTTPS rewrite)", fix: CLONE_FIX });
7169
+ const installed = readInstalledPlugins();
7170
+ let pluginCheck = buildPluginInstallRecordCheck({
7171
+ isOrgRepo: Boolean(cfg.sagaApiUrl),
7172
+ settings: readClaudeSettings(),
7173
+ installed,
7174
+ projectPath: process.cwd(),
7175
+ mirrorFrom: existingMirrorRecord(installed)
7176
+ });
7177
+ if (!pluginCheck.ok && pluginCheck.recordToInsert && !opts.json) {
7178
+ if (writeProjectInstallRecord(pluginCheck.recordToInsert)) {
7179
+ pluginCheck = { ...pluginCheck, ok: true };
7180
+ if (!opts.banner) console.error(" \u21BB repaired: registered mmi@mmi project install record \u2014 run /reload-plugins to load it this session");
7181
+ }
7182
+ }
7183
+ checks.push(pluginCheck);
7184
+ let gitignoreCheck = buildGitignoreManagedBlockCheck({ isOrgRepo: Boolean(cfg.sagaApiUrl), content: readGitignore() });
7185
+ if (!gitignoreCheck.ok && gitignoreCheck.contentToWrite && !opts.json) {
7186
+ if (writeGitignore(gitignoreCheck.contentToWrite)) {
7187
+ gitignoreCheck = { ...gitignoreCheck, ok: true };
7188
+ if (!opts.banner) console.error(" \u21BB repaired: enforced .gitignore managed block (.playwright-mcp/, .claude/worktrees/, /*.png)");
7189
+ }
7190
+ }
7191
+ checks.push(gitignoreCheck);
5084
7192
  const gaps = checks.filter((c) => !c.ok);
5085
7193
  if (opts.json) {
5086
7194
  console.log(JSON.stringify({ ok: gaps.length === 0, checks }, null, 2));