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.
Files changed (73) hide show
  1. package/dist/src/api/main.js +2 -104
  2. package/dist/src/api/operator.controller.js +18 -1
  3. package/dist/src/api/operator.service.js +19 -14
  4. package/dist/src/api/server.js +114 -0
  5. package/dist/src/config/schema.js +5 -0
  6. package/dist/src/exec/checks.js +17 -0
  7. package/dist/src/orchestrator/contract.js +58 -0
  8. package/dist/src/orchestrator/progress.js +106 -0
  9. package/dist/src/orchestrator/taskRunner.js +167 -11
  10. package/dist/src/plan/brief.js +2 -0
  11. package/dist/src/plan/importPlan.js +1 -0
  12. package/dist/src/plan/schema.js +28 -1
  13. package/dist/src/plan/systemGuide.js +61 -6
  14. package/dist/src/protocol/parser.js +98 -5
  15. package/dist/src/session/compose.js +1 -1
  16. package/dist/src/session/exports.js +6 -0
  17. package/dist/src/session/handoff.js +42 -0
  18. package/dist/src/session/metrics.js +95 -0
  19. package/dist/src/session/store.js +58 -10
  20. package/dist/src/transport/chatTransport.js +32 -0
  21. package/dist/src/transport/copilotTransport.js +18 -0
  22. package/dist/src/vcs/contentIntegrity.js +174 -0
  23. package/dist/src/vcs/git.js +42 -0
  24. package/dist/src/vcs/scope.js +132 -0
  25. package/dist/src/vcs/taskVcs.js +43 -2
  26. package/dist/web/404.html +4 -3
  27. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
  28. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
  29. package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +1 -0
  30. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
  31. package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +1 -0
  32. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
  33. package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
  34. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
  35. package/dist/web/_next/static/chunks/app/level1/page-dd3ebf37d29dce4f.js +1 -0
  36. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
  37. package/dist/web/_next/static/chunks/app/presets/page-93ab518db67e52a2.js +1 -0
  38. package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +1 -0
  39. package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
  40. package/dist/web/appearance.html +4 -3
  41. package/dist/web/appearance.txt +12 -12
  42. package/dist/web/defaults.html +4 -3
  43. package/dist/web/defaults.txt +12 -12
  44. package/dist/web/history.html +4 -3
  45. package/dist/web/history.txt +12 -12
  46. package/dist/web/import.html +4 -3
  47. package/dist/web/import.txt +12 -12
  48. package/dist/web/index.html +4 -3
  49. package/dist/web/index.txt +12 -12
  50. package/dist/web/level1.html +4 -3
  51. package/dist/web/level1.txt +12 -12
  52. package/dist/web/presets.html +5 -4
  53. package/dist/web/presets.txt +12 -12
  54. package/dist/web/sessions/view.html +4 -3
  55. package/dist/web/sessions/view.txt +12 -12
  56. package/dist/web/system.html +4 -3
  57. package/dist/web/system.txt +12 -12
  58. package/package.json +16 -4
  59. package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
  60. package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
  61. package/dist/web/_next/static/chunks/867-3d4ee08c5f9f0e71.js +0 -1
  62. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
  63. package/dist/web/_next/static/chunks/app/defaults/page-dfcbd748f454d2d7.js +0 -1
  64. package/dist/web/_next/static/chunks/app/history/page-49413c3a8266a0b7.js +0 -1
  65. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
  66. package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
  67. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
  68. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
  69. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
  70. package/dist/web/_next/static/chunks/app/sessions/view/page-a82e5e801dabc423.js +0 -1
  71. package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
  72. /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_buildManifest.js +0 -0
  73. /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 result = ReplySchema.safeParse(value);
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 (attempt ${attempt} of ${maxAttempts}).`,
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
- 'Resend the same answer as exactly one fenced code block tagged json, with the fields',
172
- 'status, steps and notes, and nothing else tagged json in the reply.',
173
- ].join(' ');
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
- if (patch.title !== undefined)
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
+ }