@cat-factory/executor-harness 1.76.0 → 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 +25 -12
- package/dist/agent.js +9 -1
- package/dist/coding-agent.js +52 -10
- package/dist/pr-description.js +18 -6
- package/dist/pr-template.js +250 -0
- package/package.json +5 -4
- package/src/agent.ts +9 -1
- package/src/coding-agent.ts +74 -7
- package/src/pr-description.ts +40 -6
- package/src/pr-template.ts +366 -0
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
|
|
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. **
|
|
76
|
-
|
|
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
|
-
|
|
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
|
|
85
|
-
remains, then recorded as `inconclusive`. It runs BEFORE step
|
|
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
|
-
|
|
88
|
-
ONLY if step
|
|
89
|
-
and opens no PR. Absent `validationChecks` / `reproduction`, steps
|
|
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.
|
|
98
|
-
|
|
99
|
-
|
|
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),
|
package/dist/coding-agent.js
CHANGED
|
@@ -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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
944
|
-
|
|
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;
|
package/dist/pr-description.js
CHANGED
|
@@ -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
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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.
|
|
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,15 +26,16 @@
|
|
|
26
26
|
"hono": "^4.12.32",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/kernel": "0.
|
|
30
|
-
"@cat-factory/
|
|
31
|
-
"@cat-factory/
|
|
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",
|
|
35
35
|
"typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
|
|
36
36
|
"start": "node dist/server.js",
|
|
37
37
|
"test": "vitest run",
|
|
38
|
+
"test:run": "vitest run",
|
|
38
39
|
"test:acceptance": "vitest run --config vitest.acceptance.config.ts",
|
|
39
40
|
"image:publish": "bash scripts/publish-image.sh"
|
|
40
41
|
}
|
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),
|
package/src/coding-agent.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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(
|
|
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
|
package/src/pr-description.ts
CHANGED
|
@@ -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
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
+
}
|