@cat-factory/executor-harness 1.60.0 → 1.64.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 +10 -1
- package/dist/agent-runner.js +81 -8
- package/dist/agent.js +9 -3
- package/dist/claude-stream.js +18 -0
- package/dist/coding-agent.js +32 -4
- package/dist/embed.js +2 -1
- package/dist/git.js +0 -319
- package/dist/host-markdown.js +142 -0
- package/dist/pi-workspace.js +35 -2
- package/dist/pi.js +15 -184
- package/dist/pr-description.js +157 -0
- package/dist/progress-guard.js +211 -0
- package/dist/subagents.js +1 -50
- package/dist/vcs-api.js +402 -0
- package/package.json +4 -3
- package/src/agent-runner.ts +88 -8
- package/src/agent.ts +8 -3
- package/src/claude-stream.ts +19 -0
- package/src/coding-agent.ts +45 -3
- package/src/embed.ts +5 -3
- package/src/git.ts +1 -385
- package/src/host-markdown.ts +155 -0
- package/src/pi-workspace.ts +40 -4
- package/src/pi.ts +26 -252
- package/src/pr-description.ts +171 -0
- package/src/progress-guard.ts +285 -0
- package/src/subagents.ts +7 -16
- package/src/vcs-api.ts +512 -0
package/dist/git.js
CHANGED
|
@@ -132,40 +132,6 @@ export function describeGitFailure(stderr) {
|
|
|
132
132
|
}
|
|
133
133
|
return undefined;
|
|
134
134
|
}
|
|
135
|
-
/**
|
|
136
|
-
* Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
|
|
137
|
-
* undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
|
|
138
|
-
* {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
|
|
139
|
-
* load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
|
|
140
|
-
* Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
|
|
141
|
-
* request vs merge request). Pure, so it is unit-tested per status.
|
|
142
|
-
*/
|
|
143
|
-
export function describePrOpenFailure(status, provider) {
|
|
144
|
-
const noun = provider === 'gitlab' ? 'merge request' : 'pull request';
|
|
145
|
-
if (status === 401) {
|
|
146
|
-
return (`The credential was rejected while opening the ${noun}. The GitHub App installation token ` +
|
|
147
|
-
'(or, in local mode, the GITHUB_PAT) is most likely expired, rotated, or revoked — reconnect ' +
|
|
148
|
-
'the GitHub App for the workspace (or regenerate the PAT), then retry.');
|
|
149
|
-
}
|
|
150
|
-
if (status === 403) {
|
|
151
|
-
const scope = provider === 'gitlab'
|
|
152
|
-
? 'the GitLab token needs the `api` scope and Developer+ access to the project'
|
|
153
|
-
: 'the GitHub App needs the "Pull requests: write" permission (or the PAT the `repo` scope) and write access to the repository';
|
|
154
|
-
return `The credential lacks permission to open a ${noun}: ${scope}. Grant it, then retry.`;
|
|
155
|
-
}
|
|
156
|
-
if (status === 404) {
|
|
157
|
-
return (`The repository could not be found while opening the ${noun} — it may have been deleted, ` +
|
|
158
|
-
'renamed, or made private, or the credential can no longer see it. Confirm the repo and the ' +
|
|
159
|
-
"credential's access to it, then retry.");
|
|
160
|
-
}
|
|
161
|
-
if (status === 422 || status === 400) {
|
|
162
|
-
return (`GitHub/GitLab rejected the ${noun} as invalid. Usually the head or base branch does not ` +
|
|
163
|
-
'exist, the two branches are identical (nothing to compare), or the base branch is protected ' +
|
|
164
|
-
'against direct PRs. Check the branch names and that the head has commits ahead of the base, ' +
|
|
165
|
-
'then retry.');
|
|
166
|
-
}
|
|
167
|
-
return undefined;
|
|
168
|
-
}
|
|
169
135
|
/**
|
|
170
136
|
* Wrap a git failure into a credential-scrubbed {@link HarnessFailure}('git') with an ACCURATE
|
|
171
137
|
* message. Three cases the old bare "Command failed: git …" collapsed together:
|
|
@@ -878,288 +844,3 @@ export async function reinitAndPush(opts) {
|
|
|
878
844
|
env: await authEnv(opts.ghToken),
|
|
879
845
|
});
|
|
880
846
|
}
|
|
881
|
-
/**
|
|
882
|
-
* The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
|
|
883
|
-
* auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
|
|
884
|
-
* GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
|
|
885
|
-
* the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
|
|
886
|
-
* self-managed instances named that way) is treated as GitLab.
|
|
887
|
-
*/
|
|
888
|
-
export function inferVcsProvider(cloneUrl) {
|
|
889
|
-
let host = '';
|
|
890
|
-
try {
|
|
891
|
-
host = new URL(cloneUrl).host.toLowerCase();
|
|
892
|
-
}
|
|
893
|
-
catch {
|
|
894
|
-
return 'github';
|
|
895
|
-
}
|
|
896
|
-
if (host === 'gitlab.com' || host.startsWith('gitlab.') || host.includes('.gitlab.')) {
|
|
897
|
-
return 'gitlab';
|
|
898
|
-
}
|
|
899
|
-
return 'github';
|
|
900
|
-
}
|
|
901
|
-
/** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
|
|
902
|
-
export function gitlabApiBaseFromCloneUrl(cloneUrl) {
|
|
903
|
-
const u = new URL(cloneUrl);
|
|
904
|
-
return `${u.protocol}//${u.host}/api/v4`;
|
|
905
|
-
}
|
|
906
|
-
/**
|
|
907
|
-
* The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
|
|
908
|
-
* survive), with the trailing `.git` stripped, e.g.
|
|
909
|
-
* `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
|
|
910
|
-
*/
|
|
911
|
-
export function gitlabProjectPath(cloneUrl) {
|
|
912
|
-
const path = new URL(cloneUrl).pathname.replace(/^\/+/, '').replace(/\.git$/, '');
|
|
913
|
-
return encodeURIComponent(path);
|
|
914
|
-
}
|
|
915
|
-
/** The abort reason as an Error (the watchdog aborts with one), or a generic fallback. */
|
|
916
|
-
function abortError(signal) {
|
|
917
|
-
return signal.reason instanceof Error ? signal.reason : new Error('aborted');
|
|
918
|
-
}
|
|
919
|
-
/** Whether a thrown fetch error is an AbortError (caller-initiated, never retried). */
|
|
920
|
-
function isAbortError(err) {
|
|
921
|
-
return err instanceof Error && err.name === 'AbortError';
|
|
922
|
-
}
|
|
923
|
-
/**
|
|
924
|
-
* Parse a `Retry-After` header into ms, bounded so it can't stall the job. Accepts BOTH
|
|
925
|
-
* forms the spec allows: integer delay-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026
|
|
926
|
-
* 07:28:00 GMT`); the latter is turned into a delay from now. A past/zero/unparseable value
|
|
927
|
-
* yields undefined so the caller falls back to exponential backoff.
|
|
928
|
-
*/
|
|
929
|
-
function retryAfterMs(res) {
|
|
930
|
-
const raw = res.headers.get('retry-after');
|
|
931
|
-
if (!raw)
|
|
932
|
-
return undefined;
|
|
933
|
-
const secs = Number(raw);
|
|
934
|
-
if (Number.isFinite(secs)) {
|
|
935
|
-
return secs > 0 ? Math.min(secs * 1000, MAX_RETRY_AFTER_MS) : undefined;
|
|
936
|
-
}
|
|
937
|
-
const at = Date.parse(raw);
|
|
938
|
-
if (Number.isNaN(at))
|
|
939
|
-
return undefined;
|
|
940
|
-
const ms = at - Date.now();
|
|
941
|
-
return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined;
|
|
942
|
-
}
|
|
943
|
-
/** Sleep `ms`, rejecting immediately (with the abort reason) if `signal` aborts meanwhile. */
|
|
944
|
-
function abortableDelay(ms, signal) {
|
|
945
|
-
return new Promise((resolve, reject) => {
|
|
946
|
-
if (signal?.aborted)
|
|
947
|
-
return reject(abortError(signal));
|
|
948
|
-
const onAbort = () => {
|
|
949
|
-
clearTimeout(timer);
|
|
950
|
-
reject(abortError(signal));
|
|
951
|
-
};
|
|
952
|
-
const timer = setTimeout(() => {
|
|
953
|
-
signal?.removeEventListener('abort', onAbort);
|
|
954
|
-
resolve();
|
|
955
|
-
}, ms);
|
|
956
|
-
signal?.addEventListener('abort', onAbort, { once: true });
|
|
957
|
-
});
|
|
958
|
-
}
|
|
959
|
-
const MAX_RETRY_AFTER_MS = 8_000;
|
|
960
|
-
const RETRY_BASE_MS = 500;
|
|
961
|
-
const RETRY_MAX_DELAY_MS = 4_000;
|
|
962
|
-
/**
|
|
963
|
-
* Run a single HTTP request with bounded retry for TRANSIENT failures, so a momentary
|
|
964
|
-
* upstream blip (a 5xx, a 429 rate-limit, or a dropped connection) no longer fails an
|
|
965
|
-
* otherwise-complete run on its very last step (opening the PR/MR). Up to 3 attempts
|
|
966
|
-
* (2 retries) with exponential backoff + jitter (honoring a `Retry-After` on a 429),
|
|
967
|
-
* every wait abort-aware so the inactivity/max-duration watchdog still cancels promptly.
|
|
968
|
-
*
|
|
969
|
-
* ONLY transient failures retry: a `>=500`/`429` response, or a network-level fetch
|
|
970
|
-
* rejection. A 4xx (incl. the 422/409 "already exists" the callers treat as success) is
|
|
971
|
-
* returned to the caller unretried, and a caller abort is rethrown at once. The response
|
|
972
|
-
* body is never read here, so the caller's existing status handling is unchanged.
|
|
973
|
-
*/
|
|
974
|
-
async function withApiRetry(fn, opts = {}) {
|
|
975
|
-
const maxAttempts = opts.attempts ?? 3;
|
|
976
|
-
let lastError;
|
|
977
|
-
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
978
|
-
if (opts.signal?.aborted)
|
|
979
|
-
throw abortError(opts.signal);
|
|
980
|
-
let res;
|
|
981
|
-
try {
|
|
982
|
-
res = await fn();
|
|
983
|
-
}
|
|
984
|
-
catch (err) {
|
|
985
|
-
// A caller/watchdog abort is terminal; a network error is transient → retry.
|
|
986
|
-
if (isAbortError(err) || opts.signal?.aborted)
|
|
987
|
-
throw err;
|
|
988
|
-
lastError = err;
|
|
989
|
-
}
|
|
990
|
-
if (res) {
|
|
991
|
-
const transient = res.status >= 500 || res.status === 429;
|
|
992
|
-
if (!transient || attempt >= maxAttempts)
|
|
993
|
-
return res;
|
|
994
|
-
const after = retryAfterMs(res);
|
|
995
|
-
// Discard the unread body before retrying so the connection can be reused.
|
|
996
|
-
await res.body?.cancel().catch(() => { });
|
|
997
|
-
await abortableDelay(after ?? backoffMs(attempt), opts.signal);
|
|
998
|
-
continue;
|
|
999
|
-
}
|
|
1000
|
-
if (attempt >= maxAttempts)
|
|
1001
|
-
break;
|
|
1002
|
-
await abortableDelay(backoffMs(attempt), opts.signal);
|
|
1003
|
-
}
|
|
1004
|
-
// Exhausted on a network-level rejection (no HTTP response): an upstream API failure.
|
|
1005
|
-
const message = lastError instanceof Error ? lastError.message : 'API request failed after retries';
|
|
1006
|
-
throw new HarnessFailure('api', redactSecrets(message));
|
|
1007
|
-
}
|
|
1008
|
-
/** Exponential backoff (base 500ms, capped 4s) with up to 25% positive jitter. */
|
|
1009
|
-
function backoffMs(attempt) {
|
|
1010
|
-
const base = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
1011
|
-
return base + Math.floor(base * 0.25 * Math.random());
|
|
1012
|
-
}
|
|
1013
|
-
/**
|
|
1014
|
-
* Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
|
|
1015
|
-
* The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
|
|
1016
|
-
* falling back to host inference from the clone URL only when it didn't — so a self-managed
|
|
1017
|
-
* GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
|
|
1018
|
-
* GitHub's API. The GitHub path is unchanged.
|
|
1019
|
-
*/
|
|
1020
|
-
export async function openPullRequest(opts) {
|
|
1021
|
-
const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github');
|
|
1022
|
-
if (provider === 'gitlab') {
|
|
1023
|
-
if (!opts.cloneUrl) {
|
|
1024
|
-
throw new Error('Cannot open a GitLab merge request without the repo clone URL');
|
|
1025
|
-
}
|
|
1026
|
-
return openGitLabMergeRequest({ ...opts, cloneUrl: opts.cloneUrl });
|
|
1027
|
-
}
|
|
1028
|
-
const apiBase = opts.apiBase ?? 'https://api.github.com';
|
|
1029
|
-
const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
|
|
1030
|
-
const res = await withApiRetry(() => fetch(`${apiBase}/repos/${path}/pulls`, {
|
|
1031
|
-
method: 'POST',
|
|
1032
|
-
headers: {
|
|
1033
|
-
authorization: `Bearer ${opts.ghToken}`,
|
|
1034
|
-
accept: 'application/vnd.github+json',
|
|
1035
|
-
'user-agent': 'cat-factory-executor',
|
|
1036
|
-
'x-github-api-version': '2022-11-28',
|
|
1037
|
-
'content-type': 'application/json',
|
|
1038
|
-
},
|
|
1039
|
-
body: JSON.stringify({
|
|
1040
|
-
title: opts.pr.title,
|
|
1041
|
-
head: opts.head,
|
|
1042
|
-
base: opts.base,
|
|
1043
|
-
body: opts.pr.body,
|
|
1044
|
-
}),
|
|
1045
|
-
// Bound on the watchdog so a hung GitHub call can't stall the job.
|
|
1046
|
-
...(opts.signal ? { signal: opts.signal } : {}),
|
|
1047
|
-
}), { signal: opts.signal });
|
|
1048
|
-
if (!res.ok) {
|
|
1049
|
-
const detail = await res.text().catch(() => '');
|
|
1050
|
-
// A resumed run pushes to a branch that already has an open PR; GitHub answers
|
|
1051
|
-
// 422 "A pull request already exists". That's success for us — return the
|
|
1052
|
-
// existing PR's url rather than failing the resumed run.
|
|
1053
|
-
if (res.status === 422 && /pull request already exists/i.test(detail)) {
|
|
1054
|
-
const existing = await findOpenPullRequestUrl(opts);
|
|
1055
|
-
if (existing)
|
|
1056
|
-
return existing;
|
|
1057
|
-
}
|
|
1058
|
-
// The head branch has nothing ahead of base ("No commits between <base> and <head>").
|
|
1059
|
-
// That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
|
|
1060
|
-
// branch whose earlier PR was merged with a merge commit, leaving the branch reachable
|
|
1061
|
-
// from base). Signal it with null so the caller records a clean no-op instead of failing
|
|
1062
|
-
// the run with GitHub's opaque 422.
|
|
1063
|
-
if (res.status === 422 && /no commits between/i.test(detail))
|
|
1064
|
-
return null;
|
|
1065
|
-
const remedy = describePrOpenFailure(res.status, 'github');
|
|
1066
|
-
const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
1067
|
-
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
|
|
1068
|
-
}
|
|
1069
|
-
const body = (await res.json());
|
|
1070
|
-
if (!body.html_url)
|
|
1071
|
-
throw new HarnessFailure('api', 'GitHub did not return a PR url');
|
|
1072
|
-
return body.html_url;
|
|
1073
|
-
}
|
|
1074
|
-
/** GitLab API headers for the PAT (the `PRIVATE-TOKEN` auth GitLab uses). */
|
|
1075
|
-
function gitlabHeaders(token) {
|
|
1076
|
-
return {
|
|
1077
|
-
'private-token': token,
|
|
1078
|
-
accept: 'application/json',
|
|
1079
|
-
'user-agent': 'cat-factory-executor',
|
|
1080
|
-
'content-type': 'application/json',
|
|
1081
|
-
};
|
|
1082
|
-
}
|
|
1083
|
-
/**
|
|
1084
|
-
* Open a GitLab merge request (the analogue of {@link openPullRequest} for GitLab). The REST
|
|
1085
|
-
* base + project path are derived from the clone URL's host, so it works for gitlab.com and a
|
|
1086
|
-
* self-managed instance alike. `head`→`source_branch`, `base`→`target_branch`. On a duplicate
|
|
1087
|
-
* (a resumed run whose branch already has an open MR — GitLab answers 409) the existing MR's
|
|
1088
|
-
* web URL is returned instead of failing the run, mirroring the GitHub 422 handling.
|
|
1089
|
-
*/
|
|
1090
|
-
async function openGitLabMergeRequest(opts) {
|
|
1091
|
-
const apiBase = gitlabApiBaseFromCloneUrl(opts.cloneUrl);
|
|
1092
|
-
const project = gitlabProjectPath(opts.cloneUrl);
|
|
1093
|
-
const res = await withApiRetry(() => fetch(`${apiBase}/projects/${project}/merge_requests`, {
|
|
1094
|
-
method: 'POST',
|
|
1095
|
-
headers: gitlabHeaders(opts.ghToken),
|
|
1096
|
-
body: JSON.stringify({
|
|
1097
|
-
source_branch: opts.head,
|
|
1098
|
-
target_branch: opts.base,
|
|
1099
|
-
title: opts.pr.title,
|
|
1100
|
-
description: opts.pr.body,
|
|
1101
|
-
}),
|
|
1102
|
-
...(opts.signal ? { signal: opts.signal } : {}),
|
|
1103
|
-
}), { signal: opts.signal });
|
|
1104
|
-
if (!res.ok) {
|
|
1105
|
-
const detail = await res.text().catch(() => '');
|
|
1106
|
-
// GitLab returns 409 (sometimes 400) when an open MR already exists for this source
|
|
1107
|
-
// branch; that is success for a resumed run — return the existing MR's url.
|
|
1108
|
-
if ((res.status === 409 || res.status === 400) &&
|
|
1109
|
-
/already exists|open merge request/i.test(detail)) {
|
|
1110
|
-
const existing = await findOpenMergeRequestUrl(apiBase, project, opts);
|
|
1111
|
-
if (existing)
|
|
1112
|
-
return existing;
|
|
1113
|
-
}
|
|
1114
|
-
const remedy = describePrOpenFailure(res.status, 'gitlab');
|
|
1115
|
-
const base = redactSecrets(`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
1116
|
-
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
|
|
1117
|
-
}
|
|
1118
|
-
const body = (await res.json());
|
|
1119
|
-
if (!body.web_url)
|
|
1120
|
-
throw new HarnessFailure('api', 'GitLab did not return a merge request url');
|
|
1121
|
-
return body.web_url;
|
|
1122
|
-
}
|
|
1123
|
-
/** Find the open GitLab MR for `opts.head`→`opts.base`, returning its web_url or undefined. */
|
|
1124
|
-
async function findOpenMergeRequestUrl(apiBase, project, opts) {
|
|
1125
|
-
// Filter by BOTH branches: a source branch can have open MRs to several targets, so the
|
|
1126
|
-
// source alone could match an MR against a different base than the one we just tried to open.
|
|
1127
|
-
const query = new URLSearchParams({
|
|
1128
|
-
source_branch: opts.head,
|
|
1129
|
-
target_branch: opts.base,
|
|
1130
|
-
state: 'opened',
|
|
1131
|
-
});
|
|
1132
|
-
const res = await fetch(`${apiBase}/projects/${project}/merge_requests?${query}`, {
|
|
1133
|
-
headers: gitlabHeaders(opts.ghToken),
|
|
1134
|
-
...(opts.signal ? { signal: opts.signal } : {}),
|
|
1135
|
-
});
|
|
1136
|
-
if (!res.ok)
|
|
1137
|
-
return undefined;
|
|
1138
|
-
const list = (await res.json().catch(() => []));
|
|
1139
|
-
return Array.isArray(list) && list[0]?.web_url ? list[0].web_url : undefined;
|
|
1140
|
-
}
|
|
1141
|
-
/** Find the open PR for `opts.head` on `opts.base`, returning its html_url or undefined. */
|
|
1142
|
-
async function findOpenPullRequestUrl(opts) {
|
|
1143
|
-
const apiBase = opts.apiBase ?? 'https://api.github.com';
|
|
1144
|
-
// Encode the ref-derived query params: a branch/owner containing `&` or `#` would
|
|
1145
|
-
// otherwise split the query string or inject an unintended parameter.
|
|
1146
|
-
const query = new URLSearchParams({
|
|
1147
|
-
head: `${opts.owner}:${opts.head}`,
|
|
1148
|
-
base: opts.base,
|
|
1149
|
-
state: 'open',
|
|
1150
|
-
});
|
|
1151
|
-
const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
|
|
1152
|
-
const res = await fetch(`${apiBase}/repos/${path}/pulls?${query}`, {
|
|
1153
|
-
headers: {
|
|
1154
|
-
authorization: `Bearer ${opts.ghToken}`,
|
|
1155
|
-
accept: 'application/vnd.github+json',
|
|
1156
|
-
'user-agent': 'cat-factory-executor',
|
|
1157
|
-
'x-github-api-version': '2022-11-28',
|
|
1158
|
-
},
|
|
1159
|
-
...(opts.signal ? { signal: opts.signal } : {}),
|
|
1160
|
-
});
|
|
1161
|
-
if (!res.ok)
|
|
1162
|
-
return undefined;
|
|
1163
|
-
const list = (await res.json().catch(() => []));
|
|
1164
|
-
return Array.isArray(list) && list[0]?.html_url ? list[0].html_url : undefined;
|
|
1165
|
-
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The TEXT BOUNDARY for agent-authored text the harness writes onto a VCS host.
|
|
3
|
+
//
|
|
4
|
+
// A pull-request description is NOT an inert string sink. The host parses it: `#123` becomes
|
|
5
|
+
// an issue link, `@name` notifies a real person, a closing keyword in front of an issue
|
|
6
|
+
// reference CLOSES that issue when the PR merges, and an unbalanced code fence swallows
|
|
7
|
+
// everything rendered after it — including the fenced JSON block the engine later appends as
|
|
8
|
+
// the verification report's machine-readable contract.
|
|
9
|
+
//
|
|
10
|
+
// The agent's reviewer briefing (`pr-description.ts`) is model-authored prose that lands
|
|
11
|
+
// verbatim on that surface, so it crosses this boundary first. "This closes #42" is idiomatic
|
|
12
|
+
// for a briefing to emit and must not close issue 42; "@alice owns the rounding rule" is
|
|
13
|
+
// idiomatic and must not page whoever holds that handle.
|
|
14
|
+
//
|
|
15
|
+
// This is a deliberate COPY of `hostMarkdown` in `@cat-factory/kernel`
|
|
16
|
+
// (`src/shared/host-markdown.logic.ts`), for the same reason `isSafeTestPath` is copied: the
|
|
17
|
+
// container image is built from `src/` plus typescript alone (the Dockerfile cannot resolve a
|
|
18
|
+
// `workspace:*` dependency), so the harness carries no runtime dependency on any package here.
|
|
19
|
+
// `test/host-markdown.conformity.test.ts` pins the two implementations to byte-identical
|
|
20
|
+
// output over a shared corpus, so the copy cannot drift — change one, change the other.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
/**
|
|
23
|
+
* The host's closing keywords. A PR body carrying one of these in front of an issue reference
|
|
24
|
+
* closes that issue on merge — a side effect the harness must never trigger on the agent's
|
|
25
|
+
* behalf. Same list on GitHub and GitLab.
|
|
26
|
+
*/
|
|
27
|
+
const CLOSING_KEYWORDS = 'close[sd]?|closing|fix|fixe[sd]|fixing|resolve[sd]?|resolving|implement(?:s|ed)?|implementing';
|
|
28
|
+
/** An issue/MR URL on either host, in the form a closing keyword can reference. */
|
|
29
|
+
const ISSUE_URL = String.raw `https?://\S+?/(?:issues|-/issues|merge_requests|pull)/\d+`;
|
|
30
|
+
/**
|
|
31
|
+
* Every auto-linking trigger, in ONE alternation.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately a single pass rather than chained `.replace()` calls: each escape EMITS a `#`,
|
|
34
|
+
* so a later rule would re-escape the output of an earlier one (`@` → `@` → `&#64;`).
|
|
35
|
+
* One regex means the replacement text is never rescanned.
|
|
36
|
+
*/
|
|
37
|
+
const AUTO_LINK_TRIGGERS = new RegExp([
|
|
38
|
+
// A closing keyword in front of an issue/MR URL. The URL form survives the character
|
|
39
|
+
// escapes below (nothing in it is a trigger), so the KEYWORD is what gets defused.
|
|
40
|
+
String.raw `(?<keyword>\b(?:${CLOSING_KEYWORDS}))(?=\s*:?\s+${ISSUE_URL})`,
|
|
41
|
+
// `@name` / `@org/team` — a mention notifies a real account.
|
|
42
|
+
String.raw `(?<at>@(?=[A-Za-z0-9]))`,
|
|
43
|
+
// `#123` and `owner/repo#123` — an issue/PR cross-reference.
|
|
44
|
+
String.raw `(?<hash>#(?=\d))`,
|
|
45
|
+
// `!123` — GitLab's merge-request reference.
|
|
46
|
+
String.raw `(?<bang>!(?=\d))`,
|
|
47
|
+
].join('|'), 'gi');
|
|
48
|
+
/**
|
|
49
|
+
* Neutralise the host's auto-linking triggers in ONE line of untrusted text, leaving inline
|
|
50
|
+
* code spans alone (the host does not auto-link inside them, so escaping there would only
|
|
51
|
+
* show the reader a literal `#`).
|
|
52
|
+
*
|
|
53
|
+
* The escapes are numeric HTML entities, which render as the original character but are
|
|
54
|
+
* invisible to the reference parser — so the reader sees exactly what the agent wrote while
|
|
55
|
+
* the mention/close side effects are defused.
|
|
56
|
+
*/
|
|
57
|
+
function inertLine(line) {
|
|
58
|
+
return mapOutsideCodeSpans(line, (text) => text.replace(AUTO_LINK_TRIGGERS, (match, ...args) => {
|
|
59
|
+
const groups = args[args.length - 1];
|
|
60
|
+
// Entity-escaping the FIRST character is enough to break the parser's match while
|
|
61
|
+
// rendering identically — which matters most for the keyword, whose remaining letters
|
|
62
|
+
// are ordinary prose the reader should still see.
|
|
63
|
+
return `&#${match.charCodeAt(0)};${groups.keyword ? match.slice(1) : ''}`;
|
|
64
|
+
}));
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Apply `fn` to the parts of `line` that are NOT inline code spans. Code spans are matched by
|
|
68
|
+
* a backtick run and its matching closer, which is CommonMark's rule and — more to the point
|
|
69
|
+
* — the rule the host renderer applies when deciding where to auto-link.
|
|
70
|
+
*/
|
|
71
|
+
function mapOutsideCodeSpans(line, fn) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let index = 0;
|
|
74
|
+
const span = /(`+)[\s\S]*?\1/g;
|
|
75
|
+
let match;
|
|
76
|
+
while ((match = span.exec(line)) !== null) {
|
|
77
|
+
out.push(fn(line.slice(index, match.index)), match[0]);
|
|
78
|
+
index = match.index + match[0].length;
|
|
79
|
+
}
|
|
80
|
+
return out.join('') + fn(line.slice(index));
|
|
81
|
+
}
|
|
82
|
+
/** A line that opens or closes a fenced code block, with the fence it uses. */
|
|
83
|
+
function fenceAt(line) {
|
|
84
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
85
|
+
if (!match)
|
|
86
|
+
return null;
|
|
87
|
+
const fence = match[1];
|
|
88
|
+
// A ``` fence's info string may not contain a backtick (CommonMark), which is what stops an
|
|
89
|
+
// inline span from being read as a fence.
|
|
90
|
+
if (fence.startsWith('`') && match[2].includes('`'))
|
|
91
|
+
return null;
|
|
92
|
+
return { char: fence[0], length: fence.length, info: match[2].trim().length > 0 };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Walk `lines`, tracking fenced-code state, and hand each line to `visit` together with
|
|
96
|
+
* whether it sits INSIDE a fenced block. Returns the fence still open at the end, if any.
|
|
97
|
+
*
|
|
98
|
+
* One shared walker so the three things that care about fences — leaving code untouched,
|
|
99
|
+
* closing what the text left open, and finding the briefing's title heading — can never
|
|
100
|
+
* disagree about where a block starts and ends.
|
|
101
|
+
*/
|
|
102
|
+
export function walkFences(lines, visit) {
|
|
103
|
+
let open = null;
|
|
104
|
+
for (const line of lines) {
|
|
105
|
+
const fence = fenceAt(line);
|
|
106
|
+
// The fence line itself belongs to the code block, so it is never rewritten.
|
|
107
|
+
visit(line, open !== null || fence !== null);
|
|
108
|
+
if (!fence)
|
|
109
|
+
continue;
|
|
110
|
+
if (!open)
|
|
111
|
+
open = { char: fence.char, length: fence.length };
|
|
112
|
+
else if (fence.char === open.char && fence.length >= open.length && !fence.info)
|
|
113
|
+
open = null;
|
|
114
|
+
}
|
|
115
|
+
return open;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Render untrusted multi-line markdown safe to send to a host: auto-link triggers defused
|
|
119
|
+
* outside fenced code, and any fence the text leaves open closed again.
|
|
120
|
+
*
|
|
121
|
+
* Unlike kernel's `hostMarkdown.prose` this does NOT cap the length — the caller
|
|
122
|
+
* ({@link import('./pr-description.js')}) applies its own budget with its own visible note
|
|
123
|
+
* BEFORE calling here, so an escape entity can never be sliced in half. With that one
|
|
124
|
+
* difference the output is identical, which the conformity test pins.
|
|
125
|
+
*/
|
|
126
|
+
export function inertMarkdown(text) {
|
|
127
|
+
const normalised = text.replace(/\r\n?/g, '\n');
|
|
128
|
+
const rewritten = [];
|
|
129
|
+
const open = walkFences(normalised.split('\n'), (line, insideFence) => {
|
|
130
|
+
rewritten.push(insideFence ? line : inertLine(line));
|
|
131
|
+
});
|
|
132
|
+
const joined = rewritten.join('\n');
|
|
133
|
+
return open ? `${joined}\n${open.char.repeat(open.length)}` : joined;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Render untrusted text INLINE (a pull-request title): newlines folded to spaces because the
|
|
137
|
+
* surrounding line has its own meaning, and auto-link triggers defused. The caller caps the
|
|
138
|
+
* length first, for the same reason as {@link inertMarkdown}.
|
|
139
|
+
*/
|
|
140
|
+
export function inertInline(text) {
|
|
141
|
+
return inertLine(text.replace(/\s+/g, ' '));
|
|
142
|
+
}
|
package/dist/pi-workspace.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { readEffortReport } from './effort.js';
|
|
5
5
|
import { log } from './logger.js';
|
|
6
|
-
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources,
|
|
6
|
+
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
7
|
+
import { mergeGuardLimits, progressGuardLimitsFromEnv, } from './progress-guard.js';
|
|
7
8
|
import { runSubscriptionHarness } from './agent-runner.js';
|
|
8
9
|
// The thin base every container agent shares: an ephemeral working directory, and
|
|
9
10
|
// one Pi run inside it driven by the harness-written context. The agents differ in
|
|
@@ -106,6 +107,29 @@ export async function acquireRepoCheckout(opts, fn) {
|
|
|
106
107
|
return withPersistentWorkspace(opts.repo, fn);
|
|
107
108
|
return withWorkspace(opts.prefix, fn);
|
|
108
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Whether the run's checkout actually ships a `blueprints/` folder — what gates the blueprint
|
|
112
|
+
* orientation note in AGENTS.md (an external repo has none, so the note would be ~10 lines of
|
|
113
|
+
* dead guidance pointing at files that don't exist, re-sent on every turn).
|
|
114
|
+
*
|
|
115
|
+
* A MULTI-REPO run's `dir` is the workspace ROOT with each repo checked out as a sibling under
|
|
116
|
+
* it, so the root itself never holds `blueprints/`: the legs are checked too, and the note is
|
|
117
|
+
* included when ANY leg ships one (it orients the agent to the concept, and the agent finds the
|
|
118
|
+
* per-repo folder from there). Best-effort throughout — any stat/readdir failure simply omits
|
|
119
|
+
* the note rather than failing the dispatch.
|
|
120
|
+
*/
|
|
121
|
+
export async function checkoutHasBlueprints(dir, multiRepo) {
|
|
122
|
+
const isBlueprintDir = (path) => stat(join(path, 'blueprints'))
|
|
123
|
+
.then((s) => s.isDirectory())
|
|
124
|
+
.catch(() => false);
|
|
125
|
+
if (await isBlueprintDir(dir))
|
|
126
|
+
return true;
|
|
127
|
+
if (!multiRepo)
|
|
128
|
+
return false;
|
|
129
|
+
const legs = await readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
130
|
+
const checks = await Promise.all(legs.filter((e) => e.isDirectory()).map((e) => isBlueprintDir(join(dir, e.name))));
|
|
131
|
+
return checks.some(Boolean);
|
|
132
|
+
}
|
|
109
133
|
/**
|
|
110
134
|
* Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
|
|
111
135
|
* then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
|
|
@@ -148,6 +172,13 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
148
172
|
...(spec.skill ? { skill: spec.skill } : {}),
|
|
149
173
|
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
150
174
|
signal: opts.signal,
|
|
175
|
+
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
176
|
+
// defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
|
|
177
|
+
// no-edit allowance, so a claude-code run that stops making progress is killed early
|
|
178
|
+
// instead of burning the full wall-clock budget. The claude runner consumes it; codex
|
|
179
|
+
// ignores it for now (its stream isn't wired to the guard).
|
|
180
|
+
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
181
|
+
expectsEdits: spec.expectsEdits ?? true,
|
|
151
182
|
onActivity: opts.onActivity,
|
|
152
183
|
onProgress: opts.onProgress,
|
|
153
184
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
@@ -180,11 +211,13 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
180
211
|
const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv });
|
|
181
212
|
if (webSearch)
|
|
182
213
|
await writeWebToolsConfig(webSearch);
|
|
214
|
+
const hasBlueprints = await checkoutHasBlueprints(spec.dir, spec.multiRepo === true);
|
|
183
215
|
await writeAgentsContext(spec.systemPrompt, {
|
|
184
216
|
webSearch: Boolean(webSearch),
|
|
185
217
|
guidance: spec.webToolsGuidance,
|
|
186
218
|
serviceDirectory: spec.serviceDirectory,
|
|
187
219
|
contextFiles,
|
|
220
|
+
hasBlueprints,
|
|
188
221
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
189
222
|
});
|
|
190
223
|
await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
|