copilot-operator 0.1.7 → 0.1.9

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 (72) 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 +30 -11
  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/progress.js +106 -0
  8. package/dist/src/orchestrator/taskRunner.js +110 -10
  9. package/dist/src/plan/brief.js +6 -4
  10. package/dist/src/plan/importPlan.js +1 -0
  11. package/dist/src/plan/schema.js +7 -0
  12. package/dist/src/plan/systemGuide.js +67 -12
  13. package/dist/src/session/compose.js +25 -20
  14. package/dist/src/session/exports.js +5 -0
  15. package/dist/src/session/handoff.js +42 -0
  16. package/dist/src/session/interruption.js +104 -0
  17. package/dist/src/session/metrics.js +95 -0
  18. package/dist/src/session/store.js +35 -5
  19. package/dist/src/session/story.js +1 -1
  20. package/dist/src/transport/chatTransport.js +32 -0
  21. package/dist/src/vcs/contentIntegrity.js +174 -0
  22. package/dist/src/vcs/git.js +42 -0
  23. package/dist/src/vcs/scope.js +129 -0
  24. package/dist/src/vcs/taskVcs.js +43 -2
  25. package/dist/web/404.html +4 -3
  26. package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +1 -0
  27. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
  28. package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +1 -0
  29. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
  30. package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +1 -0
  31. package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +1 -0
  32. package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +1 -0
  33. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
  34. package/dist/web/_next/static/chunks/app/level1/page-be366b35f4cc97ce.js +1 -0
  35. package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +1 -0
  36. package/dist/web/_next/static/chunks/app/presets/page-cabc3876a8111edd.js +1 -0
  37. package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +1 -0
  38. package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
  39. package/dist/web/appearance.html +4 -3
  40. package/dist/web/appearance.txt +12 -12
  41. package/dist/web/defaults.html +4 -3
  42. package/dist/web/defaults.txt +12 -12
  43. package/dist/web/history.html +4 -3
  44. package/dist/web/history.txt +12 -12
  45. package/dist/web/import.html +4 -3
  46. package/dist/web/import.txt +12 -12
  47. package/dist/web/index.html +4 -3
  48. package/dist/web/index.txt +12 -12
  49. package/dist/web/level1.html +4 -3
  50. package/dist/web/level1.txt +12 -12
  51. package/dist/web/presets.html +4 -3
  52. package/dist/web/presets.txt +12 -12
  53. package/dist/web/sessions/view.html +4 -3
  54. package/dist/web/sessions/view.txt +12 -12
  55. package/dist/web/system.html +4 -3
  56. package/dist/web/system.txt +12 -12
  57. package/package.json +13 -4
  58. package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
  59. package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
  60. package/dist/web/_next/static/chunks/867-4aa80f5a32311bce.js +0 -1
  61. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
  62. package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +0 -1
  63. package/dist/web/_next/static/chunks/app/history/page-2251664c0abe3f11.js +0 -1
  64. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
  65. package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
  66. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
  67. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
  68. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
  69. package/dist/web/_next/static/chunks/app/sessions/view/page-9517dd74c6d00f85.js +0 -1
  70. package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
  71. /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_buildManifest.js +0 -0
  72. /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_ssgManifest.js +0 -0
