@cat-factory/executor-harness 1.76.2 → 1.80.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/README.md CHANGED
@@ -68,25 +68,34 @@ The implementation job (`POST /run`) is the canonical sequence:
68
68
  it reads real installed packages instead of inferring a library's capabilities from a
69
69
  manifest entry. Best-effort and never a gate: the outcome (success or the captured
70
70
  failure) is folded into the agent's prompt — on EVERY pass, including the repair passes of
71
- steps 5 and 6, which start a fresh agent — and the run continues either way. Whatever the
71
+ steps 6 and 7, which start a fresh agent — and the run continues either way. Whatever the
72
72
  install materialises is excluded from git first, so no later `git add -A` can sweep a
73
73
  dependency tree into the pull request (see
74
74
  [dependency prepopulation](../../../docs/initiatives/agent-dependency-prepopulation.md)),
75
- 4. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
76
- 5. **validate** the checkout, when the job body carries `validationChecks` — the service's
75
+ 4. **resolve the repo's pull-request template**, when this dispatch opens a PR (`src/pr-template.ts`)
76
+ — `.github/PULL_REQUEST_TEMPLATE.md` and its root/`docs/`/multi-template-directory variants, or
77
+ GitLab's `.gitlab/merge_request_templates/`, read straight off the checkout (a symlinked template
78
+ is followed only while it resolves INSIDE the checkout — this is the one repo-chosen path the
79
+ harness reads unprompted). Found, it is folded into the agent's prompt (on EVERY pass, as with
80
+ the install above) asking it to write its briefing AS that template, filled in. This exists
81
+ because neither host applies a template to an API-created pull request, so nothing else would:
82
+ the template only reaches the web form a human opens. A directory of several templates with no
83
+ `default` is left alone deliberately — it exists so a human can choose per pull request,
84
+ 5. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
85
+ 6. **validate** the checkout, when the job body carries `validationChecks` — the service's
77
86
  configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
78
87
  while they fail and the attempt budget remains the agent is re-run with the captured output
79
88
  as its instruction (see [pre-PR validation](../../../docs/initiatives/pre-pr-validation.md)),
80
- 6. **prove the reproduction**, when the job body carries `reproduction` — the declared check is
89
+ 7. **prove the reproduction**, when the job body carries `reproduction` — the declared check is
81
90
  run against the pre-fix tree and the tree the PR will open from, in two freshly-created
82
91
  symmetric `git worktree` checkouts, and only red-then-green is reported as proof (see
83
92
  [bugfix reproduction proof](../../../docs/initiatives/bugfix-reproduction-proof.md)). Unlike
84
- step 5 this NEVER gates the PR: a failed verification is fed back to the agent while budget
85
- remains, then recorded as `inconclusive`. It runs BEFORE step 5 so validation stays the last
93
+ step 6 this NEVER gates the PR: a failed verification is fed back to the agent while budget
94
+ remains, then recorded as `inconclusive`. It runs BEFORE step 6 so validation stays the last
86
95
  thing to touch the tree,
87
- 7. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
88
- ONLY if step 5 ended green. A spent budget returns an error result with the validation report
89
- and opens no PR. Absent `validationChecks` / `reproduction`, steps 5 and 6 do not happen at
96
+ 8. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
97
+ ONLY if step 6 ended green. A spent budget returns an error result with the validation report
98
+ and opens no PR. Absent `validationChecks` / `reproduction`, steps 6 and 7 do not happen at
90
99
  all. The PR's description prefers the agent-authored reviewer briefing over the generic
91
100
  dispatch-time text the job body carries: a PR-opening agent is prompted to write one to the
92
101
  `.cat-pr-description.md` sentinel at the checkout root (one per sibling repo in a multi-repo
@@ -94,9 +103,13 @@ The implementation job (`POST /run`) is the canonical sequence:
94
103
  title), and `src/pr-description.ts` lifts it — secret-scrubbed, size-capped with a visible
95
104
  note, made inert for the host by `src/host-markdown.ts`, kept out of the commit like the
96
105
  effort/follow-ups sentinels — onto `openPullRequest`. Absent or unusable ⇒ the fallback text,
