copilot-operator 0.1.8 → 0.1.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/api/main.js +2 -104
- package/dist/src/api/operator.controller.js +18 -1
- package/dist/src/api/operator.service.js +19 -14
- package/dist/src/api/server.js +114 -0
- package/dist/src/config/schema.js +5 -0
- package/dist/src/exec/checks.js +17 -0
- package/dist/src/orchestrator/contract.js +58 -0
- package/dist/src/orchestrator/progress.js +106 -0
- package/dist/src/orchestrator/taskRunner.js +167 -11
- package/dist/src/plan/brief.js +2 -0
- package/dist/src/plan/importPlan.js +1 -0
- package/dist/src/plan/schema.js +28 -1
- package/dist/src/plan/systemGuide.js +61 -6
- package/dist/src/protocol/parser.js +98 -5
- package/dist/src/session/compose.js +1 -1
- package/dist/src/session/exports.js +6 -0
- package/dist/src/session/handoff.js +42 -0
- package/dist/src/session/metrics.js +95 -0
- package/dist/src/session/store.js +58 -10
- package/dist/src/transport/chatTransport.js +32 -0
- package/dist/src/transport/copilotTransport.js +18 -0
- package/dist/src/vcs/contentIntegrity.js +174 -0
- package/dist/src/vcs/git.js +42 -0
- package/dist/src/vcs/scope.js +132 -0
- package/dist/src/vcs/taskVcs.js +43 -2
- package/dist/web/404.html +4 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
- package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-dd3ebf37d29dce4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-93ab518db67e52a2.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +1 -0
- package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
- package/dist/web/appearance.html +4 -3
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +4 -3
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +4 -3
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +4 -3
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +4 -3
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +4 -3
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +5 -4
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +4 -3
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +4 -3
- package/dist/web/system.txt +12 -12
- package/package.json +16 -4
- package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
- package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
- package/dist/web/_next/static/chunks/867-3d4ee08c5f9f0e71.js +0 -1
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-dfcbd748f454d2d7.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-49413c3a8266a0b7.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
- package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
- package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
- package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-a82e5e801dabc423.js +0 -1
- package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_ssgManifest.js +0 -0
|
@@ -60,8 +60,83 @@ export function stripCitations(text) {
|
|
|
60
60
|
return undefined;
|
|
61
61
|
return text.replace(/\s*【[^】]*】/g, '').replace(/[ ]+$/gm, '').trim();
|
|
62
62
|
}
|
|
63
|
+
/*
|
|
64
|
+
* Prose that arrived in another shape.
|
|
65
|
+
*
|
|
66
|
+
* `tried`, `needed`, `notes` and `summary` are sentences for a person to read; nothing in them is
|
|
67
|
+
* run. A chat that writes `tried` as a list of objects — `{"approach": "…", "result": "…"}` — has
|
|
68
|
+
* said exactly what was asked, in a shape the schema did not expect, and refusing the whole reply
|
|
69
|
+
* for it cost a format round each time and, three in a row, the task: it ended "failed" with the
|
|
70
|
+
* work done and its results already recorded. So these fields are read as text whatever shape they
|
|
71
|
+
* come in, and the reading is reported. The fields that are acted on — `status`, `steps` and
|
|
72
|
+
* everything in them — are never coerced: a step the runner would have to guess at is not run.
|
|
73
|
+
*/
|
|
74
|
+
const PROSE_KEYS = ['approach', 'what', 'tried', 'attempt', 'action', 'description', 'text', 'summary', 'detail', 'result', 'outcome', 'why', 'reason', 'needed'];
|
|
75
|
+
function proseOf(value) {
|
|
76
|
+
if (typeof value === 'string')
|
|
77
|
+
return value;
|
|
78
|
+
if (value === null || value === undefined)
|
|
79
|
+
return '';
|
|
80
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
81
|
+
return String(value);
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return value.map(proseOf).filter((x) => x.trim()).join('\n');
|
|
84
|
+
if (typeof value === 'object') {
|
|
85
|
+
const record = value;
|
|
86
|
+
const named = PROSE_KEYS.filter((k) => typeof record[k] === 'string' && record[k].trim()).map((k) => record[k]);
|
|
87
|
+
if (named.length > 0)
|
|
88
|
+
return named.join(' — ');
|
|
89
|
+
const strings = Object.values(record).filter((v) => typeof v === 'string' && v.trim() !== '');
|
|
90
|
+
return strings.length > 0 ? strings.join(' — ') : JSON.stringify(value);
|
|
91
|
+
}
|
|
92
|
+
return String(value);
|
|
93
|
+
}
|
|
94
|
+
export function normaliseProse(value) {
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
96
|
+
return { value, coerced: [] };
|
|
97
|
+
const out = { ...value };
|
|
98
|
+
const coerced = [];
|
|
99
|
+
for (const key of ['notes', 'summary', 'needed']) {
|
|
100
|
+
if (key in out && out[key] !== undefined && typeof out[key] !== 'string') {
|
|
101
|
+
out[key] = proseOf(out[key]);
|
|
102
|
+
coerced.push(key);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if ('tried' in out && out.tried !== undefined) {
|
|
106
|
+
const tried = out.tried;
|
|
107
|
+
if (typeof tried === 'string') {
|
|
108
|
+
out.tried = tried.trim() ? [tried] : [];
|
|
109
|
+
coerced.push('tried');
|
|
110
|
+
}
|
|
111
|
+
else if (Array.isArray(tried) && tried.some((t) => typeof t !== 'string')) {
|
|
112
|
+
out.tried = tried.map(proseOf).filter((t) => t.trim() !== '');
|
|
113
|
+
tried.forEach((t, i) => {
|
|
114
|
+
if (typeof t !== 'string')
|
|
115
|
+
coerced.push(`tried.${i}`);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { value: out, coerced };
|
|
120
|
+
}
|
|
121
|
+
/** What each field must look like, said in the message that asks for a reformatted reply. */
|
|
122
|
+
const FIELD_SHAPES = [
|
|
123
|
+
[/^status$/, '"status" is one of "continue", "done", "blocked"'],
|
|
124
|
+
[/^steps(\.|$)/, '"steps" is a list of {"id": 1, "type": "command", "cmd": "the command"} ("shell", "expect", "timeoutSec" optional)'],
|
|
125
|
+
[/^tried(\.|$)/, '"tried" is a list of plain strings, one sentence per approach'],
|
|
126
|
+
[/^deviations(\.|$)/, '"deviations" is a list of {"instruction": "…", "did": "…", "why": "…"} with plain strings'],
|
|
127
|
+
[/^disputed(\.|$)/, '"disputed" is a list of {"finding": "…", "why": "…", "evidence": "…"} with plain strings'],
|
|
128
|
+
[/^(summary|notes|needed)$/, '"summary", "notes" and "needed" are plain strings'],
|
|
129
|
+
];
|
|
130
|
+
const FORMAT_EXAMPLE = [
|
|
131
|
+
'```json',
|
|
132
|
+
'{"status": "continue", "notes": "what this round does", "steps": [{"id": 1, "type": "command", "cmd": "npm test"}]}',
|
|
133
|
+
'```',
|
|
134
|
+
'or, to end: {"status": "done", "summary": "several sentences on what was done and what it shows"}',
|
|
135
|
+
'or {"status": "blocked", "summary": "…", "tried": ["first approach", "second approach"], "needed": "what would unblock it"}',
|
|
136
|
+
].join('\n');
|
|
63
137
|
export function parseReply(markdown, opts) {
|
|
64
138
|
const candidates = findJsonCandidates(markdown);
|
|
139
|
+
const paths = new Set();
|
|
65
140
|
if (candidates.length === 0) {
|
|
66
141
|
return {
|
|
67
142
|
ok: false,
|
|
@@ -80,9 +155,12 @@ export function parseReply(markdown, opts) {
|
|
|
80
155
|
errors.push(`JSON.parse failed: ${e.message}`);
|
|
81
156
|
continue;
|
|
82
157
|
}
|
|
83
|
-
const
|
|
158
|
+
const prose = normaliseProse(value);
|
|
159
|
+
const result = ReplySchema.safeParse(prose.value);
|
|
84
160
|
if (!result.success) {
|
|
85
161
|
errors.push(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '));
|
|
162
|
+
for (const i of result.error.issues)
|
|
163
|
+
paths.add(i.path.join('.') || '(root)');
|
|
86
164
|
continue;
|
|
87
165
|
}
|
|
88
166
|
const reply = result.data;
|
|
@@ -106,6 +184,7 @@ export function parseReply(markdown, opts) {
|
|
|
106
184
|
done: reply.status === 'done' || (markerHit && hasSummary && !blocked),
|
|
107
185
|
blocked,
|
|
108
186
|
json: text.trim(),
|
|
187
|
+
...(prose.coerced.length > 0 ? { coerced: prose.coerced } : {}),
|
|
109
188
|
};
|
|
110
189
|
}
|
|
111
190
|
}
|
|
@@ -113,6 +192,7 @@ export function parseReply(markdown, opts) {
|
|
|
113
192
|
ok: false,
|
|
114
193
|
reason: 'invalid-json',
|
|
115
194
|
detail: errors.slice(0, 4).join(' | '),
|
|
195
|
+
...(paths.size > 0 ? { paths: [...paths] } : {}),
|
|
116
196
|
};
|
|
117
197
|
}
|
|
118
198
|
/**
|
|
@@ -162,15 +242,28 @@ export function parseReview(markdown, opts) {
|
|
|
162
242
|
return { ok: false, reason: 'invalid-json', detail: errors.slice(0, 4).join(' | ') };
|
|
163
243
|
}
|
|
164
244
|
/** The message sent back when a reply did not validate. Names the problem, nothing else. */
|
|
245
|
+
/**
|
|
246
|
+
* The message that asks for the same answer again, in the format.
|
|
247
|
+
*
|
|
248
|
+
* It names each rejected field with the shape it must have, gives a valid example, and says what
|
|
249
|
+
* did not happen: nothing from the rejected reply was run, so the results of the steps before it
|
|
250
|
+
* stand and the answer only needs reformatting. Without that last part a chat told "invalid" tends
|
|
251
|
+
* to start the round again, or to keep the same shape and change the wording, and the rounds run out.
|
|
252
|
+
*/
|
|
165
253
|
export function formatErrorMessage(fail, attempt, maxAttempts) {
|
|
254
|
+
const shapes = [...new Set((fail.paths ?? []).flatMap((p) => FIELD_SHAPES.filter(([re]) => re.test(p)).map(([, s]) => s)))];
|
|
166
255
|
return [
|
|
167
|
-
`Your last reply did not match the required format (
|
|
256
|
+
`Your last reply did not match the required format (format retry ${attempt} of ${maxAttempts}).`,
|
|
168
257
|
fail.reason === 'no-json-block'
|
|
169
258
|
? 'There was no fenced json code block in it.'
|
|
170
259
|
: `The json block did not validate: ${fail.detail}`,
|
|
171
|
-
|
|
172
|
-
'
|
|
173
|
-
|
|
260
|
+
shapes.length > 0 ? `What those fields must be: ${shapes.join('; ')}.` : '',
|
|
261
|
+
'Only the format is wrong. Nothing from that reply was run, and the results of the steps before it stand.',
|
|
262
|
+
'Send the same answer again, reformatted, as exactly one fenced code block tagged json — do not start over and do not change what it asks for. For example:',
|
|
263
|
+
FORMAT_EXAMPLE,
|
|
264
|
+
]
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.join('\n');
|
|
174
267
|
}
|
|
175
268
|
/**
|
|
176
269
|
* Spots a command that arrived damaged rather than badly written.
|
|
@@ -11,7 +11,7 @@ const TASK_HEADER = '## Task';
|
|
|
11
11
|
* and a note that is empty simply leaves nothing behind.
|
|
12
12
|
*/
|
|
13
13
|
function runnerBlock(input) {
|
|
14
|
-
const notes = [input.workDirNote, input.readOnlyNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
|
|
14
|
+
const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
|
|
15
15
|
return notes.length > 0 ? `${notes.join('\n\n')}\n\n` : '';
|
|
16
16
|
}
|
|
17
17
|
/**
|
|
@@ -168,6 +168,8 @@ export function buildPlanExport(scope) {
|
|
|
168
168
|
task.review = false;
|
|
169
169
|
if (t.readOnly)
|
|
170
170
|
task.readOnly = true;
|
|
171
|
+
if (t.scope && t.scope.length > 0)
|
|
172
|
+
task.scope = [...t.scope];
|
|
171
173
|
const earlier = earlierPlans(t, level2);
|
|
172
174
|
if (earlier.length > 0)
|
|
173
175
|
task.earlierAttempts = earlier;
|
|
@@ -394,6 +396,10 @@ async function domainTask(session, task, runsDir) {
|
|
|
394
396
|
title: task.title,
|
|
395
397
|
attempt: task.attempt ?? 1,
|
|
396
398
|
readOnly: task.readOnly ?? false,
|
|
399
|
+
scope: task.scope ?? [],
|
|
400
|
+
scopeReverted: task.scopeReverted ?? [],
|
|
401
|
+
handoff: task.handoff ?? null,
|
|
402
|
+
stopCode: task.stopCode ?? null,
|
|
397
403
|
prompt: p.prompt,
|
|
398
404
|
expected: p.expected,
|
|
399
405
|
level2: task.level2,
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function composeHandoff(task, notRun = []) {
|
|
2
|
+
const failedChecks = (task.checkResults ?? []).filter((c) => !c.passed);
|
|
3
|
+
const openFindings = task.review?.verdict === 'fail' ? (task.review.findings ?? []).filter((f) => f.about !== 'task') : [];
|
|
4
|
+
const vcs = task.vcs ?? {};
|
|
5
|
+
const knownIssues = [
|
|
6
|
+
...failedChecks.map((c) => `check failed: ${c.name} — ${c.detail}`),
|
|
7
|
+
...openFindings.map((f) => `review finding${f.id ? ` ${f.id}` : ''}: ${f.what}${f.where ? ` (${f.where})` : ''}`),
|
|
8
|
+
...(task.checkResults ?? [])
|
|
9
|
+
.filter((c) => c.passed && /still there after being pointed out once/.test(c.detail))
|
|
10
|
+
.map((c) => `committed with a finding left in place: ${c.name}`),
|
|
11
|
+
...(vcs.suspicious ?? []).map((s) => `committed although it looks like ${s.reason}: ${s.path}`),
|
|
12
|
+
...(vcs.foreignCommits ?? []).map((c) => `a commit on the branch the runner did not make: ${c}`),
|
|
13
|
+
...(task.scopeReverted?.length ? [`put back because outside the task's scope: ${task.scopeReverted.join(', ')}`] : []),
|
|
14
|
+
...(task.deviations ?? []).map((d) => `did not follow "${d.instruction}" as written: ${d.did}`),
|
|
15
|
+
];
|
|
16
|
+
const manual = [];
|
|
17
|
+
if (vcs.problem)
|
|
18
|
+
manual.push(`version control: ${vcs.problem}`);
|
|
19
|
+
if (vcs.commit && vcs.branch)
|
|
20
|
+
manual.push(`push ${vcs.branch} when you are ready — the runner never pushes`);
|
|
21
|
+
if (task.status === 'blocked')
|
|
22
|
+
manual.push(`unblock it: ${task.reason ?? 'see the reason'}`);
|
|
23
|
+
if (task.status === 'limit-reached' || task.status === 'aborted')
|
|
24
|
+
manual.push('continue it in the same chat from the register, or run it again');
|
|
25
|
+
if (task.status === 'failed')
|
|
26
|
+
manual.push('read why it failed, fix the prompt or the checks, and queue it again');
|
|
27
|
+
if (openFindings.length > 0)
|
|
28
|
+
manual.push(`${openFindings.length} review finding(s) are open`);
|
|
29
|
+
if ((vcs.suspicious?.length ?? 0) > 0 || (vcs.foreignCommits?.length ?? 0) > 0)
|
|
30
|
+
manual.push('look at the committed files and commits listed above');
|
|
31
|
+
return {
|
|
32
|
+
outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
|
|
33
|
+
changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
|
|
34
|
+
validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
|
|
35
|
+
...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
|
|
36
|
+
knownIssues,
|
|
37
|
+
evidence: { ...(task.runId ? { runId: task.runId } : {}), checks: task.checkResults?.length ?? 0, reviewRounds: task.review?.rounds ?? 0 },
|
|
38
|
+
vcs: { ...(vcs.branch ? { branch: vcs.branch } : {}), ...(vcs.commit ? { commit: vcs.commit } : {}), pushed: false, ...(vcs.problem ? { problem: vcs.problem } : {}) },
|
|
39
|
+
manual,
|
|
40
|
+
notExecuted: notRun.slice(0, 30).map((n) => `${n.command} — ${n.why}`),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
const ENDED = ['done', 'failed', 'blocked', 'aborted', 'limit-reached'];
|
|
2
|
+
function attemptsOf(task) {
|
|
3
|
+
const all = [...(task.attempts ?? [])];
|
|
4
|
+
if (ENDED.includes(task.status))
|
|
5
|
+
all.push(task);
|
|
6
|
+
return all.filter((a) => ENDED.includes(a.status));
|
|
7
|
+
}
|
|
8
|
+
function modelOf(session) {
|
|
9
|
+
return (session.modelInUse ?? '').trim() || (session.model ?? '').trim() || '(the chat default)';
|
|
10
|
+
}
|
|
11
|
+
function row(group, tasks) {
|
|
12
|
+
const r = {
|
|
13
|
+
group,
|
|
14
|
+
tasks: 0,
|
|
15
|
+
attempts: 0,
|
|
16
|
+
firstPass: { n: 0, of: 0 },
|
|
17
|
+
doneInTheEnd: { n: 0, of: 0 },
|
|
18
|
+
ended: {},
|
|
19
|
+
freshRetries: 0,
|
|
20
|
+
manualInterventions: 0,
|
|
21
|
+
falseCompletion: { n: 0, of: 0 },
|
|
22
|
+
reviewRejection: { n: 0, of: 0 },
|
|
23
|
+
repeatedCommands: { n: 0, of: 0 },
|
|
24
|
+
scopeViolation: { n: 0, of: 0 },
|
|
25
|
+
noProgress: { n: 0, of: 0 },
|
|
26
|
+
unrelatedDiff: { n: 0, of: 0 },
|
|
27
|
+
resumed: { n: 0, of: 0 },
|
|
28
|
+
withStats: 0,
|
|
29
|
+
};
|
|
30
|
+
for (const task of tasks) {
|
|
31
|
+
const list = attemptsOf(task);
|
|
32
|
+
if (list.length === 0)
|
|
33
|
+
continue;
|
|
34
|
+
r.tasks += 1;
|
|
35
|
+
r.attempts += list.length;
|
|
36
|
+
r.firstPass.of += 1;
|
|
37
|
+
if (list[0].status === 'done')
|
|
38
|
+
r.firstPass.n += 1;
|
|
39
|
+
const last = list[list.length - 1];
|
|
40
|
+
r.doneInTheEnd.of += 1;
|
|
41
|
+
if (last.status === 'done')
|
|
42
|
+
r.doneInTheEnd.n += 1;
|
|
43
|
+
r.ended[last.status] = (r.ended[last.status] ?? 0) + 1;
|
|
44
|
+
list.forEach((a, i) => {
|
|
45
|
+
if (a.freshRetry)
|
|
46
|
+
r.freshRetries += 1;
|
|
47
|
+
else if (i > 0)
|
|
48
|
+
r.manualInterventions += 1;
|
|
49
|
+
r.unrelatedDiff.of += 1;
|
|
50
|
+
if ((a.vcs?.suspicious?.length ?? 0) > 0 || (a.vcs?.foreignCommits?.length ?? 0) > 0)
|
|
51
|
+
r.unrelatedDiff.n += 1;
|
|
52
|
+
if ((a.scope?.length ?? 0) > 0) {
|
|
53
|
+
r.scopeViolation.of += 1;
|
|
54
|
+
if ((a.scopeReverted?.length ?? 0) > 0)
|
|
55
|
+
r.scopeViolation.n += 1;
|
|
56
|
+
}
|
|
57
|
+
if (a.continuing) {
|
|
58
|
+
r.resumed.of += 1;
|
|
59
|
+
if (a.status === 'done')
|
|
60
|
+
r.resumed.n += 1;
|
|
61
|
+
}
|
|
62
|
+
if (a.review && a.review.verdict !== 'skipped') {
|
|
63
|
+
r.reviewRejection.of += 1;
|
|
64
|
+
if ((a.stats?.reviewRejections ?? 0) > 0 || a.review.verdict === 'fail' || (a.review.rounds ?? 0) > 1)
|
|
65
|
+
r.reviewRejection.n += 1;
|
|
66
|
+
}
|
|
67
|
+
if (!a.stats)
|
|
68
|
+
return;
|
|
69
|
+
r.withStats += 1;
|
|
70
|
+
r.manualInterventions += a.stats.operatorStops;
|
|
71
|
+
r.falseCompletion.of += 1;
|
|
72
|
+
if (a.stats.doneRejected > 0)
|
|
73
|
+
r.falseCompletion.n += 1;
|
|
74
|
+
r.repeatedCommands.of += 1;
|
|
75
|
+
if (a.stats.repeatsRefused > 0)
|
|
76
|
+
r.repeatedCommands.n += 1;
|
|
77
|
+
r.noProgress.of += 1;
|
|
78
|
+
if (a.stats.stoppedFor === 'no-progress')
|
|
79
|
+
r.noProgress.n += 1;
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return r;
|
|
83
|
+
}
|
|
84
|
+
/** All tasks, then one row per model the sessions ran on, busiest first. */
|
|
85
|
+
export function computeMetrics(sessions, now = new Date()) {
|
|
86
|
+
const all = sessions.flatMap((s) => s.tasks);
|
|
87
|
+
const byModel = new Map();
|
|
88
|
+
for (const s of sessions)
|
|
89
|
+
byModel.set(modelOf(s), [...(byModel.get(modelOf(s)) ?? []), ...s.tasks]);
|
|
90
|
+
const models = [...byModel.entries()]
|
|
91
|
+
.map(([group, tasks]) => row(group, tasks))
|
|
92
|
+
.filter((r) => r.tasks > 0)
|
|
93
|
+
.sort((a, b) => b.tasks - a.tasks);
|
|
94
|
+
return { rows: [row('all', all), ...(models.length > 1 ? models : [])], computedAt: now.toISOString() };
|
|
95
|
+
}
|
|
@@ -42,6 +42,47 @@ export const DEFAULT_MIRROR = {
|
|
|
42
42
|
respectGitignore: true,
|
|
43
43
|
includeEnvFiles: false,
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Applies an edit to a task: the one place the rule about a new intent lives.
|
|
47
|
+
*
|
|
48
|
+
* A task given a new prompt is a new question. The checks the reviews of its earlier attempts
|
|
49
|
+
* added were findings about the old question, and inheriting them gated the new one on work it
|
|
50
|
+
* no longer asks for: a maintenance task rewritten as an audit still had to pass "the build uses
|
|
51
|
+
* the new config", and went blocked on an artificial finding. So a changed prompt drops them, with
|
|
52
|
+
* the reason kept beside each, and they stay on the record. The plan's own checks are not guessed
|
|
53
|
+
* at: whoever writes the new prompt says which still apply (the edit form and the new-prompt
|
|
54
|
+
* dialog both show them), and `checks` in the patch is that answer. A re-run with the same prompt
|
|
55
|
+
* keeps everything, because it is the same question asked again.
|
|
56
|
+
*/
|
|
57
|
+
export function applyTaskPatch(t, patch) {
|
|
58
|
+
const newPrompt = patch.prompt !== undefined && patch.prompt.trim() !== '' && patch.prompt.trim() !== t.prompt.trim();
|
|
59
|
+
if (patch.title !== undefined)
|
|
60
|
+
t.title = patch.title.trim() || t.title;
|
|
61
|
+
if (patch.prompt !== undefined && patch.prompt.trim())
|
|
62
|
+
t.prompt = patch.prompt;
|
|
63
|
+
if (patch.level2 !== undefined)
|
|
64
|
+
t.level2 = patch.level2;
|
|
65
|
+
if (patch.vcsPlan !== undefined)
|
|
66
|
+
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
67
|
+
if (patch.checks !== undefined)
|
|
68
|
+
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
69
|
+
if (patch.readOnly !== undefined) {
|
|
70
|
+
if (patch.readOnly)
|
|
71
|
+
t.readOnly = true;
|
|
72
|
+
else
|
|
73
|
+
delete t.readOnly;
|
|
74
|
+
}
|
|
75
|
+
if (patch.scope !== undefined) {
|
|
76
|
+
const scope = patch.scope.map((p) => p.trim()).filter(Boolean);
|
|
77
|
+
if (scope.length > 0)
|
|
78
|
+
t.scope = scope;
|
|
79
|
+
else
|
|
80
|
+
delete t.scope;
|
|
81
|
+
}
|
|
82
|
+
if (newPrompt && t.reviewChecks?.length) {
|
|
83
|
+
t.reviewChecks = t.reviewChecks.map((rc) => rc.state === 'dropped' ? rc : { ...rc, state: 'dropped', droppedBecause: 'the task was given a new prompt; this was a finding about the old one' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
45
86
|
export class SessionStore {
|
|
46
87
|
defaultLevel1Path;
|
|
47
88
|
dir;
|
|
@@ -251,6 +292,8 @@ export class SessionStore {
|
|
|
251
292
|
...(input.reviewEnabled === false ? { reviewEnabled: false } : {}),
|
|
252
293
|
// Only `true` is worth storing: a task may change files unless a plan said otherwise.
|
|
253
294
|
...(input.readOnly === true ? { readOnly: true } : {}),
|
|
295
|
+
// Stored only when it limits something: absent means anywhere, as before scopes existed.
|
|
296
|
+
...((input.scope ?? []).filter((p) => p.trim()).length > 0 ? { scope: (input.scope ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
|
|
254
297
|
};
|
|
255
298
|
await this.updateSession(sessionId, (s) => {
|
|
256
299
|
s.tasks.push(task);
|
|
@@ -352,20 +395,18 @@ export class SessionStore {
|
|
|
352
395
|
checks: t.checks,
|
|
353
396
|
vcsPlan: t.vcsPlan,
|
|
354
397
|
review: t.review,
|
|
398
|
+
stats: t.stats,
|
|
399
|
+
continuing: t.continuing,
|
|
400
|
+
buildsOn: t.buildsOn,
|
|
401
|
+
freshRetry: t.freshRetry,
|
|
402
|
+
stopCode: t.stopCode,
|
|
403
|
+
scope: t.scope,
|
|
404
|
+
scopeReverted: t.scopeReverted,
|
|
355
405
|
vcs: t.vcs,
|
|
356
406
|
},
|
|
357
407
|
];
|
|
358
408
|
// An edit is applied only after the old attempt has been put beyond its reach.
|
|
359
|
-
|
|
360
|
-
t.title = patch.title.trim() || t.title;
|
|
361
|
-
if (patch.prompt !== undefined && patch.prompt.trim())
|
|
362
|
-
t.prompt = patch.prompt;
|
|
363
|
-
if (patch.level2 !== undefined)
|
|
364
|
-
t.level2 = patch.level2;
|
|
365
|
-
if (patch.vcsPlan !== undefined)
|
|
366
|
-
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
367
|
-
if (patch.checks !== undefined)
|
|
368
|
-
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
409
|
+
applyTaskPatch(t, patch);
|
|
369
410
|
t.attempt = (t.attempt ?? 1) + 1;
|
|
370
411
|
t.status = 'queued';
|
|
371
412
|
t.iterations = 0;
|
|
@@ -390,6 +431,13 @@ export class SessionStore {
|
|
|
390
431
|
// the one thing a new attempt should inherit.
|
|
391
432
|
t.deviations = undefined;
|
|
392
433
|
t.disputes = undefined;
|
|
434
|
+
// Counts, the ending and what was put back belong to the attempt above too. A fresh-chat retry
|
|
435
|
+
// marks itself again after this, in the runner.
|
|
436
|
+
t.stats = undefined;
|
|
437
|
+
t.handoff = undefined;
|
|
438
|
+
t.scopeReverted = undefined;
|
|
439
|
+
t.freshRetry = undefined;
|
|
440
|
+
t.stopCode = undefined;
|
|
393
441
|
// The branch of the finished attempt stays in the repository and stays on the record
|
|
394
442
|
// above; the next attempt gets its own, cut from the same commit this one started at.
|
|
395
443
|
t.vcs = undefined;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the runner, the reviewer and the API need from a chat, and where that chat comes from.
|
|
3
|
+
*
|
|
4
|
+
* Everything that talks to Copilot goes through `CopilotTransport`, which drives a real Edge window
|
|
5
|
+
* signed in to Microsoft 365. That is the product, and it is also why nothing above the transport
|
|
6
|
+
* could be tested end to end: every run opened a browser, signed in and spent the operator's hourly
|
|
7
|
+
* message allowance. The loop itself — send, read the reply, run the steps, report back, stop at the
|
|
8
|
+
* limit, carry on after an interruption, commit on the right branch — never touches the page, only
|
|
9
|
+
* these few methods. So the rest of the code asks for a `ChatTransport` and gets one from
|
|
10
|
+
* `createTransport`, and the automated checks put a scripted chat in its place (test/support/fakeChat.ts)
|
|
11
|
+
* that answers from a script and never opens a window.
|
|
12
|
+
*
|
|
13
|
+
* The type is a `Pick` of the real class rather than an interface written out by hand, so the two
|
|
14
|
+
* cannot drift apart: a method that changes its signature in `CopilotTransport` changes it here, and
|
|
15
|
+
* the scripted chat stops compiling instead of passing checks against a shape the product no longer has.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately, nothing outside the code can swap the chat: no environment variable, no setting, no
|
|
18
|
+
* command-line flag. A scripted chat hands the runner steps to execute, and a switch that a file or
|
|
19
|
+
* an environment could flip would be a way to make the bot run commands nobody reviewed. Only code
|
|
20
|
+
* running in this process — a check script — can call `setTransportFactory`.
|
|
21
|
+
*/
|
|
22
|
+
import { CopilotTransport } from './copilotTransport.js';
|
|
23
|
+
const realChat = (opts) => new CopilotTransport(opts);
|
|
24
|
+
let factory = realChat;
|
|
25
|
+
/** A chat for these options: the real Copilot window, unless a check has put a scripted one in. */
|
|
26
|
+
export function createTransport(opts) {
|
|
27
|
+
return factory(opts);
|
|
28
|
+
}
|
|
29
|
+
/** For checks only: every chat opened from now on comes from `f`. `null` puts the real one back. */
|
|
30
|
+
export function setTransportFactory(f) {
|
|
31
|
+
factory = f ?? realChat;
|
|
32
|
+
}
|
|
@@ -750,12 +750,30 @@ Current URL: ${url}`);
|
|
|
750
750
|
await this.closeMenu();
|
|
751
751
|
return { ok: false, current: before, reason: `The picker does not offer "${name}" any more.` };
|
|
752
752
|
}
|
|
753
|
+
/*
|
|
754
|
+
* Already the one in force: close the menu and say so, without clicking.
|
|
755
|
+
*
|
|
756
|
+
* The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
|
|
757
|
+
* reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
|
|
758
|
+
* model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
|
|
759
|
+
* click it used to get did nothing in Copilot: the row already in force ignores it, the menu
|
|
760
|
+
* stayed open over the composer, and the run went on with the model list hanging on screen.
|
|
761
|
+
*/
|
|
762
|
+
if (target.selected) {
|
|
763
|
+
await this.closeMenu();
|
|
764
|
+
this.emit('model-already-selected', { model: target.name, buttonShows: before });
|
|
765
|
+
return { ok: true, current: target.name };
|
|
766
|
+
}
|
|
753
767
|
if (target.disabled) {
|
|
754
768
|
await this.closeMenu();
|
|
755
769
|
return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
|
|
756
770
|
}
|
|
757
771
|
await this.clickMenuRow(target.name, inSubmenu);
|
|
758
772
|
await this.p.waitForTimeout(1_000);
|
|
773
|
+
// Whatever the click did, the menu is not left open over the chat: a build that keeps it open
|
|
774
|
+
// after a choice, or a click that did not land, would otherwise leave it for the next message.
|
|
775
|
+
if ((await this.readMenuRows()).length > 0)
|
|
776
|
+
await this.closeMenu();
|
|
759
777
|
const after = await this.currentModel();
|
|
760
778
|
// The button shows the choice, so it is the check. It also **shortens** it: picking
|
|
761
779
|
// "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the text a task wrote should not contain, checked before it is committed.
|
|
3
|
+
*
|
|
4
|
+
* `commitHygiene.ts` looks at which files would be committed. This looks inside them, because the
|
|
5
|
+
* damage a Windows shell does to text is invisible to a formatter and to a reviewer reading a diff
|
|
6
|
+
* in a browser: PowerShell 5.1's `Set-Content -Encoding utf8` puts a byte-order mark in front of a
|
|
7
|
+
* JSON file and `JSON.parse` then refuses it; text read in one code page and written in another
|
|
8
|
+
* comes out as `é` or, for Cyrillic, `а` and `Р°`; a decoder that gave up leaves `�`; a terminal's
|
|
9
|
+
* colour codes pasted into a file leave ESC characters; one file ends up half CRLF and half LF.
|
|
10
|
+
* Also a private key or an access token written into a file, which a path check cannot see.
|
|
11
|
+
*
|
|
12
|
+
* Every finding is about what THIS task did. A file is compared with the same file at the commit the
|
|
13
|
+
* task started from, and a kind of problem already there is not reported: a repository that has
|
|
14
|
+
* always had CRLF and LF mixed in one file is not this task's fault, and blaming it would send the
|
|
15
|
+
* chat to "fix" a file it was never asked to touch.
|
|
16
|
+
*
|
|
17
|
+
* Used as a check the runner adds by itself when it is going to commit (`content-clean`, see
|
|
18
|
+
* `checks.ts`), with the same rule as the path check: pointed out to the chat once; a second "done"
|
|
19
|
+
* with the problem still there is committed and the finding stays on the task where a person sees it.
|
|
20
|
+
*/
|
|
21
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
22
|
+
import { join } from 'node:path';
|
|
23
|
+
import { gitBytes } from './git.js';
|
|
24
|
+
import { looksGenerated } from './commitHygiene.js';
|
|
25
|
+
/** Written files larger than this are almost always generated output. */
|
|
26
|
+
export const OVERSIZED_BYTES = 1024 * 1024;
|
|
27
|
+
/** Files that are binary by nature: their bytes are not text and are not looked into. */
|
|
28
|
+
const BINARY_EXTENSIONS = new Set([
|
|
29
|
+
'png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'webp', 'tif', 'tiff', 'avif', 'psd',
|
|
30
|
+
'pdf', 'zip', '7z', 'gz', 'tgz', 'bz2', 'xz', 'rar', 'jar', 'war', 'nupkg',
|
|
31
|
+
'woff', 'woff2', 'ttf', 'otf', 'eot',
|
|
32
|
+
'exe', 'dll', 'so', 'dylib', 'bin', 'obj', 'o', 'a', 'lib', 'pdb', 'class', 'wasm',
|
|
33
|
+
'mp3', 'mp4', 'wav', 'ogg', 'webm', 'mov', 'avi',
|
|
34
|
+
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods',
|
|
35
|
+
'sqlite', 'db', 'mdb', 'pfx', 'p12', 'der',
|
|
36
|
+
]);
|
|
37
|
+
/**
|
|
38
|
+
* Text formats where a byte-order mark breaks the reader: JSON.parse, a shell's shebang line, YAML
|
|
39
|
+
* and TOML parsers, Node's module loader for the first line. A BOM in these is reported even in a
|
|
40
|
+
* new file; in any other text file only when the file had none before.
|
|
41
|
+
*/
|
|
42
|
+
const BOM_BREAKS = new Set(['json', 'jsonc', 'js', 'mjs', 'cjs', 'ts', 'mts', 'cts', 'tsx', 'jsx', 'sh', 'bash', 'yml', 'yaml', 'toml', 'py', 'env', 'html', 'css', 'scss', 'md']);
|
|
43
|
+
function extensionOf(path) {
|
|
44
|
+
const name = path.replace(/\\/g, '/').split('/').pop() ?? '';
|
|
45
|
+
const dot = name.lastIndexOf('.');
|
|
46
|
+
return dot > 0 ? name.slice(dot + 1).toLowerCase() : '';
|
|
47
|
+
}
|
|
48
|
+
/*
|
|
49
|
+
* Mojibake: UTF-8 bytes decoded in a single-byte code page and written back as UTF-8.
|
|
50
|
+
*
|
|
51
|
+
* Latin text through Windows-1252 turns "é" into "é" and "’" into "’". Cyrillic through
|
|
52
|
+
* Windows-1252 turns "а" into "а"; through Windows-1251, into "Р°". The second character is what
|
|
53
|
+
* gives it away: it is one of the characters the code page puts at bytes 0x80–0xBF, which real text
|
|
54
|
+
* almost never places right after "Ã", "Ð", "Ñ", "Р" or "С". Plain "Ра" (Cyrillic Р then а) is
|
|
55
|
+
* ordinary Bulgarian and does not match, because "а" is not in that range.
|
|
56
|
+
*/
|
|
57
|
+
const CP1252_HIGH = '€‚ƒ„…†‡ˆ‰Š‹ŒŽ‘’“”•–—˜™š›œžŸ -¿';
|
|
58
|
+
const CP1251_HIGH = 'ЂЃ‚ѓ„…†‡€‰Љ‹ЊЌЋЏђ‘’“”•–—™љ›њќћџ ЎўЈ¤Ґ¦§Ё©Є«¬®Ї°±Ііґµ¶·ё№є»јЅѕї';
|
|
59
|
+
const MOJIBAKE = new RegExp(`[ÃÂÐÑâ][${CP1252_HIGH}]|[РС][${CP1251_HIGH}]`, 'g');
|
|
60
|
+
/** Control characters other than tab, line feed and carriage return; NUL is counted as binary. */
|
|
61
|
+
const CONTROL = /[\u0001-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
62
|
+
/*
|
|
63
|
+
* Credentials by their shape. Only formats that are unambiguous — a private key block, and tokens
|
|
64
|
+
* whose prefix is the issuer's own — so a test fixture that says `password = "secret"` is not one.
|
|
65
|
+
*/
|
|
66
|
+
const SECRETS = [
|
|
67
|
+
[/-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED |PGP )?PRIVATE KEY(?: BLOCK)?-----/, 'a private key'],
|
|
68
|
+
[/\bAKIA[0-9A-Z]{16}\b/, 'an AWS access key id'],
|
|
69
|
+
[/\bgh[pousr]_[A-Za-z0-9]{36,}\b/, 'a GitHub token'],
|
|
70
|
+
[/\bgithub_pat_[A-Za-z0-9_]{50,}\b/, 'a GitHub token'],
|
|
71
|
+
[/\bxox[abposr]-[A-Za-z0-9-]{10,}\b/, 'a Slack token'],
|
|
72
|
+
[/\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{32,}\b/, 'an API secret key'],
|
|
73
|
+
[/AccountKey=[A-Za-z0-9+/=]{40,}/, 'an Azure storage account key'],
|
|
74
|
+
[/\bAIza[0-9A-Za-z_-]{35}\b/, 'a Google API key'],
|
|
75
|
+
];
|
|
76
|
+
/** Every kind of problem a file's bytes show, with a short description of each. */
|
|
77
|
+
export function traitsOf(path, bytes) {
|
|
78
|
+
const t = {};
|
|
79
|
+
if (bytes.length > OVERSIZED_BYTES)
|
|
80
|
+
t.oversized = `${(bytes.length / 1024 / 1024).toFixed(1)} MB, far more than anyone writes by hand`;
|
|
81
|
+
// A NUL in the first 8 KB is how git itself decides a file is binary.
|
|
82
|
+
if (bytes.subarray(0, 8192).includes(0)) {
|
|
83
|
+
t['binary-in-text'] = 'NUL bytes in what should be a text file';
|
|
84
|
+
return t;
|
|
85
|
+
}
|
|
86
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf)
|
|
87
|
+
t.bom = 'starts with a UTF-8 byte-order mark';
|
|
88
|
+
const text = bytes.toString('utf8');
|
|
89
|
+
const replacement = (text.match(/�/g) ?? []).length;
|
|
90
|
+
if (replacement > 0)
|
|
91
|
+
t['replacement-char'] = `${replacement} "�" (text that was decoded with the wrong encoding, or invalid UTF-8)`;
|
|
92
|
+
const mojibake = text.match(MOJIBAKE) ?? [];
|
|
93
|
+
// Two or more, so one stray pair in a legitimate string does not count.
|
|
94
|
+
if (mojibake.length >= 2)
|
|
95
|
+
t.mojibake = `${mojibake.length} sequences such as "${mojibake.slice(0, 3).join('", "')}" (UTF-8 read in another code page)`;
|
|
96
|
+
const control = (text.match(CONTROL) ?? []).length;
|
|
97
|
+
if (control > 0)
|
|
98
|
+
t['control-chars'] = `${control} control character(s)${text.includes('\u001B[') ? ', including terminal colour codes' : ''}`;
|
|
99
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
100
|
+
const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
|
|
101
|
+
if (crlf > 0 && lf > 0)
|
|
102
|
+
t['mixed-line-endings'] = `${crlf} CRLF and ${lf} LF line ending(s) in one file`;
|
|
103
|
+
for (const [re, what] of SECRETS) {
|
|
104
|
+
if (re.test(text)) {
|
|
105
|
+
t.secret = `what looks like ${what}`;
|
|
106
|
+
break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return t;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The problems `now` has that `before` did not. `before` is null for a file the task created.
|
|
113
|
+
*
|
|
114
|
+
* A BOM in a new file counts only for formats it breaks; in a changed file, whenever the file had
|
|
115
|
+
* none. Size counts only when the file crossed the line in this task.
|
|
116
|
+
*/
|
|
117
|
+
export function newProblems(path, now, before) {
|
|
118
|
+
const ext = extensionOf(path);
|
|
119
|
+
if (BINARY_EXTENSIONS.has(ext))
|
|
120
|
+
return [];
|
|
121
|
+
const had = before ? traitsOf(path, before) : {};
|
|
122
|
+
const has = traitsOf(path, now);
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const [kind, detail] of Object.entries(has)) {
|
|
125
|
+
if (had[kind])
|
|
126
|
+
continue;
|
|
127
|
+
if (kind === 'bom' && !before && !BOM_BREAKS.has(ext))
|
|
128
|
+
continue;
|
|
129
|
+
out.push({ path, kind, detail });
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
/** The problems the task added to these working-tree paths, compared with `baseCommit`. */
|
|
134
|
+
export async function scanChanges(dir, paths, baseCommit, limit = 400) {
|
|
135
|
+
const found = [];
|
|
136
|
+
for (const rel of paths.slice(0, limit)) {
|
|
137
|
+
if (rel.endsWith('/') || looksGenerated(rel))
|
|
138
|
+
continue;
|
|
139
|
+
const abs = join(dir, rel);
|
|
140
|
+
const info = await stat(abs).catch(() => null);
|
|
141
|
+
if (!info?.isFile())
|
|
142
|
+
continue;
|
|
143
|
+
if (BINARY_EXTENSIONS.has(extensionOf(rel)))
|
|
144
|
+
continue;
|
|
145
|
+
// A very large file is not read: its size is the finding, compared with the size it had before.
|
|
146
|
+
if (info.size > OVERSIZED_BYTES * 4) {
|
|
147
|
+
const was = baseCommit ? await gitBytes(dir, ['cat-file', '-s', `${baseCommit}:${rel.replace(/\\/g, '/')}`]) : null;
|
|
148
|
+
const wasSize = was?.ok ? Number(was.stdout.toString('utf8').trim()) : 0;
|
|
149
|
+
if (wasSize <= OVERSIZED_BYTES) {
|
|
150
|
+
found.push({ path: rel, kind: 'oversized', detail: `${(info.size / 1024 / 1024).toFixed(1)} MB, far more than anyone writes by hand` });
|
|
151
|
+
}
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const now = await readFile(abs).catch(() => null);
|
|
155
|
+
if (!now)
|
|
156
|
+
continue;
|
|
157
|
+
let before = null;
|
|
158
|
+
if (baseCommit) {
|
|
159
|
+
const shown = await gitBytes(dir, ['show', `${baseCommit}:${rel.replace(/\\/g, '/')}`]);
|
|
160
|
+
before = shown.ok ? shown.stdout : null;
|
|
161
|
+
}
|
|
162
|
+
found.push(...newProblems(rel, now, before));
|
|
163
|
+
}
|
|
164
|
+
return found;
|
|
165
|
+
}
|
|
166
|
+
/** What the chat is told, once, before the commit. */
|
|
167
|
+
export function integrityDetail(found) {
|
|
168
|
+
const list = found.slice(0, 20).map((f) => `${f.path}: ${f.detail}`).join('; ');
|
|
169
|
+
return (`${found.length} problem(s) in the text of files this task changed, none of them there before it: ${list}` +
|
|
170
|
+
`${found.length > 20 ? `; and ${found.length - 20} more` : ''}. Write the files again as UTF-8 without a byte-order mark ` +
|
|
171
|
+
'(in PowerShell 5.1 use [IO.File]::WriteAllText with New-Object Text.UTF8Encoding($false), not Set-Content -Encoding utf8), ' +
|
|
172
|
+
'one kind of line ending per file, no terminal colour codes, and no credentials — load those from the environment. ' +
|
|
173
|
+
'If one of these is intended, leave it and say why in your summary: it will be committed and the finding kept on the task.');
|
|
174
|
+
}
|