@bensandee/tooling 0.95.0 → 0.96.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.95.0"
178
+ version: "0.96.0"
179
179
  });
180
180
  //#endregion
181
181
  //#region ../cli-kit/src/logger.ts
@@ -1747,7 +1747,8 @@ const LintSweepConfigSchema = z.strictObject({
1747
1747
  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." }) });
1748
1748
  const AgentConfigSchema = z.strictObject({
1749
1749
  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`. `pnpm exec 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." }),
1750
- 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)." }),
1750
+ autonomy: z.boolean().optional().meta({ description: "Retired and ignored -- it armed neither the advance rung nor the sweeps it once did. Advance is now the unit's own `agent-flags/advance` or `agent-flags/autopilot` label, and the trunk-red detector and world-changed retrigger sweep read `agent.trunkRed`. The key is still accepted so an existing config keeps syncing; the agent warns once per launch for a repo that still sets it." }),
1751
+ trunkRed: z.boolean().optional().meta({ description: "Whether this repo's trunk-red detector and world-changed retrigger sweep run (default: on). The detector files a tracking issue when the default branch's combined status settles red and labels it agent/spec, and closes that issue with a comment if the branch recovers on its own before a human acts on it. The retrigger sweep re-queues a pull request labelled agent-flags/autopilot parked at a -stuck or -failed terminal when the fact its park recorded stops being true (the default branch went green, or moved past a conflicted base). A missing key means on; false disarms both." }),
1751
1752
  preRunRebase: z.boolean().optional().meta({ description: "Merge the current default branch into the work branch before the container starts (default true) -- for an implement (execute) run, and for a spec or plan rerun on an existing branch. 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 -- a conflict, timeout or failed merge never fails the run, for any phase. Only the execute phase keeps the two-strike rule: two consecutive conflicting attempts hand the unit to agent/repair before any container runs, while a docs run just tries again on its next slice. Set false to skip the attempt entirely for this repo, in every phase: no fetch, no merge, no abort, and the branch is worked exactly as cloned." }),
1752
1753
  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." }),
1753
1754
  lintSweep: LintSweepConfigSchema.optional().meta({ description: "The lint-warning sweep: a periodic agent job that measures this repo's oxlint warnings on its default branch in a container and, past minWarnings, opens a pull request carrying a generated plan for an agent/implement run to work off." }),
@@ -1829,7 +1830,7 @@ const ToolingConfigSchema = z.strictObject({
1829
1830
  ]).optional().meta({ description: "What compiles the project. Detected from the package.json build script, then config-file presence. Setting this key pins its value, even one that equals detection, and repo:sync keeps it until the key is deleted." }),
1830
1831
  checks: ChecksConfigSchema.optional().meta({ description: "Check-pipeline overrides: extra steps to add, and steps to skip (unioned with CI defaults)." }),
1831
1832
  lint: LintConfigSchema.optional().meta({ description: "bst lint:types (the type-aware oxlint gate) overrides. See lint.strictTypeAware." }),
1832
- agent: AgentConfigSchema.optional().meta({ description: "agent:start behaviour overrides. Opt-in: see agent.autonomy for the interior-autonomy gate." }),
1833
+ agent: AgentConfigSchema.optional().meta({ description: "agent:start behaviour overrides. Opt-in: agent.enabled is the consent gate -- every other key in the block does nothing without it." }),
1833
1834
  branchPrune: BranchPruneConfigSchema.optional().meta({ description: "bst branch:prune overrides: mergedDays/backupDays windows, and extra never-touch exclusions layered on top of the hard-coded list." }),
1834
1835
  issues: IssuesConfigSchema.optional().meta({ description: "Repo-level issue policy -- currently just the default reviewer(s) `bst issue:create` assigns. The natural home for any future issue-policy key." })
1835
1836
  });
@@ -2613,6 +2614,13 @@ function resolveComposeFileSet(readFile, projectRoot, composeFiles) {
2613
2614
  //#endregion
2614
2615
  //#region ../forgejo/src/fetcher.ts
2615
2616
  const LONG_FETCH_TIMEOUT_MS = 6e5;
2617
+ function morePagesFollow(res, pageLength, received, pageSize) {
2618
+ if (pageLength === 0) return false;
2619
+ const raw = res.headers.get("x-total-count")?.trim() ?? "";
2620
+ const total = raw === "" ? NaN : Number(raw);
2621
+ if (!Number.isSafeInteger(total) || total < 0) return pageLength >= pageSize;
2622
+ return received < total;
2623
+ }
2616
2624
  //#endregion
2617
2625
  //#region ../forgejo/src/fetch-retry.ts
2618
2626
  const DEFAULT_RETRY_POLICY = {
@@ -5524,7 +5532,7 @@ function buildManagedDevDeps(ctx) {
5524
5532
  const devDeps = { ...rootDevDeps(ctx.config) };
5525
5533
  if (!isMonorepo) Object.assign(devDeps, perPackageDevDeps(ctx.config));
5526
5534
  if (ctx.config.stack !== "none") devDeps["@bensandee/config"] = isWorkspacePackage(ctx, "@bensandee/config") ? "workspace:*" : "0.15.0";
5527
- if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.95.0";
5535
+ if (!isWorkspacePackage(ctx, "@bensandee/tooling")) devDeps["@bensandee/tooling"] = "0.96.0";
5528
5536
  if (ctx.config.useEslintPlugin && ctx.config.stack !== "none") devDeps["@bensandee/eslint-plugin"] = isWorkspacePackage(ctx, "@bensandee/eslint-plugin") ? "workspace:*" : "0.17.0";
5529
5537
  if (wantsAgentCli(ctx)) devDeps[AGENT_CLI_PACKAGE] = "0.1.3";
5530
5538
  if (ctx.config.formatter === "oxfmt") devDeps["oxfmt"] = {
@@ -9535,11 +9543,12 @@ const PLANNING_GUIDE_BODY = [
9535
9543
  "",
9536
9544
  "## Working through a plan",
9537
9545
  "",
9538
- `Do the tasks in order, commit after each, and tick a box only once its result is verified. Each task starts red -- write its test, watch it fail, make it pass -- and its commit body carries the testing guide's \`${TEST_EVIDENCE_PREFIXES.red}\` or \`${TEST_EVIDENCE_PREFIXES.noTest}\` line. A box you cannot finish keeps its text exactly as written and gets one note underneath:`,
9546
+ `Do the tasks in order, commit after each, and tick a box only once its result is verified. Each task starts red -- write its test, watch it fail, make it pass -- and its commit body carries the testing guide's \`${TEST_EVIDENCE_PREFIXES.red}\` or \`${TEST_EVIDENCE_PREFIXES.noTest}\` line. A box you cannot finish, or cannot finish in full, keeps its text exactly as written and gets one of four notes underneath:`,
9539
9547
  "",
9540
9548
  "- `**Progress:**` -- partly done: what landed, what remains, where to pick up. The next pass resumes it.",
9541
9549
  "- `**Blocked:**` -- tried and failing: the error. The next pass retries it.",
9542
9550
  "- `**Declined:**` -- must not be done, with the reason: a standing rule, a repo convention, or a check that found it moot. No pass touches it again. \"Too hard\" or \"out of budget\" is Progress, not Declined.",
9551
+ "- `**Deferred:** Task <n> -- <reason>` -- part of the box waits on a later task. Split that box first, so the work that waits is a box of its own, and defer only that one; deferring the whole box drops the part that does not wait. The note says which task and what in it the box waits on, both on that one line. The same plan-only commit adds `- [ ] **Carried from Task <source>:** <the work>` to that task, which is the work going on there; the target cannot be the retire task. The deferral then resolves the box: it says \"later\", not \"never\". With no carried box in the target, nothing is deferred and the box stays outstanding, counted against every later pass.",
9543
9552
  "",
9544
9553
  "To run a plan through subagents, see the subagents guide."
9545
9554
  ].join("\n");
@@ -10163,7 +10172,7 @@ function reportFailures(failed, command) {
10163
10172
  function generateMigratePrompt(results, config, detected) {
10164
10173
  const sections = [];
10165
10174
  const timestamp = (/* @__PURE__ */ new Date()).toISOString();
10166
- sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.95.0 repo:sync\` on ${timestamp}_\n`);
10175
+ sections.push(`# Migration Prompt\n\n_Generated by \`@bensandee/tooling@0.96.0 repo:sync\` on ${timestamp}_\n`);
10167
10176
  const changed = results.filter((r) => r.action === "created" || r.action === "updated" || r.action === "archived");
10168
10177
  if (changed.length > 0) {
10169
10178
  const items = changed.map((r) => {
@@ -14139,7 +14148,7 @@ const LabelSchema$1 = z.object({
14139
14148
  exclusive: z.boolean().optional()
14140
14149
  });
14141
14150
  const LabelListSchema = z.array(LabelSchema$1);
14142
- const LABELS_PAGE_SIZE = 100;
14151
+ const LABELS_PAGE_SIZE = 50;
14143
14152
  const LABELS_MAX_PAGES = 20;
14144
14153
  async function fetchAllForgejoLabels(executor, conn) {
14145
14154
  const labels = [];
@@ -14152,7 +14161,7 @@ async function fetchAllForgejoLabels(executor, conn) {
14152
14161
  const parsed = LabelListSchema.safeParse(await res.json());
14153
14162
  if (!parsed.success) throw new UnexpectedError(`Unexpected label-list response: ${parsed.error.message}`);
14154
14163
  labels.push(...parsed.data);
14155
- if (parsed.data.length < LABELS_PAGE_SIZE) break;
14164
+ if (!morePagesFollow(res, parsed.data.length, labels.length, LABELS_PAGE_SIZE)) break;
14156
14165
  }
14157
14166
  return labels;
14158
14167
  }
@@ -14276,7 +14285,7 @@ async function listForgejoComments(executor, conn, number, options = {}) {
14276
14285
  }, issueNotFoundMessage(conn, number));
14277
14286
  const parsed = parseCommentListOrThrow(await res.json());
14278
14287
  comments.push(...parsed.map(normalizeComment));
14279
- if (parsed.length < COMMENTS_PAGE_SIZE) break;
14288
+ if (!morePagesFollow(res, parsed.length, comments.length, COMMENTS_PAGE_SIZE)) break;
14280
14289
  if (page === COMMENTS_MAX_PAGES) {
14281
14290
  log.warn(`Comment listing for #${String(number)} hit the ${String(COMMENTS_MAX_PAGES)}-page cap; results may be truncated`);
14282
14291
  options.onPageCap?.();
@@ -14392,7 +14401,10 @@ async function fetchIssueListPage(executor, conn, params) {
14392
14401
  verb: "list issues",
14393
14402
  resource: conn.repository
14394
14403
  }, repoNotFoundMessage(conn));
14395
- return parseIssueListOrThrow(await res.json());
14404
+ return {
14405
+ raw: parseIssueListOrThrow(await res.json()),
14406
+ res
14407
+ };
14396
14408
  }
14397
14409
  function normalizeDetailedIssues(raw, type) {
14398
14410
  const normalized = raw.map(normalizeIssue);
@@ -14403,7 +14415,7 @@ const DETAILED_LIST_MAX_PAGES = 20;
14403
14415
  async function listForgejoIssuesDetailedPaged(executor, conn, query, options) {
14404
14416
  const issues = [];
14405
14417
  for (let page = 1; page <= DETAILED_LIST_MAX_PAGES; page++) {
14406
- const raw = await fetchIssueListPage(executor, conn, issueListParams(query.type, {
14418
+ const { raw } = await fetchIssueListPage(executor, conn, issueListParams(query.type, {
14407
14419
  ...query,
14408
14420
  limit: DETAILED_LIST_PAGE_SIZE,
14409
14421
  page
@@ -18316,22 +18328,90 @@ function taskLabelId(text) {
18316
18328
  return `${digits}${match?.[2] ?? ""}`;
18317
18329
  }
18318
18330
  //#endregion
18331
+ //#region src/plan-workspace/plan-units.ts
18332
+ const AFTER_LABEL = /^[ \t]*(?::|\.|--?)?[ \t]*/;
18333
+ function planTask(headingText, label) {
18334
+ const unwrapped = headingText.replace(/^(\*\*|__)/, "").replace(/(\*\*|__)$/, "");
18335
+ return {
18336
+ label,
18337
+ title: unwrapped.slice(unwrapped.indexOf(label) + label.length).replace(AFTER_LABEL, "").trim()
18338
+ };
18339
+ }
18340
+ function groupPlanUnits(text, boxes) {
18341
+ const lines = text.split("\n");
18342
+ const fenced = fencedLines(lines);
18343
+ const boxAtLine = /* @__PURE__ */ new Map();
18344
+ for (const box of boxes) boxAtLine.set(box.line, box);
18345
+ const units = [];
18346
+ const open = [];
18347
+ for (const [index, rawLine] of lines.entries()) {
18348
+ if (fenced[index] === true) continue;
18349
+ const line = rawLine ?? "";
18350
+ const heading = HEADING.exec(line);
18351
+ if (heading !== null) {
18352
+ const level = (heading[1] ?? "").length;
18353
+ while (open.length > 0 && (open[open.length - 1]?.level ?? 0) >= level) open.pop();
18354
+ const headingText = (heading[2] ?? "").trim();
18355
+ const label = taskLabelId(headingText);
18356
+ if (label === null) continue;
18357
+ const unit = {
18358
+ key: headingText,
18359
+ task: planTask(headingText, label),
18360
+ boxes: []
18361
+ };
18362
+ units.push(unit);
18363
+ open.push({
18364
+ level,
18365
+ unit
18366
+ });
18367
+ continue;
18368
+ }
18369
+ const box = boxAtLine.get(index);
18370
+ if (box === void 0) continue;
18371
+ const section = open[open.length - 1];
18372
+ if (section !== void 0) {
18373
+ section.unit.boxes.push(box);
18374
+ continue;
18375
+ }
18376
+ units.push({
18377
+ key: box.title,
18378
+ task: null,
18379
+ boxes: [box]
18380
+ });
18381
+ }
18382
+ return units;
18383
+ }
18384
+ //#endregion
18319
18385
  //#region src/plan-workspace/plan-boxes.ts
18320
18386
  const BOX_LINE_REGEX = /^[ \t]*[-*] \[([ xX])\][ \t]*(.*)$/;
18321
18387
  const HEADING_REGEX = /^#{1,6} /;
18322
18388
  const DECLINED_LINE_REGEX = /^[ \t]*(?:[-*][ \t]+)?\*\*Declined:\*\*[ \t]*(.*)$/;
18323
- const NOTE_LINE_REGEX = /^[ \t]*(?:[-*][ \t]+)?\*\*(Progress|Blocked|Declined):\*\*[ \t]*(.*)$/;
18389
+ const NOTE_LINE_REGEX = /^[ \t]*(?:[-*][ \t]+)?\*\*(Progress|Blocked|Declined|Deferred):\*\*[ \t]*(.*)$/;
18390
+ const DEFERRED_LINE_REGEX = /^[ \t]*(?:[-*][ \t]+)?\*\*Deferred:\*\*[ \t]*(.*)$/;
18391
+ const DEFERRAL_SEPARATOR_REGEX = /^[ \t]*(?::|\.|--|-)?[ \t]*/;
18324
18392
  const RETIRE_TASK_REGEX = /^(?:\*\*|__)?Retire this plan(?![A-Za-z0-9])/;
18393
+ function describeDeferralCheck(check) {
18394
+ switch (check) {
18395
+ case "empty-reason": return "the note names no reason";
18396
+ case "no-target-label": return "the note names no target task";
18397
+ case "no-own-task": return "the box names no task of its own";
18398
+ case "target-not-found": return "no task in the plan carries that label";
18399
+ case "target-not-later": return "the target task does not come later in the plan";
18400
+ case "target-is-retire": return "the target is the retire task";
18401
+ case "no-carried-box": return "the target task holds no box carried from this one";
18402
+ }
18403
+ }
18325
18404
  function isOutstanding(box, options) {
18326
- if (box.checked || box.declined) return false;
18405
+ if (box.checked || box.declined || box.deferred) return false;
18327
18406
  return !(box.retireTask && options?.planRetired === true);
18328
18407
  }
18329
18408
  function noteKind(label) {
18330
18409
  if (label === "Progress") return "progress";
18331
18410
  if (label === "Blocked") return "blocked";
18411
+ if (label === "Deferred") return "deferred";
18332
18412
  return "declined";
18333
18413
  }
18334
- const PARAGRAPH_BREAK_REGEX = /^[ \t]*(?:[-*+][ \t]|[0-9]+[.)][ \t]|\*\*(?:Progress|Blocked|Declined):\*\*|<!--)/;
18414
+ const PARAGRAPH_BREAK_REGEX = /^[ \t]*(?:[-*+][ \t]|[0-9]+[.)][ \t]|\*\*(?:Progress|Blocked|Declined|Deferred):\*\*|<!--)/;
18335
18415
  const ABBREVIATION_REGEX = /(?:^|[^A-Za-z])(?:e\.g|i\.e|etc|vs|cf)$/i;
18336
18416
  function boxParagraph(lines, fenced, start, end, first) {
18337
18417
  const parts = [first];
@@ -18365,6 +18445,100 @@ function unwrapEmphasis(rawTitle) {
18365
18445
  if (emphasis === void 0 || !rawTitle.endsWith(emphasis)) return rawTitle;
18366
18446
  return rawTitle.slice(emphasis.length, -emphasis.length).trim();
18367
18447
  }
18448
+ function deferredLineOf(lines, fenced, start, blockEnd) {
18449
+ for (let index = start + 1; index < blockEnd; index++) {
18450
+ if (fenced[index] === true) continue;
18451
+ const match = DEFERRED_LINE_REGEX.exec(lines[index] ?? "");
18452
+ if (match === null) continue;
18453
+ return (match[1] ?? "").trim();
18454
+ }
18455
+ return null;
18456
+ }
18457
+ function readDeferralNote(text) {
18458
+ if (text === "") return {
18459
+ target: null,
18460
+ reason: "",
18461
+ failed: "empty-reason"
18462
+ };
18463
+ const target = taskLabelId(text);
18464
+ if (target === null) return {
18465
+ target: null,
18466
+ reason: "",
18467
+ failed: "no-target-label"
18468
+ };
18469
+ const reason = text.slice(text.indexOf(target) + target.length).replace(DEFERRAL_SEPARATOR_REGEX, "").trim();
18470
+ if (reason === "") return {
18471
+ target,
18472
+ reason: "",
18473
+ failed: "empty-reason"
18474
+ };
18475
+ return {
18476
+ target,
18477
+ reason,
18478
+ failed: null
18479
+ };
18480
+ }
18481
+ function ownTaskLabel(box, units) {
18482
+ const unit = units.find((candidate) => candidate.boxes.some((other) => other.line === box.line));
18483
+ const fromSection = unit?.task?.label ?? null;
18484
+ if (fromSection !== null) return fromSection;
18485
+ return unit?.task === void 0 ? null : taskLabelId(box.title);
18486
+ }
18487
+ function holdsCarriedBox(unit, sourceLabel) {
18488
+ const escaped = sourceLabel.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
18489
+ const carried = new RegExp(`^Carried from Task ${escaped}(?![0-9A-Za-z]):`);
18490
+ return unit.boxes.some((box) => carried.test(box.title));
18491
+ }
18492
+ function resolveDeferrals(boxes, units) {
18493
+ for (const { box, deferredLine } of boxes) {
18494
+ if (deferredLine === null || box.checked || box.declined) continue;
18495
+ const deferral = readDeferralNote(deferredLine);
18496
+ box.deferral = deferral;
18497
+ if (deferral.failed !== null || deferral.target === null) continue;
18498
+ const ownIndex = units.findIndex((unit) => unit.boxes.some((other) => other.line === box.line));
18499
+ const sourceLabel = ownTaskLabel(box, units);
18500
+ if (sourceLabel === null) {
18501
+ box.deferral = {
18502
+ ...deferral,
18503
+ failed: "no-own-task"
18504
+ };
18505
+ continue;
18506
+ }
18507
+ const targetIndexes = units.flatMap((unit, index) => unit.task?.label === deferral.target ? [index] : []);
18508
+ if (targetIndexes.length !== 1) {
18509
+ box.deferral = {
18510
+ ...deferral,
18511
+ failed: "target-not-found"
18512
+ };
18513
+ continue;
18514
+ }
18515
+ const targetIndex = targetIndexes[0] ?? -1;
18516
+ if (targetIndex <= ownIndex) {
18517
+ box.deferral = {
18518
+ ...deferral,
18519
+ failed: "target-not-later"
18520
+ };
18521
+ continue;
18522
+ }
18523
+ const target = units[targetIndex];
18524
+ if (target === void 0) continue;
18525
+ if (target.boxes.some((other) => other.retireTask) || target.task !== null && RETIRE_TASK_REGEX.test(target.task.title)) {
18526
+ box.deferral = {
18527
+ ...deferral,
18528
+ failed: "target-is-retire"
18529
+ };
18530
+ continue;
18531
+ }
18532
+ if (!holdsCarriedBox(target, sourceLabel)) {
18533
+ box.deferral = {
18534
+ ...deferral,
18535
+ failed: "no-carried-box"
18536
+ };
18537
+ continue;
18538
+ }
18539
+ box.deferred = true;
18540
+ }
18541
+ }
18368
18542
  function parsePlanBoxes(planText) {
18369
18543
  const lines = planText.split("\n");
18370
18544
  const fenced = fencedLines(lines);
@@ -18379,7 +18553,7 @@ function parsePlanBoxes(planText) {
18379
18553
  rawTitle: (match[2] ?? "").trim()
18380
18554
  });
18381
18555
  }
18382
- return boxStarts.map((box, position) => {
18556
+ const built = boxStarts.map((box, position) => {
18383
18557
  const nextBoxIndex = boxStarts[position + 1]?.index ?? lines.length;
18384
18558
  let blockEnd = nextBoxIndex;
18385
18559
  for (let index = box.index + 1; index < nextBoxIndex; index++) {
@@ -18392,13 +18566,18 @@ function parsePlanBoxes(planText) {
18392
18566
  const title = stripStrong(unwrapEmphasis(firstSentence$1(boxParagraph(lines, fenced, box.index, blockEnd, box.rawTitle))));
18393
18567
  const retireTask = RETIRE_TASK_REGEX.test(title);
18394
18568
  if (box.checked) return {
18395
- line: box.index,
18396
- checked: true,
18397
- declined: false,
18398
- reason: null,
18399
- title,
18400
- retireTask,
18401
- notes: []
18569
+ box: {
18570
+ line: box.index,
18571
+ checked: true,
18572
+ declined: false,
18573
+ reason: null,
18574
+ title,
18575
+ retireTask,
18576
+ notes: [],
18577
+ deferred: false,
18578
+ deferral: null
18579
+ },
18580
+ deferredLine: null
18402
18581
  };
18403
18582
  const notes = [];
18404
18583
  for (let index = box.index + 1; index < blockEnd; index++) {
@@ -18423,15 +18602,22 @@ function parsePlanBoxes(planText) {
18423
18602
  }
18424
18603
  }
18425
18604
  return {
18426
- line: box.index,
18427
- checked: false,
18428
- declined: reason !== null,
18429
- reason,
18430
- title,
18431
- retireTask,
18432
- notes
18605
+ box: {
18606
+ line: box.index,
18607
+ checked: false,
18608
+ declined: reason !== null,
18609
+ reason,
18610
+ title,
18611
+ retireTask,
18612
+ notes,
18613
+ deferred: false,
18614
+ deferral: null
18615
+ },
18616
+ deferredLine: deferredLineOf(lines, fenced, box.index, blockEnd)
18433
18617
  };
18434
18618
  });
18619
+ if (built.some((entry) => entry.deferredLine !== null)) resolveDeferrals(built, groupPlanUnits(planText, built.map((entry) => entry.box)));
18620
+ return built.map((entry) => entry.box);
18435
18621
  }
18436
18622
  function countOutstandingBoxes(planText, options) {
18437
18623
  return parsePlanBoxes(planText).filter((box) => isOutstanding(box, options)).length;
@@ -18462,6 +18648,11 @@ function planStatus(planText, planPath) {
18462
18648
  kind: note.kind,
18463
18649
  text: note.text
18464
18650
  })));
18651
+ const deferrals = boxes.flatMap((box) => box.deferral === null ? [] : [{
18652
+ task: box.title,
18653
+ target: box.deferral.target,
18654
+ failed: box.deferral.failed
18655
+ }]);
18465
18656
  const nextBox = open[0];
18466
18657
  return {
18467
18658
  plan: planPath,
@@ -18469,6 +18660,8 @@ function planStatus(planText, planPath) {
18469
18660
  checked: boxes.filter((box) => box.checked).length,
18470
18661
  outstanding: open.length,
18471
18662
  declined: boxes.filter((box) => box.declined).length,
18663
+ deferred: boxes.filter((box) => box.deferred).length,
18664
+ deferrals,
18472
18665
  next: nextBox === void 0 || nextBox.title === "" ? null : nextBox.title,
18473
18666
  nextTask: nextBox === void 0 ? null : enclosingTaskLabel(planText, nextBox.line),
18474
18667
  onlyRetireLeft: open.length > 0 && open.every((box) => box.retireTask),
@@ -18821,7 +19014,7 @@ async function docsSection({ git, docsExtraDirs, resolved }) {
18821
19014
  path,
18822
19015
  status: {
18823
19016
  ...status,
18824
- total: status.checked + status.outstanding + status.declined
19017
+ total: status.checked + status.outstanding + status.declined + status.deferred
18825
19018
  }
18826
19019
  });
18827
19020
  }
@@ -29006,7 +29199,12 @@ function renderStatus(report) {
29006
29199
  const next = report.next ?? "(none)";
29007
29200
  const task = report.nextTask === null || taskLabelId(next) === report.nextTask ? "" : `Task ${report.nextTask} -- `;
29008
29201
  const lines = [`${report.plan}${report.retired ? " (retired)" : ""}: ${String(report.checked)} done, ${String(report.outstanding)} outstanding, ${String(report.declined)} declined`, `next: ${task}${next}${report.onlyRetireLeft ? " -- only the retire task is left" : ""}`];
29202
+ if (report.deferred > 0) lines.splice(1, 0, `${String(report.deferred)} deferred to a later task`);
29009
29203
  for (const note of report.notes) lines.push(` [${note.kind}] ${note.task}: ${note.text}`);
29204
+ for (const deferral of report.deferrals) {
29205
+ if (deferral.failed === null) continue;
29206
+ lines.push(` [deferred -- not counted] ${deferral.task}: ${describeDeferralCheck(deferral.failed)}.`);
29207
+ }
29010
29208
  return lines.join("\n");
29011
29209
  }
29012
29210
  const planStatusCommand = defineCommand({
@@ -29018,7 +29216,7 @@ const planStatusCommand = defineCommand({
29018
29216
  plan: PLAN_ARG,
29019
29217
  json: {
29020
29218
  type: "boolean",
29021
- description: "Emit one JSON document: plan, retired, checked, outstanding, declined, next (or null), nextTask (a task label string such as \"3\" or \"18a\", or null), onlyRetireLeft, notes ([{task, kind (progress | blocked | declined), text}]). next is null when there is none; nextTask is null when no task label encloses it."
29219
+ description: "Emit one JSON document: plan, retired, checked, outstanding, declined, deferred, deferrals ([{task, target (a task label string or null), failed (empty-reason | no-target-label | no-own-task | target-not-found | target-not-later | target-is-retire | no-carried-box, or null when valid)}]), next (or null), nextTask (a task label string such as \"3\" or \"18a\", or null), onlyRetireLeft, notes ([{task, kind (progress | blocked | declined | deferred), text}]). next is null when there is none; nextTask is null when no task label encloses it."
29022
29220
  }
29023
29221
  },
29024
29222
  run({ args }) {
@@ -29191,7 +29389,7 @@ async function runCli(config) {
29191
29389
  //#region src/cli-main.ts
29192
29390
  setLogLevel(resolveLogLevel({}));
29193
29391
  setForgejoLogger(log$1);
29194
- const displayVersion = "0.95.0";
29392
+ const displayVersion = "0.96.0";
29195
29393
  const bstSubCommands = {
29196
29394
  "repo:sync": syncCommand,
29197
29395
  "checks:run": runChecksCommand,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bensandee/tooling",
3
- "version": "0.95.0",
3
+ "version": "0.96.0",
4
4
  "description": "CLI tool to bootstrap and maintain standardized TypeScript project tooling",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -698,7 +698,7 @@
698
698
  "additionalProperties": false
699
699
  },
700
700
  "agent": {
701
- "description": "agent:start behaviour overrides. Opt-in: see agent.autonomy for the interior-autonomy gate.",
701
+ "description": "agent:start behaviour overrides. Opt-in: agent.enabled is the consent gate -- every other key in the block does nothing without it.",
702
702
  "type": "object",
703
703
  "properties": {
704
704
  "enabled": {
@@ -706,7 +706,11 @@
706
706
  "type": "boolean"
707
707
  },
708
708
  "autonomy": {
709
- "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).",
709
+ "description": "Retired and ignored -- it armed neither the advance rung nor the sweeps it once did. Advance is now the unit's own `agent-flags/advance` or `agent-flags/autopilot` label, and the trunk-red detector and world-changed retrigger sweep read `agent.trunkRed`. The key is still accepted so an existing config keeps syncing; the agent warns once per launch for a repo that still sets it.",
710
+ "type": "boolean"
711
+ },
712
+ "trunkRed": {
713
+ "description": "Whether this repo's trunk-red detector and world-changed retrigger sweep run (default: on). The detector files a tracking issue when the default branch's combined status settles red and labels it agent/spec, and closes that issue with a comment if the branch recovers on its own before a human acts on it. The retrigger sweep re-queues a pull request labelled agent-flags/autopilot parked at a -stuck or -failed terminal when the fact its park recorded stops being true (the default branch went green, or moved past a conflicted base). A missing key means on; false disarms both.",
710
714
  "type": "boolean"
711
715
  },
712
716
  "preRunRebase": {