97
- unchanged. On a RESUMED run the PR already exists, so an agent briefing additionally refreshes
98
- its title/description in place (carrying the engine's managed report region across); the
99
- generic fallback never does, so a human's edit is safe.
106
+ unchanged. When the repo ships a template (step 4) that briefing IS the filled template: it
107
+ crosses the same scrub/cap/inert boundary on the way out, but the leading-`#` title rule is
108
+ switched OFF for it (`titleFromHeading: false`), because those headings are the repo's and
109
+ lifting the template's top heading would retitle the PR after it and drop it from the body. On a
110
+ RESUMED run the PR already exists, so an agent briefing additionally refreshes its
111
+ title/description in place (carrying the engine's managed report region across); the generic
112
+ fallback never does, so a human's edit is safe.
100
113
 
101
114
  Bootstrap differs at the ends — it may start from an empty dir, and **resets
102
115
  history to one commit and force-pushes** the default branch instead of opening a
@@ -384,7 +384,7 @@ export async function runClaudeCode(opts) {
384
384
  // either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
385
385
  // competing with it — picking the further-along view collapsed the list to the dispatched
386
386
  // slices alone the moment the first subagent returned. See ./progress.ts.
387
- const sliceTracker = createSliceTracker();
387
+ const sliceTracker = createSliceTracker(secrets);
388
388
  const planTracker = createTaskPlanTracker();
389
389
  let lastTodo;
390
390
  const emitProgress = () => {
@@ -394,6 +394,17 @@ export async function runClaudeCode(opts) {
394
394
  if (progress)
395
395
  opts.onProgress(progress);
396
396
  };
397
+ // Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
398
+ // the two answer different questions and have different lifetimes: progress is a disposable
399
+ // count the UI renders, while these carry the slices' actual review WORK and are persisted so a
400
+ // run that dies before its aggregation can be resumed from them.
401
+ const emitSliceReviews = () => {
402
+ if (!opts.onSliceReviews)
403
+ return;
404
+ const reviews = sliceTracker.sliceReviews();
405
+ if (reviews.length > 0)
406
+ opts.onSliceReviews(reviews);
407
+ };
397
408
  // No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
398
409
  // absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
399
410
  // turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
@@ -471,6 +482,9 @@ export async function runClaudeCode(opts) {
471
482
  sliceTracker.onUser(content);
472
483
  planTracker.onUser(content);
473
484
  emitProgress();
485
+ // A slice's report lands on exactly this turn, so publish here: waiting for the next
486
+ // progress tick would risk the job dying with the report captured but never surfaced.
487
+ emitSliceReviews();
474
488
  // Not on the at-close flush: the CLI has already exited, so tripping the guard there
475
489
  // would kill nothing and only convert a clean exit into a spurious failure.
476
490
  if (!meta?.final)
package/dist/agent.js CHANGED
@@ -802,8 +802,12 @@ async function runCodingMode(job, opts) {
802
802
  * Assemble the {@link runCodingAgent} spec for the ordinary single-repo coding flow. Extracted
803
803
  * from {@link runSingleRepoCoding} so the many optional-field spreads don't inflate that
804
804
  * function's cyclomatic complexity; the mapping is a straight field copy off `job`.
805
+ *
806
+ * Exported for the `opensPr` assertion: whether a dispatch fills the repo's PR template turns on
807
+ * this one spread, and the in-place fixers reach it through the SAME function as the implementer,
808
+ * so no structural guard can tell their cases apart.
805
809
  */
806
- function buildSingleRepoCodingSpec(job, pushBranch) {
810
+ export function buildSingleRepoCodingSpec(job, pushBranch) {
807
811
  return {
808
812
  kind: 'agent',
809
813
  jobId: job.jobId,
@@ -828,6 +832,10 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
828
832
  guardLimits: job.guardLimits,
829
833
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
830
834
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
835
+ // Whether a pull request will open at all is exactly `job.pr` (see the `if (job.pr)` guard in
836
+ // `runSingleRepoCoding`), and it is what decides whether the repo's PR template is worth
837
+ // resolving. Read off the same field rather than a new job-body flag, so the two can't drift.
838
+ ...(job.pr ? { opensPr: true } : {}),
831
839
  ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
832
840
  // Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
833
841
  ...agentCapabilities(job),
@@ -49,6 +49,29 @@ export function claudeAssistantContent(content) {
49
49
  }
50
50
  return { text, reasoning, toolUses };
51
51
  }
52
+ /**
53
+ * The text a `tool_result` block carries. The CLI writes it either as a bare string or as an
54
+ * array of content blocks (the shape a subagent's terminal report arrives in), so both are read
55
+ * here rather than at each call site. Non-text blocks (an image a tool returned) contribute
56
+ * nothing. Returns '' when the block carries no readable text.
57
+ *
58
+ * This is what makes a parallel subagent's work observable to the harness at all: the parent
59
+ * stream shows a subagent's dispatch and its terminal `tool_result` and nothing in between, so
60
+ * this text is the ONLY place its findings surface outside its own untailed transcript.
61
+ */
62
+ export function claudeToolResultText(block) {
63
+ const content = block.content;
64
+ if (typeof content === 'string')
65
+ return content;
66
+ if (!Array.isArray(content))
67
+ return '';
68
+ let text = '';
69
+ for (const part of content) {
70
+ if (isObject(part) && part.type === 'text' && typeof part.text === 'string')
71
+ text += part.text;
72
+ }
73
+ return text;
74
+ }
52
75
  /**
53
76
  * Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
54
77
  * the cumulative `result` total).
@@ -11,6 +11,7 @@ import { log } from './logger.js';
11
11
  import { runValidationLoop, } from './validation-checks.js';
12
12
  import { runReproductionLoop, } from './reproduction-proof.js';
13
13
  import { prepopulateDependencies, withDependencyNote, } from './dependency-install.js';
14
+ import { resolvePrTemplateNote, withPrTemplateNote, } from './pr-template.js';
14
15
  /**
15
16
  * How often the harness checkpoints the agent's work mid-run by pushing the branch.
16
17
  * A per-run container can be evicted at any moment; pushing the agent's commits
@@ -149,17 +150,31 @@ export async function runCodingAgent(spec, opts = {}) {
149
150
  logger,
150
151
  opts,
151
152
  });
153
+ // THE REPO'S OWN PR TEMPLATE: when this dispatch opens a pull request and the repo ships a
154
+ // template, the agent is asked to write its briefing AS that template rather than free-form
155
+ // (see `pr-template.ts` for why neither host applies it to an API-created PR for us).
156
+ // Discovered at the CHECKOUT ROOT, never `workDir`: a template is a fact about the
157
+ // repository, so a monorepo service dispatch reads the same one as any other.
158
+ const prTemplate = await resolvePrTemplateNote({
159
+ targets: spec.opensPr
160
+ ? [{ repoDir: dir, ...(spec.repo.provider ? { provider: spec.repo.provider } : {}) }]
161
+ : [],
162
+ logger,
163
+ });
152
164
  // One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
153
165
  // validation loop below can re-run the agent with a repair instruction without
154
166
  // re-deriving (or drifting from) the dispatch's own settings.
155
167
  //
156
168
  // The dependency note rides EVERY pass, not just the first: a repair round starts a fresh
157
169
  // agent, and one that is not told the tree is already installed spends the round it was
158
- // given to fix something reinstalling it instead.
170
+ // given to fix something reinstalling it instead. The PR-template note rides every pass for
171
+ // the mirror-image reason: a repair-round agent still carries the description guidance, so
172
+ // one that is not told about the template would replace the filled template with a
173
+ // free-form briefing.
159
174
  const runAgentPass = (userPrompt) => runAgentInWorkspace({
160
175
  dir: workDir,
161
176
  systemPrompt: spec.systemPrompt,
162
- userPrompt: withDependencyNote(userPrompt, dependencyNote),
177
+ userPrompt: withDependencyNote(withPrTemplateNote(userPrompt, prTemplate.note), dependencyNote),
163
178
  model: spec.model,
164
179
  harness: spec.harness,
165
180
  subscriptionToken: spec.subscriptionToken,
@@ -267,6 +282,7 @@ export async function runCodingAgent(spec, opts = {}) {
267
282
  pushWorkOnce,
268
283
  inFlightPush,
269
284
  agentRun,
285
+ prTemplate,
270
286
  });
271
287
  }
272
288
  finally {
@@ -388,7 +404,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
388
404
  * {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
389
405
  */
390
406
  async function finalizeCodingRun(args) {
391
- const { validationReport, reproductionReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
407
+ const { validationReport, reproductionReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, prTemplate, } = args;
392
408
  const { signal } = opts;
393
409
  const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
394
410
  let outcome;
@@ -405,8 +421,13 @@ async function finalizeCodingRun(args) {
405
421
  // changed what the briefing should say) and removed so it never lingers in the checkout. The
406
422
  // prompt asks for it at the top level of the checkout; a monorepo agent working in a service
407
423
  // subdirectory may drop it in its cwd instead, so probe the checkout root first, then the cwd.
408
- const prDescription = (await readPrDescription(dir)) ??
409
- (workDir !== dir ? await readPrDescription(workDir) : undefined);
424
+ //
425
+ // When the repo ships a template the briefing IS that template filled in, so its headings are
426
+ // the REPO's: a leading `# …` there is the template's own top heading, not the title line the
427
+ // description guidance asks a free-form briefing for, and lifting it would retitle the PR after
428
+ // the template and delete the heading from the body.
429
+ const readDescription = (from) => readPrDescription(from, { titleFromHeading: !prTemplate.templated.has(dir) });
430
+ const prDescription = (await readDescription(dir)) ?? (workDir !== dir ? await readDescription(workDir) : undefined);
410
431
  // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
411
432
  // push, so the two never run a concurrent `git push` to the same branch (the
412
433
  // final push below is then a fresh attempt whose failure is the real signal).
@@ -744,6 +765,20 @@ export async function runMultiRepoCoding(job, opts = {}) {
744
765
  opts,
745
766
  })
746
767
  : undefined;
768
+ // THE REPOS' OWN PR TEMPLATES: one per leg that will actually open a pull request, each named
769
+ // by its sibling directory so the agent knows which checkout's briefing takes which shape —
770
+ // the repos in a workspace need not share a template, or ship one at all. A read-only
771
+ // reference leg is excluded by construction: it carries no `pr`, so nothing publishes for it.
772
+ const prTemplate = await resolvePrTemplateNote({
773
+ targets: legs
774
+ .filter((leg) => leg.pr)
775
+ .map((leg) => ({
776
+ repoDir: leg.dir,
777
+ repoLabel: leg.dirName,
778
+ ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
779
+ })),
780
+ logger,
781
+ });
747
782
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
748
783
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
749
784
  // note + the backend system-prompt section explain the layout.
@@ -752,7 +787,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
752
787
  const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
753
788
  dir: root,
754
789
  systemPrompt: job.systemPrompt,
755
- userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
790
+ userPrompt: withDependencyNote(withPrTemplateNote(job.userPrompt, prTemplate.note), dependencyNote),
756
791
  model: job.model,
757
792
  harness: job.harness,
758
793
  subscriptionToken: job.subscriptionToken,
@@ -772,7 +807,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
772
807
  multiRepo: true,
773
808
  }, opts);
774
809
  // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
775
- const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts, root);
810
+ const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts, root, prTemplate);
776
811
  const anyWork = primaryPushed || peerPullRequests.length > 0;
777
812
  if (!anyWork) {
778
813
  // Nothing changed in ANY repo. For the implementer this is a failure (as in the
@@ -923,7 +958,9 @@ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
923
958
  */
924
959
  async function pushMultiRepoLegs(legs, job, logger, opts,
925
960
  /** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
926
- root) {
961
+ root,
962
+ /** Which legs' briefings are filled templates — see the `titleFromHeading` read below. */
963
+ prTemplate) {
927
964
  const { signal } = opts;
928
965
  opts.onPhase?.('push');
929
966
  let primaryPushed = false;
@@ -940,8 +977,13 @@ root) {
940
977
  // read the prompt loosely may well have written a single briefing there instead. Fall back
941
978
  // to it for the PRIMARY leg only: at the root there is nothing to say which repo it
942
979
  // describes, and the primary is the one the run is actually about.
943
- const agentPrDescription = (await readPrDescription(leg.dir)) ??
944
- (leg.primary ? await readPrDescription(root) : undefined);
980
+ //
981
+ // Per-leg `titleFromHeading`: only a leg whose OWN repo ships a template has repo-authored
982
+ // headings in its sentinel, and the legs of a workspace need not agree about that — so this
983
+ // is keyed on the leg, never on whether the run found any template at all.
984
+ const readOptions = { titleFromHeading: !prTemplate.templated.has(leg.dir) };
985
+ const agentPrDescription = (await readPrDescription(leg.dir, readOptions)) ??
986
+ (leg.primary ? await readPrDescription(root, readOptions) : undefined);
945
987
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
946
988
  const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
947
989
  let hasWork = advanced || leg.resumed;
@@ -182,6 +182,10 @@ export async function runAgentInWorkspace(spec, opts = {}) {
182
182
  expectsEdits: spec.expectsEdits ?? true,
183
183
  onActivity: opts.onActivity,
184
184
  onProgress: opts.onProgress,
185
+ // Per-slice review capture, so a parallel review's finished slices are persisted as they
186
+ // land rather than only in the terminal output. Only the subscription runners fan work out
187
+ // across subagents, so this is the only path that can produce it.
188
+ onSliceReviews: opts.onSliceReviews,
185
189
  // Stream this run's per-call telemetry to the job's live drain. The subscription
186
190
  // harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
187
191
  // proxy as they happen), so this is the only path that needs the hook.
@@ -29,8 +29,14 @@ export const PR_DESCRIPTION_FILE = '.cat-pr-description.md';
29
29
  * rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
30
30
  * so a briefing budget that does not leave the report room would surface as a report that
31
31
  * silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
32
+ *
33
+ * Exported because the PR-TEMPLATE note states it to the agent (`pr-template.ts`): a filled
34
+ * template is the one briefing shape whose length is dictated by a file the agent did not write,
35
+ * so an agent that does not know the ceiling can answer a long template past it and have
36
+ * {@link capBody} cut the repo's last sections — the very failure the inline budget avoids on the
37
+ * way IN.
32
38
  */
33
- const MAX_PR_BODY_CHARS = 15_000;
39
+ export const MAX_PR_BODY_CHARS = 15_000;
34
40
  /** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
35
41
  const MAX_PR_TITLE_CHARS = 160;
36
42
  /** Opens the engine-managed region of a PR body (kept in sync with `kernel/domain/pr-report.ts`). */
@@ -51,16 +57,17 @@ const MANAGED_SECTION_MARKER = /<!--\s*cat-factory:verification-report:(?:start|
51
57
  * the dispatch-time text.
52
58
  *
53
59
  * A SINGLE `# <title>` heading on the first line sets the PR title; everything after it is the
54
- * body (see {@link splitTitle} for why a LONE heading is required). The whole text is
55
- * secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent cut would
56
- * read as the complete briefing), and both halves are made inert for the host.
60
+ * body (see {@link splitTitle} for why a LONE heading is required, and
61
+ * {@link ReadPrDescriptionOptions.titleFromHeading} for the caller that must switch it off). The
62
+ * whole text is secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent
63
+ * cut would read as the complete briefing), and both halves are made inert for the host.
57
64
  *
58
65
  * On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
59
66
  * briefing sentence like "the token: handling changed" loses its next word. That is the right
60
67
  * trade for a surface this public — the rule is shared with every other redaction path, and
61
68
  * narrowing it so prose reads better would weaken all of them.
62
69
  */
63
- export async function readPrDescription(dir) {
70
+ export async function readPrDescription(dir, opts = {}) {
64
71
  const path = join(dir, PR_DESCRIPTION_FILE);
65
72
  let raw;
66
73
  try {
@@ -74,7 +81,7 @@ export async function readPrDescription(dir) {
74
81
  const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim();
75
82
  if (!text)
76
83
  return undefined;
77
- const split = splitTitle(text);
84
+ const split = opts.titleFromHeading === false ? { body: text } : splitTitle(text);
78
85
  // Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
79
86
  const title = split.title ? inertInline(capTitle(split.title)) : undefined;
80
87
  const body = split.body ? inertMarkdown(capBody(split.body)) : undefined;
@@ -92,6 +99,11 @@ export async function readPrDescription(dir) {
92
99
  * silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
93
100
  * "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
94
101
  * quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
102
+ *
103
+ * The "single H1" test is what makes this safe for the free-form shape and is exactly what makes
104
+ * it WRONG for a filled template, whose H1 count is the repo's choice — hence
105
+ * {@link ReadPrDescriptionOptions.titleFromHeading}, which skips this entirely rather than piling
106
+ * another heuristic on top of one that cannot serve both shapes.
95
107
  */
96
108
  function splitTitle(text) {
97
109
  const lines = text.split('\n');
@@ -0,0 +1,250 @@
1
+ import { readdir, readFile, realpath } from 'node:fs/promises';
2
+ import { join, sep } from 'node:path';
3
+ import { fencedOutput } from './captured-command.js';
4
+ import { MAX_PR_BODY_CHARS, PR_DESCRIPTION_FILE } from './pr-description.js';
5
+ /**
6
+ * How much template text is inlined into the agent's prompt.
7
+ *
8
+ * Over this, the template is NAMED rather than inlined and the agent is told to read it from the
9
+ * checkout — which it can, because the file is on disk. That is strictly better than the
10
+ * alternatives: truncating a template would have the agent fill a structure whose tail it never
11
+ * saw (silently dropping the repo's last sections), and skipping it entirely would abandon the
12
+ * feature on exactly the repos with the most demanding process.
13
+ */
14
+ export const MAX_INLINE_PR_TEMPLATE_CHARS = 8_000;
15
+ /**
16
+ * The shared inline budget across a multi-repo run's legs. Each repo's template competes for it in
17
+ * leg order, and a leg that does not fit is NAMED rather than inlined (as above) — so a workspace
18
+ * of four template-carrying repos cannot quietly consume 32k of the agent's prompt.
19
+ */
20
+ export const MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS = 12_000;
21
+ /** Extensions a template file may carry. Both hosts also accept an extensionless file. */
22
+ const TEMPLATE_EXTENSIONS = new Set(['.md', '.markdown', '.txt']);
23
+ /** GitHub's single-file template basename, matched case-insensitively as GitHub itself does. */
24
+ const GITHUB_TEMPLATE_STEM = 'pull_request_template';
25
+ /**
26
+ * Where a template can live, in each host's OWN precedence order. A `stem` entry is a single file
27
+ * in that directory; a `pick` entry is a directory of templates (see {@link chooseFromDirectory}).
28
+ */
29
+ const GITHUB_LOCATIONS = [
30
+ { dir: '.github', stem: GITHUB_TEMPLATE_STEM },
31
+ { dir: '', stem: GITHUB_TEMPLATE_STEM },
32
+ { dir: 'docs', stem: GITHUB_TEMPLATE_STEM },
33
+ { dir: '.github/PULL_REQUEST_TEMPLATE', pick: true },
34
+ ];
35
+ /**
36
+ * GitLab keeps merge-request templates only in a directory — there is no root single-file
37
+ * convention to probe, so none is invented here.
38
+ */
39
+ const GITLAB_LOCATIONS = [
40
+ { dir: '.gitlab/merge_request_templates', pick: true },
41
+ ];
42
+ /**
43
+ * THE entry point: find each target's pull-request template and build the prompt note that asks
44
+ * the agent to fill it, plus the set of checkouts whose sentinel will therefore hold a filled
45
+ * template rather than a free-form briefing (see {@link PrTemplateResolution}).
46
+ *
47
+ * Pass NO targets for a dispatch that opens no pull request — an in-place fixer amending someone
48
+ * else's PR, a read-only explore run. Asking such a run to fill a template would be asking for a
49
+ * document nothing publishes.
50
+ *
51
+ * Never rejects: a template is an improvement to a PR body, so no failure reading one may cost a
52
+ * run that otherwise succeeded. An unreadable or empty template is simply no template.
53
+ */
54
+ export async function resolvePrTemplateNote(args) {
55
+ const { targets, logger } = args;
56
+ const notes = [];
57
+ const templated = new Set();
58
+ let inlineBudget = MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS;
59
+ for (const target of targets) {
60
+ const found = await discoverPrTemplate(target.repoDir, target.provider);
61
+ if (!found)
62
+ continue;
63
+ // Spend the shared budget in leg order; a template that no longer fits is named, not cut. The
64
+ // per-file budget was already spent inside discovery, so an absent `text` means THAT ceiling —
65
+ // distinguished in the log because the two want different fixes (a smaller template vs a
66
+ // workspace carrying more template text than one prompt should hold).
67
+ const text = found.text;
68
+ const inline = text !== undefined && text.length <= inlineBudget;
69
+ if (inline)
70
+ inlineBudget -= text.length;
71
+ const reason = inline
72
+ ? undefined
73
+ : text === undefined
74
+ ? 'over-file-budget'
75
+ : 'over-shared-budget';
76
+ logger.info('pr template: found', {
77
+ path: found.path,
78
+ chars: found.chars,
79
+ inlined: inline,
80
+ ...(reason ? { reason } : {}),
81
+ ...(target.repoLabel ? { repo: target.repoLabel } : {}),
82
+ });
83
+ templated.add(target.repoDir);
84
+ notes.push(buildPrTemplateNote(inline ? found : { path: found.path, chars: found.chars }, target.repoLabel));
85
+ }
86
+ return { ...(notes.length > 0 ? { note: notes.join('\n\n') } : {}), templated };
87
+ }
88
+ /**
89
+ * Locate the template in `repoDir`, probing the repo's OWN host convention first and the other
90
+ * host's second — a repo mirrored across both, or one whose provider the dispatcher did not set,
91
+ * still gets its template respected rather than falling to whichever list happened to be first.
92
+ */
93
+ export async function discoverPrTemplate(repoDir, provider) {
94
+ const locations = provider === 'gitlab'
95
+ ? [...GITLAB_LOCATIONS, ...GITHUB_LOCATIONS]
96
+ : [...GITHUB_LOCATIONS, ...GITLAB_LOCATIONS];
97
+ for (const location of locations) {
98
+ const entries = await listDirectory(join(repoDir, location.dir));
99
+ if (entries.length === 0)
100
+ continue;
101
+ const name = location.pick ? chooseFromDirectory(entries) : chooseSingleFile(entries, location);
102
+ if (!name)
103
+ continue;
104
+ const path = location.dir ? `${location.dir}/${name}` : name;
105
+ const text = await readTemplate(join(repoDir, location.dir, name), repoDir);
106
+ // An empty (or unreadable) file imposes no structure, so keep probing: a repo can carry a
107
+ // placeholder at one location and its real template at another.
108
+ if (!text)
109
+ continue;
110
+ const chars = text.length;
111
+ return chars <= MAX_INLINE_PR_TEMPLATE_CHARS ? { path, chars, text } : { path, chars };
112
+ }
113
+ return undefined;
114
+ }
115
+ /** Directory entries with their kind, or `[]` for a directory that is absent or unreadable. */
116
+ async function listDirectory(path) {
117
+ try {
118
+ const entries = await readdir(path, { withFileTypes: true });
119
+ // Sorted so a repo carrying several matches always yields the SAME one: readdir order is
120
+ // filesystem-defined, and a PR body that changed shape between two runs of the same repo
121
+ // would be a genuinely baffling thing to debug.
122
+ return entries
123
+ .map((entry) => ({ name: entry.name, directory: entry.isDirectory() }))
124
+ .sort((a, b) => a.name.localeCompare(b.name));
125
+ }
126
+ catch {
127
+ return [];
128
+ }
129
+ }
130
+ /**
131
+ * The single-file match. Case-insensitive on both the stem and the extension, and extensionless
132
+ * is allowed because both hosts accept it — which is why the DIRECTORY check matters here:
133
+ * `.github/PULL_REQUEST_TEMPLATE/` is itself an extensionless match for the stem, and reading a
134
+ * directory as a template would produce nothing but a swallowed EISDIR.
135
+ */
136
+ function chooseSingleFile(entries, location) {
137
+ return entries.find((entry) => {
138
+ if (entry.directory)
139
+ return false;
140
+ const { stem, extension } = splitName(entry.name);
141
+ return stem === location.stem && (extension === '' || TEMPLATE_EXTENSIONS.has(extension));
142
+ })?.name;
143
+ }
144
+ /**
145
+ * Pick from a directory of templates — GitHub's `.github/PULL_REQUEST_TEMPLATE/`, GitLab's
146
+ * `.gitlab/merge_request_templates/`.
147
+ *
148
+ * A `default` template wins (GitLab applies `Default.md` by itself, so it is unambiguously the
149
+ * one meant for a PR that names none). Failing that, a lone template is taken: a repo with
150
+ * exactly one has expressed exactly one convention.
151
+ *
152
+ * SEVERAL templates with no default yields NOTHING, deliberately. That directory exists so a
153
+ * HUMAN can choose per pull request — "bug report" vs "release" vs "RFC" — and the choice is
154
+ * usually not inferable from a diff. Picking one arbitrarily would file every run's work under
155
+ * whichever name sorts first, which is worse than the free-form briefing: it looks like a
156
+ * deliberate categorisation and is not.
157
+ */
158
+ function chooseFromDirectory(entries) {
159
+ const usable = entries.filter((entry) => !entry.directory && TEMPLATE_EXTENSIONS.has(splitName(entry.name).extension));
160
+ const fallback = usable.find((entry) => splitName(entry.name).stem === 'default');
161
+ if (fallback)
162
+ return fallback.name;
163
+ return usable.length === 1 ? usable[0].name : undefined;
164
+ }
165
+ /** Lower-cased stem + extension ('' when the name carries none). */
166
+ function splitName(name) {
167
+ const lower = name.toLowerCase();
168
+ const dot = lower.lastIndexOf('.');
169
+ if (dot <= 0)
170
+ return { stem: lower, extension: '' };
171
+ return { stem: lower.slice(0, dot), extension: lower.slice(dot) };
172
+ }
173
+ /**
174
+ * The template's text, or undefined when it is unreadable or carries nothing.
175
+ *
176
+ * A checkout is REPO-AUTHORED, symlinks included, and this is the one read the harness performs on
177
+ * a repo-chosen path without the agent asking for it — so the resolved target must stay inside
178
+ * `repoDir`. A link out of the tree would inline an arbitrary container file (the run's own env,
179
+ * a sibling checkout) into the prompt, and from there into a body only `redactSecrets` stands in
180
+ * front of. Containment rather than a blanket symlink refusal, because a monorepo pointing
181
+ * `.github/PULL_REQUEST_TEMPLATE.md` at a doc it keeps elsewhere in the repo is a real and
182
+ * legitimate layout.
183
+ */
184
+ async function readTemplate(path, repoDir) {
185
+ try {
186
+ // Both sides resolved, so the comparison is between two canonical paths — `repoDir` itself is
187
+ // routinely reached through a symlinked temp dir (macOS `/tmp`), which a raw prefix test on the
188
+ // unresolved root would read as an escape.
189
+ const [target, root] = await Promise.all([realpath(path), realpath(repoDir)]);
190
+ if (target !== root && !target.startsWith(root.endsWith(sep) ? root : root + sep)) {
191
+ return undefined;
192
+ }
193
+ return (await readFile(target, 'utf8')).trim() || undefined;
194
+ }
195
+ catch {
196
+ return undefined;
197
+ }
198
+ }
199
+ /**
200
+ * The prompt note. It has to do more than show the template, because the agent has already been
201
+ * told (by the backend-composed `PR_DESCRIPTION_GUIDANCE`) to write a free-form briefing, and the
202
+ * two genuinely conflict: a template that asks for a test plan or a checklist is asking for
203
+ * exactly the "restated diff" that guidance rules out. So the note states which wins, and states
204
+ * why the template is not already applied — an agent that believes the host will merge the
205
+ * template with its text has no reason to reproduce the structure itself.
206
+ *
207
+ * The template is delimited with `fencedOutput`, the same helper every other captured-text-into-a-
208
+ * prompt path uses. Templates routinely carry fenced blocks of their own, and a fixed three-tick
209
+ * wrapper closes on the first of them — spilling the rest of the template, and the instructions
210
+ * after it, into the prompt as prose. `fencedOutput` sizes the fence one tick longer than the
211
+ * longest run in the body, which is what CommonMark specifies for exactly this, so no template can
212
+ * break out of its own block. A plain `--- BEGIN/END ---` rule would read more nicely and is
213
+ * trivially forgeable by the template's own content, which is the whole thing being defended.
214
+ */
215
+ export function buildPrTemplateNote(template, repoLabel) {
216
+ const subject = repoLabel ? `The \`${repoLabel}\` repository` : 'This repository';
217
+ const lead = `PULL REQUEST TEMPLATE — ${subject} ships a pull request template at \`${template.path}\` ` +
218
+ '(relative to the repository root). The platform opens the pull request through the host API, ' +
219
+ 'and neither GitHub nor GitLab applies a template to an API-created pull request — that only ' +
220
+ 'happens for a human opening one in the web form. So following it is on you.';
221
+ const instructions = `Write \`${PR_DESCRIPTION_FILE}\` as that template, FILLED IN${repoLabel ? ` (in the \`${repoLabel}\` checkout)` : ''}: keep its headings, their order, and any structure it defines; answer every section from ` +
222
+ 'the work you actually did; delete its instructional HTML comments and any placeholder text; ' +
223
+ 'and complete checklists honestly, ticking only what is true. Leave a section that genuinely ' +
224
+ 'does not apply in place with a brief "n/a" and why, rather than deleting the heading — a ' +
225
+ 'reviewer looking for it needs to see it was considered. Where the template asks for ' +
226
+ 'something the general description guidance does not, the TEMPLATE wins; where it leaves room ' +
227
+ 'for prose, brief it as that guidance describes. The platform rules still hold either way: no ' +
228
+ 'secrets, and no issue/PR numbers, @-mentions, or issue-closing wording. Do NOT put a title ' +
229
+ "line above the template — the platform titles this pull request itself, so the template's " +
230
+ 'own first heading stays the first line of the file. Keep the finished file under ' +
231
+ `${MAX_PR_BODY_CHARS.toLocaleString('en-US')} characters: the platform truncates a longer ` +
232
+ "body, which would cut the template's last sections.";
233
+ if (template.text === undefined) {
234
+ // Reached both when the file alone exceeds the inline budget and when a multi-repo run's
235
+ // earlier legs spent the shared one, so the wording states the size and stays true of both.
236
+ return (`${lead} ${instructions} The template (${template.chars} characters) is not reproduced ` +
237
+ 'here — read it from the checkout.');
238
+ }
239
+ return `${lead} ${instructions}\n\nThe template follows, in a fenced block that is NOT part of it:\n${fencedOutput(template.text)}`;
240
+ }
241
+ /**
242
+ * Fold the note into a prompt. The sibling of `withDependencyNote`, and deliberately not inlined
243
+ * for the same reason: it rides EVERY agent pass, including the validation and reproduction
244
+ * REPAIR passes. Those start a fresh agent that still carries the description guidance in its
245
+ * system prompt, so one that is not also told about the template would rewrite the briefing
246
+ * free-form and undo the filled template the first pass produced.
247
+ */
248
+ export function withPrTemplateNote(userPrompt, note) {
249
+ return note ? `${userPrompt}\n\n${note}` : userPrompt;
250
+ }
package/dist/runner.js CHANGED
@@ -220,6 +220,9 @@ export class JobRegistry {
220
220
  onValidationReport: (report) => {
221
221
  entry.validationReport = report;
222
222
  },
223
+ onSliceReviews: (reviews) => {
224
+ entry.sliceReviews = reviews;
225
+ },
223
226
  onReproductionProof: (report) => {
224
227
  entry.reproductionReport = report;
225
228
  },