tldr-experts 0.18.2 → 0.18.3

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/tldrx.js CHANGED
@@ -7290,6 +7290,7 @@ var EVENT_TYPES = [
7290
7290
  "questions.policy_changed",
7291
7291
  "story.reopened",
7292
7292
  "story.base_fastforwarded",
7293
+ "story.base_updated",
7293
7294
  "story.review_retried",
7294
7295
  "story.work_rescued",
7295
7296
  "story.touches_widened",
@@ -8361,6 +8362,16 @@ function withoutSrcToken(line) {
8361
8362
  function hasSrcMarker(line) {
8362
8363
  return line.includes(SRC_MARKER);
8363
8364
  }
8365
+ function unclosedSrcMarker(text) {
8366
+ const open = text.lastIndexOf(SRC_MARKER);
8367
+ if (open < 0)
8368
+ return null;
8369
+ return text.includes("]", open + SRC_MARKER.length) ? null : open;
8370
+ }
8371
+ function foldsUnclosedSrcMarker(line) {
8372
+ const token = parseSrcToken(line);
8373
+ return token !== null && token.raw.indexOf(SRC_MARKER, SRC_MARKER.length) >= 0;
8374
+ }
8364
8375
  function classifySrc(src, repos) {
8365
8376
  const raw = src;
8366
8377
  if (raw === "")
@@ -10405,8 +10416,7 @@ function splitArgv(command) {
10405
10416
  if (/[\n\r]/.test(command))
10406
10417
  return null;
10407
10418
  const argv = [];
10408
- const pattern = /"([^"]*)"|'([^']*)'|([^\s"']+)/g;
10409
- for (const match of command.matchAll(pattern)) {
10419
+ for (const match of command.matchAll(TOKEN_RE)) {
10410
10420
  if (match[1] !== undefined || match[2] !== undefined) {
10411
10421
  argv.push(match[1] ?? match[2] ?? "");
10412
10422
  continue;
@@ -10418,6 +10428,21 @@ function splitArgv(command) {
10418
10428
  }
10419
10429
  return argv.length === 0 ? null : argv;
10420
10430
  }
10431
+ var TOKEN_RE = /"([^"]*)"|'([^']*)'|([^\s"']+)/g;
10432
+ var SEPARATOR_RE = /\|\||&&|2>&1|>>|\$\((?!\()|[|&;<>`]/;
10433
+ function unquotedShellSeparator(command) {
10434
+ if (/[\n\r]/.test(command))
10435
+ return `
10436
+ `;
10437
+ for (const match of command.matchAll(TOKEN_RE)) {
10438
+ if (match[1] !== undefined || match[2] !== undefined)
10439
+ continue;
10440
+ const found = SEPARATOR_RE.exec(match[3] ?? "");
10441
+ if (found !== null)
10442
+ return found[0];
10443
+ }
10444
+ return null;
10445
+ }
10421
10446
 
10422
10447
  class DodCommandRefused extends Error {
10423
10448
  }
@@ -11300,7 +11325,12 @@ function clean(text) {
11300
11325
  const stripped = text.replace(GROUNDING_TAG_RE, "").replace(/\s+/g, " ").replace(/\*\*/g, "").trim();
11301
11326
  if (stripped.length <= MAX_CLAIM_CHARS)
11302
11327
  return stripped;
11303
- return `${stripped.slice(0, MAX_CLAIM_CHARS - 1).trimEnd()}…`;
11328
+ const cut = stripped.slice(0, MAX_CLAIM_CHARS - 1);
11329
+ const open = unclosedSrcMarker(cut);
11330
+ if (open === null)
11331
+ return `${cut.trimEnd()}…`;
11332
+ const kept = cut.slice(0, open).trimEnd();
11333
+ return kept === "" ? stripped : `${kept}…`;
11304
11334
  }
11305
11335
 
11306
11336
  // src/core/distill/aidlcQuestions.ts
@@ -13892,7 +13922,7 @@ function createRunLocked(options) {
13892
13922
  write(temp, handoffPath, handoff, written);
13893
13923
  const check = validateHandoff(handoff, toSrcContext(workspace, temp));
13894
13924
  if (!check.ok) {
13895
- throw new NewRunError(`seeded handoff is invalid: ${describeHandoff(check.missingSections, check.emptySections, check.unsourced, check.unresolved)}`);
13925
+ throw new NewRunError(`seeded handoff is invalid: ${describeHandoff(check.missingSections, check.emptySections, check.unsourced, check.unresolved, handoff)}`);
13896
13926
  }
13897
13927
  }
13898
13928
  let factsYaml = null;
@@ -13926,7 +13956,7 @@ function createRunLocked(options) {
13926
13956
  const srcCtx = toSrcContext(workspace, temp);
13927
13957
  const handoffCheck = validateHandoff(handoff, srcCtx);
13928
13958
  if (!handoffCheck.ok) {
13929
- throw new NewRunError(`distilled handoff is invalid: ${describeHandoff(handoffCheck.missingSections, handoffCheck.emptySections, handoffCheck.unsourced, handoffCheck.unresolved)}`);
13959
+ throw new NewRunError(`distilled handoff is invalid: ${describeHandoff(handoffCheck.missingSections, handoffCheck.emptySections, handoffCheck.unsourced, handoffCheck.unresolved, handoff)}`);
13930
13960
  }
13931
13961
  if (result2.conflicts.length > 0) {
13932
13962
  const text = renderQuestions(runId, phaseId, at, result2.conflicts);
@@ -14096,7 +14126,7 @@ function assertValid(what, validation) {
14096
14126
  const first = validation.issues[0];
14097
14127
  throw new NewRunError(`refusing to create a run with an invalid ${what}: ${first?.path ?? ""} ${first?.message ?? ""}`);
14098
14128
  }
14099
- function describeHandoff(missing, empty, unsourced, unresolved) {
14129
+ function describeHandoff(missing, empty, unsourced, unresolved, text = "") {
14100
14130
  if (missing.length > 0)
14101
14131
  return `missing section(s) ${missing.join(", ")}`;
14102
14132
  if (empty.length > 0) {
@@ -14104,7 +14134,24 @@ function describeHandoff(missing, empty, unsourced, unresolved) {
14104
14134
  }
14105
14135
  if (unsourced.length > 0)
14106
14136
  return `unsourced bullet(s) on line(s) ${unsourced.join(", ")}`;
14107
- return unresolved[0]?.message ?? "unknown problem";
14137
+ const first = unresolved[0];
14138
+ if (first === undefined)
14139
+ return "unknown problem";
14140
+ return `${first.message}${foldedCitationNote(text, first.line)}`;
14141
+ }
14142
+ var MAX_QUOTED_CHARS2 = 120;
14143
+ function foldedCitationNote(text, line) {
14144
+ if (text === "" || line < 1)
14145
+ return "";
14146
+ const raw = text.split(`
14147
+ `)[line - 1];
14148
+ if (raw === undefined || raw.trim() === "")
14149
+ return "";
14150
+ if (!foldsUnclosedSrcMarker(raw))
14151
+ return "";
14152
+ const trimmed = raw.trim();
14153
+ const quoted = trimmed.length <= MAX_QUOTED_CHARS2 ? trimmed : `${trimmed.slice(0, MAX_QUOTED_CHARS2)}…`;
14154
+ return ` — L${String(line)} carries an unterminated \`[src:\` citation, so the path above is two ` + `citations folded into one and is not a file to look for; close or remove the inline citation ` + `in the seed (shorter bullets are safest). L${String(line)} reads \`${quoted}\``;
14108
14155
  }
14109
14156
  function yymmdd(now) {
14110
14157
  return now.toISOString().slice(2, 10).replace(/-/g, "");
@@ -25784,14 +25831,10 @@ async function mergeNoFf(cwd, branch, message4) {
25784
25831
  detail: conflicts.length > 0 ? `conflict in ${conflicts.join(", ")}` : firstLine7(merged.stderr) || firstLine7(merged.stdout) || `git merge exited ${String(merged.exitCode)}`
25785
25832
  };
25786
25833
  }
25787
- async function commitsBetween(cwd, base, head) {
25788
- const result2 = await git(["rev-list", "--count", `${base}..${head}`], cwd);
25789
- if (!result2.ok)
25790
- return 0;
25791
- const n = Number.parseInt(result2.stdout.trim(), 10);
25792
- return Number.isFinite(n) ? n : 0;
25834
+ function uncountedCount(base, head) {
25835
+ return `\`git rev-list --count ${base}..${head}\` could not count what it carries`;
25793
25836
  }
25794
- async function commitsAhead(cwd, base, head) {
25837
+ async function commitsBetween(cwd, base, head) {
25795
25838
  const result2 = await git(["rev-list", "--count", `${base}..${head}`], cwd);
25796
25839
  if (!result2.ok)
25797
25840
  return null;
@@ -25799,12 +25842,15 @@ async function commitsAhead(cwd, base, head) {
25799
25842
  return Number.isFinite(n) ? n : null;
25800
25843
  }
25801
25844
  async function baseStateOf(cwd, branch, base) {
25802
- const ahead = await commitsBetween(cwd, base, branch);
25803
- const behind = await commitsBetween(cwd, branch, base);
25845
+ const counted = await commitsBetween(cwd, base, branch);
25846
+ const countedBack = await commitsBetween(cwd, branch, base);
25847
+ const ahead = counted ?? 0;
25848
+ const behind = countedBack ?? 0;
25804
25849
  return {
25805
25850
  state: behind === 0 ? "current" : ahead === 0 ? "behind" : "diverged",
25806
25851
  ahead,
25807
25852
  behind,
25853
+ uncounted: counted === null ? uncountedCount(base, branch) : countedBack === null ? uncountedCount(branch, base) : null,
25808
25854
  branchSha: await shaOf(cwd, branch),
25809
25855
  baseSha: await shaOf(cwd, base)
25810
25856
  };
@@ -26364,6 +26410,9 @@ function scopedNote(result2) {
26364
26410
  function dodGreen(outcome) {
26365
26411
  return outcome.dod.length > 0 && outcome.dod.every((r) => !dodRefused(r) && r.exitCode === 0 && !r.timedOut);
26366
26412
  }
26413
+ function dodFailure(dod) {
26414
+ return dod.find((r) => dodRefused(r) || r.exitCode !== 0 || r.timedOut);
26415
+ }
26367
26416
  function dodFailureReason(result2, repo) {
26368
26417
  if (dodRefused(result2)) {
26369
26418
  return `\`${result2.command}\` was REFUSED in repo ${repo} and never ran — ` + `${result2.refusedBecause ?? DOD_REFUSAL_FALLBACK}`;
@@ -26374,6 +26423,15 @@ function dodFailureReason(result2, repo) {
26374
26423
  return `\`${result2.rendered ?? result2.command}\`${scopedNote(result2)} exited ${String(result2.exitCode ?? "?")} in repo ${repo}` + `${result2.timedOut ? " (timed out)" : ""} — ${result2.tail}` + (result2.outputPath === undefined ? "" : ` [src: ${result2.outputPath}:${String(result2.outputLine ?? 1)}]`);
26375
26424
  }
26376
26425
 
26426
+ // src/core/build/developerGrants.ts
26427
+ var DEVELOPER_GIT_VERBS = ["add", "commit", "rm", "mv", "restore"];
26428
+ function developerGitGrants() {
26429
+ return DEVELOPER_GIT_VERBS.map((verb) => `Bash(git ${verb} *)`);
26430
+ }
26431
+ function isDeveloperGitVerb(verb) {
26432
+ return DEVELOPER_GIT_VERBS.includes(verb);
26433
+ }
26434
+
26377
26435
  // src/core/build/prompts.ts
26378
26436
  var MAX_TOUCHED_BYTES = 64 * 1024;
26379
26437
  var MAX_TOUCHED_FILES = 24;
@@ -26403,6 +26461,10 @@ function testFastRule(fast, full) {
26403
26461
  ]
26404
26462
  ];
26405
26463
  }
26464
+ function gitVerbList() {
26465
+ const names = DEVELOPER_GIT_VERBS.map((verb) => `\`git ${verb}\``);
26466
+ return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1] ?? ""}`;
26467
+ }
26406
26468
  function buildDeveloperPrompt(parts) {
26407
26469
  const { story } = parts.story;
26408
26470
  const extra = parts.story.extraInputs ?? [];
@@ -26472,7 +26534,9 @@ function buildDeveloperPrompt(parts) {
26472
26534
  ...parts.commands.length === 0 ? ["- (this repo declares no commands in workspace.yml — the DoD will run nothing)"] : parts.commands.map((command2) => `- \`${command2}\``),
26473
26535
  "",
26474
26536
  ...parts.testFast === undefined ? [] : [...testFastRule(parts.testFast.fast, parts.testFast.full), ""],
26475
- "Commit with `git add` and `git commit`. Nothing else about git is yours to do.",
26537
+ `Commit with \`git add\` and \`git commit\`. The git verbs you hold are exactly ${gitVerbList()}.`,
26538
+ "`git restore <path>` is how to put a file back (there is no checkout in that list). Nothing",
26539
+ "else about git is yours to do.",
26476
26540
  "",
26477
26541
  "## Rules",
26478
26542
  "",
@@ -28581,6 +28645,68 @@ function verdictReviewer(checkPayload, lastSpawn) {
28581
28645
  return lastSpawn;
28582
28646
  }
28583
28647
 
28648
+ // src/core/build/refusalKind.ts
28649
+ var MAX_SEPARATOR_RETRIES = 1;
28650
+ function classifyRefusal(command2) {
28651
+ const separator = unquotedShellSeparator(command2);
28652
+ if (separator !== null)
28653
+ return { kind: "separator", separator };
28654
+ const argv = command2.trim().split(/\s+/);
28655
+ if (argv[0] !== "git")
28656
+ return { kind: "unknown" };
28657
+ const verb = argv[1] ?? "";
28658
+ if (verb === "" || verb.startsWith("-"))
28659
+ return { kind: "unknown" };
28660
+ if (isDeveloperGitVerb(verb))
28661
+ return { kind: "unknown" };
28662
+ return { kind: "verb", verb, equivalent: grantedEquivalent(verb, argv.slice(2)) };
28663
+ }
28664
+ function grantedEquivalent(verb, args) {
28665
+ if (verb === "checkout") {
28666
+ const switching = args.some((a) => a === "-b" || a === "-B" || a === "--orphan" || a === "-t" || a === "--track");
28667
+ if (switching)
28668
+ return null;
28669
+ const hasTarget = args.some((a) => !a.startsWith("-")) || args.includes("--");
28670
+ return hasTarget ? "git restore <path>" : null;
28671
+ }
28672
+ if (verb === "reset") {
28673
+ const mode = args.some((a) => /^--(hard|soft|mixed|merge|keep)$/.test(a));
28674
+ if (mode)
28675
+ return null;
28676
+ if (args.includes("--"))
28677
+ return "git restore --staged <path>";
28678
+ const [first2, second] = args.filter((a) => !a.startsWith("-"));
28679
+ if (first2 === "HEAD" && second !== undefined)
28680
+ return "git restore --staged <path>";
28681
+ return null;
28682
+ }
28683
+ return null;
28684
+ }
28685
+ function refusalCure(kind) {
28686
+ switch (kind.kind) {
28687
+ case "separator":
28688
+ return "run each command alone — shell separators split a line into subcommands that each need their own grant";
28689
+ case "verb":
28690
+ return kind.equivalent === null ? `\`git ${kind.verb}\` is not granted` : `\`git ${kind.verb}\` is not granted; use \`${kind.equivalent}\``;
28691
+ case "unknown":
28692
+ return "";
28693
+ }
28694
+ }
28695
+ function withCure(sentence, command2) {
28696
+ const cure = refusalCure(classifyRefusal(command2));
28697
+ return cure === "" ? sentence : `${sentence}. The cure: ${cure}`;
28698
+ }
28699
+ function separatorCurePrefix(command2) {
28700
+ return [
28701
+ `Your previous command \`${command2}\` was refused because it chains commands. Run each command alone.`,
28702
+ "The permission layer splits a line at every shell separator (`&&`, `;`, `|`, `>`, `2>&1`, `$()`)",
28703
+ "and each fragment must match its own grant, so a compound line is refused even when every",
28704
+ "command in it is allowed on its own. This is the one retry: a second refusal blocks the story.",
28705
+ ""
28706
+ ].join(`
28707
+ `);
28708
+ }
28709
+
28584
28710
  // src/core/build/review.ts
28585
28711
  var VERDICT_WORDS = ["approve", "fixlist", "changes"];
28586
28712
  var VERDICT_ENUM = VERDICT_WORDS.join("|");
@@ -28752,7 +28878,7 @@ function renderReviewLog(outcome) {
28752
28878
  `- Branch: \`${outcome.branch}\` → \`${outcome.epicBranch}\` (${mergeWord(outcome)})`,
28753
28879
  `- Commit: ${outcome.commit ?? "(none)"}`,
28754
28880
  ...outcome.developerError === null ? [] : [`- Developer: **FAILED** — ${outcome.developerError}`],
28755
- ...outcome.permissionRefused == null ? [] : [`- Developer: \`${outcome.permissionRefused}\` was refused for approval by the agent's own ` + "permission layer; the tree held committed work, so the Definition of Done below decided"],
28881
+ ...outcome.permissionRefused == null ? [] : [withCure(`- Developer: \`${outcome.permissionRefused}\` was refused for approval by the agent's own ` + "permission layer; the tree held committed work, so the Definition of Done below decided", outcome.permissionRefused)],
28756
28882
  ...outcome.budgetDeath == null ? [] : [`- Developer: died on its per-story cap — ${outcome.budgetDeath}; the tree held work, ` + "so the Definition of Done below decided"],
28757
28883
  "",
28758
28884
  "## Definition of done",
@@ -29354,7 +29480,7 @@ function renderBuildHandoff(parts) {
29354
29480
  "",
29355
29481
  ...notDone.length === 0 && (parts.carried ?? []).length === 0 && (parts.unreadableStories ?? []).length === 0 && (parts.foreignWork ?? []).length === 0 && (parts.notStarted ?? []).length === 0 && refusedButMeasured.length === 0 && diedButMeasured.length === 0 ? [`- none — every scheduled story reached \`done\` and no carried finding is unowned ` + `[src: absent:04-build/log]`] : [],
29356
29482
  ...notDone.map((o) => `- ${o.id} is \`${o.status}\` and needs a human: ${o.reason ?? "see the review"} ` + `[src: ${o.reviewRel}:1]`),
29357
- ...refusedButMeasured.map((o) => `- ${o.id}'s developer had \`${o.permissionRefused ?? ""}\` refused for approval by the agent's own ` + `permission layer; its tree held committed work, so the Definition of Done decided and the story is ` + `\`${o.status}\` — a person may still want to know the command it could not run [src: ${o.reviewRel}:1]`),
29483
+ ...refusedButMeasured.map((o) => withCure(`- ${o.id}'s developer had \`${o.permissionRefused ?? ""}\` refused for approval by the agent's own ` + `permission layer; its tree held committed work, so the Definition of Done decided and the story is ` + `\`${o.status}\` — a person may still want to know the command it could not run`, o.permissionRefused ?? "") + ` [src: ${o.reviewRel}:1]`),
29358
29484
  ...diedButMeasured.map((o) => `- ${o.id}'s developer died on its per-story cap (${o.budgetDeath ?? ""}) with work in its tree, ` + `so the Definition of Done decided and the story is \`${o.status}\` — the plan priced this story ` + `below what it cost, which is worth knowing before the next one [src: ${o.reviewRel}:1]`),
29359
29485
  ...(parts.notStarted ?? []).map((row2) => `- ${row2.id} was scheduled and never started, and is still \`${row2.status}\` — ` + `${row2.reason} [src: ${row2.rel}:1]`),
29360
29486
  ...carriedBullets(parts.carried ?? []),
@@ -29769,9 +29895,9 @@ function quoteLine(text3, line) {
29769
29895
  if (found === undefined || found.trim() === "")
29770
29896
  return null;
29771
29897
  const trimmed = found.trim();
29772
- return trimmed.length <= MAX_QUOTED_CHARS2 ? trimmed : `${trimmed.slice(0, MAX_QUOTED_CHARS2)}…`;
29898
+ return trimmed.length <= MAX_QUOTED_CHARS3 ? trimmed : `${trimmed.slice(0, MAX_QUOTED_CHARS3)}…`;
29773
29899
  }
29774
- var MAX_QUOTED_CHARS2 = 120;
29900
+ var MAX_QUOTED_CHARS3 = 120;
29775
29901
  function describeHandoff2(rel, validation, text3 = "") {
29776
29902
  if (validation.ok)
29777
29903
  return [];
@@ -35532,6 +35658,10 @@ function storyWorktreePath(root2, repo, runId, storyId) {
35532
35658
  async function refreshStoryBase(parts) {
35533
35659
  const id = parts.storyId;
35534
35660
  const base2 = await baseStateOf(parts.repoDir, parts.branch, parts.epicBranch);
35661
+ if (base2.uncounted !== null && base2.branchSha !== "") {
35662
+ parts.lines.push(` · ${id}: ${base2.uncounted} — \`${parts.branch}\` was left exactly as it is, ` + `and the dispatch below is on its current tip`);
35663
+ return;
35664
+ }
35535
35665
  if (base2.state === "current")
35536
35666
  return;
35537
35667
  const where = relative14(parts.root, parts.worktree) || parts.worktree;
@@ -35646,6 +35776,24 @@ async function rescueUncommitted(parts) {
35646
35776
  return { sha, branch: parts.branch, worktree: null, failure: null };
35647
35777
  });
35648
35778
  }
35779
+ async function updateStoryBase(parts) {
35780
+ const base2 = await baseStateOf(parts.repoDir, parts.branch, parts.epicBranch);
35781
+ if (base2.state === "current" && base2.uncounted === null)
35782
+ return { kind: "current", why: null };
35783
+ await assertWorktreeOn(parts.worktree, parts.branch, "story worktree");
35784
+ const from = await headSha(parts.worktree);
35785
+ const merged = await mergeNoFf(parts.worktree, parts.epicBranch, `sync(${parts.storyId}): \`${parts.epicBranch}\` moved under this story before it merged back`);
35786
+ if (!merged.ok)
35787
+ return { kind: "blocked", conflicts: merged.conflicts, detail: merged.detail };
35788
+ const to = await headSha(parts.worktree);
35789
+ if (to === from || to === "")
35790
+ return { kind: "current", why: base2.uncounted };
35791
+ return { kind: "updated", from, to, behind: base2.behind, uncounted: base2.uncounted };
35792
+ }
35793
+ function staleBaseConflict(branch, epicBranch, conflicts, detail, where) {
35794
+ const files = conflicts.length > 0 ? conflicts.join(", ") : detail;
35795
+ return `\`${epicBranch}\` moved since \`${branch}\` was cut, and bringing the story up to it ` + `CONFLICTS in ${files} — nothing was merged and nothing is half-applied. ` + `Resolve it in ${where} (\`git merge ${epicBranch}\`), then reopen the story`;
35796
+ }
35649
35797
 
35650
35798
  // src/core/build/entryProbe.ts
35651
35799
  import { accessSync, constants, existsSync as existsSync72, statSync as statSync20 } from "node:fs";
@@ -35892,7 +36040,7 @@ function asideBranchOf(epicBranch, ownerRunId) {
35892
36040
  return `${epicBranch}${ASIDE_SEPARATOR}${ownerRunId}`;
35893
36041
  }
35894
36042
  function uncountedReason(base2, branch) {
35895
- return `\`git rev-list --count ${base2}..${branch}\` could not count what it carries, ` + "and an uncounted branch is never deleted";
36043
+ return `${uncountedCount(base2, branch)}, and an uncounted branch is never deleted`;
35896
36044
  }
35897
36045
  async function releaseEpicBranch(parts) {
35898
36046
  const { repoDir, branch } = parts;
@@ -35902,7 +36050,7 @@ async function releaseEpicBranch(parts) {
35902
36050
  if (checkout !== null) {
35903
36051
  return { kind: "kept", branch, reason: `it is checked out in ${checkout}` };
35904
36052
  }
35905
- const commits = await commitsAhead(repoDir, parts.base, branch);
36053
+ const commits = await commitsBetween(repoDir, parts.base, branch);
35906
36054
  if (commits === null)
35907
36055
  return { kind: "kept", branch, reason: uncountedReason(parts.base, branch) };
35908
36056
  const sha = await shaOf(repoDir, branch);
@@ -38645,6 +38793,10 @@ async function rederiveImplicitPlan(ctx, workspace, parts, lines) {
38645
38793
  const content = implicitPlanContent(parts);
38646
38794
  const storyBranch = storyBranchOf(ctx.runId, IMPLICIT_STORY_ID);
38647
38795
  const commits = await commitsBetween(repoDirOf(workspace, content.repo), content.branch, storyBranch);
38796
+ if (commits === null) {
38797
+ kept(uncountedCount(content.branch, storyBranch));
38798
+ return;
38799
+ }
38648
38800
  if (commits > 0) {
38649
38801
  kept(`\`${storyBranch}\` carries ${String(commits)} commit(s) beyond \`${content.branch}\``);
38650
38802
  return;
@@ -39102,55 +39254,64 @@ class BuildSession {
39102
39254
  };
39103
39255
  }
39104
39256
  const handed = await headSha(story.worktree);
39105
- const developer = await this.spawnDeveloper(story);
39106
- const capDeath = developer.error !== null && diedOnCap(developer.error);
39257
+ let developer = await this.spawnDeveloper(story);
39107
39258
  let budgetDeath = null;
39108
- if (developer.error !== null) {
39109
- const proven = capDeath && await workSince({
39110
- workspaceRoot: this.workspace.root,
39111
- repoDir: story.repoDir,
39112
- worktree: story.worktree,
39113
- since: handed
39114
- });
39115
- if (!proven) {
39116
- return {
39117
- story,
39118
- cost: developer.cost,
39119
- dod: [],
39120
- commit: null,
39121
- failure: null,
39122
- developerError: developer.error,
39123
- before
39124
- };
39259
+ let spent = 0;
39260
+ let separatorRetries = 0;
39261
+ for (;; ) {
39262
+ spent = round25(spent + developer.cost);
39263
+ const capDeath = developer.error !== null && diedOnCap(developer.error);
39264
+ if (developer.error !== null) {
39265
+ const proven2 = capDeath && await workSince({
39266
+ workspaceRoot: this.workspace.root,
39267
+ repoDir: story.repoDir,
39268
+ worktree: story.worktree,
39269
+ since: handed
39270
+ });
39271
+ if (!proven2) {
39272
+ return {
39273
+ story,
39274
+ cost: spent,
39275
+ dod: [],
39276
+ commit: null,
39277
+ failure: null,
39278
+ developerError: developer.error,
39279
+ before
39280
+ };
39281
+ }
39282
+ budgetDeath = developer.error;
39283
+ this.capDeaths.set(story.planned.story.id, developer.error);
39125
39284
  }
39126
- budgetDeath = developer.error;
39127
- this.capDeaths.set(story.planned.story.id, developer.error);
39128
- }
39129
- const spent = developer.cost;
39130
- if (developer.refused !== null) {
39285
+ if (developer.refused === null)
39286
+ break;
39131
39287
  const proven = await workSince({
39132
39288
  workspaceRoot: this.workspace.root,
39133
39289
  repoDir: story.repoDir,
39134
39290
  worktree: story.worktree,
39135
39291
  since: handed
39136
39292
  });
39137
- if (!proven) {
39138
- return {
39139
- story,
39140
- cost: spent,
39141
- dod: [],
39142
- commit: null,
39143
- failure: permissionBlockReason(developer.refused),
39144
- developerError: null,
39145
- before
39146
- };
39293
+ if (proven) {
39294
+ this.refusals.set(story.planned.story.id, developer.refused);
39295
+ break;
39147
39296
  }
39148
- this.refusals.set(story.planned.story.id, developer.refused);
39297
+ if (classifyRefusal(developer.refused).kind === "separator" && separatorRetries < MAX_SEPARATOR_RETRIES) {
39298
+ separatorRetries += 1;
39299
+ developer = await this.spawnDeveloper(story, { after: developer.refused });
39300
+ continue;
39301
+ }
39302
+ return {
39303
+ story,
39304
+ cost: spent,
39305
+ dod: [],
39306
+ commit: null,
39307
+ failure: permissionBlockReason(developer.refused, { retried: separatorRetries > 0 }),
39308
+ developerError: null,
39309
+ before
39310
+ };
39149
39311
  }
39150
39312
  const dod = await this.runDod(story);
39151
- const green = dod.length === 0 ? dodIsSatisfiedEmpty(this.plan) : dodGreen({ dod });
39152
- if (!green) {
39153
- const failing = dod.find((r) => dodRefused(r) || r.exitCode !== 0 || r.timedOut);
39313
+ if (!this.dodProves(dod)) {
39314
+ const failing = dodFailure(dod);
39154
39315
  const why = failing === undefined ? "the story declares no dod commands, so nothing could prove it" : dodFailureReason(failing, story.planned.story.repo);
39155
39316
  return {
39156
39317
  story,
@@ -39194,18 +39355,49 @@ class BuildSession {
39194
39355
  await this.block(story, "the story produced no commit to review", half.cost, dod);
39195
39356
  return "settled";
39196
39357
  }
39358
+ const update = await updateStoryBase({
39359
+ storyId: story.planned.story.id,
39360
+ repoDir: story.repoDir,
39361
+ worktree: story.worktree,
39362
+ branch: story.branch,
39363
+ epicBranch: story.epicBranch
39364
+ });
39365
+ if (update.kind === "blocked") {
39366
+ await this.block(story, staleBaseConflict(story.branch, story.epicBranch, update.conflicts, update.detail, relative16(this.ctx.root, story.worktree) || story.worktree), half.cost, dod, { commit: commit2, conflicts: update.conflicts });
39367
+ return "settled";
39368
+ }
39369
+ let proven = dod;
39370
+ if (update.kind === "updated") {
39371
+ this.lines.push(` · ${story.planned.story.id}: \`${story.branch}\` was ${String(update.behind)} commit(s) behind ` + `\`${story.epicBranch}\`${update.uncounted === null ? "" : ` (${update.uncounted})`} — merged the epic ` + `into it (${update.from.slice(0, 7)} → ${update.to.slice(0, 7)}) and re-ran the DoD on the result`);
39372
+ this.ctx.emit("story.base_updated", {
39373
+ phase: this.ctx.phaseId,
39374
+ story: story.planned.story.id,
39375
+ repo: story.planned.story.repo,
39376
+ branch: story.branch,
39377
+ base: story.epicBranch,
39378
+ from: update.from,
39379
+ to: update.to,
39380
+ commits: update.behind
39381
+ });
39382
+ proven = await this.runDod(story);
39383
+ if (!this.dodProves(proven)) {
39384
+ const failing = dodFailure(proven);
39385
+ await this.block(story, `${failing === undefined ? "the story declares no dod commands, so nothing could prove it" : dodFailureReason(failing, story.planned.story.repo)}` + ` — re-run after \`${story.epicBranch}\` moved under \`${story.branch}\` ` + `(${update.from.slice(0, 7)} → ${update.to.slice(0, 7)}); nothing was merged`, half.cost, proven, { commit: commit2 });
39386
+ return "settled";
39387
+ }
39388
+ }
39197
39389
  const carried = await commitsBetween(story.repoDir, story.epicBranch, story.branch);
39198
39390
  const epicShaBefore = await fullShaOf(story.repoDir, story.epicBranch);
39199
39391
  const merge = await this.mergeIntoEpic(story);
39200
39392
  if (!merge.ok) {
39201
- await this.block(story, `merge into \`${story.epicBranch}\` failed: ${merge.detail}`, half.cost, dod, {
39393
+ await this.block(story, `merge into \`${story.epicBranch}\` failed: ${merge.detail}`, half.cost, proven, {
39202
39394
  commit: commit2,
39203
39395
  conflicts: merge.conflicts
39204
39396
  });
39205
39397
  return "settled";
39206
39398
  }
39207
39399
  this.noteMerged(story, carried);
39208
- return await this.reviewAndSettle(story, dod, commit2, half.cost, carried, epicShaBefore);
39400
+ return await this.reviewAndSettle(story, proven, commit2, half.cost, carried, epicShaBefore);
39209
39401
  }
39210
39402
  async reviewAndSettle(story, dod, commit2, priorCost, carried, epicBase, supplied) {
39211
39403
  if (supplied === undefined && this.ctx.attendedByHost) {
@@ -39481,9 +39673,8 @@ class BuildSession {
39481
39673
  }
39482
39674
  async pipelineFromDod(story, developerCost) {
39483
39675
  const dod = await this.runDod(story);
39484
- const green = dod.length === 0 ? dodIsSatisfiedEmpty(this.plan) : dodGreen({ dod });
39485
- if (!green) {
39486
- const failing = dod.find((r) => dodRefused(r) || r.exitCode !== 0 || r.timedOut);
39676
+ if (!this.dodProves(dod)) {
39677
+ const failing = dodFailure(dod);
39487
39678
  await this.block(story, failing === undefined ? "the story declares no dod commands, so nothing could prove it" : dodFailureReason(failing, story.planned.story.repo), developerCost, dod);
39488
39679
  return "settled";
39489
39680
  }
@@ -39586,7 +39777,7 @@ class BuildSession {
39586
39777
  advisories: this.advisories
39587
39778
  });
39588
39779
  }
39589
- async spawnDeveloper(story) {
39780
+ async spawnDeveloper(story, retry = null) {
39590
39781
  const cap2 = developerCap(this.capParts, story.planned.story.id, story.attempt);
39591
39782
  const commands = this.repoCommands(story.planned.story.repo);
39592
39783
  this.ctx.emit("agent.spawned", {
@@ -39595,10 +39786,11 @@ class BuildSession {
39595
39786
  role: "developer",
39596
39787
  model: this.model(),
39597
39788
  effort: this.ctx.effort,
39598
- max_budget_usd: cap2
39789
+ max_budget_usd: cap2,
39790
+ ...retry === null ? {} : { retry: "separator-cure", retry_after: retry.after }
39599
39791
  }, 0, "developer");
39600
39792
  const agent = await spawnAgent({
39601
- prompt: this.developerPrompt(story),
39793
+ prompt: (retry === null ? "" : separatorCurePrefix(retry.after)) + this.developerPrompt(story),
39602
39794
  model: this.model(),
39603
39795
  effort: this.ctx.effort,
39604
39796
  maxBudgetUsd: cap2,
@@ -39640,6 +39832,9 @@ class BuildSession {
39640
39832
  });
39641
39833
  return { cost: round25(agent.costUsd), error, refused: null };
39642
39834
  }
39835
+ dodProves(dod) {
39836
+ return dod.length === 0 ? dodIsSatisfiedEmpty(this.plan) : dodGreen({ dod });
39837
+ }
39643
39838
  async runDod(story) {
39644
39839
  const scoped = await this.scopedParts(story);
39645
39840
  const results = await runStoryDod({
@@ -40008,7 +40203,7 @@ class BuildSession {
40008
40203
  if (pruning && (rescued === null || rescued.worktree === null)) {
40009
40204
  await removeWorktree(story.repoDir, story.worktree);
40010
40205
  }
40011
- this.lines.push(` ${status === "done" ? "✓" : "·"} ${id} → \`${status}\`` + (outcome.reason === null ? "" : ` (${outcome.reason})`) + (outcome.permissionRefused == null ? "" : ` — \`${outcome.permissionRefused}\` was refused for approval; the tree held committed work, so the DoD decided`));
40206
+ this.lines.push(` ${status === "done" ? "✓" : "·"} ${id} → \`${status}\`` + (outcome.reason === null ? "" : ` (${outcome.reason})`) + (outcome.permissionRefused == null ? "" : ` — ${withCure(`\`${outcome.permissionRefused}\` was refused for approval; the tree held committed work, so the DoD decided`, outcome.permissionRefused)}`));
40012
40207
  }
40013
40208
  writeLog(outcome) {
40014
40209
  const dir = join100(this.ctx.runDir, BUILD_PHASE, LOG_DIR);
@@ -40691,15 +40886,12 @@ function developerTools(repoCommands, options = {}) {
40691
40886
  ...BASE_TOOLS,
40692
40887
  ...options.skills === true ? ["Skill"] : [],
40693
40888
  ...repoCommands.flatMap((command2) => bashGrantsFor(command2)),
40694
- "Bash(git add *)",
40695
- "Bash(git commit *)",
40696
- "Bash(git rm *)",
40697
- "Bash(git mv *)",
40698
- "Bash(git restore *)"
40889
+ ...developerGitGrants()
40699
40890
  ];
40700
40891
  }
40701
- function permissionBlockReason(command2) {
40702
- return `permission — \`${command2}\` was refused for approval by the agent's own permission layer, ` + "and a headless turn has nobody to approve it: the same allowance would refuse it again, " + "so this attempt was not repeated";
40892
+ function permissionBlockReason(command2, options = {}) {
40893
+ const base2 = `permission — \`${command2}\` was refused for approval by the agent's own permission layer, ` + "and a headless turn has nobody to approve it: the same allowance would refuse it again, " + "so this attempt was not repeated" + (options.retried === true ? " beyond the one re-spawn with the cure stated, which was refused too" : "");
40894
+ return withCure(base2, command2);
40703
40895
  }
40704
40896
  function diedOnCap(error) {
40705
40897
  return error.includes("Reached maximum budget");
@@ -52045,6 +52237,8 @@ function bullet2(item, trail) {
52045
52237
  return `${prefix5}story ${text3(payload2.story) || "?"}'s \`touches:\` WIDENED by ${actor}` + ` (${basisOf(payload2)})` + ` — +${pathList(payload2.paths)}` + ` (${String(lengthOf(payload2.before))} → ${String(lengthOf(payload2.after))} path(s))` + `${note(payload2.note)}`;
52046
52238
  case "story.base_fastforwarded":
52047
52239
  return `${prefix5}story ${text3(payload2.story) || "?"}'s base fast-forwarded to` + ` \`${text3(payload2.base) || "?"}\` — ${text3(payload2.from) || "?"} → ${text3(payload2.to) || "?"}` + ` (${String(payload2.commits ?? "?")} commit(s))`;
52240
+ case "story.base_updated":
52241
+ return `${prefix5}story ${text3(payload2.story) || "?"}'s base UPDATED to` + ` \`${text3(payload2.base) || "?"}\` before merging — ${text3(payload2.from) || "?"} →` + ` ${text3(payload2.to) || "?"} (${String(payload2.commits ?? "?")} commit(s) behind), DoD re-run`;
52048
52242
  case "epic.released":
52049
52243
  return `${prefix5}epic \`${text3(payload2.branch) || "?"}\` of run ${text3(payload2.owner) || "?"} ` + (text3(payload2.outcome) === "deleted" ? `deleted — no commit beyond \`${text3(payload2.base) || "?"}\`` : `renamed to \`${text3(payload2.renamed_to) || "?"}\` — ${String(payload2.commits ?? "?")} commit(s) ` + `beyond \`${text3(payload2.base) || "?"}\` survive there`) + ` (${text3(payload2.via) || "?"})`;
52050
52244
  case "story.work_rescued":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tldr-experts",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
4
  "description": "tldr-experts: an evidence-first, file-based AI development framework - five stages, a gate on every one, and every claim cited or refused. Installs the `tldrx` (and `tldr-experts`) command. Beta.",
5
5
  "license": "MIT",
6
6
  "author": "Alan Martinez",
@@ -2,7 +2,7 @@
2
2
  "$doc": "Shape verified from https://code.claude.com/docs/en/plugins.md (Quickstart > Create the plugin manifest). Fields used here: name, description, version, author.name. Only plugin.json goes inside .claude-plugin/; skills/, agents/ and hooks/ live at the plugin root.",
3
3
  "name": "tldrx",
4
4
  "description": "tldr-experts: an evidence-first, file-based AI development framework. Five stages, a gate on every one, every claim cited or refused. Beta.",
5
- "version": "0.18.2",
5
+ "version": "0.18.3",
6
6
  "author": {
7
7
  "name": "Alan Martinez"
8
8
  }