@mutmutco/cli 0.11.0 → 2.0.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 +23 -7
  2. package/dist/index.cjs +1024 -128
  3. package/package.json +2 -2
package/dist/index.cjs CHANGED
@@ -3064,6 +3064,10 @@ 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
+ }
3067
3071
 
3068
3072
  // src/docs-sync.ts
3069
3073
  var SYNCED_DOCS = ["README.md", "architecture.md"];
@@ -3100,6 +3104,7 @@ function parseHookInput(stdin) {
3100
3104
  var import_node_child_process4 = require("node:child_process");
3101
3105
  var import_node_util3 = require("node:util");
3102
3106
  var import_node_path4 = require("node:path");
3107
+ var import_node_os = require("node:os");
3103
3108
 
3104
3109
  // src/saga-head-maintainer.ts
3105
3110
  var import_node_child_process = require("node:child_process");
@@ -3235,7 +3240,6 @@ function buildIssueArgs({ type, title, body, priority, repo, labels }) {
3235
3240
  const args = ["issue", "create"];
3236
3241
  if (repo) args.push("--repo", repo);
3237
3242
  args.push("--title", title, "--body", body, "--label", type);
3238
- args.push("--label", `priority:${priority}`);
3239
3243
  for (const label of labels ?? []) args.push("--label", label);
3240
3244
  return args;
3241
3245
  }
@@ -3288,6 +3292,12 @@ function resolveSession(d) {
3288
3292
  return { id, source: "generated" };
3289
3293
  }
3290
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
+
3291
3301
  // src/saga-health.ts
3292
3302
  function buildHealth(i) {
3293
3303
  const problems = [];
@@ -3313,8 +3323,17 @@ function healthBanner(report) {
3313
3323
  const suffix = report.problems.length > 2 ? ` (+${report.problems.length - 2} more)` : "";
3314
3324
  return `saga health: CHECK - ${summary}${suffix}`;
3315
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
+ }
3316
3329
 
3317
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
+ }
3318
3337
  function buildNoteCapture(summary, o, id, evidence) {
3319
3338
  const queueOp = o.queueAdd ? { op: "add", text: o.queueAdd } : o.queueDone != null ? { op: "done", index: Number(o.queueDone) } : void 0;
3320
3339
  const state = o.diagnostic ? "diagnostic" : o.verified ? "verified" : "asserted";
@@ -3335,7 +3354,7 @@ function buildNoteCapture(summary, o, id, evidence) {
3335
3354
  state,
3336
3355
  source,
3337
3356
  evidence: Object.keys(ev).length ? ev : void 0,
3338
- surface: process.env.MMI_AGENT_SURFACE || "claude",
3357
+ surface: agentSurface(),
3339
3358
  supersedes: o.supersedes,
3340
3359
  anchor,
3341
3360
  anchorForce: o.anchorForce || void 0
@@ -3392,6 +3411,16 @@ function buildVersionLagReport(input) {
3392
3411
  releasedVersion: input.releasedVersion
3393
3412
  };
3394
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
+ }
3395
3424
  function versionAutoUpdateAction(report, hasPluginRoot) {
3396
3425
  if (report.ok || report.staleAgainst !== "released") return "none";
3397
3426
  return hasPluginRoot ? "plugin-pull" : "npm";
@@ -3603,7 +3632,7 @@ function parseIssueSelector(selector, defaultRepo) {
3603
3632
  if (local) return { repo: defaultRepo, number: Number(local[1]) };
3604
3633
  throw new Error(`expected an issue selector like 123, #123, owner/repo#123, or a GitHub issue URL`);
3605
3634
  }
3606
- function partitionBoardItems(items, viewer, currentRepo) {
3635
+ function partitionBoardItems(items, viewer, currentRepo, writableRepos) {
3607
3636
  const empty = () => ({ userOwned: [], claimable: [], taken: [] });
3608
3637
  const groups = { primary: empty(), secondary: empty() };
3609
3638
  const viewerKey = viewer.toLowerCase();
@@ -3615,7 +3644,7 @@ function partitionBoardItems(items, viewer, currentRepo) {
3615
3644
  const assignedToViewer = assignees.includes(viewerKey);
3616
3645
  if (assignedToViewer) {
3617
3646
  groups[scope].userOwned.push(item);
3618
- } 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()))) {
3619
3648
  groups[scope].claimable.push(item);
3620
3649
  } else if (item.assignees.length > 0) {
3621
3650
  groups[scope].taken.push(item);
@@ -3695,12 +3724,12 @@ async function collectBoardItems(cfg, options, deps) {
3695
3724
  try {
3696
3725
  const page = await fetchProjectPage(gh, cfg, after);
3697
3726
  viewer ||= page.viewer.login;
3698
- const project = page.organization?.projectV2;
3699
- if (!project) throw new Error(`project ${cfg.projectOwner}#${cfg.projectNumber} not found`);
3700
- projectId = project.id;
3701
- projectTitle ||= project.title;
3702
- nodes.push(...project.items.nodes ?? []);
3703
- 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;
3704
3733
  } catch (e) {
3705
3734
  const message = `partial board read: ${e.message}`;
3706
3735
  if (!nodes.length || !options.allowPartial) throw new Error(message);
@@ -3711,11 +3740,46 @@ async function collectBoardItems(cfg, options, deps) {
3711
3740
  } while (after);
3712
3741
  return { items: nodesToItems(nodes, warnings), viewer, repo: currentRepo, projectId, projectTitle, warnings, partial };
3713
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
+ }
3714
3775
  async function readBoard(options, deps = {}) {
3715
3776
  const cfg = resolveBoardConfig(options.config);
3716
3777
  const gh = deps.gh ?? defaultGh;
3717
3778
  const collected = await collectBoardItems(cfg, options, deps);
3718
- const groups = partitionBoardItems(collected.items, collected.viewer, collected.repo);
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);
3719
3783
  const report = {
3720
3784
  project: { owner: cfg.projectOwner, number: cfg.projectNumber, id: collected.projectId, title: collected.projectTitle || String(cfg.projectNumber) },
3721
3785
  viewer: collected.viewer,
@@ -3799,8 +3863,23 @@ async function showBoardItem(options, deps = {}) {
3799
3863
  async function claimBoardIssue(options, deps = {}) {
3800
3864
  const cfg = resolveBoardConfig(options.config);
3801
3865
  const gh = deps.gh ?? defaultGh;
3802
- const report = await readBoard({ config: cfg, repo: options.repo, allowPartial: options.allowPartial }, deps);
3803
- 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
+ }
3804
3883
  const item = findClaimableItem(report, selector);
3805
3884
  if (item.contentType !== "Issue") throw new Error(`${item.ref} is not an issue`);
3806
3885
  const assignee = options.assignee ?? "@me";
@@ -4206,6 +4285,12 @@ function stagePlan(stage2 = {}) {
4206
4285
  { label: "check health", command: stage2.healthUrl ? `curl --fail ${stage2.healthUrl}` : "(no stage.healthUrl configured)" }
4207
4286
  ];
4208
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
+ }
4209
4294
  function trainPlan(command) {
4210
4295
  if (command === "rc") {
4211
4296
  return [
@@ -4244,8 +4329,83 @@ function bootstrapPlan(repo, repoClass) {
4244
4329
  ];
4245
4330
  }
4246
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
+
4247
4406
  // src/doctor.ts
4248
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";
4249
4409
  function buildGithubAuthCheck(input) {
4250
4410
  const ok = Boolean(input.login?.trim());
4251
4411
  return {
@@ -4254,6 +4414,62 @@ function buildGithubAuthCheck(input) {
4254
4414
  fix: input.ghInstalled ? GH_PROJECT_LOGIN_FIX : `install GitHub CLI (https://cli.github.com), then: ${GH_PROJECT_LOGIN_FIX.replace(/^run: /, "")}`
4255
4415
  };
4256
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
+ }
4257
4473
 
4258
4474
  // src/stage-runner.ts
4259
4475
  var import_node_child_process3 = require("node:child_process");
@@ -4417,8 +4633,8 @@ var import_node_fs3 = require("node:fs");
4417
4633
  var BLOCK = 100;
4418
4634
  var SPAN = 10;
4419
4635
  var FIRST = 3e3;
4420
- function nextPortBlock(registry) {
4421
- const bases = Object.values(registry).map(([start]) => start);
4636
+ function nextPortBlock(registry2) {
4637
+ const bases = Object.values(registry2).map(([start]) => start);
4422
4638
  const base = bases.length ? Math.max(...bases) + BLOCK : FIRST;
4423
4639
  return [base, base + SPAN];
4424
4640
  }
@@ -4434,20 +4650,42 @@ function loadPortRegistry(path) {
4434
4650
  return out;
4435
4651
  }
4436
4652
  function ensurePortRange(repo, path) {
4437
- const registry = loadPortRegistry(path);
4438
- const existing = registry[repo];
4653
+ const registry2 = loadPortRegistry(path);
4654
+ const existing = registry2[repo];
4439
4655
  if (existing) return existing;
4440
- const range = nextPortBlock(registry);
4656
+ const range = nextPortBlock(registry2);
4441
4657
  const raw = (0, import_node_fs3.existsSync)(path) ? JSON.parse((0, import_node_fs3.readFileSync)(path, "utf8")) : {};
4442
4658
  raw[repo] = range;
4443
4659
  (0, import_node_fs3.writeFileSync)(path, JSON.stringify(raw, null, 2) + "\n", "utf8");
4444
4660
  return range;
4445
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
+ }
4446
4681
 
4447
4682
  // src/access.ts
4448
4683
  var OWNER = "mutmutco";
4449
4684
  var LOCKED_APP = "mmi-github-app";
4450
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
+ };
4451
4689
  function lockedBranches(repoClass) {
4452
4690
  return repoClass === "content" ? ["main"] : ["development", "rc", "main"];
4453
4691
  }
@@ -4549,21 +4787,56 @@ async function auditTrainBranch(repo, branch, owners, deps, projectAdmins = /* @
4549
4787
  }
4550
4788
  return findings;
4551
4789
  }
4552
- async function auditRepoAccess(repo, repoClass, owners, deps, projectAdmins = /* @__PURE__ */ new Set()) {
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) {
4553
4809
  const findings = [];
4554
4810
  findings.push(...await auditRepoCollaborators(repo, owners, deps));
4811
+ if (dataAccess) findings.push(...auditDataAccessContracts(repo, dataAccess));
4555
4812
  for (const branch of lockedBranches(repoClass)) {
4556
4813
  findings.push(...await auditTrainBranch(repo, branch, owners, deps, projectAdmins));
4557
4814
  }
4558
4815
  return { repo, class: repoClass, ok: !findings.some((f) => f.severity === "high"), findings };
4559
4816
  }
4560
- async function auditOrgAccess(targets, deps, matrix = {}) {
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) {
4561
4832
  const owners = new Set(await resolveOwners(deps));
4833
+ const orgFindings = await auditOrgBasePermission(deps);
4562
4834
  const repos = [];
4563
4835
  for (const target of targets) {
4564
- repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? [])));
4836
+ repos.push(await auditRepoAccess(target.repo, target.class, owners, deps, new Set(matrix[target.repo] ?? []), dataAccess));
4565
4837
  }
4566
- return { ok: repos.every((r) => r.ok), owners: [...owners], repos };
4838
+ const ok = orgFindings.every((f) => f.severity !== "high") && repos.every((r) => r.ok);
4839
+ return { ok, owners: [...owners], orgFindings, repos };
4567
4840
  }
4568
4841
  function loadAccessTargets(projectsJson, fanoutJson) {
4569
4842
  const projects = safeJson(projectsJson, {}).projects ?? [];
@@ -4571,8 +4844,8 @@ function loadAccessTargets(projectsJson, fanoutJson) {
4571
4844
  const contentNames = new Set(fanout.filter((r) => r.class === "content").map((r) => r.repo));
4572
4845
  const seen = /* @__PURE__ */ new Set();
4573
4846
  const targets = [];
4574
- for (const project of projects) {
4575
- for (const repo of project.repos ?? []) {
4847
+ for (const project2 of projects) {
4848
+ for (const repo of project2.repos ?? []) {
4576
4849
  if (seen.has(repo)) continue;
4577
4850
  seen.add(repo);
4578
4851
  targets.push({ repo, class: contentNames.has(repo.split("/")[1]) ? "content" : "deployable" });
@@ -4584,8 +4857,37 @@ function loadAccessMatrix(matrixJson) {
4584
4857
  if (!matrixJson) return {};
4585
4858
  return safeJson(matrixJson, {}).projectAdmins ?? {};
4586
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
+ }
4587
4885
  function renderAccessReport(report) {
4588
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
+ }
4589
4891
  for (const repo of report.repos) {
4590
4892
  lines.push(`${repo.ok ? "OK" : "FLAG"} ${repo.repo} (${repo.class})`);
4591
4893
  for (const finding of repo.findings) {
@@ -4604,19 +4906,19 @@ var requiredIssueTemplates = [
4604
4906
  ".github/ISSUE_TEMPLATE/task.yml",
4605
4907
  ".github/ISSUE_TEMPLATE/config.yml"
4606
4908
  ];
4607
- var requiredWorkflows = [".github/workflows/pr-to-board.yml"];
4909
+ var requiredWorkflows = [];
4608
4910
  var requiredLabels = ["bug", "feature", "task", "priority:urgent", "priority:high", "priority:medium", "priority:low"];
4609
4911
  var requiredPriorityOptions = ["Urgent", "High", "Medium", "Low"];
4610
4912
  var strayDefaultLabels = ["documentation", "duplicate", "enhancement", "good first issue", "help wanted", "invalid", "question", "wontfix"];
4611
4913
  var requiredStatusOptions = ["Todo", "In Progress", "In Review", "Done"];
4612
4914
  var requiredProjectWorkflows = [
4613
4915
  "Auto-add sub-issues to project",
4614
- "Auto-close issue",
4916
+ "Auto-archive items",
4615
4917
  "Item added to project",
4616
- "Item closed",
4617
- "Pull request linked to issue",
4618
- "Pull request merged"
4918
+ "Item closed"
4619
4919
  ];
4920
+ var requiredOrgRulesetTypes = ["pull_request", "non_fast_forward", "deletion"];
4921
+ var requiredHubStatusChecks = ["cli", "infra", "docs"];
4620
4922
  var requiredActionsVariables = ["MMI_APP_ID"];
4621
4923
  var requiredActionsSecrets = ["MMI_APP_PRIVATE_KEY"];
4622
4924
  function expectedBranches(repoClass) {
@@ -4672,6 +4974,27 @@ function hasPushAllowlist(p) {
4672
4974
  function optionDetail(missing) {
4673
4975
  return missing.length === 0 ? void 0 : `missing: ${missing.join(", ")}`;
4674
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
+ }
4675
4998
  function localRegistryCheck(deps, path, predicate) {
4676
4999
  const text = deps.readLocalFile?.(path);
4677
5000
  if (text == null) return null;
@@ -4724,7 +5047,7 @@ async function verifyBootstrap(repo, repoClass, deps) {
4724
5047
  checks.push({ ok: labelNames.has(label), label: `label exists: ${label}` });
4725
5048
  }
4726
5049
  const strays = strayDefaultLabels.filter((l) => labelNames.has(l));
4727
- checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: optionDetail(strays) });
5050
+ checks.push({ ok: strays.length === 0, label: "no stray GitHub-default labels", detail: presentDetail(strays) });
4728
5051
  const actions = await ghJson2(deps, ["api", `repos/${repo}/actions/permissions`], {});
4729
5052
  checks.push({ ok: actions.enabled === true, label: "GitHub Actions enabled" });
4730
5053
  const variables = await ghJson2(deps, ["variable", "list", "--repo", repo, "--json", "name"], []);
@@ -4743,12 +5066,12 @@ async function verifyBootstrap(repo, repoClass, deps) {
4743
5066
  label: ".mmi project board config exists"
4744
5067
  });
4745
5068
  if (config?.projectOwner && config.projectNumber != null) {
4746
- const project = await ghJson2(
5069
+ const project2 = await ghJson2(
4747
5070
  deps,
4748
5071
  ["project", "field-list", String(config.projectNumber), "--owner", config.projectOwner, "--format", "json"],
4749
5072
  {}
4750
5073
  );
4751
- const fields = project.fields || [];
5074
+ const fields = project2.fields || [];
4752
5075
  const statusField = fields.find((field) => field.name === "Status");
4753
5076
  const labelField = fields.find((field) => field.name === "Labels");
4754
5077
  checks.push({
@@ -4822,19 +5145,31 @@ async function verifyBootstrap(repo, repoClass, deps) {
4822
5145
  if (fanout != null) checks.push({ ok: fanout, label: `fanout target registered on ${baseBranch}` });
4823
5146
  const projectRegistry = localRegistryCheck(deps, "projects.json", (json) => Array.isArray(json?.projects) && json.projects.some((p) => (p.repos || []).includes(repo)));
4824
5147
  if (projectRegistry != null) checks.push({ ok: projectRegistry, label: "cloud-agent project registry includes repo" });
4825
- const rulesets = await ghJson2(
5148
+ const rulesetList = await ghJson2(
4826
5149
  deps,
4827
5150
  ["api", `repos/${repo}/rulesets?includes_parents=true`],
4828
5151
  []
4829
5152
  );
4830
- const orgRuleset = rulesets.some(
5153
+ const rulesets = await rulesetDetails(deps, repo, rulesetList);
5154
+ const activeOrgRulesets = rulesets.filter(
4831
5155
  (r) => r.source_type === "Organization" && r.target === "branch" && r.enforcement === "active"
4832
5156
  );
5157
+ const orgRuleset = activeOrgRulesets.find((ruleset) => missingRuleTypes(ruleset, requiredOrgRulesetTypes).length === 0);
5158
+ const missingOrgRuleTypes = activeOrgRulesets.length === 0 ? requiredOrgRulesetTypes : missingRuleTypes(activeOrgRulesets[0], requiredOrgRulesetTypes);
4833
5159
  checks.push({
4834
- ok: orgRuleset,
5160
+ ok: Boolean(orgRuleset),
4835
5161
  label: "covered by an active org ruleset",
4836
- detail: orgRuleset ? void 0 : "no active Organization-sourced branch ruleset targets this repo"
5162
+ detail: orgRuleset ? void 0 : activeOrgRulesets.length === 0 ? "no active Organization-sourced branch ruleset targets this repo" : `missing rule types: ${missingOrgRuleTypes.join(", ")}`
4837
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
+ }
4838
5173
  const waived = applyWaivers(checks, config?.verifyWaivers ?? []);
4839
5174
  return { ok: waived.every((c) => c.ok || c.waived), repo, class: repoClass, baseBranch, checks: waived };
4840
5175
  }
@@ -4852,45 +5187,14 @@ function renderBootstrapVerifyReport(report) {
4852
5187
  return lines.join("\n");
4853
5188
  }
4854
5189
 
4855
- // src/bootstrap-seeds.ts
4856
- var PLACEHOLDER_RE = /\{\{([A-Z0-9_]+)\}\}/g;
4857
- function loadBootstrapSeeds(manifestJson) {
4858
- let parsed;
4859
- try {
4860
- parsed = JSON.parse(manifestJson);
4861
- } catch {
4862
- throw new Error("bootstrap seed manifest is not valid JSON");
4863
- }
4864
- const obj = parsed ?? {};
4865
- const seeds = obj.seeds ?? [];
4866
- for (const s of seeds) {
4867
- if (!s || !s.target || !s.source || !Array.isArray(s.classes)) {
4868
- throw new Error(`invalid seed entry (needs target, source, classes): ${JSON.stringify(s)}`);
4869
- }
4870
- if (s.ownership !== "org" && s.ownership !== "repo") {
4871
- throw new Error(`invalid seed ownership '${s.ownership}' for ${s.target} (must be 'org' or 'repo')`);
4872
- }
4873
- }
4874
- return {
4875
- seeds,
4876
- labels: obj.labels ?? [],
4877
- placeholders: obj.placeholders ?? []
4878
- };
4879
- }
4880
- function renderSeed(template, vars) {
4881
- return template.replace(PLACEHOLDER_RE, (match, key) => key in vars ? vars[key] : match);
4882
- }
4883
- function missingPlaceholders(rendered) {
4884
- const out = /* @__PURE__ */ new Set();
4885
- for (const m of rendered.matchAll(PLACEHOLDER_RE)) out.add(m[1]);
4886
- return [...out];
4887
- }
4888
-
4889
5190
  // src/bootstrap-apply.ts
4890
5191
  function planSeedAction(seed, exists) {
4891
5192
  if (seed.source === "fanout") {
4892
5193
  return { target: seed.target, action: "skip", ownership: "fanout", reason: "delivered by the fanout pipeline" };
4893
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
+ }
4894
5198
  if (seed.ownership === "repo") {
4895
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" };
4896
5200
  }
@@ -4913,6 +5217,49 @@ function resolveSeedContent(seed, vars, readFile2) {
4913
5217
  }
4914
5218
  return null;
4915
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
+ }
4916
5263
  function contentPutArgs(repo, path, content, branch, sha) {
4917
5264
  const args = [
4918
5265
  "api",
@@ -4930,6 +5277,93 @@ function contentPutArgs(repo, path, content, branch, sha) {
4930
5277
  return args;
4931
5278
  }
4932
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
+
4933
5367
  // src/kb.ts
4934
5368
  var DEFAULT_KB = { owner: "mutmutco", repo: "MM-KB", ref: "main" };
4935
5369
  function resolveKbSource(rawBase) {
@@ -4961,7 +5395,7 @@ var import_node_path3 = require("node:path");
4961
5395
  var PLANS_DIR = "plans";
4962
5396
  var META_FILE = (0, import_node_path3.join)(PLANS_DIR, ".plan-meta.json");
4963
5397
  var planPath = (slug) => (0, import_node_path3.join)(PLANS_DIR, `${slug}.md`);
4964
- var metaKey = (project, slug) => `${project}/${slug}`;
5398
+ var metaKey = (project2, slug) => `${project2}/${slug}`;
4965
5399
  function parseMeta(raw) {
4966
5400
  if (!raw) return {};
4967
5401
  try {
@@ -4983,23 +5417,46 @@ function hashContent(s) {
4983
5417
  return (h >>> 0).toString(16);
4984
5418
  }
4985
5419
  function staleHint(slug) {
4986
- return `remote "${slug}" is newer \u2014 run \`mmi-cli plan pull ${slug}\` first (your local is based on an older version), or re-push with \`--force\` to overwrite`;
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`;
4987
5421
  }
4988
5422
  function formatPlanList(plans) {
4989
5423
  return plans.map((p) => `${p.slug} \xB7 ${p.updatedAt ?? "-"} \xB7 ${p.project}`).join("\n");
4990
5424
  }
4991
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
+ }
4992
5449
  async function planPush(deps, slug, opts = {}) {
4993
5450
  const raw = deps.readLocal(slug);
4994
5451
  if (raw == null) {
4995
5452
  deps.err(`no local ${planPath(slug)} to push`);
4996
- return;
5453
+ return false;
4997
5454
  }
4998
5455
  const content = normalizeEol(raw);
4999
- const project = opts.project ?? await deps.project();
5456
+ const project2 = opts.project ?? await deps.project();
5000
5457
  const meta = parseMeta(deps.readMetaRaw());
5001
- const entry = meta[metaKey(project, slug)];
5002
- const body = { project, slug, content };
5458
+ const entry = meta[metaKey(project2, slug)];
5459
+ const body = { project: project2, slug, content };
5003
5460
  if (opts.force) body.force = true;
5004
5461
  else if (entry?.etag) body.baseEtag = entry.etag;
5005
5462
  const res = await deps.fetch(`${deps.apiUrl}/plan/put`, {
@@ -5010,25 +5467,28 @@ async function planPush(deps, slug, opts = {}) {
5010
5467
  });
5011
5468
  if (res.ok) {
5012
5469
  const out = await res.json();
5013
- 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() };
5014
5471
  deps.writeMetaRaw(serializeMeta(meta));
5015
5472
  deps.log(`pushed ${slug}`);
5473
+ return true;
5016
5474
  } else if (res.status === 409) {
5017
5475
  deps.err(staleHint(slug));
5476
+ return false;
5018
5477
  } else {
5019
5478
  deps.err(`plan push failed: HTTP ${res.status}`);
5479
+ return false;
5020
5480
  }
5021
5481
  }
5022
5482
  async function planPull(deps, slug, opts = {}) {
5023
- const project = opts.project ?? await deps.project();
5483
+ const project2 = opts.project ?? await deps.project();
5024
5484
  const meta = parseMeta(deps.readMetaRaw());
5025
- const entry = meta[metaKey(project, slug)];
5485
+ const entry = meta[metaKey(project2, slug)];
5026
5486
  const local = deps.readLocal(slug);
5027
5487
  if (local != null && entry && !opts.force && hashContent(normalizeEol(local)) !== entry.hash) {
5028
5488
  deps.err(`local ${planPath(slug)} has unpushed edits \u2014 push it, or pull with --force to overwrite`);
5029
5489
  return;
5030
5490
  }
5031
- const qs = new URLSearchParams({ project, slug }).toString();
5491
+ const qs = new URLSearchParams({ project: project2, slug }).toString();
5032
5492
  const res = await deps.fetch(`${deps.apiUrl}/plan/get?${qs}`, {
5033
5493
  method: "GET",
5034
5494
  headers: await deps.headers(),
@@ -5045,7 +5505,7 @@ async function planPull(deps, slug, opts = {}) {
5045
5505
  const doc = await res.json();
5046
5506
  const content = normalizeEol(doc.content ?? "");
5047
5507
  deps.writeLocal(slug, content);
5048
- 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() };
5049
5509
  deps.writeMetaRaw(serializeMeta(meta));
5050
5510
  deps.log(`pulled ${slug} \u2192 ${planPath(slug)}`);
5051
5511
  }
@@ -5078,11 +5538,11 @@ async function planList(deps, opts = {}) {
5078
5538
  deps.log(formatPlanList(plans));
5079
5539
  }
5080
5540
  async function planDelete(deps, slug, opts = {}) {
5081
- const project = opts.project ?? await deps.project();
5541
+ const project2 = opts.project ?? await deps.project();
5082
5542
  const res = await deps.fetch(`${deps.apiUrl}/plan/delete`, {
5083
5543
  method: "POST",
5084
5544
  headers: await deps.headers({ "content-type": "application/json" }),
5085
- body: JSON.stringify({ project, slug }),
5545
+ body: JSON.stringify({ project: project2, slug }),
5086
5546
  signal: AbortSignal.timeout(TIMEOUT_MS)
5087
5547
  });
5088
5548
  if (!res.ok) {
@@ -5091,10 +5551,29 @@ async function planDelete(deps, slug, opts = {}) {
5091
5551
  }
5092
5552
  deps.removeLocal(slug);
5093
5553
  const meta = parseMeta(deps.readMetaRaw());
5094
- delete meta[metaKey(project, slug)];
5554
+ delete meta[metaKey(project2, slug)];
5095
5555
  deps.writeMetaRaw(serializeMeta(meta));
5096
5556
  deps.log(`deleted ${slug}`);
5097
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
+ }
5098
5577
 
5099
5578
  // src/secrets.ts
5100
5579
  var OWNER2 = "mutmutco";
@@ -5120,11 +5599,47 @@ function formatSecretList(items) {
5120
5599
  const width = Math.max(...items.map((i) => i.key.length));
5121
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.");
5122
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
+ }
5123
5634
  var TIMEOUT_MS2 = 8e3;
5124
5635
  var repoOf = (slug) => `${OWNER2}/${slug}`;
5125
5636
  async function targetRepo(deps, opts) {
5126
5637
  return opts.repo ?? repoOf(await deps.slug());
5127
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
+ }
5128
5643
  async function readErr(res) {
5129
5644
  try {
5130
5645
  const j = await res.json();
@@ -5133,6 +5648,23 @@ async function readErr(res) {
5133
5648
  return "";
5134
5649
  }
5135
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
+ }
5136
5668
  async function secretsList(deps, opts) {
5137
5669
  const repo = await targetRepo(deps, opts);
5138
5670
  const qs = new URLSearchParams({ repo }).toString();
@@ -5261,6 +5793,69 @@ async function secretsUse(deps, key, _opts) {
5261
5793
  );
5262
5794
  }
5263
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);
5857
+ }
5858
+
5264
5859
  // src/index.ts
5265
5860
  var rawExecFileP2 = (0, import_node_util3.promisify)(import_node_child_process4.execFile);
5266
5861
  var execFileP3 = (file, args, options = {}) => (
@@ -5288,16 +5883,58 @@ async function githubLogin() {
5288
5883
  return void 0;
5289
5884
  }
5290
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
+ }
5291
5898
  async function sagaHeaders(extra = {}) {
5292
5899
  const t = await githubToken();
5293
5900
  return t ? { ...extra, Authorization: `Bearer ${t}` } : extra;
5294
5901
  }
5295
5902
  async function loadConfig() {
5903
+ let file = {};
5296
5904
  try {
5297
- 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"));
5298
5906
  } catch {
5299
- return {};
5300
- }
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;
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();
5301
5938
  }
5302
5939
  var DEFAULT_RULES_SOURCE = "https://raw.githubusercontent.com/mutmutco/MMI-Hub/development";
5303
5940
  var SESSION_FILE = ".mmi/.session";
@@ -5458,11 +6095,8 @@ function readRepoVersion() {
5458
6095
  }
5459
6096
  async function fetchReleasedVersion() {
5460
6097
  try {
5461
- const res = await fetch("https://raw.githubusercontent.com/mutmutco/MMI-Hub/main/.claude-plugin/plugin.json", {
5462
- signal: AbortSignal.timeout(5e3)
5463
- });
5464
- if (!res.ok) return void 0;
5465
- return (await res.json()).version;
6098
+ const { stdout } = await execFileP3("gh", pluginManifestVersionArgs(), { timeout: 5e3 });
6099
+ return parseManifestVersion(stdout);
5466
6100
  } catch {
5467
6101
  return void 0;
5468
6102
  }
@@ -5501,7 +6135,7 @@ rules.command("sync").option("--quiet", "stay silent unless something changed or
5501
6135
  if (!opts.quiet) console.log('mmi-cli rules: source repo (orgRulesSource: "self") \u2014 skipping self-sync');
5502
6136
  return;
5503
6137
  }
5504
- const base = (cfg.orgRulesSource ?? DEFAULT_RULES_SOURCE).replace(/\/$/, "");
6138
+ const base = resolveRulesBase(cfg.orgRulesSource, DEFAULT_RULES_SOURCE);
5505
6139
  const token = await githubToken();
5506
6140
  let changed = 0;
5507
6141
  for (const file of ["AGENTS.md", "CLAUDE.md", ".claude/settings.json"]) {
@@ -5567,7 +6201,10 @@ saga.command("show").option("--quiet", "no-op silently when unconfigured/unreach
5567
6201
  const key = await sagaKey(cfg);
5568
6202
  const qs = opts.latestAnywhere ? "scope=anywhere" : new URLSearchParams({ project: key.project, branch: key.branch }).toString();
5569
6203
  const res = await fetch(`${cfg.sagaApiUrl}/saga/head?${qs}`, { headers: await sagaHeaders(), signal: AbortSignal.timeout(8e3) });
5570
- 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
+ }
5571
6208
  if (!opts.quiet) console.log(`saga show failed: HTTP ${res.status}`);
5572
6209
  } catch (e) {
5573
6210
  if (!opts.quiet) console.error(`saga show: ${e.message}`);
@@ -5712,7 +6349,7 @@ async function resolveRepo(repo) {
5712
6349
  }
5713
6350
  }
5714
6351
  async function attachToProject(issueNumber, repo, priority) {
5715
- const cfg = await loadConfig();
6352
+ const cfg = await loadConfigOrDiscover();
5716
6353
  if (!cfg.projectId) return void 0;
5717
6354
  if (repo) {
5718
6355
  const skip = boardAttachSkipReason(await resolveRepo(), repo);
@@ -5825,17 +6462,27 @@ async function withPlan(quiet, run) {
5825
6462
  }
5826
6463
  await run(makePlanDeps(cfg));
5827
6464
  }
5828
- var plan = program2.command("plan").description("your cross-device plans/SSOTs (S3-backed, git-clean)");
5829
- plan.command("push <slug>").description("push a local plan (plans/<slug>.md) to the server").option("--project <name>", "override the project key").option("--force", "overwrite the remote even if it changed since your last sync").action((slug, o) => withPlan(false, (d) => planPush(d, slug, o)));
5830
- plan.command("pull <slug>").description("pull a plan from the server into plans/<slug>.md").option("--project <name>", "override the project key").option("--force", "overwrite local even if it has unpushed edits").action((slug, o) => withPlan(false, (d) => planPull(d, slug, o)));
5831
- plan.command("list").description("list your plans (cross-device)").option("--project <name>", "filter by project").option("--json", "machine-readable output").option("--quiet", "silent when unconfigured/empty/unreachable (SessionStart hook)").action((o) => withPlan(o.quiet ?? false, (d) => planList(d, o)));
5832
- plan.command("open <slug>").description("pull if needed, then open plans/<slug>.md in $EDITOR").option("--project <name>", "override the project key").action(
5833
- (slug, o) => withPlan(false, async (d) => {
5834
- await planPull(d, slug, { project: o.project });
5835
- openInEditor(planPath(slug));
5836
- })
5837
- );
5838
- plan.command("delete <slug>").description("delete a plan from the server and the local copy").option("--project <name>", "override the project key").action((slug, o) => withPlan(false, (d) => planDelete(d, slug, o)));
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);
5839
6486
  async function readSecretStdin() {
5840
6487
  if (process.stdin.isTTY) {
5841
6488
  process.stderr.write(
@@ -5867,6 +6514,7 @@ async function withSecrets(run) {
5867
6514
  await run(makeSecretsDeps(cfg));
5868
6515
  }
5869
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)));
5870
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)));
5871
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)));
5872
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)));
@@ -5875,6 +6523,140 @@ secrets.command("rm <key>").description("remove a secret (project tier self-serv
5875
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)));
5876
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, {})));
5877
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
+ });
5878
6660
  var issue = program2.command("issue").description("issues \u2014 reliable create with structured output");
5879
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) => {
5880
6662
  let args;
@@ -5949,7 +6731,7 @@ pr.command("merge <number>").description("merge a PR (squash by default) and cle
5949
6731
  async function runBoardRead(o) {
5950
6732
  try {
5951
6733
  const report = await readBoard({
5952
- config: await loadConfig(),
6734
+ config: await loadConfigOrDiscover(),
5953
6735
  repo: o.repo,
5954
6736
  includeBundleDetails: o.bundleDetails,
5955
6737
  allowPartial: o.allowPartial
@@ -5964,7 +6746,7 @@ board.command("read", { isDefault: true }).description("read the board and print
5964
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) => {
5965
6747
  try {
5966
6748
  const result = await claimBoardIssue({
5967
- config: await loadConfig(),
6749
+ config: await loadConfigOrDiscover(),
5968
6750
  selector: issueRef,
5969
6751
  repo: o.repo,
5970
6752
  assignee: o.for,
@@ -5978,7 +6760,7 @@ board.command("claim <issue>").description("assign a Todo issue and move its Pro
5978
6760
  });
5979
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) => {
5980
6762
  try {
5981
- const item = await showBoardItem({ config: await loadConfig(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
6763
+ const item = await showBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, repo: o.repo, allowPartial: o.allowPartial });
5982
6764
  console.log(o.json ? JSON.stringify(item) : renderBoardItem(item));
5983
6765
  } catch (e) {
5984
6766
  fail(`board show failed: ${e.message}`);
@@ -5989,7 +6771,7 @@ board.command("move <issue> <status>").description(`move a board item's Status t
5989
6771
  return fail(`board move failed: unknown status '${status}'; expected one of ${BOARD_STATUSES.join(", ")}`);
5990
6772
  }
5991
6773
  try {
5992
- const result = await moveBoardItem({ config: await loadConfig(), selector: issueRef, status, repo: o.repo, allowPartial: o.allowPartial });
6774
+ const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status, repo: o.repo, allowPartial: o.allowPartial });
5993
6775
  if (o.json) return console.log(JSON.stringify(result));
5994
6776
  console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> ${result.status}`);
5995
6777
  } catch (e) {
@@ -5999,7 +6781,7 @@ board.command("move <issue> <status>").description(`move a board item's Status t
5999
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) => {
6000
6782
  try {
6001
6783
  const result = await backfillBoardPriorities({
6002
- config: await loadConfig(),
6784
+ config: await loadConfigOrDiscover(),
6003
6785
  repo: o.repo,
6004
6786
  dryRun: o.dryRun,
6005
6787
  concurrency: Number(o.concurrency) || 8
@@ -6015,7 +6797,7 @@ board.command("backfill-priority").description("set board Priority from priority
6015
6797
  });
6016
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) => {
6017
6799
  try {
6018
- const result = await moveBoardItem({ config: await loadConfig(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
6800
+ const result = await moveBoardItem({ config: await loadConfigOrDiscover(), selector: issueRef, status: "Done", repo: o.repo, allowPartial: o.allowPartial });
6019
6801
  if (o.json) return console.log(JSON.stringify(result));
6020
6802
  console.log(result.partial ? `Partially moved ${result.item.ref}: ${result.warning}` : `Moved ${result.item.ref} -> Done`);
6021
6803
  } catch (e) {
@@ -6042,9 +6824,15 @@ function printLine(value) {
6042
6824
  function stageKeepAlive() {
6043
6825
  return setTimeout(() => void 0, 5 * 60 * 1e3);
6044
6826
  }
6045
- program2.command("port-range <repo>").description("assign (idempotently) + print the repo's local stage port block from infra/port-ranges.json").option("--json", "machine-readable output").action((repo, o) => {
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) => {
6046
6828
  const path = (0, import_node_path4.join)(process.cwd(), "infra", "port-ranges.json");
6047
- const [start, end] = ensurePortRange(repo, path);
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);
6048
6836
  printLine(o.json ? JSON.stringify({ repo, portRange: [start, end] }) : `${repo}: stage.portRange [${start}, ${end}]`);
6049
6837
  });
6050
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) => {
@@ -6129,9 +6917,14 @@ stage.command("run").description("force-stop previous stage, build, start, and h
6129
6917
  fail(`stage run: ${e.message}`);
6130
6918
  }
6131
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
+ });
6132
6925
  for (const commandName of ["rc", "release", "hotfix"]) {
6133
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) => {
6134
- 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`);
6135
6928
  const steps = trainPlan(commandName);
6136
6929
  console.log(o.json ? JSON.stringify({ command: commandName, steps }, null, 2) : renderSteps(`mmi-cli ${commandName}: dry-run plan`, steps));
6137
6930
  });
@@ -6146,9 +6939,11 @@ var bootstrap = program2.command("bootstrap").description("plan repo bootstrap o
6146
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) => {
6147
6940
  const o = { class: rawValue("--class", "deployable"), json: rawFlag("--json") };
6148
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 });
6149
6944
  const report = await verifyBootstrap(repo, o.class, {
6150
6945
  gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }),
6151
- readLocalFile: (path) => (0, import_node_fs4.existsSync)(path) ? (0, import_node_fs4.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
6152
6947
  });
6153
6948
  console.log(o.json ? JSON.stringify(report, null, 2) : renderBootstrapVerifyReport(report));
6154
6949
  if (!report.ok) process.exitCode = 1;
@@ -6178,12 +6973,17 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
6178
6973
  const resolved = { ...seed, target: seed.target.replace("{{REPO_SLUG}}", slug) };
6179
6974
  let exists = false;
6180
6975
  let sha;
6976
+ let remoteContent = null;
6181
6977
  if (resolved.source !== "fanout") {
6182
6978
  try {
6183
6979
  const r = await gh(["api", `repos/${repo}/contents/${enc(resolved.target)}?ref=${baseBranch}`]);
6184
6980
  exists = true;
6185
6981
  try {
6186
- sha = JSON.parse(r.stdout).sha;
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
+ }
6187
6987
  } catch {
6188
6988
  }
6189
6989
  } catch {
@@ -6193,15 +6993,18 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
6193
6993
  const action = planSeedAction(resolved, exists);
6194
6994
  actions.push(action);
6195
6995
  if (o.execute && (action.action === "create" || action.action === "update")) {
6196
- const content = resolveSeedContent(resolved, vars, readFile2);
6996
+ const isBlock = resolved.source === "managed-block";
6997
+ const content = isBlock ? upsertManagedGitignoreBlock(remoteContent).content : resolveSeedContent(resolved, vars, readFile2);
6197
6998
  if (content == null) {
6198
6999
  applied.push(`skip ${resolved.target} (no resolvable content)`);
6199
7000
  continue;
6200
7001
  }
6201
- const missing = missingPlaceholders(content);
6202
- if (missing.length) {
6203
- applied.push(`skip ${resolved.target} (unfilled: ${missing.join(", ")} \u2014 pass --var)`);
6204
- continue;
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
+ }
6205
7008
  }
6206
7009
  await gh(contentPutArgs(repo, resolved.target, content, baseBranch, action.action === "update" ? sha : void 0));
6207
7010
  applied.push(`${action.action} ${resolved.target}`);
@@ -6217,7 +7020,20 @@ bootstrap.command("apply <repo>").description("idempotent seed apply from skills
6217
7020
  }
6218
7021
  }
6219
7022
  }
6220
- if (o.json) console.log(JSON.stringify({ repo, class: o.class, execute: o.execute, actions, applied }, null, 2));
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));
6221
7037
  else {
6222
7038
  console.log(renderSeedPlan(actions));
6223
7039
  if (o.execute) console.log(`
@@ -6230,21 +7046,77 @@ access.command("audit").description("audit collaborator roles + train-branch pus
6230
7046
  const o = { json: rawFlag("--json"), repo: rawValue("--repo", ""), class: rawValue("--class", "deployable") };
6231
7047
  const deps = { gh: async (args) => execFileP3("gh", args, { timeout: 2e4 }) };
6232
7048
  let targets;
7049
+ const cfg = await loadConfig();
7050
+ const registryProjects = await fetchProjectsList(registryClientDeps(cfg));
6233
7051
  if (o.repo) {
6234
7052
  if (o.class !== "deployable" && o.class !== "content") return fail("access audit: --class must be deployable or content");
6235
7053
  targets = [{ repo: o.repo, class: o.class }];
6236
7054
  } else {
6237
- if (!(0, import_node_fs4.existsSync)("projects.json")) return fail("access audit: projects.json not found; run from the MMI-Hub repo root or pass --repo <owner/repo>");
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>");
6238
7057
  const fanoutJson = (0, import_node_fs4.existsSync)(".github/fanout-targets.json") ? (0, import_node_fs4.readFileSync)(".github/fanout-targets.json", "utf8") : null;
6239
- targets = loadAccessTargets((0, import_node_fs4.readFileSync)("projects.json", "utf8"), fanoutJson);
7058
+ targets = loadAccessTargets(projectsJson, fanoutJson);
6240
7059
  }
6241
- const matrix = (0, import_node_fs4.existsSync)("access-matrix.json") ? loadAccessMatrix((0, import_node_fs4.readFileSync)("access-matrix.json", "utf8")) : {};
6242
- const report = await auditOrgAccess(targets, deps, matrix);
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);
6243
7065
  console.log(o.json ? JSON.stringify(report, null, 2) : renderAccessReport(report));
6244
7066
  if (!report.ok) process.exitCode = 1;
6245
7067
  });
6246
7068
  var isWin = process.platform === "win32";
6247
- program2.command("doctor").description("check onboarding gates (GitHub auth, mmi-cli on PATH, repo config, plugin git clone) and print fixes").option("--banner", "one-line resume summary; silent when all gates pass").option("--json", "machine-readable output").action(async (opts) => {
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) => {
6248
7120
  const checks = [];
6249
7121
  const login = await githubLogin();
6250
7122
  let ghInstalled = true;
@@ -6276,6 +7148,7 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
6276
7148
  checks.push(versionReport);
6277
7149
  const cfg = await loadConfig();
6278
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() }));
6279
7152
  const REWRITE_KEY = "url.https://github.com/.insteadOf";
6280
7153
  const CLONE_FIX = 'run: git config --global url."https://github.com/".insteadOf "git@github.com:"';
6281
7154
  let cloneOk = false;
@@ -6293,6 +7166,29 @@ program2.command("doctor").description("check onboarding gates (GitHub auth, mmi
6293
7166
  }
6294
7167
  }
6295
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);
6296
7192
  const gaps = checks.filter((c) => !c.ok);
6297
7193
  if (opts.json) {
6298
7194
  console.log(JSON.stringify({ ok: gaps.length === 0, checks }, null, 2));