@bensandee/tooling 0.90.0 → 0.91.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.
package/dist/bin.mjs CHANGED
@@ -175,7 +175,7 @@ function loadEnvFileForBin(rawArgs, bin, io = processIo) {
175
175
  //#region src/env-file-preload.ts
176
176
  const envFileLoad = loadEnvFileForBin(process.argv.slice(2), {
177
177
  name: "bst",
178
- version: "0.90.0"
178
+ version: "0.91.0"
179
179
  });
180
180
  //#endregion
181
181
  //#region ../cli-kit/src/logger.ts
@@ -783,7 +783,7 @@ const LintConfigSchema = z.strictObject({ strictTypeAware: z.boolean().optional(
783
783
  const ChecksConfigSchema = z.strictObject({
784
784
  add: z.array(z.string()).optional().meta({ description: "Extra package.json scripts to run as first-class check steps, appended to the built-in list (e.g. ['lint:php']). An added step with no matching script is a hard failure." }),
785
785
  skip: z.array(z.string()).optional().meta({ description: "Check steps to skip, unioned with the built-in CI defaults (never replacing them). Supports glob patterns." }),
786
- runOnVersionPr: z.boolean().optional().meta({ description: "Run the CI check job on the changesets version PR (changeset-release/main). Default false: the generated check job is skipped there, because that branch is regenerated on every push to main and main's own run gates the release. Has no effect unless releaseStrategy is changesets." })
786
+ runOnVersionPr: z.boolean().optional().meta({ description: "Run the CI check job on the changesets version PR (changeset-release/<defaultBranch>). Default false: the generated check job is skipped there, because that branch is regenerated on every push to the default branch and that branch's own run gates the release. Has no effect unless releaseStrategy is changesets." })
787
787
  });
788
788
  const CoverageConfigSchema = z.strictObject({
789
789
  history: z.enum(["orphan-branch", "none"]).optional().meta({ description: "How coverage history is persisted. 'orphan-branch' (default) appends to a dedicated branch; 'none' disables CI history but keeps the local ratchet." }),
@@ -835,6 +835,16 @@ const TolerantDockerCheckConfigSchema = z.object({
835
835
  ...DockerCheckConfigSchema.shape,
836
836
  healthChecks: z.array(tolerantBlock(DeclarativeHealthCheckSchema)).optional()
837
837
  });
838
+ const DEFAULT_BRANCH_NAME_PATTERN = /^(?!.*\/\/)(?!.*\.\.)(?!.*\/$)(?!.*\.lock$)[A-Za-z0-9][A-Za-z0-9._/-]*$/;
839
+ const DefaultBranchNameSchema = z.string().regex(DEFAULT_BRANCH_NAME_PATTERN, "must be a plain branch name: ASCII letters, digits, '.', '_', '-' and '/', starting with a letter or digit, with no '//', '..', trailing '/', or '.lock' suffix");
840
+ const AgentEnabledSliceSchema = z.looseObject({ agent: z.looseObject({ enabled: z.boolean().optional() }).optional() });
841
+ function agentEnabledIn(raw) {
842
+ try {
843
+ return parseToolingConfigSlice(raw, AgentEnabledSliceSchema, { block: "agent" })?.agent?.enabled === true;
844
+ } catch {
845
+ return false;
846
+ }
847
+ }
838
848
  //#endregion
839
849
  //#region src/utils/stack.ts
840
850
  const IGNORED_DIRS = /* @__PURE__ */ new Set([
@@ -1700,7 +1710,7 @@ const LintSweepConfigSchema = z.strictObject({
1700
1710
  });
1701
1711
  const AgentEgressConfigSchema = z.strictObject({ browseHosts: z.array(z.string().min(1)).optional().meta({ description: "Host patterns this repo's agent containers may fetch from, on top of the hosts every run needs: an exact host (`fastdl.mongodb.org`) or a wildcard over its leftmost label (`*.example.com`). No ports. These join the browse lane, which serves GET and HEAD only." }) });
1702
1712
  const AgentConfigSchema = z.strictObject({
1703
- enabled: z.boolean().optional().meta({ description: "The repo's explicit consent to the agent (default false). Only `true`, read from the repo's **default branch**, lets the agent work the repo: claim units, run sweeps, recover orphans, merge, act on a chat proposal or start a new spec. A missing key, `false`, a missing `.tooling.json` or a read that fails all mean not enabled, and the answer is re-read within about a minute. Every other `agent.*` key below has no effect without this one; `repo:sync` warns when the block sets one of them and this key is not `true`. Where this is `true`, `repo:sync` also installs `@bensandee/agent-cli`. Provisioning the agent's labels is not consent, and is never gated by this key." }),
1713
+ enabled: z.boolean().optional().meta({ description: "The repo's explicit consent to the agent (default false). Only `true`, read from the repo's **default branch**, lets the agent work the repo: claim units, run sweeps, recover orphans, merge, act on a chat proposal or start a new spec. A missing key, `false`, a missing `.tooling.json` or a read that fails all mean not enabled, and the answer is re-read within about a minute. Every other `agent.*` key below has no effect without this one; `repo:sync` warns when the block sets one of them and this key is not `true`. Where this is `true`, `repo:sync` also installs `@bensandee/agent-cli`. `bst setup:agent` sets it to true and runs the sync. Provisioning the agent's labels is not consent, and is never gated by this key." }),
1704
1714
  autonomy: z.boolean().optional().meta({ description: "Opt into interior autonomy (default false). When a phase's latest rationale reports readiness \"ready\" with an empty openQuestions[] and the review phase is clean, the agent applies the existing plan -> implement -> review -> park-at-mergeable advance itself instead of parking for a human. Never applies to the design/spec gate or the final merge from the mergeable terminal -- those always wait on a human. Also arms the trunk-red detector: when this repo's default branch combined status settles red, the agent files a tracking issue naming the failing checks and labels it agent/spec, starting the fix through the ordinary spec/plan/implement/review pipeline. It also closes that issue, with a comment, if the default branch recovers on its own before a human acts on it. Also arms the world-changed retrigger sweep: a pull request labelled agent-flags/autopilot that is parked at a -stuck or -failed terminal is re-queued when the fact its park recorded stops being true (the default branch went green, or moved past a conflicted base)." }),
1705
1715
  preRunRebase: z.boolean().optional().meta({ description: "Merge the current default branch into the work branch before the implement (execute) phase's container starts (default true). A clean merge or an already-up-to-date branch runs the container as is; a conflicting merge is aborted, recorded on the run's status comment, and the run proceeds on the un-synced branch -- it never fails the run. Two consecutive conflicting attempts hand the unit to agent/repair before any container runs; a timeout or a failed merge never counts toward that pair. Set false to skip the attempt entirely for this repo: no fetch, no merge, no abort, and the branch is worked exactly as cloned." }),
1706
1716
  backlog: z.boolean().optional().meta({ description: "Whether the agent may self-trigger backlog runs consuming this repo's .agent/findings/ ledger (default: on wherever the ledger exists, which only happens in a repo whose own review loop wrote it). A backlog run opens a pull request that fixes or refutes the pending findings; false disarms it, leaving the ledger to accumulate for a human. A run starts only once at least three findings are pending or one is older than seven days." }),
@@ -1769,7 +1779,7 @@ const ToolingConfigSchema = z.strictObject({
1769
1779
  docs: z.union([z.literal(false), DocsConfigSchema]).optional().meta({ description: "Plan/spec lifecycle gate (`bst docs:check`). Overrides only -- dirs are discovered by convention. Set to false to disable. Violations are advisory until a pair is named in `enforced`." }),
1770
1780
  deploy: DeployConfigSchema.optional().meta({ description: "Generate a deploy-dispatch step in the release workflow that triggers a workflow_dispatch event in another repo after a successful release. The resolved release token must have rights on `dispatchRepo`." }),
1771
1781
  pnpmWorkspace: z.literal(false).optional().meta({ description: "Set to false to opt out of pnpm-workspace.yaml management (skips both generation and the repo:sync --check validation)." }),
1772
- defaultBranch: z.string().optional().meta({ description: "Repository default branch name. Used by `checks:run` to decide when to enable coverage automatically. Default: detected via git (origin/HEAD), falling back to 'main'." }),
1782
+ defaultBranch: DefaultBranchNameSchema.optional().meta({ description: "Repository default branch name, B. Drives the generated CI `on.push` trigger and every default-branch gate in `ci.yml` and `release.yml`, the release path (`release:changesets`, the version PR, `release:trigger`) and `checks:run`'s coverage gate. Generated output uses 'main' when unset; `checks:run` still falls back to `origin/HEAD`. Must be a plain branch name: ASCII letters, digits, '.', '_', '-' and '/'." }),
1773
1783
  unmanagedDevDependencies: UnmanagedDevDependenciesSchema.optional().meta({ description: "devDependency names repo:sync must never add or re-pin. Escape hatch for holding a tooling-managed dep (oxlint, vitest, knip, ...) at a deliberately off-canonical version; without it, repo:sync raises any managed devDep whose version has drifted below the canonical pin." }),
1774
1784
  stack: StackSchema.optional().meta({ description: "Whether the repo has TS/JS source. Detected from source evidence; TypeScript is never inferred from absence." }),
1775
1785
  builder: z.enum([
@@ -1786,6 +1796,7 @@ const ToolingConfigSchema = z.strictObject({
1786
1796
  });
1787
1797
  const TolerantToolingConfigSchema = z.object({
1788
1798
  ...ToolingConfigSchema.shape,
1799
+ defaultBranch: z.string().optional(),
1789
1800
  docker: TolerantDockerMapSchema.optional(),
1790
1801
  dockerCheck: z.union([z.literal(false), TolerantDockerCheckConfigSchema]).optional(),
1791
1802
  coverage: z.union([z.literal(false), tolerantBlock(CoverageConfigSchema)]).optional(),
@@ -1831,10 +1842,16 @@ function loadToolingConfig(targetDir) {
1831
1842
  if (!file) return void 0;
1832
1843
  return parseConfigFile(file, TolerantToolingConfigSchema);
1833
1844
  }
1845
+ function parseToolingConfigStrict(raw, displayPath) {
1846
+ return parseConfigFile({
1847
+ raw,
1848
+ displayPath
1849
+ }, ToolingConfigSchema);
1850
+ }
1834
1851
  function loadToolingConfigStrict(targetDir) {
1835
1852
  const file = readConfigFile(targetDir);
1836
1853
  if (!file) return void 0;
1837
- return parseConfigFile(file, ToolingConfigSchema);
1854
+ return parseToolingConfigStrict(file.raw, file.displayPath);
1838
1855
  }
1839
1856
  const OVERRIDE_KEYS = [
1840
1857
  "structure",
@@ -4449,13 +4466,22 @@ const SETUP_MISE_ACTION_SHA = "ebe622ef0d24c7efec4a5f6093ddb18500900e24";
4449
4466
  function actionsExpr(expr) {
4450
4467
  return `\${{ ${expr} }}`;
4451
4468
  }
4452
- const MAIN_BRANCH_REF = "refs/heads/main";
4453
- const IS_MAIN_BRANCH = `github.ref == '${MAIN_BRANCH_REF}'`;
4454
- const IS_NOT_MAIN_BRANCH = `github.ref != '${MAIN_BRANCH_REF}'`;
4455
- const IS_MAIN_JOB_ENV = { IS_MAIN: actionsExpr(IS_MAIN_BRANCH) };
4469
+ function branchNameLiteral(name) {
4470
+ if (DEFAULT_BRANCH_NAME_PATTERN.test(name)) return name;
4471
+ return name.replace(/'/g, "''");
4472
+ }
4473
+ function isDefaultBranch(name) {
4474
+ return `github.ref == 'refs/heads/${branchNameLiteral(name)}'`;
4475
+ }
4476
+ function isNotDefaultBranch(name) {
4477
+ return `github.ref != 'refs/heads/${branchNameLiteral(name)}'`;
4478
+ }
4479
+ function defaultBranchJobEnv(name) {
4480
+ return { IS_MAIN: actionsExpr(isDefaultBranch(name)) };
4481
+ }
4456
4482
  const IS_MAIN_ENV_GUARD = "env.IS_MAIN == 'true'";
4457
- function onlyOnDefaultBranch(condition) {
4458
- return `${IS_MAIN_BRANCH} && (${condition})`;
4483
+ function onlyOnDefaultBranch(name, condition) {
4484
+ return `${isDefaultBranch(name)} && (${condition})`;
4459
4485
  }
4460
4486
  function npmrcAuthKey(registryUrl) {
4461
4487
  return `${registryUrl.replace(/^https?:/i, "").replace(/\/+$/, "")}/`;
@@ -4860,9 +4886,8 @@ function diagnoseCheckScriptForwardingAt(targetDir) {
4860
4886
  //#endregion
4861
4887
  //#region src/generators/agent-cli-dep.ts
4862
4888
  const AGENT_CLI_PACKAGE = "@bensandee/agent-cli";
4863
- const AgentEnabledSliceSchema = z.looseObject({ agent: z.looseObject({ enabled: z.boolean().optional() }).optional() });
4864
4889
  function agentEnabled(ctx) {
4865
- return parseToolingConfigSlice(ctx.read(".tooling.json"), AgentEnabledSliceSchema, { block: "agent" })?.agent?.enabled === true;
4890
+ return agentEnabledIn(ctx.read(".tooling.json"));
4866
4891
  }
4867
4892
  function wantsAgentCli(ctx) {
4868
4893
  if (!agentEnabled(ctx)) return false;
@@ -5408,9 +5433,9 @@ function buildManagedDevDeps(ctx) {
5408
5433
  const devDeps = { ...rootDevDeps(ctx.config) };
5409
5434
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
5410
5435
  if (ctx.config.stack !== "none" || ctx.config.ci === "forgejo") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.15.0";
5411
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.90.0";
5436
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.91.0";
5412
5437
  if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.17.0";
5413
- if (wantsAgentCli(ctx)) devDeps[AGENT_CLI_PACKAGE] = "0.1.1";
5438
+ if (wantsAgentCli(ctx)) devDeps[AGENT_CLI_PACKAGE] = "0.1.2";
5414
5439
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
5415
5440
  "@changesets/cli": "3.0.3",
5416
5441
  "@release-it/bumper": "8.0.1",
@@ -7332,6 +7357,59 @@ async function generateGitattributes(ctx) {
7332
7357
  };
7333
7358
  }
7334
7359
  //#endregion
7360
+ //#region src/default-branch.ts
7361
+ const DEFAULT_BRANCH = "main";
7362
+ const DefaultBranchSliceSchema = z.object({ defaultBranch: z.union([z.literal(""), DefaultBranchNameSchema]).optional() });
7363
+ function configuredDefaultBranch(raw) {
7364
+ const configured = parseToolingConfigSlice(raw, DefaultBranchSliceSchema, { block: "defaultBranch" })?.defaultBranch;
7365
+ if (configured === void 0 || configured === "") return DEFAULT_BRANCH;
7366
+ return configured;
7367
+ }
7368
+ function readConfiguredDefaultBranch(dir) {
7369
+ const configPath = path.join(dir, ".tooling.json");
7370
+ if (!existsSync(configPath)) return DEFAULT_BRANCH;
7371
+ let raw;
7372
+ try {
7373
+ raw = readFileSync(configPath, "utf-8");
7374
+ } catch (error) {
7375
+ throw new FatalError(`Cannot read ${configPath}: ${describeError$1(error)}`, { cause: error });
7376
+ }
7377
+ return configuredDefaultBranch(raw);
7378
+ }
7379
+ function processCiEventSource() {
7380
+ return {
7381
+ env: process.env,
7382
+ readFile: (absolutePath) => {
7383
+ try {
7384
+ return readFileSync(absolutePath, "utf-8");
7385
+ } catch (_error) {
7386
+ return;
7387
+ }
7388
+ }
7389
+ };
7390
+ }
7391
+ const CiEventSchema = z.object({ repository: z.object({ default_branch: z.string().min(1) }) });
7392
+ function ciDefaultBranch(source) {
7393
+ if (source === void 0 || source.env["GITHUB_ACTIONS"] !== "true") return void 0;
7394
+ const eventPath = source.env["GITHUB_EVENT_PATH"];
7395
+ if (eventPath === void 0 || eventPath === "") return void 0;
7396
+ const raw = source.readFile(eventPath);
7397
+ if (raw === void 0 || raw.trim() === "") return void 0;
7398
+ let json;
7399
+ try {
7400
+ json = JSON.parse(raw);
7401
+ } catch (_error) {
7402
+ return;
7403
+ }
7404
+ const result = CiEventSchema.safeParse(json);
7405
+ return result.success ? result.data.repository.default_branch : void 0;
7406
+ }
7407
+ function refuseForgeDefaultMismatch(configured, source, command) {
7408
+ const reported = ciDefaultBranch(source);
7409
+ if (reported === void 0 || reported === configured) return;
7410
+ throw new FatalError(`${command}: .tooling.json#defaultBranch is "${configured}", but the forge reports "${reported}" as this repository's default branch. Set .tooling.json#defaultBranch to "${reported}" and run bst repo:sync.`);
7411
+ }
7412
+ //#endregion
7335
7413
  //#region src/generators/release-ci.ts
7336
7414
  const DEFAULT_POST_RELEASE_COMMAND = [
7337
7415
  "pnpm exec bst webhook:send",
@@ -7351,7 +7429,7 @@ function hookSecretEnv(secrets) {
7351
7429
  };
7352
7430
  }
7353
7431
  function postReleaseHookStep(opts) {
7354
- const ifCondition = opts?.condition ?? (opts?.guardMain ? IS_MAIN_BRANCH : void 0);
7432
+ const ifCondition = opts?.condition ?? (opts?.guardMain ? isDefaultBranch(opts.defaultBranch) : void 0);
7355
7433
  return { step: {
7356
7434
  name: "Post-release hook",
7357
7435
  ...ifCondition && { if: ifCondition },
@@ -7362,7 +7440,7 @@ function postReleaseHookStep(opts) {
7362
7440
  function postReleaseHookJob(opts) {
7363
7441
  return {
7364
7442
  needs: opts.needsJob,
7365
- if: onlyOnDefaultBranch(opts.publishCondition),
7443
+ if: onlyOnDefaultBranch(opts.defaultBranch, opts.publishCondition),
7366
7444
  "continue-on-error": true,
7367
7445
  steps: [...commonSteps(opts.ci), { step: {
7368
7446
  name: "Post-release hook",
@@ -7506,6 +7584,7 @@ function releaseWorkflowPath(ci) {
7506
7584
  async function generateReleaseCi(ctx) {
7507
7585
  const filePath = "release-ci";
7508
7586
  const isGitHub = ctx.config.ci === "github";
7587
+ const defaultBranch = configuredDefaultBranch(ctx.read(".tooling.json"));
7509
7588
  if (ctx.config.ci === "none" || ctx.config.releaseStrategy === "none") return {
7510
7589
  filePath,
7511
7590
  action: "skipped",
@@ -7549,6 +7628,7 @@ async function generateReleaseCi(ctx) {
7549
7628
  let extraJobs;
7550
7629
  let jobOutputs;
7551
7630
  if (ctx.config.releaseStrategy === "release-it") steps.push(postReleaseHookStep({
7631
+ defaultBranch,
7552
7632
  command: getPostReleaseHookCommand(ctx),
7553
7633
  secrets: hookSecrets
7554
7634
  }));
@@ -7558,6 +7638,7 @@ async function generateReleaseCi(ctx) {
7558
7638
  tag: actionsExpr("steps.release.outputs.tag")
7559
7639
  };
7560
7640
  extraJobs = { "post-release-hook": postReleaseHookJob({
7641
+ defaultBranch,
7561
7642
  ci: ctx.config.ci,
7562
7643
  needsJob: "release",
7563
7644
  publishCondition: "needs.release.outputs.pushed == 'true'",
@@ -7573,7 +7654,7 @@ async function generateReleaseCi(ctx) {
7573
7654
  on,
7574
7655
  ...isGitHub && { permissions: { contents: "write" } },
7575
7656
  jobName: "release",
7576
- jobIf: IS_MAIN_BRANCH,
7657
+ jobIf: isDefaultBranch(defaultBranch),
7577
7658
  ...jobOutputs && { jobOutputs },
7578
7659
  steps,
7579
7660
  ...extraJobs && { extraJobs }
@@ -7604,7 +7685,9 @@ async function generateReleaseCi(ctx) {
7604
7685
  }
7605
7686
  //#endregion
7606
7687
  //#region src/release/version-branch.ts
7607
- const VERSION_PR_BRANCH = "changeset-release/main";
7688
+ function versionPrBranch(defaultBranch) {
7689
+ return `changeset-release/${defaultBranch}`;
7690
+ }
7608
7691
  //#endregion
7609
7692
  //#region src/generators/ci.ts
7610
7693
  function coverageHistorySteps() {
@@ -7638,7 +7721,7 @@ function healthSteps(includeHistory) {
7638
7721
  run: "pnpm exec bst health:status --limit 10"
7639
7722
  } }];
7640
7723
  }
7641
- function changesetsReleaseStep(ci, publishesNpm, npmAuth) {
7724
+ function changesetsReleaseStep(ci, publishesNpm, npmAuth, defaultBranch) {
7642
7725
  if (ci === "github") {
7643
7726
  const changesetsEnv = {
7644
7727
  GITHUB_TOKEN: actionsExpr("secrets.RELEASE_TOKEN"),
@@ -7647,7 +7730,7 @@ function changesetsReleaseStep(ci, publishesNpm, npmAuth) {
7647
7730
  return { step: {
7648
7731
  id: "release",
7649
7732
  uses: "changesets/action@v1",
7650
- if: IS_MAIN_BRANCH,
7733
+ if: isDefaultBranch(defaultBranch),
7651
7734
  with: {
7652
7735
  publish: "pnpm changeset publish",
7653
7736
  version: "pnpm changeset version"
@@ -7665,19 +7748,19 @@ function changesetsReleaseStep(ci, publishesNpm, npmAuth) {
7665
7748
  return { step: {
7666
7749
  id: "release",
7667
7750
  name: "Release",
7668
- if: IS_MAIN_BRANCH,
7751
+ if: isDefaultBranch(defaultBranch),
7669
7752
  env: releaseEnv,
7670
7753
  run: npmAuth.publishRun("pnpm exec bst release:changesets")
7671
7754
  } };
7672
7755
  }
7673
- function ciConcurrency(ci) {
7756
+ function ciConcurrency(ci, defaultBranch) {
7674
7757
  if (ci === "forgejo") return {
7675
- group: `ci-${actionsExpr(`${IS_MAIN_BRANCH} && github.sha || github.ref`)}`,
7676
- "cancel-in-progress": actionsExpr(IS_NOT_MAIN_BRANCH)
7758
+ group: `ci-${actionsExpr(`${isDefaultBranch(defaultBranch)} && github.sha || github.ref`)}`,
7759
+ "cancel-in-progress": actionsExpr(isNotDefaultBranch(defaultBranch))
7677
7760
  };
7678
7761
  return {
7679
7762
  group: `ci-${actionsExpr("github.ref")}`,
7680
- "cancel-in-progress": actionsExpr(IS_NOT_MAIN_BRANCH)
7763
+ "cancel-in-progress": actionsExpr(isNotDefaultBranch(defaultBranch))
7681
7764
  };
7682
7765
  }
7683
7766
  function checkSteps(ci, coverageEnabled, emitDockerSweepStep) {
@@ -7708,6 +7791,7 @@ async function generateCi(ctx) {
7708
7791
  action: "skipped",
7709
7792
  description: "CI workflow not requested"
7710
7793
  };
7794
+ const defaultBranch = configuredDefaultBranch(ctx.read(".tooling.json"));
7711
7795
  const isGitHub = ctx.config.ci === "github";
7712
7796
  const isForgejo = !isGitHub;
7713
7797
  const isChangesets = ctx.config.releaseStrategy === "changesets";
@@ -7722,7 +7806,7 @@ async function generateCi(ctx) {
7722
7806
  const hasAssets = isChangesets && (hasReleaseAssets(ctx) || hasVersionStamp(ctx));
7723
7807
  const host = hasDocker ? resolveGenTimeHost(ctx) : null;
7724
7808
  const publishedIf = "steps.release.outputs.published == 'true'";
7725
- const publishedOnMainIf = onlyOnDefaultBranch(publishedIf);
7809
+ const publishedOnDefaultIf = onlyOnDefaultBranch(defaultBranch, publishedIf);
7726
7810
  const githubChangesetsDeriveTagStep = { step: {
7727
7811
  name: "Derive release tag",
7728
7812
  id: "derive-tag",
@@ -7743,17 +7827,17 @@ async function generateCi(ctx) {
7743
7827
  ...checkSteps(ctx.config.ci, coverage.enabled, hasDockerPackages(ctx)),
7744
7828
  ...emitCoverageSteps ? coverageHistorySteps() : [],
7745
7829
  ...healthSteps(emitHealthHistory),
7746
- ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth)] : [],
7830
+ ...isChangesets ? [changesetsReleaseStep(ctx.config.ci, publishesNpm, npmAuth, defaultBranch)] : [],
7747
7831
  ...isChangesets && isGitHub ? [githubChangesetsDeriveTagStep] : [],
7748
7832
  ...hasAssets ? [{ step: {
7749
7833
  name: "Upload Release Assets",
7750
- if: publishedOnMainIf,
7834
+ if: publishedOnDefaultIf,
7751
7835
  env: releaseEnv,
7752
7836
  run: "pnpm exec bst release:assets"
7753
7837
  } }] : [],
7754
7838
  ...hasDocker ? [{ step: {
7755
7839
  name: "Publish Docker images",
7756
- if: publishedOnMainIf,
7840
+ if: publishedOnDefaultIf,
7757
7841
  env: dockerPublishEnv(host),
7758
7842
  run: "pnpm exec bst docker:publish"
7759
7843
  } }] : [],
@@ -7761,7 +7845,7 @@ async function generateCi(ctx) {
7761
7845
  deploy,
7762
7846
  packageName: ctx.config.name,
7763
7847
  releaseEnv,
7764
- condition: publishedOnMainIf,
7848
+ condition: publishedOnDefaultIf,
7765
7849
  secrets: hookSecrets
7766
7850
  })] : []
7767
7851
  ];
@@ -7773,6 +7857,7 @@ async function generateCi(ctx) {
7773
7857
  tag: changesetsTagOutputExpr
7774
7858
  };
7775
7859
  extraJobs = { "post-release-hook": postReleaseHookJob({
7860
+ defaultBranch,
7776
7861
  ci: ctx.config.ci,
7777
7862
  needsJob: "check",
7778
7863
  publishCondition: "needs.check.outputs.published == 'true'",
@@ -7781,7 +7866,7 @@ async function generateCi(ctx) {
7781
7866
  secrets: hookSecrets
7782
7867
  }) };
7783
7868
  }
7784
- const checkJobEnv = emitCoverageSteps || emitHealthHistory ? IS_MAIN_JOB_ENV : {};
7869
+ const checkJobEnv = emitCoverageSteps || emitHealthHistory ? defaultBranchJobEnv(defaultBranch) : {};
7785
7870
  const checksSlice = parseToolingConfigSlice(ctx.read(".tooling.json"), z.object({ checks: ChecksConfigSchema.optional() }), { block: "checks" });
7786
7871
  const skipOnVersionPr = isChangesets && checksSlice?.checks?.runOnVersionPr !== true;
7787
7872
  const content = buildWorkflowYaml({
@@ -7789,16 +7874,16 @@ async function generateCi(ctx) {
7789
7874
  name: "CI",
7790
7875
  on: {
7791
7876
  push: {
7792
- branches: ["main"],
7877
+ branches: [defaultBranch],
7793
7878
  "tags-ignore": ["**"]
7794
7879
  },
7795
7880
  pull_request: null
7796
7881
  },
7797
7882
  ...isGitHub && (emitCoverageSteps || emitHealthHistory) && { permissions: { contents: "write" } },
7798
7883
  enableEmailNotifications: isForgejo,
7799
- ...isChangesets && { concurrency: ciConcurrency(ctx.config.ci) },
7884
+ ...isChangesets && { concurrency: ciConcurrency(ctx.config.ci, defaultBranch) },
7800
7885
  jobName: "check",
7801
- ...skipOnVersionPr && { jobIf: `github.head_ref != 'changeset-release/main'` },
7886
+ ...skipOnVersionPr && { jobIf: `github.head_ref != '${versionPrBranch(defaultBranch)}'` },
7802
7887
  ...Object.keys(checkJobEnv).length > 0 && { jobEnv: checkJobEnv },
7803
7888
  ...jobOutputs && { jobOutputs },
7804
7889
  steps,
@@ -8495,7 +8580,7 @@ async function generateReleaseIt(ctx) {
8495
8580
  }
8496
8581
  //#endregion
8497
8582
  //#region src/generators/changesets.ts
8498
- function buildConfig$2() {
8583
+ function buildConfig$2(defaultBranch) {
8499
8584
  return {
8500
8585
  $schema: "https://unpkg.com/@changesets/config@3.1.1/schema.json",
8501
8586
  changelog: "@changesets/cli/changelog",
@@ -8503,7 +8588,7 @@ function buildConfig$2() {
8503
8588
  fixed: [],
8504
8589
  linked: [],
8505
8590
  access: "public",
8506
- baseBranch: "main",
8591
+ baseBranch: defaultBranch,
8507
8592
  updateInternalDependencies: "patch",
8508
8593
  ignore: []
8509
8594
  };
@@ -8515,7 +8600,7 @@ async function generateChangesets(ctx) {
8515
8600
  action: "skipped",
8516
8601
  description: "Changesets not requested"
8517
8602
  };
8518
- const content = JSON.stringify(buildConfig$2(), null, 2) + "\n";
8603
+ const content = JSON.stringify(buildConfig$2(configuredDefaultBranch(ctx.read(".tooling.json"))), null, 2) + "\n";
8519
8604
  const existing = ctx.read(filePath);
8520
8605
  if (existing) return {
8521
8606
  filePath,
@@ -9143,6 +9228,7 @@ const AGENT_RULES_MID = [
9143
9228
  const TS_EXTENSIONLESS_RULE = "- Prefer extensionless imports; if an extension is required, use `.ts` over `.js`.";
9144
9229
  const CHANGESETS_RULE = "- A user-facing change owes a changeset in `.changeset/`, written with the change: `patch` for a fix, `minor` for a feature, naming every package it affects. One file per smallest unit of work.";
9145
9230
  const WORK_STATUS_RULE = "- Before starting or resuming work, run `bst work:status`.";
9231
+ const WORK_START_RULE = "- Before starting or resuming work, run `bst work:start <n>`.";
9146
9232
  const FIND_COMMAND_RULE = "- Find a command with `bst --help`, its flags with `bst <command> --help`, before guessing.";
9147
9233
  const FORGEJO_CLI_RULE_SUFFIX = " For Forgejo API work, run `bst-fj --help`.";
9148
9234
  const AGENT_CLI_RULE_SUFFIX = " For the agent's runs and transcripts, run `bst-agent --help`.";
@@ -9163,20 +9249,22 @@ function declaresAgentCli(packageJson) {
9163
9249
  function findCommandRule(packageJson) {
9164
9250
  return FIND_COMMAND_RULE + (declaresForgejoCli(packageJson) ? FORGEJO_CLI_RULE_SUFFIX : "") + (declaresAgentCli(packageJson) ? AGENT_CLI_RULE_SUFFIX : "");
9165
9251
  }
9166
- const AGENT_RULES_TAIL = [
9167
- "- The bst-hook blocks a short list of dangerous commands (`.claude/hooks/bst-hook-guide.md` lists them); Claude Code's auto mode judges everything else.",
9168
- "- Machine-reading a `bst` command? Pass `--json` after the command name (`bst <cmd> --json`, not `bst --json <cmd>`); capture `2>&1` and parse it as one JSON document, failures included as `{ok: false, error}`. `--verbose` breaks this.",
9169
- WORK_STATUS_RULE,
9170
- FIND_COMMAND_RULE,
9171
- "",
9172
- "#### Git discipline",
9173
- "",
9174
- "**The branch you are on is the mode switch.** The bst-hook refuses any command that would move a protected branch, so on one you leave changes unstaged; on a feature branch you commit, push, and open the PR yourself. Pick the mode, announce it, and only ask when it's genuinely ambiguous.",
9175
- "",
9176
- "**Small change** -- a fix, a doc edit, a one-file tweak: work on the default branch and leave it unstaged. Don't commit; surface the commit message as text for the user to run.",
9177
- "",
9178
- "**Large effort** -- anything that warranted a written plan or spec (spec, then plan, then code, each approved before the next): start it in a worktree (see the Git guide), commit as you go, finish with `bst pr:create`. Push later commits with `bst pr:update`, which rewrites nothing unless given `--title`/`--body`. The one exception: a headless agent run (a spec, plan, execute, or review container) never starts a worktree -- it works and commits on the branch it was started on."
9179
- ];
9252
+ function agentRulesTail(agentWorked) {
9253
+ return [
9254
+ "- The bst-hook blocks a short list of dangerous commands (`.claude/hooks/bst-hook-guide.md` lists them); Claude Code's auto mode judges everything else.",
9255
+ "- Machine-reading a `bst` command? Pass `--json` after the command name (`bst <cmd> --json`, not `bst --json <cmd>`); capture `2>&1` and parse it as one JSON document, failures included as `{ok: false, error}`. `--verbose` breaks this.",
9256
+ agentWorked ? WORK_START_RULE : WORK_STATUS_RULE,
9257
+ FIND_COMMAND_RULE,
9258
+ "",
9259
+ "#### Git discipline",
9260
+ "",
9261
+ "**The branch you are on is the mode switch.** The bst-hook refuses any command that would move a protected branch, so on one you leave changes unstaged; on a feature branch you commit, push, and open the PR yourself. Pick the mode, announce it, and only ask when it's genuinely ambiguous.",
9262
+ "",
9263
+ "**Small change** -- a fix, a doc edit, a one-file tweak: work on the default branch and leave it unstaged. Don't commit; surface the commit message as text for the user to run.",
9264
+ "",
9265
+ "**Large effort** -- anything that warranted a written plan or spec (spec, then plan, then code, each approved before the next): start it in a worktree (see the Git guide), commit as you go, finish with `bst pr:create`. Push later commits with `bst pr:update`, which rewrites nothing unless given `--title`/`--body`. The one exception: a headless agent run (a spec, plan, execute, or review container) never starts a worktree -- it works and commits on the branch it was started on."
9266
+ ];
9267
+ }
9180
9268
  const COMMENTS_GUIDE_BODY = [
9181
9269
  "# Comments",
9182
9270
  "",
@@ -9215,7 +9303,11 @@ const GIT_GUIDE_BODY = [
9215
9303
  "",
9216
9304
  "## Claiming work by hand",
9217
9305
  "",
9218
- "If the bst agent drives this repo, claim an issue or pull request before you work it by hand, so the agent does not start a run beside you. First check its labels: an `agent/<stage>-running` label means a run is live -- do not start; wait or ask. Otherwise add `agent-flags/hand-authored` (on Forgejo, `bst-fj issue:label <n> agent-flags/hand-authored`, which takes a pull request number too). The flag on either an issue or its pull request holds both. Remove it when you hand the work back, before you add any `agent/` label. If the repo has no such label, no agent drives it; skip this step.",
9306
+ "If the bst agent drives this repo, claim an issue or pull request before you work it by hand, so the agent does not start a run beside you: run `bst work:start <n>`. It prints the same orientation `bst work:status` does and then puts `agent-flags/hand-authored` on the object. It claims only where the repo's default branch sets `agent.enabled: true`, and does nothing anywhere else. **Exit 1 means do not start**: a run is live on the pair, or the agent holds it -- the output names which. Exit 0 means the pair is yours, whether the command wrote the flag or found it there already.",
9307
+ "",
9308
+ "A pull request you open yourself that closes no claimed issue needs its own `bst work:start <n>` once it exists -- the flag on either half of a pair holds both, but there is no pair until something closes something.",
9309
+ "",
9310
+ "Hand the work back with the control panel's **Release to the agent**, or by removing `agent-flags/hand-authored` from both halves. Removing it does not queue anything: the agent picks the object up on its next cycle only if a label or a comment asks it to.",
9219
9311
  "",
9220
9312
  "## Issue linking",
9221
9313
  "",
@@ -9596,13 +9688,13 @@ function guideIndex(guides) {
9596
9688
  for (const guide of guides.filter((g) => g.enabled)) lines.push(`- Before you ${guide.trigger}: \`${GUIDES_DIR}/${guide.name}\`.`);
9597
9689
  return lines.join("\n");
9598
9690
  }
9599
- function agentRulesBody(config, guides, packageJson) {
9691
+ function agentRulesBody(config, guides, packageJson, agentWorked) {
9600
9692
  const lines = [...AGENT_RULES_HEAD];
9601
9693
  if (config.stack === "typescript") lines.push(TS_TYPECAST_RULE);
9602
9694
  lines.push(...AGENT_RULES_MID);
9603
9695
  if (config.stack === "typescript") lines.push(TS_EXTENSIONLESS_RULE);
9604
9696
  if (config.releaseStrategy === "changesets") lines.push(CHANGESETS_RULE);
9605
- const tail = AGENT_RULES_TAIL.map((line) => line === FIND_COMMAND_RULE ? findCommandRule(packageJson) : line);
9697
+ const tail = agentRulesTail(agentWorked).map((line) => line === FIND_COMMAND_RULE ? findCommandRule(packageJson) : line);
9606
9698
  lines.push(...tail, "", guideIndex(guides));
9607
9699
  return lines.join("\n");
9608
9700
  }
@@ -9666,7 +9758,7 @@ function syncClaudeMdFile(ctx, guides) {
9666
9758
  name: "agent-rules",
9667
9759
  startMarker: "<!-- @tooling:agent-rules -->",
9668
9760
  endMarker: "<!-- @tooling:endagent-rules -->",
9669
- body: agentRulesBody(ctx.config, guides, ctx.packageJson),
9761
+ body: agentRulesBody(ctx.config, guides, ctx.packageJson, agentEnabled(ctx)),
9670
9762
  enabled: true
9671
9763
  }, ...RETIRED_BLOCK_NAMES.map((name) => ({
9672
9764
  name,
@@ -9907,7 +9999,7 @@ function reportFailures(failed, command) {
9907
9999
  function generateMigratePrompt(results, config, detected) {
9908
10000
  const sections = [];
9909
10001
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
9910
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.90.0 repo:sync\` on ${timestamp}_\n`);
10002
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.91.0 repo:sync\` on ${timestamp}_\n`);
9911
10003
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
9912
10004
  if (changed.length > 0) {
9913
10005
  const items = changed.map((r) => {
@@ -10035,7 +10127,7 @@ function logDetectionSummary(ctx) {
10035
10127
  log$1.info(`Release assets convention detected in ${label} -- emit build artifacts to ${CONVENTION_OUTPUT_DIR}/ (gitignored).`);
10036
10128
  }
10037
10129
  }
10038
- async function runInit(config, options = {}) {
10130
+ async function runInitReport(config, options = {}) {
10039
10131
  const detected = detectProject(config.targetDir);
10040
10132
  const { ctx, archivedFiles, sawCrlf } = createContext(config, options.confirmOverwrite ?? (async (relativePath) => {
10041
10133
  const result = await select({
@@ -10072,7 +10164,10 @@ async function runInit(config, options = {}) {
10072
10164
  }
10073
10165
  if (!hasChanges && options.noPrompt) {
10074
10166
  if (failed.length === 0) log$1.success("Repository is up to date.");
10075
- return results;
10167
+ return {
10168
+ results,
10169
+ install: "not-run"
10170
+ };
10076
10171
  }
10077
10172
  if (results.some((r) => r.action === "archived" && r.filePath.startsWith(".husky/"))) try {
10078
10173
  execSync("git config --unset core.hooksPath", {
@@ -10091,16 +10186,19 @@ async function runInit(config, options = {}) {
10091
10186
  if (!options.noPrompt) log$1.info(`Migration prompt written to ${promptPath}`);
10092
10187
  const packageJsonChanged = results.some((r) => r.filePath === "package.json" && (r.action === "created" || r.action === "updated"));
10093
10188
  const hasLockfile = ctx.exists("pnpm-lock.yaml");
10189
+ let install = "not-run";
10094
10190
  if (packageJsonChanged && hasLockfile) {
10095
10191
  log$1.info("package.json changed -- running pnpm install...");
10096
10192
  try {
10097
10193
  execSync("pnpm install", {
10098
10194
  cwd: config.targetDir,
10099
- stdio: "inherit",
10195
+ stdio: options.installOutput === "capture" ? "ignore" : "inherit",
10100
10196
  timeout: 12e4
10101
10197
  });
10198
+ install = "ran";
10102
10199
  } catch (_error) {
10103
10200
  log$1.warn("pnpm install failed -- run it manually");
10201
+ install = "failed";
10104
10202
  }
10105
10203
  }
10106
10204
  if (hasChanges && ctx.exists("package.json")) try {
@@ -10115,7 +10213,13 @@ async function runInit(config, options = {}) {
10115
10213
  "Run: pnpm check",
10116
10214
  ...!options.noPrompt ? ["Run: claude \"Execute the migration steps in .tooling-migrate.md\""] : []
10117
10215
  ].map((step, i) => `${i + 1}. ${step}`).join("\n"), "Next steps");
10118
- return results;
10216
+ return {
10217
+ results,
10218
+ install
10219
+ };
10220
+ }
10221
+ async function runInit(config, options = {}) {
10222
+ return (await runInitReport(config, options)).results;
10119
10223
  }
10120
10224
  //#endregion
10121
10225
  //#region src/docker-check/compose-lint.ts
@@ -10854,34 +10958,6 @@ function repoPrefix(executor, cwd) {
10854
10958
  function ceilingFileNamesAt(prefix) {
10855
10959
  return HEALTH_CEILING_FILES.map((name) => `${prefix}${name}`);
10856
10960
  }
10857
- function processCiEventSource() {
10858
- return {
10859
- env: process.env,
10860
- readFile: (absolutePath) => {
10861
- try {
10862
- return readFileSync(absolutePath, "utf-8");
10863
- } catch (_error) {
10864
- return;
10865
- }
10866
- }
10867
- };
10868
- }
10869
- const CiEventSchema = z.object({ repository: z.object({ default_branch: z.string().min(1) }) });
10870
- function ciDefaultBranch(source) {
10871
- if (source === void 0 || source.env["GITHUB_ACTIONS"] !== "true") return void 0;
10872
- const eventPath = source.env["GITHUB_EVENT_PATH"];
10873
- if (eventPath === void 0 || eventPath === "") return void 0;
10874
- const raw = source.readFile(eventPath);
10875
- if (raw === void 0 || raw.trim() === "") return void 0;
10876
- let json;
10877
- try {
10878
- json = JSON.parse(raw);
10879
- } catch (_error) {
10880
- return;
10881
- }
10882
- const result = CiEventSchema.safeParse(json);
10883
- return result.success ? result.data.repository.default_branch : void 0;
10884
- }
10885
10961
  const DefaultBranchSchema = z.object({ defaultBranch: z.string().optional() });
10886
10962
  function parseDefaultBranchOverride(raw) {
10887
10963
  if (raw === void 0 || raw.trim() === "") return void 0;
@@ -12036,7 +12112,7 @@ function findInertAgentKeys(saved) {
12036
12112
  function runInertAgentSettingsGate(saved) {
12037
12113
  const keys = findInertAgentKeys(saved);
12038
12114
  if (keys.length === 0) return [];
12039
- log$1.warn(`.tooling.json sets ${keys.map((key) => `agent.${key}`).join(", ")}, but agent.enabled is not true, so the agent ignores them. Set "agent.enabled": true, or remove those keys.`);
12115
+ log$1.warn(`.tooling.json sets ${keys.map((key) => `agent.${key}`).join(", ")}, but agent.enabled is not true, so the agent ignores them. Run \`bst setup:agent\` to enable the agent, or remove those keys.`);
12040
12116
  return keys;
12041
12117
  }
12042
12118
  function runPublishConfigGate(config, targetDir) {
@@ -12084,6 +12160,18 @@ function warnStaleChecksAdd(config) {
12084
12160
  if (stale.length === 0) return;
12085
12161
  log$1.warn(`.tooling.json#checks.add names built-in step(s) checks:run already runs: ${stale.join(", ")}. The entries are deduplicated at run time and do nothing -- remove them.`);
12086
12162
  }
12163
+ function warnUnconfiguredDefaultBranch(targetDir, saved, git = defaultGitRunner) {
12164
+ if (saved?.defaultBranch !== void 0) return;
12165
+ const head = git([
12166
+ "symbolic-ref",
12167
+ "--short",
12168
+ "refs/remotes/origin/HEAD"
12169
+ ], targetDir);
12170
+ if (head === void 0) return;
12171
+ const name = head.startsWith("origin/") ? head.slice(7) : head;
12172
+ if (name === "" || name === "main") return;
12173
+ log$1.warn(`origin/HEAD names "${name}", but .tooling.json sets no defaultBranch, so the generated CI workflow triggers on "${DEFAULT_BRANCH}" and gates its release steps there. Set .tooling.json#defaultBranch to "${name}" and run bst repo:sync.`);
12174
+ }
12087
12175
  function warnCoveredChecksAdd(config, targetDir) {
12088
12176
  const covered = coveredChecksAddEntries(config, readPackageJson(targetDir)?.scripts ?? {});
12089
12177
  for (const entry of covered) log$1.warn(`.tooling.json#checks.add entry "${entry.name}" is already run by the "${entry.coveredBy}" step's script ("${entry.fragment}"), so every pipeline run executes it twice. Remove the add entry, or drop the inline invocation from the "${entry.coveredBy}" script.`);
@@ -12203,26 +12291,14 @@ function checkDeclaredToolsActive(targetDir, declared) {
12203
12291
  }
12204
12292
  function runToolchainGate(targetDir) {
12205
12293
  const miseTomlPath = path.join(targetDir, "mise.toml");
12206
- if (!existsSync(miseTomlPath)) return {
12207
- failures: 0,
12208
- warned: false
12209
- };
12294
+ if (!existsSync(miseTomlPath)) return 0;
12210
12295
  if (miseIsAbsent(targetDir)) {
12211
- log$1.warn(`mise.toml declares this repo's toolchain, but mise is not on PATH. Install it (${MISE_INSTALL_URL}), then add \`eval "$(mise activate bash)"\` to ~/.bashrc for interactive shells and \`eval "$(mise activate bash --shims)"\` to ~/.profile, so editors, git hooks, and AI sessions get the same tools.`);
12212
- return {
12213
- failures: 0,
12214
- warned: true
12215
- };
12296
+ log$1.error(`mise.toml declares this repo's toolchain, but mise is not on PATH. Install it (${MISE_INSTALL_URL}), then add \`eval "$(mise activate bash)"\` to ~/.bashrc for interactive shells and \`eval "$(mise activate bash --shims)"\` to ~/.profile, so editors, git hooks, and AI sessions get the same tools. If this repo's mise.toml has an [env] block, also add the directory your checkouts live in to \`trusted_config_paths\` in ~/.config/mise/config.toml.`);
12297
+ return 1;
12216
12298
  }
12217
12299
  const declared = readDeclaredTools(miseTomlPath);
12218
- if (!declared) return {
12219
- failures: 0,
12220
- warned: false
12221
- };
12222
- return {
12223
- failures: checkDeclaredToolsActive(targetDir, declared),
12224
- warned: false
12225
- };
12300
+ if (!declared) return 0;
12301
+ return checkDeclaredToolsActive(targetDir, declared);
12226
12302
  }
12227
12303
  //#endregion
12228
12304
  //#region src/commands/repo-sync.ts
@@ -12264,7 +12340,7 @@ const syncCommand = defineCommand({
12264
12340
  },
12265
12341
  json: {
12266
12342
  type: "boolean",
12267
- description: "Emit one JSON document: status (up-to-date | drift | workflow-drift | failed), actionable, failed, toolchain, checkOnlyImages, aptSteps, inertAgentKeys, warnCount, exitCode. --check only: the write mode prompts and inherits child streams."
12343
+ description: "Emit one JSON document: status (up-to-date | drift | workflow-drift | failed), actionable, failed, checkOnlyImages, aptSteps, inertAgentKeys, warnCount, exitCode. --check only: the write mode prompts and inherits child streams."
12268
12344
  }
12269
12345
  },
12270
12346
  async run({ args }) {
@@ -12302,40 +12378,59 @@ const syncCommand = defineCommand({
12302
12378
  })).length > 0) process.exitCode = 1;
12303
12379
  }
12304
12380
  });
12305
- async function runUpdate(targetDir, flags = {}) {
12381
+ async function runUpdateReport(targetDir, flags = {}, options = {}) {
12306
12382
  const dockerDiscovery = createDockerDiscoveryMemo();
12307
12383
  const saved = loadToolingConfigStrict(targetDir);
12308
12384
  if (!saved) {
12309
12385
  log$1.error("No .tooling.json found. Run `bst repo:sync` first to initialize the project.");
12310
12386
  process.exitCode = 1;
12311
- return [];
12387
+ return {
12388
+ status: "stopped",
12389
+ results: [],
12390
+ install: "not-run"
12391
+ };
12312
12392
  }
12313
12393
  warnStaleChecksAdd(saved);
12394
+ warnUnconfiguredDefaultBranch(targetDir, saved);
12314
12395
  warnCoveredChecksAdd(saved, targetDir);
12315
12396
  runInertAgentSettingsGate(saved);
12316
12397
  if (runComposeLintGate(targetDir, saved, dockerDiscovery) > 0) {
12317
12398
  process.exitCode = 1;
12318
- return [];
12399
+ return {
12400
+ status: "stopped",
12401
+ results: [],
12402
+ install: "not-run"
12403
+ };
12319
12404
  }
12320
12405
  const config = mergeWithSavedConfig(buildDefaultConfig(targetDir, flags), saved);
12321
12406
  runReleaseTargetGate(config, targetDir);
12322
12407
  runVersionStampGate(saved, targetDir, config.releaseStrategy);
12323
12408
  if (runPublishConfigGate(config, targetDir) > 0) {
12324
12409
  process.exitCode = 1;
12325
- return [];
12410
+ return {
12411
+ status: "stopped",
12412
+ results: [],
12413
+ install: "not-run"
12414
+ };
12326
12415
  }
12327
- return runInit(config, {
12328
- noPrompt: true,
12329
- confirmOverwrite: async () => "overwrite",
12330
- dockerDiscovery
12331
- });
12416
+ return {
12417
+ status: "ran",
12418
+ ...await runInitReport(config, {
12419
+ noPrompt: true,
12420
+ confirmOverwrite: async () => "overwrite",
12421
+ dockerDiscovery,
12422
+ installOutput: options.installOutput
12423
+ })
12424
+ };
12332
12425
  }
12333
- function gateFailure(toolchainWarned = false) {
12426
+ async function runUpdate(targetDir, flags = {}) {
12427
+ return (await runUpdateReport(targetDir, flags)).results;
12428
+ }
12429
+ function gateFailure() {
12334
12430
  return {
12335
12431
  status: "failed",
12336
12432
  actionable: [],
12337
12433
  failed: [],
12338
- toolchain: { warned: toolchainWarned },
12339
12434
  checkOnlyImages: [],
12340
12435
  aptSteps: [],
12341
12436
  inertAgentKeys: [],
@@ -12344,10 +12439,9 @@ function gateFailure(toolchainWarned = false) {
12344
12439
  };
12345
12440
  }
12346
12441
  function advisoryWarnCount(advisories) {
12347
- return (advisories.toolchainWarned ? 1 : 0) + advisories.checkOnlyImages.length + advisories.aptSteps.length + (advisories.inertAgentKeys.length > 0 ? 1 : 0);
12442
+ return advisories.checkOnlyImages.length + advisories.aptSteps.length + (advisories.inertAgentKeys.length > 0 ? 1 : 0);
12348
12443
  }
12349
12444
  function upToDateReport(advisories) {
12350
- const toolchain = { warned: advisories.toolchainWarned };
12351
12445
  const warnCount = advisoryWarnCount(advisories);
12352
12446
  if (warnCount > 0) {
12353
12447
  if (!isJsonMode()) rawLog$1(formatWarnMarker(warnCount));
@@ -12356,7 +12450,6 @@ function upToDateReport(advisories) {
12356
12450
  status: "up-to-date",
12357
12451
  actionable: [],
12358
12452
  failed: [],
12359
- toolchain,
12360
12453
  checkOnlyImages: advisories.checkOnlyImages,
12361
12454
  aptSteps: advisories.aptSteps,
12362
12455
  inertAgentKeys: advisories.inertAgentKeys,
@@ -12367,6 +12460,7 @@ function upToDateReport(advisories) {
12367
12460
  async function runCheckReport(targetDir) {
12368
12461
  const dockerDiscovery = createDockerDiscoveryMemo();
12369
12462
  const saved = loadToolingConfigStrict(targetDir);
12463
+ warnUnconfiguredDefaultBranch(targetDir, saved);
12370
12464
  if (saved) {
12371
12465
  warnStaleChecksAdd(saved);
12372
12466
  warnCoveredChecksAdd(saved, targetDir);
@@ -12377,13 +12471,8 @@ async function runCheckReport(targetDir) {
12377
12471
  if (runReleaseTargetGate(config, targetDir)) return gateFailure();
12378
12472
  if (runVersionStampGate(saved, targetDir, config.releaseStrategy) > 0) return gateFailure();
12379
12473
  if (runPublishConfigGate(config, targetDir) > 0) return gateFailure();
12380
- const toolchain = runToolchainGate(targetDir);
12381
- if (toolchain.failures > 0) return {
12382
- ...gateFailure(),
12383
- toolchain: { warned: toolchain.warned }
12384
- };
12474
+ if (runToolchainGate(targetDir) > 0) return gateFailure();
12385
12475
  const advisories = {
12386
- toolchainWarned: toolchain.warned,
12387
12476
  checkOnlyImages: runCheckOnlyImageGate(config, targetDir, dockerDiscovery),
12388
12477
  aptSteps: runWorkflowAptGate(targetDir),
12389
12478
  inertAgentKeys: runInertAgentSettingsGate(saved)
@@ -12395,13 +12484,11 @@ async function runCheckReport(targetDir) {
12395
12484
  reportFailures(failed, "repo:sync");
12396
12485
  const actionable = actionableResults(results, pendingWrites, targetDir);
12397
12486
  const advisoryCount = advisoryWarnCount(advisories);
12398
- const toolchainState = { warned: advisories.toolchainWarned };
12399
12487
  if (actionable.length === 0 && failed.length === 0) return upToDateReport(advisories);
12400
12488
  if (actionable.length === 0) return {
12401
12489
  status: "failed",
12402
12490
  actionable: [],
12403
12491
  failed,
12404
- toolchain: toolchainState,
12405
12492
  checkOnlyImages: [],
12406
12493
  aptSteps: [],
12407
12494
  inertAgentKeys: [],
@@ -12417,7 +12504,6 @@ async function runCheckReport(targetDir) {
12417
12504
  status: "workflow-drift",
12418
12505
  actionable,
12419
12506
  failed: [],
12420
- toolchain: toolchainState,
12421
12507
  checkOnlyImages: advisories.checkOnlyImages,
12422
12508
  aptSteps: advisories.aptSteps,
12423
12509
  inertAgentKeys: advisories.inertAgentKeys,
@@ -12429,7 +12515,6 @@ async function runCheckReport(targetDir) {
12429
12515
  status: "drift",
12430
12516
  actionable,
12431
12517
  failed,
12432
- toolchain: toolchainState,
12433
12518
  checkOnlyImages: [],
12434
12519
  aptSteps: [],
12435
12520
  inertAgentKeys: [],
@@ -12789,8 +12874,9 @@ function childOutput(result) {
12789
12874
  }
12790
12875
  //#endregion
12791
12876
  //#region src/release/publish/version.ts
12792
- const MAIN_REF = "refs/heads/main";
12793
- const BRANCH_REF = `refs/heads/${VERSION_PR_BRANCH}`;
12877
+ function defaultBranchRef(config) {
12878
+ return `refs/heads/${config.defaultBranch}`;
12879
+ }
12794
12880
  function readRemoteRef(executor, config, auth, ref) {
12795
12881
  const result = executor.exec("git", [
12796
12882
  ...auth.args,
@@ -12802,13 +12888,13 @@ function readRemoteRef(executor, config, auth, ref) {
12802
12888
  return result.stdout.split("\n").find((entry) => entry.trim().endsWith(`\t${ref}`))?.split(" ")[0]?.trim() ?? "";
12803
12889
  }
12804
12890
  function isSuperseded(executor, config, auth, baseSha) {
12805
- const remoteMain = readRemoteRef(executor, config, auth, MAIN_REF);
12806
- if (remoteMain === null || remoteMain === "" || baseSha === "") {
12807
- debug(config, "Supersession check: main tip or base commit unreadable -- proceeding");
12891
+ const remoteDefault = readRemoteRef(executor, config, auth, defaultBranchRef(config));
12892
+ if (remoteDefault === null || remoteDefault === "" || baseSha === "") {
12893
+ debug(config, `Supersession check: ${config.defaultBranch} tip or base commit unreadable -- proceeding`);
12808
12894
  return false;
12809
12895
  }
12810
- if (remoteMain === baseSha) return false;
12811
- log$1.warn(`Superseded release run: this job versioned ${baseSha}, but main has advanced to ${remoteMain}. A newer run is versioning a superset of these changesets -- skipping to avoid overwriting its version commit on ${VERSION_PR_BRANCH}.`);
12896
+ if (remoteDefault === baseSha) return false;
12897
+ log$1.warn(`Superseded release run: this job versioned ${baseSha}, but ${config.defaultBranch} has advanced to ${remoteDefault}. A newer run is versioning a superset of these changesets -- skipping to avoid overwriting its version commit on ${versionPrBranch(config.defaultBranch)}.`);
12812
12898
  return true;
12813
12899
  }
12814
12900
  const DATELESS_VERSION_RE = /^## (\d+\.\d+\.\d+(?:-[^\s)]+)?)$/gm;
@@ -12895,12 +12981,14 @@ function buildPrContent(executor, cwd, packagesBefore) {
12895
12981
  async function runVersionMode(executor, config) {
12896
12982
  log$1.info("Changesets detected -- versioning packages");
12897
12983
  const auth = buildGitAuth(config);
12984
+ const branch = versionPrBranch(config.defaultBranch);
12985
+ const branchRef = `refs/heads/${branch}`;
12898
12986
  const baseShaResult = executor.exec("git", ["rev-parse", "HEAD"], { cwd: config.cwd });
12899
12987
  const baseSha = baseShaResult.exitCode === 0 ? baseShaResult.stdout.trim() : "";
12900
- const leaseTip = config.dryRun ? null : readRemoteRef(executor, config, auth, BRANCH_REF);
12901
- if (leaseTip === null && !config.dryRun) throw new FatalError(`Could not read the current tip of ${VERSION_PR_BRANCH} from the remote, so this run cannot take a push lease. Refusing to force-push without one -- an unleased push is what allows a concurrent run's version commit to be silently discarded.`);
12988
+ const leaseTip = config.dryRun ? null : readRemoteRef(executor, config, auth, branchRef);
12989
+ if (leaseTip === null && !config.dryRun) throw new FatalError(`Could not read the current tip of ${branch} from the remote, so this run cannot take a push lease. Refusing to force-push without one -- an unleased push is what allows a concurrent run's version commit to be silently discarded.`);
12902
12990
  debug(config, `Base commit: ${baseSha || "(unknown)"}`);
12903
- debug(config, `Lease tip for ${VERSION_PR_BRANCH}: ${leaseTip === null ? "(dry-run)" : leaseTip || "(absent)"}`);
12991
+ debug(config, `Lease tip for ${branch}: ${leaseTip === null ? "(dry-run)" : leaseTip || "(absent)"}`);
12904
12992
  const packagesBefore = executor.listWorkspacePackages(config.cwd);
12905
12993
  debug(config, `Packages before versioning: ${packagesBefore.map((pkg) => `${pkg.name}@${pkg.version}`).join(", ") || "(none)"}`);
12906
12994
  const changesetConfigPath = path.join(config.cwd, ".changeset", "config.json");
@@ -12962,28 +13050,28 @@ async function runVersionMode(executor, config) {
12962
13050
  pr: "none"
12963
13051
  };
12964
13052
  if (leaseTip === null) throw new UnexpectedError("Push lease was never observed on a non-dry-run release");
12965
- debug(config, `Force-pushing version commit to ${VERSION_PR_BRANCH} (lease: ${leaseTip || "(absent)"})`);
13053
+ debug(config, `Force-pushing version commit to ${branch} (lease: ${leaseTip || "(absent)"})`);
12966
13054
  const pushResult = executor.exec("git", [
12967
13055
  ...auth.args,
12968
13056
  "push",
12969
13057
  auth.url,
12970
- `HEAD:${BRANCH_REF}`,
12971
- `--force-with-lease=${BRANCH_REF}:${leaseTip}`
13058
+ `HEAD:${branchRef}`,
13059
+ `--force-with-lease=${branchRef}:${leaseTip}`
12972
13060
  ], { cwd: config.cwd });
12973
13061
  debugExec(config, "git push", pushResult);
12974
- if (pushResult.exitCode !== 0) throw new FatalError(`git push to ${VERSION_PR_BRANCH} failed (exit code ${String(pushResult.exitCode)}). If the lease was rejected, the branch moved after this run started (expected ${leaseTip || "the branch to be absent"}) -- another run pushed a version commit, and overwriting it would discard that release. This job fails rather than clobber it.\n${pushResult.stderr || pushResult.stdout}`);
13062
+ if (pushResult.exitCode !== 0) throw new FatalError(`git push to ${branch} failed (exit code ${String(pushResult.exitCode)}). If the lease was rejected, the branch moved after this run started (expected ${leaseTip || "the branch to be absent"}) -- another run pushed a version commit, and overwriting it would discard that release. This job fails rather than clobber it.\n${pushResult.stderr || pushResult.stdout}`);
12975
13063
  const conn = {
12976
13064
  serverUrl: config.serverUrl,
12977
13065
  repository: config.repository,
12978
13066
  token: config.token
12979
13067
  };
12980
- const existingPr = await findOpenPr(executor, conn, VERSION_PR_BRANCH);
12981
- debug(config, `Existing open PR for ${VERSION_PR_BRANCH}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
13068
+ const existingPr = await findOpenPr(executor, conn, branch);
13069
+ debug(config, `Existing open PR for ${branch}: ${existingPr === null ? "(none)" : `#${String(existingPr)}`}`);
12982
13070
  if (existingPr === null) try {
12983
13071
  await createPr(executor, conn, {
12984
13072
  title,
12985
- head: VERSION_PR_BRANCH,
12986
- base: "main",
13073
+ head: branch,
13074
+ base: config.defaultBranch,
12987
13075
  body
12988
13076
  });
12989
13077
  log$1.info("Created version PR");
@@ -12993,7 +13081,7 @@ async function runVersionMode(executor, config) {
12993
13081
  };
12994
13082
  } catch (error) {
12995
13083
  debug(config, `createPr failed, retrying as update: ${describeError$1(error)}`);
12996
- const racePr = await findOpenPr(executor, conn, VERSION_PR_BRANCH);
13084
+ const racePr = await findOpenPr(executor, conn, branch);
12997
13085
  if (racePr === null) throw error;
12998
13086
  await updatePr(executor, conn, racePr, {
12999
13087
  title,
@@ -13478,7 +13566,7 @@ const releaseForgejoCommand = defineCommand({
13478
13566
  const result = await runRelease(buildReleaseConfig({
13479
13567
  dryRun,
13480
13568
  verbose: args.verbose === true || isEnvVerbose()
13481
- }), createRealExecutor$1());
13569
+ }), createRealExecutor$1(), processCiEventSource());
13482
13570
  out.json(releasePayload(result, dryRun));
13483
13571
  if (result.mode === "publish") {
13484
13572
  const outputFile = process.env["GITHUB_OUTPUT"];
@@ -13519,6 +13607,7 @@ function buildReleaseConfig(flags) {
13519
13607
  return {
13520
13608
  ...resolved.conn,
13521
13609
  cwd: process.cwd(),
13610
+ defaultBranch: readConfiguredDefaultBranch(process.cwd()),
13522
13611
  dryRun: flags.dryRun ?? false,
13523
13612
  verbose: flags.verbose ?? false
13524
13613
  };
@@ -13532,15 +13621,16 @@ function getCurrentBranch(executor, cwd) {
13532
13621
  "HEAD"
13533
13622
  ], { cwd }).stdout.trim();
13534
13623
  }
13535
- async function runRelease(config, executor) {
13624
+ async function runRelease(config, executor, ciEvent) {
13536
13625
  debug(config, `Repository: ${config.repository} on ${config.serverUrl}`);
13537
13626
  debug(config, `Config: dryRun=${String(config.dryRun)}`);
13538
13627
  const branch = getCurrentBranch(executor, config.cwd);
13539
13628
  debug(config, `Current branch: ${branch}`);
13540
- if (branch !== "main") {
13541
- debug(config, `Skipping release on non-main branch: ${branch}`);
13629
+ if (branch !== config.defaultBranch) {
13630
+ debug(config, `Skipping release: ${branch} is not the configured default branch (${config.defaultBranch})`);
13542
13631
  return { mode: "none" };
13543
13632
  }
13633
+ refuseForgeDefaultMismatch(config.defaultBranch, ciEvent, "release:changesets");
13544
13634
  const identity = await configureGitIdentity(executor, "forgejo", config.cwd, config);
13545
13635
  log$1.info(`Git identity: ${identity.name} <${identity.email}>`);
13546
13636
  const changesetFiles = executor.listChangesetFiles(config.cwd);
@@ -13742,7 +13832,7 @@ const releaseTriggerCommand = defineCommand({
13742
13832
  args: {
13743
13833
  ref: {
13744
13834
  type: "string",
13745
- description: "Git ref to trigger on (default: main)",
13835
+ description: "Git ref to trigger on (default: .tooling.json#defaultBranch, else main)",
13746
13836
  required: false
13747
13837
  },
13748
13838
  bump: {
@@ -13762,7 +13852,7 @@ const releaseTriggerCommand = defineCommand({
13762
13852
  },
13763
13853
  async run({ args }) {
13764
13854
  const out = createCliOutput(args.json === true);
13765
- const ref = args.ref ?? "main";
13855
+ const ref = args.ref ?? readConfiguredDefaultBranch(process.cwd());
13766
13856
  const inputs = {};
13767
13857
  if (args.bump) inputs["bump"] = args.bump;
13768
13858
  if (args.prerelease) inputs["prerelease"] = args.prerelease;
@@ -13779,6 +13869,104 @@ const releaseTriggerCommand = defineCommand({
13779
13869
  }
13780
13870
  });
13781
13871
  //#endregion
13872
+ //#region ../forgejo/src/label-provisioning.ts
13873
+ const LabelSchema$1 = z.object({
13874
+ id: z.number(),
13875
+ name: z.string(),
13876
+ color: z.string().optional(),
13877
+ description: z.string().optional(),
13878
+ exclusive: z.boolean().optional()
13879
+ });
13880
+ const LabelListSchema = z.array(LabelSchema$1);
13881
+ const LABELS_PAGE_SIZE = 100;
13882
+ const LABELS_MAX_PAGES = 20;
13883
+ async function fetchAllForgejoLabels(executor, conn) {
13884
+ const labels = [];
13885
+ for (let page = 1; page <= LABELS_MAX_PAGES; page++) {
13886
+ const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels?limit=${String(LABELS_PAGE_SIZE)}&page=${String(page)}`, { headers: authHeaders$2(conn) });
13887
+ if (!res.ok) await throwForgejoFailure(res, {
13888
+ verb: "list labels",
13889
+ resource: conn.repository
13890
+ }, repoNotFoundMessage(conn));
13891
+ const parsed = LabelListSchema.safeParse(await res.json());
13892
+ if (!parsed.success) throw new UnexpectedError(`Unexpected label-list response: ${parsed.error.message}`);
13893
+ labels.push(...parsed.data);
13894
+ if (parsed.data.length < LABELS_PAGE_SIZE) break;
13895
+ }
13896
+ return labels;
13897
+ }
13898
+ async function resolveLabelIds(executor, conn, names) {
13899
+ if (names.length === 0) return [];
13900
+ const labels = await fetchAllForgejoLabels(executor, conn);
13901
+ return names.map((name) => {
13902
+ const match = labels.find((label) => label.name === name);
13903
+ if (match === void 0) {
13904
+ const known = labels.map((label) => label.name).join(", ");
13905
+ throw new FatalError(`No label named "${name}" found among the repository's labels in ${conn.repository} (organization-level labels are not searched). Known labels: ${known}`);
13906
+ }
13907
+ return match.id;
13908
+ });
13909
+ }
13910
+ const LabelSpecSchema = z.strictObject({
13911
+ name: z.string().min(1),
13912
+ color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
13913
+ description: z.string(),
13914
+ exclusive: z.boolean().optional()
13915
+ });
13916
+ z.record(z.string(), z.array(LabelSpecSchema));
13917
+ //#endregion
13918
+ //#region ../forgejo/src/issue-labels.ts
13919
+ const TimelineEntrySchema = z.object({
13920
+ id: z.number(),
13921
+ type: z.string(),
13922
+ body: z.string().nullish(),
13923
+ created_at: z.string(),
13924
+ label: z.object({ name: z.string() }).nullish(),
13925
+ user: z.object({ login: z.string() }).nullish()
13926
+ });
13927
+ z.array(TimelineEntrySchema).nullable();
13928
+ async function resolveLabelId(executor, conn, name, labelIds) {
13929
+ const cached = labelIds?.get(name);
13930
+ if (cached !== void 0) return cached;
13931
+ const [id] = await resolveLabelIds(executor, conn, [name]);
13932
+ if (id === void 0) throw new UnexpectedError(`Label id resolution returned nothing for "${name}"`);
13933
+ return id;
13934
+ }
13935
+ async function addForgejoIssueLabel(executor, conn, number, name, labelIds) {
13936
+ const id = await resolveLabelId(executor, conn, name, labelIds);
13937
+ const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
13938
+ method: "POST",
13939
+ headers: jsonHeaders(conn),
13940
+ body: JSON.stringify({ labels: [id] })
13941
+ }, { idempotent: true });
13942
+ if (!res.ok) await throwForgejoFailure(res, {
13943
+ verb: "add label",
13944
+ resource: `#${String(number)}`
13945
+ }, issueNotFoundMessage(conn, number));
13946
+ }
13947
+ async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
13948
+ const id = await resolveLabelId(executor, conn, name, labelIds);
13949
+ const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels/${String(id)}`, {
13950
+ method: "DELETE",
13951
+ headers: authHeaders$2(conn)
13952
+ }, { idempotent: true });
13953
+ if (!res.ok) await throwForgejoFailure(res, {
13954
+ verb: "remove label",
13955
+ resource: `#${String(number)}`
13956
+ }, issueNotFoundMessage(conn, number));
13957
+ }
13958
+ async function replaceForgejoIssueLabels(executor, conn, number, labelIds) {
13959
+ const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
13960
+ method: "PUT",
13961
+ headers: jsonHeaders(conn),
13962
+ body: JSON.stringify({ labels: labelIds })
13963
+ }, { idempotent: true });
13964
+ if (!res.ok) await throwForgejoFailure(res, {
13965
+ verb: "replace labels",
13966
+ resource: `#${String(number)}`
13967
+ }, issueNotFoundMessage(conn, number));
13968
+ }
13969
+ //#endregion
13782
13970
  //#region ../forgejo/src/issue-comments.ts
13783
13971
  const CommentResponseSchema = z.object({
13784
13972
  id: z.number(),
@@ -13854,52 +14042,6 @@ async function addForgejoComment(executor, conn, number, body) {
13854
14042
  return normalizeComment(parseCommentOrThrow(await res.json()));
13855
14043
  }
13856
14044
  //#endregion
13857
- //#region ../forgejo/src/label-provisioning.ts
13858
- const LabelSchema$1 = z.object({
13859
- id: z.number(),
13860
- name: z.string(),
13861
- color: z.string().optional(),
13862
- description: z.string().optional(),
13863
- exclusive: z.boolean().optional()
13864
- });
13865
- const LabelListSchema = z.array(LabelSchema$1);
13866
- const LABELS_PAGE_SIZE = 100;
13867
- const LABELS_MAX_PAGES = 20;
13868
- async function fetchAllForgejoLabels(executor, conn) {
13869
- const labels = [];
13870
- for (let page = 1; page <= LABELS_MAX_PAGES; page++) {
13871
- const res = await executor.fetch(`${conn.serverUrl}/api/v1/repos/${conn.repository}/labels?limit=${String(LABELS_PAGE_SIZE)}&page=${String(page)}`, { headers: authHeaders$2(conn) });
13872
- if (!res.ok) await throwForgejoFailure(res, {
13873
- verb: "list labels",
13874
- resource: conn.repository
13875
- }, repoNotFoundMessage(conn));
13876
- const parsed = LabelListSchema.safeParse(await res.json());
13877
- if (!parsed.success) throw new UnexpectedError(`Unexpected label-list response: ${parsed.error.message}`);
13878
- labels.push(...parsed.data);
13879
- if (parsed.data.length < LABELS_PAGE_SIZE) break;
13880
- }
13881
- return labels;
13882
- }
13883
- async function resolveLabelIds(executor, conn, names) {
13884
- if (names.length === 0) return [];
13885
- const labels = await fetchAllForgejoLabels(executor, conn);
13886
- return names.map((name) => {
13887
- const match = labels.find((label) => label.name === name);
13888
- if (match === void 0) {
13889
- const known = labels.map((label) => label.name).join(", ");
13890
- throw new FatalError(`No label named "${name}" found among the repository's labels in ${conn.repository} (organization-level labels are not searched). Known labels: ${known}`);
13891
- }
13892
- return match.id;
13893
- });
13894
- }
13895
- const LabelSpecSchema = z.strictObject({
13896
- name: z.string().min(1),
13897
- color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
13898
- description: z.string(),
13899
- exclusive: z.boolean().optional()
13900
- });
13901
- z.record(z.string(), z.array(LabelSpecSchema));
13902
- //#endregion
13903
14045
  //#region ../forgejo/src/issues.ts
13904
14046
  const IssueResponseSchema = z.object({
13905
14047
  number: z.number(),
@@ -14345,6 +14487,178 @@ function resolveIssueTransport(executor, cwd, target, injectedToken, signal = nu
14345
14487
  };
14346
14488
  }
14347
14489
  //#endregion
14490
+ //#region src/work-status/labels.ts
14491
+ const SCOPE_ORDER = [
14492
+ "agent",
14493
+ "agent-flags",
14494
+ "agent-note",
14495
+ "priority",
14496
+ "other"
14497
+ ];
14498
+ function scopeOf(name) {
14499
+ const separator = name.indexOf("/");
14500
+ if (separator < 0) return "other";
14501
+ const prefix = name.slice(0, separator);
14502
+ if (prefix === "agent" || prefix === "agent-flags" || prefix === "agent-note" || prefix === "priority") return prefix;
14503
+ return "other";
14504
+ }
14505
+ function groupLabels(names, descriptions) {
14506
+ const groups = [];
14507
+ for (const scope of SCOPE_ORDER) {
14508
+ const inScope = names.filter((name) => scopeOf(name) === scope).toSorted((a, b) => a.localeCompare(b)).map((name) => ({
14509
+ name,
14510
+ description: descriptions.get(name) ?? null
14511
+ }));
14512
+ if (inScope.length === 0) continue;
14513
+ groups.push({
14514
+ scope,
14515
+ labels: inScope
14516
+ });
14517
+ }
14518
+ return groups;
14519
+ }
14520
+ const RUNNING_LABEL = /^agent\/[a-z-]+-running$/;
14521
+ const HAND_AUTHORED_LABEL = "agent-flags/hand-authored";
14522
+ function liftedFacts(names) {
14523
+ return {
14524
+ runLive: names.filter((name) => RUNNING_LABEL.test(name)).toSorted(),
14525
+ heldByHand: names.includes(HAND_AUTHORED_LABEL)
14526
+ };
14527
+ }
14528
+ function labelNamesOf(section) {
14529
+ const names = [];
14530
+ for (const group of [...section.pr, ...section.issue]) for (const label of group.labels) names.push(label.name);
14531
+ return names;
14532
+ }
14533
+ function ownerOf(names) {
14534
+ const agent = names.filter((name) => name.startsWith("agent/")).toSorted();
14535
+ const running = agent.find((name) => RUNNING_LABEL.test(name));
14536
+ if (running !== void 0) return {
14537
+ owner: "agent",
14538
+ label: running,
14539
+ running: true
14540
+ };
14541
+ if (names.includes("agent-flags/hand-authored")) return { owner: "person" };
14542
+ const held = agent[0];
14543
+ if (held !== void 0) return {
14544
+ owner: "agent",
14545
+ label: held,
14546
+ running: false
14547
+ };
14548
+ return { owner: "unclaimed" };
14549
+ }
14550
+ //#endregion
14551
+ //#region src/work-status/claim.ts
14552
+ const HANDOVER_ROUTES = "Claim it from the control panel's \"Claim for hand work\", or remove the agent label by hand and run bst work:start again.";
14553
+ function decideClaim(report, enabled) {
14554
+ if (!enabled) return {
14555
+ kind: "answer",
14556
+ exitCode: 0,
14557
+ result: {
14558
+ outcome: "not-agent-repo",
14559
+ reason: "Nothing was claimed: this repository's default branch does not set agent.enabled, so the agent does not work it and there is nothing to claim against."
14560
+ }
14561
+ };
14562
+ if (report.owner === null) return {
14563
+ kind: "answer",
14564
+ exitCode: 1,
14565
+ result: {
14566
+ outcome: "failed",
14567
+ reason: "A label read failed, so a run may be live on this object unseen. Nothing was claimed; retry once the forge read works."
14568
+ }
14569
+ };
14570
+ const held = ownershipOf(report);
14571
+ if (held.kind === "running") return {
14572
+ kind: "answer",
14573
+ exitCode: 1,
14574
+ result: {
14575
+ outcome: "refused",
14576
+ label: held.label,
14577
+ reason: `A run is live on this object (${held.label}); a claim does not stop it.`
14578
+ }
14579
+ };
14580
+ if (held.kind === "person") return {
14581
+ kind: "answer",
14582
+ exitCode: 0,
14583
+ result: { outcome: "already-claimed" }
14584
+ };
14585
+ if (held.kind === "agent") return {
14586
+ kind: "answer",
14587
+ exitCode: 1,
14588
+ result: {
14589
+ outcome: "refused",
14590
+ label: held.label,
14591
+ reason: `The agent holds this object (${held.label}). ${HANDOVER_ROUTES}`
14592
+ }
14593
+ };
14594
+ return { kind: "write" };
14595
+ }
14596
+ function ownershipOf(report) {
14597
+ if (report.labels.state === "ok") {
14598
+ const ownership = ownerOf(labelNamesOf(report.labels));
14599
+ if (ownership.owner === "agent") return ownership.running ? {
14600
+ kind: "running",
14601
+ label: ownership.label
14602
+ } : {
14603
+ kind: "agent",
14604
+ label: ownership.label
14605
+ };
14606
+ if (ownership.owner === "person") return { kind: "person" };
14607
+ return { kind: "unclaimed" };
14608
+ }
14609
+ if (report.owner === "agent") {
14610
+ const live = report.runLive?.[0];
14611
+ return live === void 0 ? {
14612
+ kind: "agent",
14613
+ label: "an agent label"
14614
+ } : {
14615
+ kind: "running",
14616
+ label: live
14617
+ };
14618
+ }
14619
+ return report.owner === "person" ? { kind: "person" } : { kind: "unclaimed" };
14620
+ }
14621
+ async function writeClaim(executor, cwd, target, number) {
14622
+ try {
14623
+ const { conn } = requireForgejoTransport(executor, cwd, target, "bst work:start claims work on Forgejo, the forge the agent drives; a GitHub checkout has no agent to hand work to.");
14624
+ const flag = (await fetchAllForgejoLabels(executor, conn)).find((label) => label.name === HAND_AUTHORED_LABEL);
14625
+ if (flag === void 0) return {
14626
+ outcome: "failed",
14627
+ reason: `The forge has no label named ${HAND_AUTHORED_LABEL}. Provision the agent's labels first with bst-agent agent:labels --repo ${conn.repository}, or the control panel's Provision labels.`
14628
+ };
14629
+ await addForgejoIssueLabel(executor, conn, number, HAND_AUTHORED_LABEL, /* @__PURE__ */ new Map([[HAND_AUTHORED_LABEL, flag.id]]));
14630
+ return {
14631
+ outcome: "claimed",
14632
+ label: HAND_AUTHORED_LABEL
14633
+ };
14634
+ } catch (error) {
14635
+ return {
14636
+ outcome: "failed",
14637
+ reason: describeError$1(error)
14638
+ };
14639
+ }
14640
+ }
14641
+ //#endregion
14642
+ //#region src/work-status/consent.ts
14643
+ async function readAgentConsent(git, defaultBranch) {
14644
+ const onBranch = await git.show(`origin/${defaultBranch}`, ".tooling.json");
14645
+ if (onBranch !== null) return {
14646
+ enabled: agentEnabledIn(onBranch),
14647
+ source: "default-branch"
14648
+ };
14649
+ return {
14650
+ enabled: agentEnabledIn(readWorkingTree(git.topLevel)),
14651
+ source: "working-tree"
14652
+ };
14653
+ }
14654
+ function readWorkingTree(topLevel) {
14655
+ try {
14656
+ return readFileSync(path.join(topLevel, ".tooling.json"), "utf8");
14657
+ } catch {
14658
+ return;
14659
+ }
14660
+ }
14661
+ //#endregion
14348
14662
  //#region ../forgejo/src/issue-run.ts
14349
14663
  var NothingToUpdateError = class extends FatalError {
14350
14664
  constructor() {
@@ -15236,6 +15550,42 @@ async function createWorkGit(executor, cwd) {
15236
15550
  };
15237
15551
  }
15238
15552
  //#endregion
15553
+ //#region src/work-status/prepare.ts
15554
+ function tryResolveWorkForge(executor, topLevel, target) {
15555
+ try {
15556
+ return resolveWorkForge(executor, topLevel, target);
15557
+ } catch (error) {
15558
+ return error instanceof Error ? error : new UnexpectedError(String(error));
15559
+ }
15560
+ }
15561
+ function sameRepository(one, other) {
15562
+ return one.toLowerCase() === other.toLowerCase();
15563
+ }
15564
+ function refNamesAnotherRepository(checkoutRepository, refRepository) {
15565
+ return new FatalError(`Usage: this command reads this checkout's repository (${checkoutRepository}); the ref names ${refRepository}`);
15566
+ }
15567
+ async function prepareWorkStatus(refText, cwd) {
15568
+ const ref = refText === "" ? null : parseIssueRef(refText);
15569
+ const executor = createRealExecutor$1();
15570
+ const git = await createWorkGit(executor, cwd);
15571
+ const target = ref?.target;
15572
+ if (target !== void 0) {
15573
+ const checkout = await git.originRepository();
15574
+ if (checkout !== null && !sameRepository(target.repository, checkout)) throw refNamesAnotherRepository(checkout, target.repository);
15575
+ }
15576
+ let forge = tryResolveWorkForge(executor, git.topLevel);
15577
+ if (target !== void 0 && !(forge instanceof Error)) {
15578
+ if (!sameRepository(target.repository, forge.repository)) throw refNamesAnotherRepository(forge.repository, target.repository);
15579
+ forge = tryResolveWorkForge(executor, git.topLevel, target);
15580
+ }
15581
+ return {
15582
+ ref,
15583
+ executor,
15584
+ git,
15585
+ forge
15586
+ };
15587
+ }
15588
+ //#endregion
15239
15589
  //#region src/docs-check/discover.ts
15240
15590
  const DOCS_ROOT = "docs";
15241
15591
  const RETIRED_DIR = "retired";
@@ -17498,45 +17848,6 @@ function feedbackSince(comments, headCommitDate, cap, agentLogin) {
17498
17848
  };
17499
17849
  }
17500
17850
  //#endregion
17501
- //#region src/work-status/labels.ts
17502
- const SCOPE_ORDER = [
17503
- "agent",
17504
- "agent-flags",
17505
- "agent-note",
17506
- "priority",
17507
- "other"
17508
- ];
17509
- function scopeOf(name) {
17510
- const separator = name.indexOf("/");
17511
- if (separator < 0) return "other";
17512
- const prefix = name.slice(0, separator);
17513
- if (prefix === "agent" || prefix === "agent-flags" || prefix === "agent-note" || prefix === "priority") return prefix;
17514
- return "other";
17515
- }
17516
- function groupLabels(names, descriptions) {
17517
- const groups = [];
17518
- for (const scope of SCOPE_ORDER) {
17519
- const inScope = names.filter((name) => scopeOf(name) === scope).toSorted((a, b) => a.localeCompare(b)).map((name) => ({
17520
- name,
17521
- description: descriptions.get(name) ?? null
17522
- }));
17523
- if (inScope.length === 0) continue;
17524
- groups.push({
17525
- scope,
17526
- labels: inScope
17527
- });
17528
- }
17529
- return groups;
17530
- }
17531
- const RUNNING_LABEL = /^agent\/[a-z-]+-running$/;
17532
- const HAND_AUTHORED_LABEL = "agent-flags/hand-authored";
17533
- function liftedFacts(names) {
17534
- return {
17535
- runLive: names.filter((name) => RUNNING_LABEL.test(name)).toSorted(),
17536
- heldByHand: names.includes(HAND_AUTHORED_LABEL)
17537
- };
17538
- }
17539
- //#endregion
17540
17851
  //#region src/work-status/report.ts
17541
17852
  const COMMIT_CAP = 20;
17542
17853
  const FEEDBACK_CAP = 20;
@@ -17772,11 +18083,14 @@ function objectSection({ resolved, forgeDown }) {
17772
18083
  return ok(facts);
17773
18084
  }
17774
18085
  function liftedFactsOf(resolved, forgeDown) {
17775
- const read = liftedFacts(labelNames([resolved.pr, resolved.issue]));
18086
+ const names = labelNames([resolved.pr, resolved.issue]);
18087
+ const read = liftedFacts(names);
18088
+ const ownership = ownerOf(names);
17776
18089
  const complete = forgeDown === null && resolved.prFailed === null && resolved.issueFailed === null;
17777
18090
  return {
17778
18091
  runLive: complete || read.runLive.length > 0 ? read.runLive : null,
17779
- heldByHand: complete || read.heldByHand ? read.heldByHand : null
18092
+ heldByHand: complete || read.heldByHand ? read.heldByHand : null,
18093
+ owner: complete || ownership.owner !== "unclaimed" ? ownership.owner : null
17780
18094
  };
17781
18095
  }
17782
18096
  async function docsSection({ git, docsExtraDirs, resolved }) {
@@ -17892,6 +18206,7 @@ async function buildWorkStatus(deps, ref) {
17892
18206
  },
17893
18207
  runLive: lifted.runLive,
17894
18208
  heldByHand: lifted.heldByHand,
18209
+ owner: lifted.owner,
17895
18210
  object: await section(() => objectSection(inputs)),
17896
18211
  docs: await section(() => docsSection(inputs)),
17897
18212
  labels: await section(() => labelsSection(inputs)),
@@ -17933,13 +18248,22 @@ function heading({ kind, branch, head }) {
17933
18248
  if (head !== null) named.push(`head ${short(head)}`);
17934
18249
  return `work:status ${named.join(", ")}`;
17935
18250
  }
17936
- function renderWorkStatus(report) {
18251
+ function claimableNumber(report) {
18252
+ if (report.object.state !== "ok") return null;
18253
+ if (report.target.kind === "issue") return report.object.issue?.number ?? null;
18254
+ return report.object.pr?.number ?? report.object.issue?.number ?? null;
18255
+ }
18256
+ function renderWorkStatus(report, options) {
17937
18257
  const quiet = nothingInFlight(report);
17938
18258
  if (quiet !== null) return [heading(report.target), quiet].join("\n");
17939
18259
  const lines = [heading(report.target)];
17940
18260
  for (const label of report.runLive ?? []) lines.push(`RUN LIVE: ${label} -- a run is working this object; do not start another.`);
17941
18261
  if (report.runLive === null || report.heldByHand === null) lines.push("UNKNOWN: whether a run is live or a human holds this -- not every label could be read.");
17942
18262
  if (report.heldByHand === true) lines.push("HELD BY HAND: agent-flags/hand-authored -- a human claimed this one; do not drive it.");
18263
+ if (options?.agentEnabled === true && report.owner === "unclaimed") {
18264
+ const number = claimableNumber(report);
18265
+ if (number !== null) lines.push(`UNCLAIMED: no agent label and no hand claim -- run \`bst work:start ${String(number)}\` before you work this.`);
18266
+ }
17943
18267
  lines.push(...block("object", report.object, objectRows));
17944
18268
  lines.push(...block("docs", report.docs, (value) => {
17945
18269
  const rows = [` base ${value.base}`];
@@ -17988,20 +18312,77 @@ function renderWorkStatus(report) {
17988
18312
  return lines.join("\n");
17989
18313
  }
17990
18314
  //#endregion
17991
- //#region src/commands/work-status.ts
17992
- function tryResolveWorkForge(executor, topLevel, target) {
17993
- try {
17994
- return resolveWorkForge(executor, topLevel, target);
17995
- } catch (error) {
17996
- return error instanceof Error ? error : new UnexpectedError(String(error));
17997
- }
18315
+ //#region src/commands/work-start.ts
18316
+ function claimLine(result) {
18317
+ if (result.outcome === "claimed") return `claim: claimed -- ${result.label ?? "agent-flags/hand-authored"} is on this object.`;
18318
+ if (result.outcome === "already-claimed") return "claim: already-claimed -- the flag is already on it; nothing was written.";
18319
+ if (result.outcome === "not-agent-repo") return `claim: not-agent-repo -- ${result.reason ?? "nothing was claimed."}`;
18320
+ return `claim: ${result.outcome} -- ${result.reason ?? "nothing was claimed."}`;
17998
18321
  }
17999
- function sameRepository(one, other) {
18000
- return one.toLowerCase() === other.toLowerCase();
18001
- }
18002
- function refNamesAnotherRepository(checkoutRepository, refRepository) {
18003
- return new FatalError(`Usage: bst work:status reads this checkout's repository (${checkoutRepository}); the ref names ${refRepository}`);
18322
+ function claimFailed(result) {
18323
+ return result.outcome === "refused" || result.outcome === "failed";
18004
18324
  }
18325
+ const workStartCommand = defineCommand({
18326
+ meta: {
18327
+ name: "work:start",
18328
+ description: "Claim an issue or pull request for hand work: put agent-flags/hand-authored on it so the agent leaves it alone"
18329
+ },
18330
+ args: {
18331
+ ref: {
18332
+ type: "positional",
18333
+ required: true,
18334
+ description: "Issue or pull request number or URL to claim"
18335
+ },
18336
+ json: {
18337
+ type: "boolean",
18338
+ description: "Emit one JSON document: the whole work:status report plus a claim member {outcome, label?, reason?}. outcome is claimed (the flag was written), already-claimed (it was already on the object, nothing written), not-agent-repo (the default branch does not set agent.enabled, nothing written), refused (a live run or an agent label holds it; exit 1), or failed (a read or write could not be done; exit 1). refused and failed carry ok: false and an error member of kind runtime, and the report survives beside them."
18339
+ },
18340
+ verbose: {
18341
+ type: "boolean",
18342
+ description: "Emit detailed debug logging"
18343
+ }
18344
+ },
18345
+ async run({ args }) {
18346
+ const { ref, executor, git, forge } = await prepareWorkStatus(typeof args.ref === "string" ? args.ref.trim() : "", process.cwd());
18347
+ const number = ref?.number;
18348
+ if (number === void 0) throw new FatalError("Usage: bst work:start needs an issue or pull request number or URL");
18349
+ const docs = resolveDocsConfig(git.topLevel);
18350
+ const defaultBranch = resolveDefaultBranch({ cwd: git.topLevel });
18351
+ const report = await buildWorkStatus({
18352
+ git,
18353
+ forge,
18354
+ docsExtraDirs: docs.extraDirs,
18355
+ defaultBranch
18356
+ }, ref);
18357
+ const consent = await readAgentConsent(git, defaultBranch);
18358
+ const decision = decideClaim(report, consent.enabled);
18359
+ let result;
18360
+ let exitCode;
18361
+ if (decision.kind === "write") {
18362
+ result = await writeClaim(executor, git.topLevel, ref?.target, number);
18363
+ exitCode = claimFailed(result) ? 1 : 0;
18364
+ } else {
18365
+ result = decision.result;
18366
+ exitCode = decision.exitCode;
18367
+ }
18368
+ const out = createCliOutput(args.json === true);
18369
+ const failed = claimFailed(result);
18370
+ out.json({
18371
+ ok: !failed,
18372
+ ...report,
18373
+ claim: result,
18374
+ ...failed ? { error: {
18375
+ kind: "runtime",
18376
+ message: result.reason ?? result.outcome
18377
+ } } : {}
18378
+ });
18379
+ out.text(renderWorkStatus(report, { agentEnabled: consent.enabled }));
18380
+ out.text(claimLine(result));
18381
+ if (exitCode !== 0) process.exitCode = exitCode;
18382
+ }
18383
+ });
18384
+ //#endregion
18385
+ //#region src/commands/work-status.ts
18005
18386
  const workStatusCommand = defineCommand({
18006
18387
  meta: {
18007
18388
  name: "work:status",
@@ -18015,7 +18396,7 @@ const workStatusCommand = defineCommand({
18015
18396
  },
18016
18397
  json: {
18017
18398
  type: "boolean",
18018
- description: "Emit one JSON document: target, runLive, heldByHand (each null when not every label could be read), and six sections (object, docs, labels, feedback, findings, commits), each {state: ok} with its fields, {state: absent, reason} or {state: unavailable, reason}. The output is advice, not a gate: the feedback cutoff is the newest agent run or review comment on the pull request and is approximate, and review comments on diff lines are not in the thread this reads."
18399
+ description: "Emit one JSON document: target, runLive, heldByHand, owner (agent, person or unclaimed; each null when not every label could be read), and six sections (object, docs, labels, feedback, findings, commits), each {state: ok} with its fields, {state: absent, reason} or {state: unavailable, reason}. The output is advice, not a gate: the feedback cutoff is the newest agent run or review comment on the pull request and is approximate, and review comments on diff lines are not in the thread this reads."
18019
18400
  },
18020
18401
  verbose: {
18021
18402
  type: "boolean",
@@ -18023,20 +18404,7 @@ const workStatusCommand = defineCommand({
18023
18404
  }
18024
18405
  },
18025
18406
  async run({ args }) {
18026
- const refText = typeof args.ref === "string" ? args.ref.trim() : "";
18027
- const ref = refText === "" ? null : parseIssueRef(refText);
18028
- const executor = createRealExecutor$1();
18029
- const git = await createWorkGit(executor, process.cwd());
18030
- const target = ref?.target;
18031
- if (target !== void 0) {
18032
- const checkout = await git.originRepository();
18033
- if (checkout !== null && !sameRepository(target.repository, checkout)) throw refNamesAnotherRepository(checkout, target.repository);
18034
- }
18035
- let forge = tryResolveWorkForge(executor, git.topLevel);
18036
- if (target !== void 0 && !(forge instanceof Error)) {
18037
- if (!sameRepository(target.repository, forge.repository)) throw refNamesAnotherRepository(forge.repository, target.repository);
18038
- forge = tryResolveWorkForge(executor, git.topLevel, target);
18039
- }
18407
+ const { ref, git, forge } = await prepareWorkStatus(typeof args.ref === "string" ? args.ref.trim() : "", process.cwd());
18040
18408
  const docs = resolveDocsConfig(git.topLevel);
18041
18409
  const defaultBranch = resolveDefaultBranch({ cwd: git.topLevel });
18042
18410
  const report = await buildWorkStatus({
@@ -18045,12 +18413,13 @@ const workStatusCommand = defineCommand({
18045
18413
  docsExtraDirs: docs.extraDirs,
18046
18414
  defaultBranch
18047
18415
  }, ref);
18416
+ const consent = await readAgentConsent(git, defaultBranch);
18048
18417
  const out = createCliOutput(args.json === true);
18049
18418
  out.json({
18050
18419
  ok: true,
18051
18420
  ...report
18052
18421
  });
18053
- out.text(renderWorkStatus(report));
18422
+ out.text(renderWorkStatus(report, { agentEnabled: consent.enabled }));
18054
18423
  }
18055
18424
  });
18056
18425
  //#endregion
@@ -18306,8 +18675,9 @@ const PASSED_VERDICT = {
18306
18675
  reason: null
18307
18676
  };
18308
18677
  async function mergeVersionPrForgejo(executor, conn, options) {
18309
- const prNumber = await findOpenPr(executor, conn, VERSION_PR_BRANCH);
18310
- if (prNumber === null) throw new FatalError(`No open PR found for branch ${VERSION_PR_BRANCH}`);
18678
+ const branch = versionPrBranch(options.defaultBranch);
18679
+ const prNumber = await findOpenPr(executor, conn, branch);
18680
+ if (prNumber === null) throw new FatalError(`No open PR found for branch ${branch}`);
18311
18681
  const sleep$1 = options.sleep ?? sleep;
18312
18682
  const recheckDelayMs = envMsOverride(process.env["BST_TEST_VERSION_MERGE_RECHECK_MS"], RECHECK_DELAY_MS);
18313
18683
  const confirmation = await confirmMergeable(executor, conn, prNumber, {
@@ -18328,12 +18698,12 @@ async function mergeVersionPrForgejo(executor, conn, options) {
18328
18698
  headSha,
18329
18699
  mergeBase: decidedFrom.mergeBase,
18330
18700
  changedFiles: decidedFrom.changedFiles,
18331
- branch: VERSION_PR_BRANCH,
18701
+ branch,
18332
18702
  rechecked,
18333
18703
  verdict: { ...PASSED_VERDICT }
18334
18704
  };
18335
18705
  if (options.dryRun) {
18336
- log$1.info(`[dry-run] Would merge PR #${String(prNumber)} at ${headSha} and delete branch ${VERSION_PR_BRANCH}`);
18706
+ log$1.info(`[dry-run] Would merge PR #${String(prNumber)} at ${headSha} and delete branch ${branch}`);
18337
18707
  return {
18338
18708
  ...outcome,
18339
18709
  status: "would-merge"
@@ -18344,7 +18714,7 @@ async function mergeVersionPrForgejo(executor, conn, options) {
18344
18714
  deleteBranch: true,
18345
18715
  expectedHeadSha: headSha
18346
18716
  });
18347
- log$1.info(`Merged PR #${String(prNumber)} and deleted branch ${VERSION_PR_BRANCH}`);
18717
+ log$1.info(`Merged PR #${String(prNumber)} and deleted branch ${branch}`);
18348
18718
  return {
18349
18719
  ...outcome,
18350
18720
  status: "merged"
@@ -18359,16 +18729,17 @@ const GitHubVersionPrSchema = z.object({
18359
18729
  changedFiles: z.number()
18360
18730
  });
18361
18731
  function mergeVersionPrGitHub(executor, options) {
18732
+ const branch = versionPrBranch(options.defaultBranch);
18362
18733
  const view = executor.exec("gh", [
18363
18734
  "pr",
18364
18735
  "view",
18365
- VERSION_PR_BRANCH,
18736
+ branch,
18366
18737
  "--json",
18367
18738
  "number,state,isDraft,mergeable,headRefOid,changedFiles"
18368
18739
  ], { cwd: process.cwd() });
18369
- if (view.exitCode !== 0) throw new FatalError(`Failed to read PR for branch ${VERSION_PR_BRANCH}: ${view.stderr || view.stdout || "unknown error"}`);
18740
+ if (view.exitCode !== 0) throw new FatalError(`Failed to read PR for branch ${branch}: ${view.stderr || view.stdout || "unknown error"}`);
18370
18741
  const parsed = GitHubVersionPrSchema.safeParse(JSON.parse(view.stdout));
18371
- if (!parsed.success) throw new FatalError(`Unexpected 'gh pr view' response for branch ${VERSION_PR_BRANCH}: ${parsed.error.message}`);
18742
+ if (!parsed.success) throw new FatalError(`Unexpected 'gh pr view' response for branch ${branch}: ${parsed.error.message}`);
18372
18743
  const data = parsed.data;
18373
18744
  const emptiness = data.changedFiles === 0 ? "empty" : "non-empty";
18374
18745
  const mergeable = data.mergeable === "CONFLICTING" ? "conflicted" : "mergeable";
@@ -18387,12 +18758,12 @@ function mergeVersionPrGitHub(executor, options) {
18387
18758
  headSha,
18388
18759
  mergeBase: null,
18389
18760
  changedFiles: data.changedFiles,
18390
- branch: VERSION_PR_BRANCH,
18761
+ branch,
18391
18762
  rechecked: false,
18392
18763
  verdict: { ...PASSED_VERDICT }
18393
18764
  };
18394
18765
  if (options.dryRun) {
18395
- log$1.info(`[dry-run] Would merge PR #${String(data.number)} at ${headSha} and delete branch ${VERSION_PR_BRANCH}`);
18766
+ log$1.info(`[dry-run] Would merge PR #${String(data.number)} at ${headSha} and delete branch ${branch}`);
18396
18767
  return {
18397
18768
  ...outcome,
18398
18769
  status: "would-merge"
@@ -18401,14 +18772,14 @@ function mergeVersionPrGitHub(executor, options) {
18401
18772
  const result = executor.exec("gh", [
18402
18773
  "pr",
18403
18774
  "merge",
18404
- VERSION_PR_BRANCH,
18775
+ branch,
18405
18776
  "--merge",
18406
18777
  "--delete-branch",
18407
18778
  "--match-head-commit",
18408
18779
  headSha
18409
18780
  ], { cwd: process.cwd() });
18410
18781
  if (result.exitCode !== 0) throw new FatalError(`Failed to merge PR: ${result.stderr || result.stdout || "unknown error"}`);
18411
- log$1.info(`Merged changesets PR and deleted branch ${VERSION_PR_BRANCH}`);
18782
+ log$1.info(`Merged changesets PR and deleted branch ${branch}`);
18412
18783
  return {
18413
18784
  ...outcome,
18414
18785
  status: "merged"
@@ -18436,7 +18807,14 @@ const releaseMergeCommand = defineCommand({
18436
18807
  const dryRun = args["dry-run"] === true;
18437
18808
  const resolved = resolveConnection(process.cwd());
18438
18809
  const executor = createRealExecutor$1();
18439
- const result = resolved.type === "forgejo" ? await mergeVersionPrForgejo(executor, resolved.conn, { dryRun }) : mergeVersionPrGitHub(executor, { dryRun });
18810
+ const defaultBranch = readConfiguredDefaultBranch(process.cwd());
18811
+ const result = resolved.type === "forgejo" ? await mergeVersionPrForgejo(executor, resolved.conn, {
18812
+ dryRun,
18813
+ defaultBranch
18814
+ }) : mergeVersionPrGitHub(executor, {
18815
+ dryRun,
18816
+ defaultBranch
18817
+ });
18440
18818
  out.json(result);
18441
18819
  }
18442
18820
  });
@@ -20896,6 +21274,102 @@ async function runHooksCheck(config) {
20896
21274
  };
20897
21275
  }
20898
21276
  //#endregion
21277
+ //#region src/commands/setup-agent.ts
21278
+ const RawToolingConfigSchema = z.record(z.string(), z.unknown());
21279
+ function previousState(saved) {
21280
+ const enabled = saved.agent?.enabled;
21281
+ if (enabled === void 0) return "absent";
21282
+ if (enabled === true) return "true";
21283
+ return "false";
21284
+ }
21285
+ function withAgentEnabled(raw) {
21286
+ const agent = RawToolingConfigSchema.safeParse(raw["agent"]);
21287
+ const block = agent.success ? { ...agent.data } : {};
21288
+ block["enabled"] = true;
21289
+ return {
21290
+ ...raw,
21291
+ agent: block
21292
+ };
21293
+ }
21294
+ const CHANGE_ACTIONS = /* @__PURE__ */ new Set([
21295
+ "created",
21296
+ "updated",
21297
+ "removed",
21298
+ "archived"
21299
+ ]);
21300
+ async function runSetupAgent(targetDir, options) {
21301
+ const saved = loadToolingConfigStrict(targetDir);
21302
+ if (saved === void 0) throw new FatalError(`No .tooling.json in ${targetDir}. Run bst repo:sync first to create one, so the repo's own conventions are chosen before the agent is enabled.`);
21303
+ const previous = previousState(saved);
21304
+ let consent = "already-enabled";
21305
+ if (previous !== "true") {
21306
+ const displayPath = path.join(targetDir, ".tooling.json");
21307
+ let parsedFile;
21308
+ try {
21309
+ parsedFile = JSON.parse(readFileSync(displayPath, "utf8"));
21310
+ } catch (error) {
21311
+ throw new FatalError(`Could not read ${displayPath} as JSON: ${describeError$1(error)}`);
21312
+ }
21313
+ const rawFile = RawToolingConfigSchema.safeParse(parsedFile);
21314
+ if (!rawFile.success) throw new FatalError(`Could not read ${displayPath} as a config object: ${rawFile.error.message}`);
21315
+ const edited = withAgentEnabled(rawFile.data);
21316
+ const content = `${JSON.stringify(edited, null, 2)}\n`;
21317
+ parseToolingConfigStrict(content, displayPath);
21318
+ const context = createContext(mergeWithSavedConfig(buildDefaultConfig(targetDir, {}), saved), async () => "overwrite");
21319
+ context.ctx.write(".tooling.json", context.ctx.format(".tooling.json", content));
21320
+ consent = "written";
21321
+ }
21322
+ if (consent === "written") log$1.success("Set agent.enabled to true in .tooling.json");
21323
+ else log$1.info("agent.enabled is already true");
21324
+ const report = await runUpdateReport(targetDir, {}, { installOutput: options.installOutput });
21325
+ const failed = failedResults(report.results);
21326
+ const changed = report.results.filter((result) => CHANGE_ACTIONS.has(result.action)).map((result) => ({
21327
+ filePath: result.filePath,
21328
+ action: result.action
21329
+ }));
21330
+ let status;
21331
+ if (report.status === "stopped" || failed.length > 0) status = "failed";
21332
+ else if (changed.length > 0) status = "changed";
21333
+ else status = "up-to-date";
21334
+ if (status === "failed") log$1.error("The sync failed. Fix what it reported, then run bst setup:agent again.");
21335
+ else log$1.info("Commit these changes and land them on the default branch; the agent reads consent there within about a minute.");
21336
+ return {
21337
+ dir: targetDir,
21338
+ consent,
21339
+ previous,
21340
+ sync: {
21341
+ status,
21342
+ changed,
21343
+ failed,
21344
+ install: report.install
21345
+ },
21346
+ exitCode: status === "failed" ? 1 : 0
21347
+ };
21348
+ }
21349
+ const setupAgentCommand = defineCommand({
21350
+ meta: {
21351
+ name: "setup:agent",
21352
+ description: "Enable the agent for this repository: set agent.enabled to true in .tooling.json, then run the same sync as repo:sync, which installs @bensandee/agent-cli and generates the files the agent reads. Commits nothing -- land the change on the default branch yourself."
21353
+ },
21354
+ args: {
21355
+ dir: {
21356
+ type: "positional",
21357
+ valueHint: "dir",
21358
+ description: "Directory to enable (default: current directory)",
21359
+ required: false
21360
+ },
21361
+ json: {
21362
+ type: "boolean",
21363
+ description: "Output the report as JSON: dir, consent, previous, sync { status, changed, failed, install }, exitCode"
21364
+ }
21365
+ },
21366
+ async run({ args }) {
21367
+ const report = await runSetupAgent(path.resolve(args.dir ?? "."), { installOutput: args.json === true || isJsonMode() ? "capture" : "inherit" });
21368
+ createCliOutput(args.json === true).json(report);
21369
+ process.exitCode = report.exitCode;
21370
+ }
21371
+ });
21372
+ //#endregion
20899
21373
  //#region src/commands/setup-secrets.ts
20900
21374
  function isCancelled(value) {
20901
21375
  return clack.isCancel(value);
@@ -21840,6 +22314,7 @@ const coverageRecordCommand = defineCommand({
21840
22314
  });
21841
22315
  return;
21842
22316
  }
22317
+ refuseForgeDefaultMismatch(readConfiguredDefaultBranch(targetDir), processCiEventSource(), "coverage:record");
21843
22318
  const summaryPath = path.join(targetDir, COVERAGE_SUMMARY_FILE);
21844
22319
  if (!existsSync(summaryPath)) throw new FatalError(`${COVERAGE_SUMMARY_FILE} not found. Run \`pnpm test:coverage\` before \`coverage:record\`.`);
21845
22320
  const summary = parseV8Summary(readFileSync(summaryPath, "utf-8"));
@@ -23312,6 +23787,7 @@ const healthRecordCommand = defineCommand({
23312
23787
  });
23313
23788
  return;
23314
23789
  }
23790
+ refuseForgeDefaultMismatch(readConfiguredDefaultBranch(targetDir), processCiEventSource(), "health:record");
23315
23791
  const executor = createRealHealthExecutor(targetDir);
23316
23792
  const headResult = executor.exec("git", ["rev-parse", "HEAD"], { cwd: targetDir });
23317
23793
  if (headResult.exitCode !== 0) throw new FatalError(`Failed to resolve HEAD sha: ${headResult.stderr.trim()}`);
@@ -25663,58 +26139,6 @@ const issueDownloadCommand = defineCommand({
25663
26139
  }
25664
26140
  });
25665
26141
  //#endregion
25666
- //#region ../forgejo/src/issue-labels.ts
25667
- const TimelineEntrySchema = z.object({
25668
- id: z.number(),
25669
- type: z.string(),
25670
- body: z.string().nullish(),
25671
- created_at: z.string(),
25672
- label: z.object({ name: z.string() }).nullish(),
25673
- user: z.object({ login: z.string() }).nullish()
25674
- });
25675
- z.array(TimelineEntrySchema).nullable();
25676
- async function resolveLabelId(executor, conn, name, labelIds) {
25677
- const cached = labelIds?.get(name);
25678
- if (cached !== void 0) return cached;
25679
- const [id] = await resolveLabelIds(executor, conn, [name]);
25680
- if (id === void 0) throw new UnexpectedError(`Label id resolution returned nothing for "${name}"`);
25681
- return id;
25682
- }
25683
- async function addForgejoIssueLabel(executor, conn, number, name, labelIds) {
25684
- const id = await resolveLabelId(executor, conn, name, labelIds);
25685
- const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
25686
- method: "POST",
25687
- headers: jsonHeaders(conn),
25688
- body: JSON.stringify({ labels: [id] })
25689
- }, { idempotent: true });
25690
- if (!res.ok) await throwForgejoFailure(res, {
25691
- verb: "add label",
25692
- resource: `#${String(number)}`
25693
- }, issueNotFoundMessage(conn, number));
25694
- }
25695
- async function removeForgejoIssueLabel(executor, conn, number, name, labelIds) {
25696
- const id = await resolveLabelId(executor, conn, name, labelIds);
25697
- const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels/${String(id)}`, {
25698
- method: "DELETE",
25699
- headers: authHeaders$2(conn)
25700
- }, { idempotent: true });
25701
- if (!res.ok) await throwForgejoFailure(res, {
25702
- verb: "remove label",
25703
- resource: `#${String(number)}`
25704
- }, issueNotFoundMessage(conn, number));
25705
- }
25706
- async function replaceForgejoIssueLabels(executor, conn, number, labelIds) {
25707
- const res = await executor.fetch(`${issuesBase(conn)}/${String(number)}/labels`, {
25708
- method: "PUT",
25709
- headers: jsonHeaders(conn),
25710
- body: JSON.stringify({ labels: labelIds })
25711
- }, { idempotent: true });
25712
- if (!res.ok) await throwForgejoFailure(res, {
25713
- verb: "replace labels",
25714
- resource: `#${String(number)}`
25715
- }, issueNotFoundMessage(conn, number));
25716
- }
25717
- //#endregion
25718
26142
  //#region ../forgejo/src/issue-run-labels.ts
25719
26143
  async function labelIssue(resolve, request) {
25720
26144
  const ref = parseIssueRef(request.ref);
@@ -26920,7 +27344,7 @@ async function runCli(config) {
26920
27344
  //#region src/cli-main.ts
26921
27345
  setLogLevel(resolveLogLevel({}));
26922
27346
  setForgejoLogger(log$1);
26923
- const displayVersion = "0.90.0";
27347
+ const displayVersion = "0.91.0";
26924
27348
  const bstSubCommands = {
26925
27349
  "repo:sync": syncCommand,
26926
27350
  "checks:run": runChecksCommand,
@@ -26943,6 +27367,7 @@ const bstSubCommands = {
26943
27367
  "hooks:audit": hooksAuditCommand,
26944
27368
  "hooks:sync": hooksSyncCommand,
26945
27369
  "setup:secrets": setupSecretsCommand,
27370
+ "setup:agent": setupAgentCommand,
26946
27371
  "webhook:send": webhookSendCommand,
26947
27372
  "coverage:summary": coverageSummaryCommand,
26948
27373
  "coverage:check": coverageCheckCommand,
@@ -26976,7 +27401,8 @@ const bstSubCommands = {
26976
27401
  "plan:brief": planBriefCommand,
26977
27402
  "plan:review-package": planReviewPackageCommand,
26978
27403
  "plan:status": planStatusCommand,
26979
- "work:status": workStatusCommand
27404
+ "work:status": workStatusCommand,
27405
+ "work:start": workStartCommand
26980
27406
  };
26981
27407
  async function runBstCli(extraSubCommands = {}) {
26982
27408
  await runCli({