@@ -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 } : {}) },
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,104 @@
1
+ /**
2
+ * Where an attempt was when the bot stopped under it — the power went, the machine shut down,
3
+ * `npm start` was stopped with Ctrl+C, the process crashed — read back from what it had written.
4
+ *
5
+ * Nothing in memory survives that, but the run folder does, and it was written as the work went:
6
+ * the transcript has every reply and every step as it was proposed, started and finished, and each
7
+ * step's output is in its own log under `steps/`. So the last reply the chat sent, which of the
8
+ * steps it asked for had finished (and how), which one was cut off while it ran, which never ran,
9
+ * and whether their results had already gone back to the chat — all of that is on disk. It is what
10
+ * the chat needs to be told to carry on rather than start over, and what the operator needs to see
11
+ * to trust that it can.
12
+ */
13
+ import { readFile } from 'node:fs/promises';
14
+ import { join } from 'node:path';
15
+ import { parseStepLog } from './story.js';
16
+ const TAIL_CHARS = 1200;
17
+ /** Reads an attempt's run folder; null when there is no transcript to read. */
18
+ export async function readInterruption(runDir) {
19
+ const raw = await readFile(join(runDir, 'transcript.jsonl'), 'utf8').catch(() => null);
20
+ if (raw === null)
21
+ return null;
22
+ const events = [];
23
+ for (const line of raw.split('\n')) {
24
+ if (!line.trim())
25
+ continue;
26
+ try {
27
+ events.push(JSON.parse(line));
28
+ }
29
+ catch {
30
+ // A line cut in half by the stop itself: the rest is still good.
31
+ }
32
+ }
33
+ const lastActivity = events.at(-1)?.at;
34
+ let replyAt = -1;
35
+ for (let i = events.length - 1; i >= 0; i -= 1) {
36
+ if (events[i].type === 'reply-parsed') {
37
+ replyAt = i;
38
+ break;
39
+ }
40
+ }
41
+ if (replyAt < 0)
42
+ return { lastActivity, iteration: 0, steps: [], resultsSent: false };
43
+ const reply = events[replyAt];
44
+ const iteration = Number(reply.iteration) || 0;
45
+ const after = events.slice(replyAt + 1);
46
+ const count = Number(reply.steps) || 0;
47
+ const steps = [];
48
+ for (let id = 1; id <= count; id += 1) {
49
+ const finished = after.find((e) => e.type === 'step-finished' && e.id === id);
50
+ const skipped = after.find((e) => e.type === 'step-skipped' && e.id === id);
51
+ const started = after.some((e) => e.type === 'step-started' && e.id === id);
52
+ const proposed = after.find((e) => e.type === 'step-proposed' && e.id === id);
53
+ const state = finished ? 'finished' : skipped ? 'skipped' : started ? 'cut' : 'not-run';
54
+ const step = { id, state };
55
+ if (typeof proposed?.description === 'string')
56
+ step.command = proposed.description;
57
+ if (finished) {
58
+ step.outcome = String(finished.outcome ?? '');
59
+ if (typeof finished.exitCode === 'number')
60
+ step.exitCode = finished.exitCode;
61
+ }
62
+ if (state === 'finished' || state === 'cut') {
63
+ const log = await readFile(join(runDir, 'steps', `${iteration}-${id}.log`), 'utf8').catch(() => '');
64
+ const output = parseStepLog(log).output;
65
+ if (output)
66
+ step.outputTail = output.length > TAIL_CHARS ? `…${output.slice(-TAIL_CHARS)}` : output;
67
+ }
68
+ steps.push(step);
69
+ }
70
+ // Sent back only when the results were written *and* a message went out after them.
71
+ const reportAt = after.findIndex((e) => e.type === 'report-written' && Number(e.iteration) === iteration);
72
+ const resultsSent = reportAt >= 0 && after.slice(reportAt + 1).some((e) => e.type === 'message-sent');
73
+ return { lastActivity, iteration, replyStatus: typeof reply.status === 'string' ? reply.status : undefined, steps, resultsSent };
74
+ }
75
+ /**
76
+ * What the chat is told about it, in the continuation message: which steps ran, with the end of
77
+ * their output, which was cut off, which never ran — or that the results were already sent.
78
+ */
79
+ export function describeInterruption(i) {
80
+ if (i.iteration === 0)
81
+ return 'It stopped before your first reply arrived, so nothing of this task had been run yet.';
82
+ if (i.resultsSent) {
83
+ return `The results of the steps from your last reply were already sent to you (above), and your answer to them was not received. Carry on from those results.`;
84
+ }
85
+ if (i.replyStatus === 'done' && i.steps.length === 0) {
86
+ return 'Your last reply reported the task done; the runner was checking it when it stopped. If nothing is left, reply again with your summary.';
87
+ }
88
+ if (i.steps.length === 0)
89
+ return 'Your last reply asked for no steps.';
90
+ const lines = [`Of the ${i.steps.length} step(s) in your last reply:`];
91
+ for (const s of i.steps) {
92
+ const what = s.state === 'finished'
93
+ ? `ran to the end (exit ${s.exitCode ?? '?'})`
94
+ : s.state === 'cut'
95
+ ? 'was running when the runner stopped — it may have done only part of its work; check its effect before relying on it'
96
+ : s.state === 'skipped'
97
+ ? 'was not run (refused or skipped)'
98
+ : 'did not run';
99
+ lines.push(`- step ${s.id} ${what}.`);
100
+ if (s.outputTail)
101
+ lines.push(' Its output ended with:', ' ```', ...s.outputTail.split('\n').map((l) => ` ${l}`), ' ```');
102
+ }
103
+ return lines.join('\n');
104
+ }
@@ -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
+ }
@@ -251,6 +251,8 @@ export class SessionStore {
251
251
  ...(input.reviewEnabled === false ? { reviewEnabled: false } : {}),
252
252
  // Only `true` is worth storing: a task may change files unless a plan said otherwise.
253
253
  ...(input.readOnly === true ? { readOnly: true } : {}),
254
+ // Stored only when it limits something: absent means anywhere, as before scopes existed.
255
+ ...((input.scope ?? []).filter((p) => p.trim()).length > 0 ? { scope: (input.scope ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
254
256
  };
255
257
  await this.updateSession(sessionId, (s) => {
256
258
  s.tasks.push(task);
@@ -292,18 +294,30 @@ export class SessionStore {
292
294
  * the old attempt is untouched too, because the next attempt gets a folder of its own.
293
295
  */
294
296
  /**
295
- * Queues a task that stopped at the runner's limit to carry on where it stopped: the same
297
+ * Queues a task that stopped before it finished to carry on where it stopped: the same
296
298
  * conversation, the same branch, a fresh count of messages. The attempt that stopped is kept on
297
299
  * the record like any other.
300
+ *
301
+ * Two kinds of stop qualify. The runner's limit (`limit-reached`), and a stop that was not the
302
+ * task's doing (`aborted`): the bot itself stopping under it — power, a shutdown, Ctrl+C, a crash,
303
+ * recorded in `interruption` at the next start — or the operator stopping it. A task that failed,
304
+ * blocked or was not done has a verdict on its work, and carrying that work on would carry the
305
+ * verdict's cause on with it; those are run again.
298
306
  */
299
307
  async continueTask(sessionId, taskId) {
300
308
  const current = (await this.getSession(sessionId))?.tasks.find((x) => x.id === taskId);
301
309
  if (!current)
302
310
  throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
303
- if (current.status !== 'limit-reached') {
304
- throw new Error('Only a task that stopped at the runner\'s limit can be continued; run this one again instead.');
311
+ if (current.status !== 'limit-reached' && current.status !== 'aborted') {
312
+ throw new Error('Only a task that stopped before it finished can be continued; run this one again instead.');
305
313
  }
306
- return await this.rerunTask(sessionId, taskId, {}, { fromAttempt: current.attempt ?? 1, stoppedBecause: current.reason });
314
+ const how = current.status === 'limit-reached' ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
315
+ return await this.rerunTask(sessionId, taskId, {}, {
316
+ fromAttempt: current.attempt ?? 1,
317
+ stoppedBecause: current.reason,
318
+ how,
319
+ ...(current.interruption ? { interruption: current.interruption } : {}),
320
+ });
307
321
  }
308
322
  async rerunTask(sessionId, taskId, patch = {},
309
323
  /** Present when the new attempt carries on from the last one (see `continueTask`). */
@@ -340,6 +354,12 @@ export class SessionStore {
340
354
  checks: t.checks,
341
355
  vcsPlan: t.vcsPlan,
342
356
  review: t.review,
357
+ stats: t.stats,
358
+ continuing: t.continuing,
359
+ buildsOn: t.buildsOn,
360
+ freshRetry: t.freshRetry,
361
+ scope: t.scope,
362
+ scopeReverted: t.scopeReverted,
343
363
  vcs: t.vcs,
344
364
  },
345
365
  ];
@@ -359,6 +379,8 @@ export class SessionStore {
359
379
  t.iterations = 0;
360
380
  t.continuing = continuing;
361
381
  t.buildsOn = buildsOn;
382
+ // Where the attempt that stopped had got to belongs to that attempt; the next one starts clean.
383
+ t.interruption = undefined;
362
384
  // Cleared so the next run starts from nothing and gets its own run folder.
363
385
  t.runId = undefined;
364
386
  t.runGroup = undefined;
@@ -376,6 +398,12 @@ export class SessionStore {
376
398
  // the one thing a new attempt should inherit.
377
399
  t.deviations = undefined;
378
400
  t.disputes = undefined;
401
+ // Counts, the ending and what was put back belong to the attempt above too. A fresh-chat retry
402
+ // marks itself again after this, in the runner.
403
+ t.stats = undefined;
404
+ t.handoff = undefined;
405
+ t.scopeReverted = undefined;
406
+ t.freshRetry = undefined;
379
407
  // The branch of the finished attempt stays in the repository and stays on the record
380
408
  // above; the next attempt gets its own, cut from the same commit this one started at.
381
409
  t.vcs = undefined;
@@ -407,7 +435,9 @@ export class SessionStore {
407
435
  for (const t of stuck) {
408
436
  t.status = 'aborted';
409
437
  t.finishedAt = t.finishedAt ?? new Date().toISOString();
410
- t.reason = 'The operator stopped while this task was in progress, so it never finished. Nothing was resumed.';
438
+ t.reason =
439
+ 'The bot stopped while this task was in progress (the program was closed, the machine went off, or it crashed), so it never finished. ' +
440
+ 'Its work so far is kept; "Continue in the same chat" carries it on where it stopped.';
411
441
  recovered.push({ sessionId: session.id, taskId: t.id, title: t.title });
412
442
  }
413
443
  session.status = 'idle';
@@ -51,7 +51,7 @@ function sectionsOf(log) {
51
51
  return out;
52
52
  }
53
53
  /** A step log: two header lines, the output, one footer line. */
54
- function parseStepLog(text) {
54
+ export function parseStepLog(text) {
55
55
  const lines = text.split('\n');
56
56
  const command = lines[1]?.replace(/^# /, '') ?? '';
57
57
  const footer = lines.map((l) => l.match(/^# outcome=(\S+) exit=(-?\d+) durationMs=(\d+)/)).filter(Boolean).pop();
@@ -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
+ }
@@ -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
+ }
@@ -17,6 +17,8 @@
17
17
  */
18
18
  import { execFile } from 'node:child_process';
19
19
  import { existsSync } from 'node:fs';
20
+ import { readFile, stat } from 'node:fs/promises';
21
+ import { createHash } from 'node:crypto';
20
22
  import { join } from 'node:path';
21
23
  /**
22
24
  * Settings every git the runner starts is given, ahead of the command.
@@ -321,6 +323,46 @@ export async function commitSubject(dir, commit) {
321
323
  return r.ok ? r.stdout.trim() : '';
322
324
  }
323
325
  /** One line per commit, newest first, for showing what a task produced. */
326
+ /**
327
+ * One hash for the whole working tree as it differs from HEAD: tracked changes, staged or not, and
328
+ * every untracked file that is not ignored, with its contents. Two equal fingerprints mean no file
329
+ * changed in between, which is what `orchestrator/progress.ts` needs to tell a loop from work.
330
+ * Null when there is no HEAD to compare with.
331
+ */
332
+ export async function treeFingerprint(dir) {
333
+ const diff = await gitBytes(dir, ['diff', 'HEAD', '--binary', '--no-ext-diff', '--no-textconv'], 60_000, 64 * 1024 * 1024);
334
+ if (!diff.ok)
335
+ return null;
336
+ const hash = createHash('sha256').update(diff.stdout);
337
+ const others = await gitBytes(dir, ['ls-files', '--others', '--exclude-standard', '-z']);
338
+ const names = others.ok ? others.stdout.toString('utf8').split('\0').filter(Boolean).sort().slice(0, 5000) : [];
339
+ for (const name of names) {
340
+ hash.update(`\0${name}\0`);
341
+ const info = await stat(join(dir, name)).catch(() => null);
342
+ if (!info?.isFile())
343
+ continue;
344
+ // A very large file is known by its size and time rather than read.
345
+ if (info.size > 8 * 1024 * 1024)
346
+ hash.update(`${info.size}:${info.mtimeMs}`);
347
+ else
348
+ hash.update(await readFile(join(dir, name)).catch(() => Buffer.alloc(0)));
349
+ }
350
+ return hash.digest('hex');
351
+ }
352
+ /** The identity the runner commits under (see `commitAll`). */
353
+ export const RUNNER_EMAIL = 'copilot-operator@localhost';
354
+ /** Whether `ancestor` is reachable from `ref`: false when the branch was reset or rewritten past it. */
355
+ export async function isAncestor(dir, ancestor, ref = 'HEAD') {
356
+ const r = await git(dir, ['merge-base', '--is-ancestor', ancestor, ref]);
357
+ return r.ok;
358
+ }
359
+ /** Commits between the two that the runner did not make, as `<short sha> <author email> <subject>`. */
360
+ export async function foreignCommits(dir, fromCommit, toRef = 'HEAD') {
361
+ const r = await git(dir, ['log', '--format=%h %ae %s', `${fromCommit}..${toRef}`]);
362
+ if (!r.ok || !r.stdout)
363
+ return [];
364
+ return r.stdout.split('\n').filter((line) => line.split(' ')[1] !== RUNNER_EMAIL);
365
+ }
324
366
  export async function commitsBetween(dir, fromCommit, toRef = 'HEAD') {
325
367
  const r = await git(dir, ['log', '--oneline', `${fromCommit}..${toRef}`]);
326
368
  return r.ok && r.stdout ? r.stdout.split('\n') : [];