@cat-factory/executor-harness 1.76.2 → 1.78.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
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),
@@ -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;
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.76.2",
3
+ "version": "1.78.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,9 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.188.0",
30
- "@cat-factory/server": "0.175.0",
31
- "@cat-factory/spend": "0.12.118"
29
+ "@cat-factory/kernel": "0.193.0",
30
+ "@cat-factory/spend": "0.12.123",
31
+ "@cat-factory/server": "0.178.2"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -962,8 +962,12 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
962
962
  * Assemble the {@link runCodingAgent} spec for the ordinary single-repo coding flow. Extracted
963
963
  * from {@link runSingleRepoCoding} so the many optional-field spreads don't inflate that
964
964
  * function's cyclomatic complexity; the mapping is a straight field copy off `job`.
965
+ *
966
+ * Exported for the `opensPr` assertion: whether a dispatch fills the repo's PR template turns on
967
+ * this one spread, and the in-place fixers reach it through the SAME function as the implementer,
968
+ * so no structural guard can tell their cases apart.
965
969
  */
966
- function buildSingleRepoCodingSpec(
970
+ export function buildSingleRepoCodingSpec(
967
971
  job: AgentJob,
968
972
  pushBranch: string,
969
973
  ): Parameters<typeof runCodingAgent>[0] {
@@ -991,6 +995,10 @@ function buildSingleRepoCodingSpec(
991
995
  guardLimits: job.guardLimits,
992
996
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
993
997
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
998
+ // Whether a pull request will open at all is exactly `job.pr` (see the `if (job.pr)` guard in
999
+ // `runSingleRepoCoding`), and it is what decides whether the repo's PR template is worth
1000
+ // resolving. Read off the same field rather than a new job-body flag, so the two can't drift.
1001
+ ...(job.pr ? { opensPr: true } : {}),
994
1002
  ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
995
1003
  // Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
996
1004
  ...agentCapabilities(job),
@@ -63,6 +63,11 @@ import {
63
63
  withDependencyNote,
64
64
  type DependencyInstallSpec,
65
65
  } from './dependency-install.js'
66
+ import {
67
+ resolvePrTemplateNote,
68
+ withPrTemplateNote,
69
+ type PrTemplateResolution,
70
+ } from './pr-template.js'
66
71
 
67
72
  // The shared skeleton for the container coding agents that clone a repo, run Pi
68
73
  // against it and push the result on a branch. The implementation (`/run`) and
@@ -114,6 +119,14 @@ export interface CodingAgentSpec extends HarnessAuthFields {
114
119
  * only for the implementer (`coder`) dispatch; absent ⇒ no tailing (e.g. the CI-fixer).
115
120
  */
116
121
  streamFollowUps?: boolean
122
+ /**
123
+ * Whether this dispatch OPENS a pull request (the caller passes `pr` to `openPullRequest`).
124
+ * Set, the harness looks for the repo's own pull-request template and asks the agent to fill it
125
+ * (see `pr-template.ts`). Absent for a dispatch that amends someone else's PR (the in-place
126
+ * fixers) — a template filled for a pull request nothing opens is wasted prompt and, worse,
127
+ * would have a CI-fixer rewrite the implementer's already-published description.
128
+ */
129
+ opensPr?: boolean
117
130
  /**
118
131
  * READ-ONLY reference branches of THIS repo (the apriori-branches reference mode): fetched
119
132
  * into `origin/<b>` after the checkout so the agent can inspect them but never commits to
@@ -365,13 +378,28 @@ export async function runCodingAgent(
365
378
  opts,
366
379
  })
367
380
 
381
+ // THE REPO'S OWN PR TEMPLATE: when this dispatch opens a pull request and the repo ships a
382
+ // template, the agent is asked to write its briefing AS that template rather than free-form
383
+ // (see `pr-template.ts` for why neither host applies it to an API-created PR for us).
384
+ // Discovered at the CHECKOUT ROOT, never `workDir`: a template is a fact about the
385
+ // repository, so a monorepo service dispatch reads the same one as any other.
386
+ const prTemplate = await resolvePrTemplateNote({
387
+ targets: spec.opensPr
388
+ ? [{ repoDir: dir, ...(spec.repo.provider ? { provider: spec.repo.provider } : {}) }]
389
+ : [],
390
+ logger,
391
+ })
392
+
368
393
  // One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
369
394
  // validation loop below can re-run the agent with a repair instruction without
370
395
  // re-deriving (or drifting from) the dispatch's own settings.
371
396
  //
372
397
  // The dependency note rides EVERY pass, not just the first: a repair round starts a fresh
373
398
  // agent, and one that is not told the tree is already installed spends the round it was
374
- // given to fix something reinstalling it instead.
399
+ // given to fix something reinstalling it instead. The PR-template note rides every pass for
400
+ // the mirror-image reason: a repair-round agent still carries the description guidance, so
401
+ // one that is not told about the template would replace the filled template with a
402
+ // free-form briefing.
375
403
  const runAgentPass = (
376
404
  userPrompt: string,
377
405
  ): Promise<Awaited<ReturnType<typeof runAgentInWorkspace>>> =>
@@ -379,7 +407,10 @@ export async function runCodingAgent(
379
407
  {
380
408
  dir: workDir,
381
409
  systemPrompt: spec.systemPrompt,
382
- userPrompt: withDependencyNote(userPrompt, dependencyNote),
410
+ userPrompt: withDependencyNote(
411
+ withPrTemplateNote(userPrompt, prTemplate.note),
412
+ dependencyNote,
413
+ ),
383
414
  model: spec.model,
384
415
  harness: spec.harness,
385
416
  subscriptionToken: spec.subscriptionToken,
@@ -499,6 +530,7 @@ export async function runCodingAgent(
499
530
  pushWorkOnce,
500
531
  inFlightPush,
501
532
  agentRun,
533
+ prTemplate,
502
534
  })
503
535
  } finally {
504
536
  // Safety net for the throw path (the happy path already cleared these above).
@@ -651,6 +683,8 @@ async function finalizeCodingRun(args: {
651
683
  pushWorkOnce: () => Promise<void>
652
684
  inFlightPush: () => Promise<void> | null
653
685
  agentRun: Awaited<ReturnType<typeof runAgentInWorkspace>>
686
+ /** The repo's PR template, if it ships one — see the `titleFromHeading` read below. */
687
+ prTemplate: PrTemplateResolution
654
688
  }): Promise<CodingAgentOutcome> {
655
689
  const {
656
690
  validationReport,
@@ -668,6 +702,7 @@ async function finalizeCodingRun(args: {
668
702
  pushWorkOnce,
669
703
  inFlightPush,
670
704
  agentRun,
705
+ prTemplate,
671
706
  } = args
672
707
  const { signal } = opts
673
708
  const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun
@@ -686,9 +721,15 @@ async function finalizeCodingRun(args: {
686
721
  // changed what the briefing should say) and removed so it never lingers in the checkout. The
687
722
  // prompt asks for it at the top level of the checkout; a monorepo agent working in a service
688
723
  // subdirectory may drop it in its cwd instead, so probe the checkout root first, then the cwd.
724
+ //
725
+ // When the repo ships a template the briefing IS that template filled in, so its headings are
726
+ // the REPO's: a leading `# …` there is the template's own top heading, not the title line the
727
+ // description guidance asks a free-form briefing for, and lifting it would retitle the PR after
728
+ // the template and delete the heading from the body.
729
+ const readDescription = (from: string): Promise<AgentPrDescription | undefined> =>
730
+ readPrDescription(from, { titleFromHeading: !prTemplate.templated.has(dir) })
689
731
  const prDescription =
690
- (await readPrDescription(dir)) ??
691
- (workDir !== dir ? await readPrDescription(workDir) : undefined)
732
+ (await readDescription(dir)) ?? (workDir !== dir ? await readDescription(workDir) : undefined)
692
733
 
693
734
  // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
694
735
  // push, so the two never run a concurrent `git push` to the same branch (the
@@ -1093,6 +1134,21 @@ export async function runMultiRepoCoding(
1093
1134
  })
1094
1135
  : undefined
1095
1136
 
1137
+ // THE REPOS' OWN PR TEMPLATES: one per leg that will actually open a pull request, each named
1138
+ // by its sibling directory so the agent knows which checkout's briefing takes which shape —
1139
+ // the repos in a workspace need not share a template, or ship one at all. A read-only
1140
+ // reference leg is excluded by construction: it carries no `pr`, so nothing publishes for it.
1141
+ const prTemplate = await resolvePrTemplateNote({
1142
+ targets: legs
1143
+ .filter((leg) => leg.pr)
1144
+ .map((leg) => ({
1145
+ repoDir: leg.dir,
1146
+ repoLabel: leg.dirName,
1147
+ ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
1148
+ })),
1149
+ logger,
1150
+ })
1151
+
1096
1152
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
1097
1153
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
1098
1154
  // note + the backend system-prompt section explain the layout.
@@ -1103,7 +1159,10 @@ export async function runMultiRepoCoding(
1103
1159
  {
1104
1160
  dir: root,
1105
1161
  systemPrompt: job.systemPrompt,
1106
- userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
1162
+ userPrompt: withDependencyNote(
1163
+ withPrTemplateNote(job.userPrompt, prTemplate.note),
1164
+ dependencyNote,
1165
+ ),
1107
1166
  model: job.model,
1108
1167
  harness: job.harness,
1109
1168
  subscriptionToken: job.subscriptionToken,
@@ -1132,6 +1191,7 @@ export async function runMultiRepoCoding(
1132
1191
  logger,
1133
1192
  opts,
1134
1193
  root,
1194
+ prTemplate,
1135
1195
  )
1136
1196
 
1137
1197
  const anyWork = primaryPushed || peerPullRequests.length > 0
@@ -1307,6 +1367,8 @@ async function pushMultiRepoLegs(
1307
1367
  opts: RunOptions,
1308
1368
  /** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
1309
1369
  root: string,
1370
+ /** Which legs' briefings are filled templates — see the `titleFromHeading` read below. */
1371
+ prTemplate: PrTemplateResolution,
1310
1372
  ): Promise<{
1311
1373
  primaryPushed: boolean
1312
1374
  primaryPrUrl: string | undefined
@@ -1327,9 +1389,14 @@ async function pushMultiRepoLegs(
1327
1389
  // read the prompt loosely may well have written a single briefing there instead. Fall back
1328
1390
  // to it for the PRIMARY leg only: at the root there is nothing to say which repo it
1329
1391
  // describes, and the primary is the one the run is actually about.
1392
+ //
1393
+ // Per-leg `titleFromHeading`: only a leg whose OWN repo ships a template has repo-authored
1394
+ // headings in its sentinel, and the legs of a workspace need not agree about that — so this
1395
+ // is keyed on the leg, never on whether the run found any template at all.
1396
+ const readOptions = { titleFromHeading: !prTemplate.templated.has(leg.dir) }
1330
1397
  const agentPrDescription =
1331
- (await readPrDescription(leg.dir)) ??
1332
- (leg.primary ? await readPrDescription(root) : undefined)
1398
+ (await readPrDescription(leg.dir, readOptions)) ??
1399
+ (leg.primary ? await readPrDescription(root, readOptions) : undefined)
1333
1400
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
1334
1401
  const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
1335
1402
  let hasWork = advanced || leg.resumed
@@ -32,8 +32,14 @@ export const PR_DESCRIPTION_FILE = '.cat-pr-description.md'
32
32
  * rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
33
33
  * so a briefing budget that does not leave the report room would surface as a report that
34
34
  * silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
35
+ *
36
+ * Exported because the PR-TEMPLATE note states it to the agent (`pr-template.ts`): a filled
37
+ * template is the one briefing shape whose length is dictated by a file the agent did not write,
38
+ * so an agent that does not know the ceiling can answer a long template past it and have
39
+ * {@link capBody} cut the repo's last sections — the very failure the inline budget avoids on the
40
+ * way IN.
35
41
  */
36
- const MAX_PR_BODY_CHARS = 15_000
42
+ export const MAX_PR_BODY_CHARS = 15_000
37
43
 
38
44
  /** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
39
45
  const MAX_PR_TITLE_CHARS = 160
@@ -57,6 +63,24 @@ export interface AgentPrDescription {
57
63
  body?: string
58
64
  }
59
65
 
66
+ /** How to read a sentinel. */
67
+ export interface ReadPrDescriptionOptions {
68
+ /**
69
+ * Whether a lone leading `# <title>` heading may be lifted off as the PR title (see
70
+ * {@link splitTitle}). Default `true` — that is what the description guidance asks a free-form
71
+ * briefing for.
72
+ *
73
+ * FALSE when the briefing is a FILLED TEMPLATE (`pr-template.ts`): then the headings are the
74
+ * repo's, not the agent's, and a template whose first heading is its only level-1 one — `#
75
+ * Pull Request` above a set of `##` sections, an entirely ordinary shape — would have that
76
+ * heading silently become the pull request's title, so the PR reads "Pull Request" instead of
77
+ * `<block> (<pipeline>)` and the body loses the heading the repo asked for. The heuristic below
78
+ * is sound for the shape the guidance describes and cannot be made to cover both, so the caller
79
+ * that KNOWS which shape it asked for says so.
80
+ */
81
+ titleFromHeading?: boolean
82
+ }
83
+
60
84
  /**
61
85
  * Read + parse + REMOVE the agent's PR-description sentinel from `dir`. Lenient: returns
62
86
  * undefined when the file is absent (the agent wrote none) or carries nothing usable. Never
@@ -64,16 +88,20 @@ export interface AgentPrDescription {
64
88
  * the dispatch-time text.
65
89
  *
66
90
  * A SINGLE `# <title>` heading on the first line sets the PR title; everything after it is the
67
- * body (see {@link splitTitle} for why a LONE heading is required). The whole text is
68
- * secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent cut would
69
- * read as the complete briefing), and both halves are made inert for the host.
91
+ * body (see {@link splitTitle} for why a LONE heading is required, and
92
+ * {@link ReadPrDescriptionOptions.titleFromHeading} for the caller that must switch it off). The
93
+ * whole text is secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent
94
+ * cut would read as the complete briefing), and both halves are made inert for the host.
70
95
  *
71
96
  * On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
72
97
  * briefing sentence like "the token: handling changed" loses its next word. That is the right
73
98
  * trade for a surface this public — the rule is shared with every other redaction path, and
74
99
  * narrowing it so prose reads better would weaken all of them.
75
100
  */
76
- export async function readPrDescription(dir: string): Promise<AgentPrDescription | undefined> {
101
+ export async function readPrDescription(
102
+ dir: string,
103
+ opts: ReadPrDescriptionOptions = {},
104
+ ): Promise<AgentPrDescription | undefined> {
77
105
  const path = join(dir, PR_DESCRIPTION_FILE)
78
106
  let raw: string
79
107
  try {
@@ -86,7 +114,8 @@ export async function readPrDescription(dir: string): Promise<AgentPrDescription
86
114
  const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim()
87
115
  if (!text) return undefined
88
116
 
89
- const split = splitTitle(text)
117
+ const split: { title?: string; body: string } =
118
+ opts.titleFromHeading === false ? { body: text } : splitTitle(text)
90
119
  // Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
91
120
  const title = split.title ? inertInline(capTitle(split.title)) : undefined
92
121
  const body = split.body ? inertMarkdown(capBody(split.body)) : undefined
@@ -104,6 +133,11 @@ export async function readPrDescription(dir: string): Promise<AgentPrDescription
104
133
  * silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
105
134
  * "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
106
135
  * quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
136
+ *
137
+ * The "single H1" test is what makes this safe for the free-form shape and is exactly what makes
138
+ * it WRONG for a filled template, whose H1 count is the repo's choice — hence
139
+ * {@link ReadPrDescriptionOptions.titleFromHeading}, which skips this entirely rather than piling
140
+ * another heuristic on top of one that cannot serve both shapes.
107
141
  */
108
142
  function splitTitle(text: string): { title?: string; body: string } {
109
143
  const lines = text.split('\n')
@@ -0,0 +1,366 @@
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 type { RepoSpec } from './job.js'
5
+ import type { Logger } from './logger.js'
6
+ import { MAX_PR_BODY_CHARS, PR_DESCRIPTION_FILE } from './pr-description.js'
7
+
8
+ // ---------------------------------------------------------------------------
9
+ // THE REPOSITORY'S OWN PULL-REQUEST TEMPLATE.
10
+ //
11
+ // A repo that ships `.github/PULL_REQUEST_TEMPLATE.md` (or GitLab's
12
+ // `.gitlab/merge_request_templates/Default.md`) is stating the shape every pull request against
13
+ // it must take — the sections its reviewers read, the checklist its process requires. A platform
14
+ // that opens PRs there and ignores that is a bad citizen: its pull requests are the only ones on
15
+ // the repo missing the structure everyone else follows.
16
+ //
17
+ // The trap this module exists for is that the template is NOT applied for us. Both hosts
18
+ // interpolate the template only into the WEB form a human opens; a PR created through the REST
19
+ // API gets exactly the body the caller sends. So nothing anywhere fails or warns — the platform's
20
+ // pull requests simply, quietly, don't follow the repo's own convention.
21
+ //
22
+ // The template is FILLED IN BY THE AGENT, not by the platform. Mechanically stuffing the
23
+ // briefing under the first heading would produce a document with the template's shape and none
24
+ // of its meaning: the sections are questions ("what is the risk?", "how was this tested?") that
25
+ // only whoever did the work can answer. So the harness discovers the template and hands it to the
26
+ // agent that just did the work, in the same prompt that already asks it for a briefing — and the
27
+ // agent answers the template's questions instead of writing a free-form one. Zero extra model
28
+ // calls, and the answers come from the run's full context rather than a summary of it.
29
+ //
30
+ // Discovery is HARNESS-side, deliberately, and reads from the checkout on disk. The backend could
31
+ // instead resolve the template through the `RepoFiles` port at dispatch, but that is an HTTP round
32
+ // trip per dispatch to answer a question the container can answer for free — and every dispatch
33
+ // that opens a pull request has a checkout by definition.
34
+ //
35
+ // The filled text goes back out through `readPrDescription`, so it crosses `redactSecrets` and
36
+ // `host-markdown.ts` on the way to the PR exactly as a free-form briefing does. Nothing here
37
+ // widens that boundary: a template is repo-committed text on the way IN, and what the agent
38
+ // writes is model-authored text on the way OUT either way. What it DOES change on the way out is
39
+ // the title heuristic: the headings are now the repo's, so the caller reads the sentinel with
40
+ // `titleFromHeading: false` (see `ReadPrDescriptionOptions`).
41
+ // ---------------------------------------------------------------------------
42
+
43
+ /** The VCS providers a repo can live on. Bound to `RepoSpec` so the two cannot drift. */
44
+ type ProviderName = NonNullable<RepoSpec['provider']>
45
+
46
+ /**
47
+ * How much template text is inlined into the agent's prompt.
48
+ *
49
+ * Over this, the template is NAMED rather than inlined and the agent is told to read it from the
50
+ * checkout — which it can, because the file is on disk. That is strictly better than the
51
+ * alternatives: truncating a template would have the agent fill a structure whose tail it never
52
+ * saw (silently dropping the repo's last sections), and skipping it entirely would abandon the
53
+ * feature on exactly the repos with the most demanding process.
54
+ */
55
+ export const MAX_INLINE_PR_TEMPLATE_CHARS = 8_000
56
+
57
+ /**
58
+ * The shared inline budget across a multi-repo run's legs. Each repo's template competes for it in
59
+ * leg order, and a leg that does not fit is NAMED rather than inlined (as above) — so a workspace
60
+ * of four template-carrying repos cannot quietly consume 32k of the agent's prompt.
61
+ */
62
+ export const MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS = 12_000
63
+
64
+ /** Extensions a template file may carry. Both hosts also accept an extensionless file. */
65
+ const TEMPLATE_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
66
+
67
+ /** GitHub's single-file template basename, matched case-insensitively as GitHub itself does. */
68
+ const GITHUB_TEMPLATE_STEM = 'pull_request_template'
69
+
70
+ /**
71
+ * Where a template can live, in each host's OWN precedence order. A `stem` entry is a single file
72
+ * in that directory; a `pick` entry is a directory of templates (see {@link chooseFromDirectory}).
73
+ */
74
+ const GITHUB_LOCATIONS: TemplateLocation[] = [
75
+ { dir: '.github', stem: GITHUB_TEMPLATE_STEM },
76
+ { dir: '', stem: GITHUB_TEMPLATE_STEM },
77
+ { dir: 'docs', stem: GITHUB_TEMPLATE_STEM },
78
+ { dir: '.github/PULL_REQUEST_TEMPLATE', pick: true },
79
+ ]
80
+
81
+ /**
82
+ * GitLab keeps merge-request templates only in a directory — there is no root single-file
83
+ * convention to probe, so none is invented here.
84
+ */
85
+ const GITLAB_LOCATIONS: TemplateLocation[] = [
86
+ { dir: '.gitlab/merge_request_templates', pick: true },
87
+ ]
88
+
89
+ interface TemplateLocation {
90
+ /** Repo-root-relative directory ('' = the root). */
91
+ dir: string
92
+ /** Match a single file with this basename (case-insensitive). */
93
+ stem?: string
94
+ /** Treat the directory as a set of templates and pick one. */
95
+ pick?: boolean
96
+ }
97
+
98
+ /** A discovered pull-request template. */
99
+ export interface PrTemplate {
100
+ /** Repo-root-relative path, forward-slashed (it is prose an agent reads). */
101
+ path: string
102
+ /**
103
+ * The template's size. ALWAYS the real one, including when {@link text} is absent: an over-budget
104
+ * template reporting `chars: 0` would read in the log exactly like an empty file, which is the
105
+ * one thing discovery treats as "no template at all".
106
+ */
107
+ chars: number
108
+ /** The template text. Absent ⇒ over budget, so the agent is told to read {@link path} itself. */
109
+ text?: string
110
+ }
111
+
112
+ /** One checkout to look for a template in. */
113
+ export interface PrTemplateTarget {
114
+ /** The repository checkout root (NOT a monorepo service subtree — a template is a repo fact). */
115
+ repoDir: string
116
+ provider?: ProviderName
117
+ /** Names this repo in the note. Omit when the run has a single checkout ("this repository"). */
118
+ repoLabel?: string
119
+ }
120
+
121
+ /** What {@link resolvePrTemplateNote} tells the run about the templates it found. */
122
+ export interface PrTemplateResolution {
123
+ /** The prompt note, or absent when no target ships a template (which is most repos). */
124
+ note?: string
125
+ /**
126
+ * The `repoDir`s whose briefing is a FILLED TEMPLATE. The push phase reads those sentinels with
127
+ * `titleFromHeading: false`, because the headings in them are the repo's — see
128
+ * `ReadPrDescriptionOptions.titleFromHeading`. A SET keyed by directory rather than a boolean
129
+ * because a multi-repo run's legs need not all ship a template.
130
+ */
131
+ templated: ReadonlySet<string>
132
+ }
133
+
134
+ /** Why a discovered template was named rather than inlined — the two are not the same fix. */
135
+ type NotInlinedReason = 'over-file-budget' | 'over-shared-budget'
136
+
137
+ /**
138
+ * THE entry point: find each target's pull-request template and build the prompt note that asks
139
+ * the agent to fill it, plus the set of checkouts whose sentinel will therefore hold a filled
140
+ * template rather than a free-form briefing (see {@link PrTemplateResolution}).
141
+ *
142
+ * Pass NO targets for a dispatch that opens no pull request — an in-place fixer amending someone
143
+ * else's PR, a read-only explore run. Asking such a run to fill a template would be asking for a
144
+ * document nothing publishes.
145
+ *
146
+ * Never rejects: a template is an improvement to a PR body, so no failure reading one may cost a
147
+ * run that otherwise succeeded. An unreadable or empty template is simply no template.
148
+ */
149
+ export async function resolvePrTemplateNote(args: {
150
+ targets: PrTemplateTarget[]
151
+ logger: Logger
152
+ }): Promise<PrTemplateResolution> {
153
+ const { targets, logger } = args
154
+ const notes: string[] = []
155
+ const templated = new Set<string>()
156
+ let inlineBudget = MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS
157
+ for (const target of targets) {
158
+ const found = await discoverPrTemplate(target.repoDir, target.provider)
159
+ if (!found) continue
160
+ // Spend the shared budget in leg order; a template that no longer fits is named, not cut. The
161
+ // per-file budget was already spent inside discovery, so an absent `text` means THAT ceiling —
162
+ // distinguished in the log because the two want different fixes (a smaller template vs a
163
+ // workspace carrying more template text than one prompt should hold).
164
+ const text = found.text
165
+ const inline = text !== undefined && text.length <= inlineBudget
166
+ if (inline) inlineBudget -= text.length
167
+ const reason: NotInlinedReason | undefined = inline
168
+ ? undefined
169
+ : text === undefined
170
+ ? 'over-file-budget'
171
+ : 'over-shared-budget'
172
+ logger.info('pr template: found', {
173
+ path: found.path,
174
+ chars: found.chars,
175
+ inlined: inline,
176
+ ...(reason ? { reason } : {}),
177
+ ...(target.repoLabel ? { repo: target.repoLabel } : {}),
178
+ })
179
+ templated.add(target.repoDir)
180
+ notes.push(
181
+ buildPrTemplateNote(
182
+ inline ? found : { path: found.path, chars: found.chars },
183
+ target.repoLabel,
184
+ ),
185
+ )
186
+ }
187
+ return { ...(notes.length > 0 ? { note: notes.join('\n\n') } : {}), templated }
188
+ }
189
+
190
+ /**
191
+ * Locate the template in `repoDir`, probing the repo's OWN host convention first and the other
192
+ * host's second — a repo mirrored across both, or one whose provider the dispatcher did not set,
193
+ * still gets its template respected rather than falling to whichever list happened to be first.
194
+ */
195
+ export async function discoverPrTemplate(
196
+ repoDir: string,
197
+ provider?: ProviderName,
198
+ ): Promise<PrTemplate | undefined> {
199
+ const locations =
200
+ provider === 'gitlab'
201
+ ? [...GITLAB_LOCATIONS, ...GITHUB_LOCATIONS]
202
+ : [...GITHUB_LOCATIONS, ...GITLAB_LOCATIONS]
203
+ for (const location of locations) {
204
+ const entries = await listDirectory(join(repoDir, location.dir))
205
+ if (entries.length === 0) continue
206
+ const name = location.pick ? chooseFromDirectory(entries) : chooseSingleFile(entries, location)
207
+ if (!name) continue
208
+ const path = location.dir ? `${location.dir}/${name}` : name
209
+ const text = await readTemplate(join(repoDir, location.dir, name), repoDir)
210
+ // An empty (or unreadable) file imposes no structure, so keep probing: a repo can carry a
211
+ // placeholder at one location and its real template at another.
212
+ if (!text) continue
213
+ const chars = text.length
214
+ return chars <= MAX_INLINE_PR_TEMPLATE_CHARS ? { path, chars, text } : { path, chars }
215
+ }
216
+ return undefined
217
+ }
218
+
219
+ /** Directory entries with their kind, or `[]` for a directory that is absent or unreadable. */
220
+ async function listDirectory(path: string): Promise<{ name: string; directory: boolean }[]> {
221
+ try {
222
+ const entries = await readdir(path, { withFileTypes: true })
223
+ // Sorted so a repo carrying several matches always yields the SAME one: readdir order is
224
+ // filesystem-defined, and a PR body that changed shape between two runs of the same repo
225
+ // would be a genuinely baffling thing to debug.
226
+ return entries
227
+ .map((entry) => ({ name: entry.name, directory: entry.isDirectory() }))
228
+ .sort((a, b) => a.name.localeCompare(b.name))
229
+ } catch {
230
+ return []
231
+ }
232
+ }
233
+
234
+ /**
235
+ * The single-file match. Case-insensitive on both the stem and the extension, and extensionless
236
+ * is allowed because both hosts accept it — which is why the DIRECTORY check matters here:
237
+ * `.github/PULL_REQUEST_TEMPLATE/` is itself an extensionless match for the stem, and reading a
238
+ * directory as a template would produce nothing but a swallowed EISDIR.
239
+ */
240
+ function chooseSingleFile(
241
+ entries: { name: string; directory: boolean }[],
242
+ location: TemplateLocation,
243
+ ): string | undefined {
244
+ return entries.find((entry) => {
245
+ if (entry.directory) return false
246
+ const { stem, extension } = splitName(entry.name)
247
+ return stem === location.stem && (extension === '' || TEMPLATE_EXTENSIONS.has(extension))
248
+ })?.name
249
+ }
250
+
251
+ /**
252
+ * Pick from a directory of templates — GitHub's `.github/PULL_REQUEST_TEMPLATE/`, GitLab's
253
+ * `.gitlab/merge_request_templates/`.
254
+ *
255
+ * A `default` template wins (GitLab applies `Default.md` by itself, so it is unambiguously the
256
+ * one meant for a PR that names none). Failing that, a lone template is taken: a repo with
257
+ * exactly one has expressed exactly one convention.
258
+ *
259
+ * SEVERAL templates with no default yields NOTHING, deliberately. That directory exists so a
260
+ * HUMAN can choose per pull request — "bug report" vs "release" vs "RFC" — and the choice is
261
+ * usually not inferable from a diff. Picking one arbitrarily would file every run's work under
262
+ * whichever name sorts first, which is worse than the free-form briefing: it looks like a
263
+ * deliberate categorisation and is not.
264
+ */
265
+ function chooseFromDirectory(entries: { name: string; directory: boolean }[]): string | undefined {
266
+ const usable = entries.filter(
267
+ (entry) => !entry.directory && TEMPLATE_EXTENSIONS.has(splitName(entry.name).extension),
268
+ )
269
+ const fallback = usable.find((entry) => splitName(entry.name).stem === 'default')
270
+ if (fallback) return fallback.name
271
+ return usable.length === 1 ? usable[0]!.name : undefined
272
+ }
273
+
274
+ /** Lower-cased stem + extension ('' when the name carries none). */
275
+ function splitName(name: string): { stem: string; extension: string } {
276
+ const lower = name.toLowerCase()
277
+ const dot = lower.lastIndexOf('.')
278
+ if (dot <= 0) return { stem: lower, extension: '' }
279
+ return { stem: lower.slice(0, dot), extension: lower.slice(dot) }
280
+ }
281
+
282
+ /**
283
+ * The template's text, or undefined when it is unreadable or carries nothing.
284
+ *
285
+ * A checkout is REPO-AUTHORED, symlinks included, and this is the one read the harness performs on
286
+ * a repo-chosen path without the agent asking for it — so the resolved target must stay inside
287
+ * `repoDir`. A link out of the tree would inline an arbitrary container file (the run's own env,
288
+ * a sibling checkout) into the prompt, and from there into a body only `redactSecrets` stands in
289
+ * front of. Containment rather than a blanket symlink refusal, because a monorepo pointing
290
+ * `.github/PULL_REQUEST_TEMPLATE.md` at a doc it keeps elsewhere in the repo is a real and
291
+ * legitimate layout.
292
+ */
293
+ async function readTemplate(path: string, repoDir: string): Promise<string | undefined> {
294
+ try {
295
+ // Both sides resolved, so the comparison is between two canonical paths — `repoDir` itself is
296
+ // routinely reached through a symlinked temp dir (macOS `/tmp`), which a raw prefix test on the
297
+ // unresolved root would read as an escape.
298
+ const [target, root] = await Promise.all([realpath(path), realpath(repoDir)])
299
+ if (target !== root && !target.startsWith(root.endsWith(sep) ? root : root + sep)) {
300
+ return undefined
301
+ }
302
+ return (await readFile(target, 'utf8')).trim() || undefined
303
+ } catch {
304
+ return undefined
305
+ }
306
+ }
307
+
308
+ /**
309
+ * The prompt note. It has to do more than show the template, because the agent has already been
310
+ * told (by the backend-composed `PR_DESCRIPTION_GUIDANCE`) to write a free-form briefing, and the
311
+ * two genuinely conflict: a template that asks for a test plan or a checklist is asking for
312
+ * exactly the "restated diff" that guidance rules out. So the note states which wins, and states
313
+ * why the template is not already applied — an agent that believes the host will merge the
314
+ * template with its text has no reason to reproduce the structure itself.
315
+ *
316
+ * The template is delimited with `fencedOutput`, the same helper every other captured-text-into-a-
317
+ * prompt path uses. Templates routinely carry fenced blocks of their own, and a fixed three-tick
318
+ * wrapper closes on the first of them — spilling the rest of the template, and the instructions
319
+ * after it, into the prompt as prose. `fencedOutput` sizes the fence one tick longer than the
320
+ * longest run in the body, which is what CommonMark specifies for exactly this, so no template can
321
+ * break out of its own block. A plain `--- BEGIN/END ---` rule would read more nicely and is
322
+ * trivially forgeable by the template's own content, which is the whole thing being defended.
323
+ */
324
+ export function buildPrTemplateNote(template: PrTemplate, repoLabel?: string): string {
325
+ const subject = repoLabel ? `The \`${repoLabel}\` repository` : 'This repository'
326
+ const lead =
327
+ `PULL REQUEST TEMPLATE — ${subject} ships a pull request template at \`${template.path}\` ` +
328
+ '(relative to the repository root). The platform opens the pull request through the host API, ' +
329
+ 'and neither GitHub nor GitLab applies a template to an API-created pull request — that only ' +
330
+ 'happens for a human opening one in the web form. So following it is on you.'
331
+ const instructions =
332
+ `Write \`${PR_DESCRIPTION_FILE}\` as that template, FILLED IN${
333
+ repoLabel ? ` (in the \`${repoLabel}\` checkout)` : ''
334
+ }: keep its headings, their order, and any structure it defines; answer every section from ` +
335
+ 'the work you actually did; delete its instructional HTML comments and any placeholder text; ' +
336
+ 'and complete checklists honestly, ticking only what is true. Leave a section that genuinely ' +
337
+ 'does not apply in place with a brief "n/a" and why, rather than deleting the heading — a ' +
338
+ 'reviewer looking for it needs to see it was considered. Where the template asks for ' +
339
+ 'something the general description guidance does not, the TEMPLATE wins; where it leaves room ' +
340
+ 'for prose, brief it as that guidance describes. The platform rules still hold either way: no ' +
341
+ 'secrets, and no issue/PR numbers, @-mentions, or issue-closing wording. Do NOT put a title ' +
342
+ "line above the template — the platform titles this pull request itself, so the template's " +
343
+ 'own first heading stays the first line of the file. Keep the finished file under ' +
344
+ `${MAX_PR_BODY_CHARS.toLocaleString('en-US')} characters: the platform truncates a longer ` +
345
+ "body, which would cut the template's last sections."
346
+ if (template.text === undefined) {
347
+ // Reached both when the file alone exceeds the inline budget and when a multi-repo run's
348
+ // earlier legs spent the shared one, so the wording states the size and stays true of both.
349
+ return (
350
+ `${lead} ${instructions} The template (${template.chars} characters) is not reproduced ` +
351
+ 'here — read it from the checkout.'
352
+ )
353
+ }
354
+ return `${lead} ${instructions}\n\nThe template follows, in a fenced block that is NOT part of it:\n${fencedOutput(template.text)}`
355
+ }
356
+
357
+ /**
358
+ * Fold the note into a prompt. The sibling of `withDependencyNote`, and deliberately not inlined
359
+ * for the same reason: it rides EVERY agent pass, including the validation and reproduction
360
+ * REPAIR passes. Those start a fresh agent that still carries the description guidance in its
361
+ * system prompt, so one that is not also told about the template would rewrite the briefing
362
+ * free-form and undo the filled template the first pass produced.
363
+ */
364
+ export function withPrTemplateNote(userPrompt: string, note: string | undefined): string {
365
+ return note ? `${userPrompt}\n\n${note}` : userPrompt
366
+ }