copilot-operator 0.1.17 → 0.1.19

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 (56) hide show
  1. package/dist/src/api/operator.service.js +33 -2
  2. package/dist/src/cli.js +11 -29
  3. package/dist/src/config/version.js +30 -0
  4. package/dist/src/orchestrator/derivedChecks.js +19 -1
  5. package/dist/src/orchestrator/review.js +2 -2
  6. package/dist/src/orchestrator/taskRunner.js +130 -10
  7. package/dist/src/plan/brief.js +38 -4
  8. package/dist/src/plan/importPlan.js +42 -1
  9. package/dist/src/plan/schema.js +53 -0
  10. package/dist/src/plan/systemGuide.js +50 -2
  11. package/dist/src/protocol/replySchema.js +1 -1
  12. package/dist/src/protocol/reviewSchema.js +10 -5
  13. package/dist/src/session/exports.js +53 -13
  14. package/dist/src/session/model.js +20 -0
  15. package/dist/src/session/store.js +80 -0
  16. package/dist/src/vcs/artifacts.js +185 -0
  17. package/dist/src/vcs/inputs.js +449 -0
  18. package/dist/src/vcs/snapshot.js +174 -22
  19. package/dist/src/vcs/taskVcs.js +272 -37
  20. package/dist/web/404.html +2 -2
  21. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
  22. package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +1 -0
  23. package/dist/web/_next/static/chunks/app/appearance/page-a18019cdc395de3d.js +1 -0
  24. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
  25. package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +1 -0
  26. package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +1 -0
  27. package/dist/web/_next/static/chunks/app/system/page-5ae1f8054208e6eb.js +1 -0
  28. package/dist/web/appearance.html +2 -2
  29. package/dist/web/appearance.txt +25 -26
  30. package/dist/web/defaults.html +2 -2
  31. package/dist/web/defaults.txt +25 -26
  32. package/dist/web/history.html +2 -2
  33. package/dist/web/history.txt +25 -26
  34. package/dist/web/import.html +2 -2
  35. package/dist/web/import.txt +25 -26
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/index.txt +25 -26
  38. package/dist/web/level1.html +2 -2
  39. package/dist/web/level1.txt +25 -26
  40. package/dist/web/presets.html +2 -2
  41. package/dist/web/presets.txt +25 -26
  42. package/dist/web/sessions/view.html +2 -2
  43. package/dist/web/sessions/view.txt +25 -26
  44. package/dist/web/system.html +2 -2
  45. package/dist/web/system.txt +25 -26
  46. package/package.json +8 -3
  47. package/prompts/level1.md +547 -546
  48. package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +0 -1
  49. package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +0 -1
  50. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +0 -1
  51. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
  52. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +0 -1
  53. package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +0 -1
  54. package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +0 -1
  55. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_buildManifest.js +0 -0
  56. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_ssgManifest.js +0 -0
@@ -13,6 +13,8 @@
13
13
  * sending the user back to the chat; it is reported as a warning instead.
14
14
  */
15
15
  import { z } from 'zod';
16
+ import { namesEverything } from '../vcs/inputs.js';
17
+ import { coveredByArtifacts } from '../vcs/artifacts.js';
16
18
  export const PLAN_VERSION = 1;
17
19
  /**
18
20
  * Version control, exactly as a session stores it, and required on every session.
@@ -77,6 +79,35 @@ const VcsInput = z
77
79
  .optional(),
78
80
  })
79
81
  .optional(),
82
+ /**
83
+ * The operator's input files. See `UserInputs`. Accepts the spelling a chat model may write:
84
+ * `capture` can only be "baseline-commit", `allowUntracked`/`allowIgnored` only true (an input
85
+ * may be either), `readOnlyAfterCapture` is `readOnly`.
86
+ */
87
+ userInputs: z
88
+ .object({
89
+ enabled: z.boolean().optional(),
90
+ paths: z.array(z.string().trim()).default([]),
91
+ capture: z.literal('baseline-commit', { error: 'userInputs.capture can only be "baseline-commit": input files go into the commit the session starts from. Leave the field out.' }).optional(),
92
+ allowUntracked: z.literal(true, { error: 'userInputs.allowUntracked can only be true: an input file may be untracked. Leave the field out.' }).optional(),
93
+ allowIgnored: z.literal(true, { error: 'userInputs.allowIgnored can only be true: an input file may be ignored by git. Leave the field out.' }).optional(),
94
+ readOnly: z.boolean().optional(),
95
+ readOnlyAfterCapture: z.boolean().optional(),
96
+ requireApproval: z.boolean().optional(),
97
+ })
98
+ .optional(),
99
+ /**
100
+ * Evidence kept with the run and never committed. See `VersionControl.artifacts`. `commit` can
101
+ * only be false and `attachToRun`/`allowIgnored` only true: that is what an artifact is.
102
+ */
103
+ artifacts: z
104
+ .object({
105
+ paths: z.array(z.string().trim()).default([]),
106
+ allowIgnored: z.literal(true, { error: 'artifacts.allowIgnored can only be true. Leave the field out.' }).optional(),
107
+ attachToRun: z.literal(true, { error: 'artifacts.attachToRun can only be true: artifacts are always kept with the run. Leave the field out.' }).optional(),
108
+ commit: z.literal(false, { error: 'artifacts.commit can only be false: an artifact is never committed. Files that should be committed are the work, not artifacts.' }).optional(),
109
+ })
110
+ .optional(),
80
111
  }, {
81
112
  error: 'Every session needs a "vcs" object saying whether the runner does version control for it and, when it does, which git repository to work in. Ask the user both questions and write the answers here.',
82
113
  })
@@ -102,6 +133,23 @@ const VcsInput = z
102
133
  message: '"tracked-only-snapshot" takes tracked changes only, and includeUntracked true asks for new files too. Keep one: policy "snapshot", or leave includeUntracked out.',
103
134
  });
104
135
  }
136
+ for (const [i, p] of (vcs.userInputs?.paths ?? []).entries()) {
137
+ if (namesEverything(p)) {
138
+ ctx.addIssue({ code: 'custom', path: ['userInputs', 'paths', i], message: `"${p}" names the whole repository; an input pattern names the operator's files, such as "rules-engine/test-data/schemas/*.yaml".` });
139
+ }
140
+ }
141
+ if (vcs.userInputs && vcs.userInputs.enabled !== false && (vcs.userInputs.paths ?? []).length > 0 && !vcs.enabled) {
142
+ ctx.addIssue({ code: 'custom', path: ['userInputs'], message: 'userInputs needs version control on: the input files go into the commit the session starts from. Turn vcs.enabled on, or leave userInputs out.' });
143
+ }
144
+ const hidden = coveredByArtifacts(vcs.userInputs?.paths ?? [], vcs.artifacts?.paths ?? []);
145
+ if (hidden.length > 0) {
146
+ ctx.addIssue({
147
+ code: 'custom',
148
+ path: ['artifacts', 'paths'],
149
+ message: `An artifacts pattern covers the input files ${hidden.map((h) => `"${h}"`).join(', ')}: artifacts are kept out of git, so the inputs would be too. ` +
150
+ 'Name only the folders the evidence is written to (for example "rules-engine/test-results/**"), not a whole project folder.',
151
+ });
152
+ }
105
153
  if (vcs.enabled && !vcs.repoDir.trim()) {
106
154
  ctx.addIssue({
107
155
  code: 'custom',
@@ -259,6 +307,11 @@ const TaskInput = z.object({
259
307
  * chat told — which a sentence in the prompt cannot be. See `vcs/scope.ts`.
260
308
  */
261
309
  scope: z.array(z.string().trim().min(1, 'A scope entry needs a path or a pattern.')).default([]),
310
+ /**
311
+ * Files the task is to produce as evidence (reports, archives), repository-relative patterns.
312
+ * Kept with the run's record when the task creates or changes them. See `Task.outputs`.
313
+ */
314
+ outputs: z.array(z.string().trim().min(1, 'An outputs entry needs a path or a pattern.')).default([]),
262
315
  /**
263
316
  * What earlier attempts of this task were asked, written by the plan export for a task that has
264
317
  * been run more than once (`earlierPlans` in `session/exports.ts`). History, not instruction:
@@ -1213,8 +1213,8 @@ export const SYSTEM_GUIDE = [
1213
1213
  kind: 'note',
1214
1214
  en: 'Uncommitted changes',
1215
1215
  bg: 'Некомитнати промени',
1216
- doesEn: 'What the first task does when the repository has uncommitted changes: keep version control out (as until now), or take them — all, or tracked only — as a starting snapshot: one commit on a branch of its own that the session starts from. In a plan: "dirtyWorktree": { "policy": "snapshot" }. Not shown for "Carry on an existing branch".',
1217
- doesBg: 'Какво прави първата задача, когато хранилището има некомитнати промени: без контрол на версиите (както досега) или ги взема — всички или само tracked — като начална снимка: един комит на отделен клон, от който сесията тръгва. В план: "dirtyWorktree": { "policy": "snapshot" }. Не се показва при „Продължи съществуващ клон“.',
1216
+ doesEn: 'What the first task does when the repository has uncommitted changes: refuse to start until they are committed, or take them — all, or tracked only — as a starting snapshot: one commit on a branch of its own that the session starts from. In a plan: "dirtyWorktree": { "policy": "snapshot" }. Not shown for "Carry on an existing branch".',
1217
+ doesBg: 'Какво прави първата задача, когато хранилището има некомитнати промени: отказва старта, докато не са комитнати, или ги взема — всички или само tracked — като начална снимка: един комит на отделен клон, от който сесията тръгва. В план: "dirtyWorktree": { "policy": "snapshot" }. Не се показва при „Продължи съществуващ клон“.',
1218
1218
  },
1219
1219
  {
1220
1220
  key: 'vcs.snapshotTake',
@@ -1224,6 +1224,46 @@ export const SYSTEM_GUIDE = [
1224
1224
  doesEn: 'Under the list of uncommitted files, each set to "Include in the starting snapshot" or "Leave out of the run": commits the included ones on the snapshot branch; left-out files go to the repository\'s .git/info/exclude. Until it is pressed a run of the session is refused and nothing changes.',
1225
1225
  doesBg: 'Под списъка с некомитнати файлове, всеки с „Включи в началната снимка“ или „Остави извън изпълнението“: комитва включените на клона на снимката; оставените извън отиват в .git/info/exclude на хранилището. Докато не е натиснат, пускане на сесията се отказва и нищо не се променя.',
1226
1226
  },
1227
+ {
1228
+ key: 'vcs.inputs',
1229
+ kind: 'note',
1230
+ en: 'Input files',
1231
+ bg: 'Входни файлове',
1232
+ doesEn: 'The heading over the operator\'s input files and whether they are read-only.',
1233
+ doesBg: 'Заглавието над входните файлове на оператора и дали са само за четене.',
1234
+ },
1235
+ {
1236
+ key: 'vcs.inputsField',
1237
+ kind: 'field',
1238
+ en: 'Your input files, one pattern per line (repository-relative)',
1239
+ bg: 'Твоите входни файлове, по един шаблон на ред (спрямо хранилището)',
1240
+ doesEn: 'Files the operator provides — schemas, test data. They go into the commit every session of the run starts from (new or ignored ones in the starting snapshot, listed for approval); a later session whose start lacks them has them committed on top of it; their SHA-256 sums are recorded. In a plan: "userInputs": { "paths": [...] }.',
1241
+ doesBg: 'Файлове, които операторът дава — схеми, тестови данни. Влизат в комита, от който тръгва всяка сесия на пускането (новите или ignored — в началната снимка, със списък за одобрение); следваща сесия, чието начало ги няма, ги получава комитнати върху него; SHA-256 сумите им се записват. В план: "userInputs": { "paths": [...] }.',
1242
+ },
1243
+ {
1244
+ key: 'vcs.inputsReadOnly',
1245
+ kind: 'checkbox',
1246
+ en: 'Read-only while the tasks run',
1247
+ bg: 'Само за четене, докато задачите работят',
1248
+ doesEn: 'On by default. A changed, deleted or added input file is put back after every round and the chat told; one that cannot be put back fails the task.',
1249
+ doesBg: 'Включено по подразбиране. Променен, изтрит или добавен входен файл се връща след всеки рунд и чатът научава; такъв, който не може да се върне, проваля задачата.',
1250
+ },
1251
+ {
1252
+ key: 'vcs.artifacts',
1253
+ kind: 'note',
1254
+ en: 'Artifacts',
1255
+ bg: 'Artifacts',
1256
+ doesEn: 'The heading over the evidence files the work writes.',
1257
+ doesBg: 'Заглавието над файловете с доказателства, които работата пише.',
1258
+ },
1259
+ {
1260
+ key: 'vcs.artifactsField',
1261
+ kind: 'field',
1262
+ en: 'Evidence the work writes, one pattern per line (repository-relative)',
1263
+ bg: 'Доказателства, които работата пише, по един шаблон на ред (спрямо хранилището)',
1264
+ doesEn: 'Reports, ZIP archives, test results: never committed (kept out through .git/info/exclude, .gitignore untouched) and copied into each attempt\'s record with their sums. In a plan: "artifacts": { "paths": [...] }.',
1265
+ doesBg: 'Отчети, ZIP архиви, резултати от тестове: никога не се комитват (стоят извън git чрез .git/info/exclude, .gitignore не се пипа) и се копират към записа на всеки опит със сумите си. В план: "artifacts": { "paths": [...] }.',
1266
+ },
1227
1267
  {
1228
1268
  key: 'vcs.ready',
1229
1269
  kind: 'note',
@@ -3201,6 +3241,14 @@ export const SYSTEM_GUIDE = [
3201
3241
  en: 'This machine',
3202
3242
  bg: 'Тази машина',
3203
3243
  controls: [
3244
+ {
3245
+ key: 'sys.version',
3246
+ kind: 'note',
3247
+ en: 'copilot-operator version',
3248
+ bg: 'Версия на copilot-operator',
3249
+ doesEn: 'The first row: which version of the bot is running, as `cop --version` says. Name it when reporting a problem.',
3250
+ doesBg: 'Първият ред: коя версия на бота работи, както я казва `cop --version`. Кажи я, когато съобщаваш за проблем.',
3251
+ },
3204
3252
  {
3205
3253
  key: 'sys.signin',
3206
3254
  kind: 'note',
@@ -83,7 +83,7 @@ export const DeviationSchema = z.object({
83
83
  * claim to test, and to the record. The finding is named by the id the runner gave it.
84
84
  */
85
85
  export const DisputeSchema = z.object({
86
- /** The finding's id as it was given: `r1f2`. */
86
+ /** The finding's id as it was given: `r1f2`, or `a2r1f2` on a later attempt. */
87
87
  finding: z.string().trim().min(1),
88
88
  /** Why it is wrong. */
89
89
  why: z.string().trim().min(1),
@@ -166,13 +166,18 @@ export function isGrounded(basis, sources) {
166
166
  return sources.some((s) => groundingKey(s).includes(key));
167
167
  }
168
168
  /**
169
- * The name a finding is referred to by, once the runner has it: round and position.
169
+ * The name a finding is referred to by, once the runner has it: round and position, and from the
170
+ * task's second attempt on the attempt as well — `r1f2`, then `a2r1f2`.
170
171
  *
171
- * Given by the runner rather than the reviewer so that it is unique across rounds and cannot
172
- * be forgotten. It is what the implementer names in `disputed`, and what the record shows.
172
+ * Given by the runner rather than the reviewer so that it is unique and cannot be forgotten. It is
173
+ * what the implementer names in `disputed`, what a check given with the finding is named after, and
174
+ * what the record shows. Unique across the task's attempts, not only its rounds, because those checks
175
+ * are kept for every later attempt (`Task.reviewChecks`) and the rounds count from 1 on each: with
176
+ * round and position alone, attempt 2's first finding was `r1f1` beside attempt 1's kept `r1f1`, and
177
+ * a dispute of the one suspended the check of the other. The first attempt keeps the short form.
173
178
  */
174
- export function findingId(round, index) {
175
- return `r${round}f${index + 1}`;
179
+ export function findingId(round, index, attempt = 1) {
180
+ return `${attempt > 1 ? `a${attempt}` : ''}r${round}f${index + 1}`;
176
181
  }
177
182
  /** True when nothing the reviewer found is something the implementer could fix. */
178
183
  export function allAboutTheTask(findings) {
@@ -21,6 +21,8 @@
21
21
  import { existsSync } from 'node:fs';
22
22
  import { readFile, writeFile } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
+ import { TASK_FIELD_ON_RERUN } from './store.js';
25
+ import { botVersion } from '../config/version.js';
24
26
  import { withoutPersona } from '../plan/importPlan.js';
25
27
  const EXPECTED_HEADER = '### Expected result';
26
28
  const GOAL_HEADER = '## Goal of this session';
@@ -149,6 +151,8 @@ export function buildPlanExport(scope) {
149
151
  ...(s.vcs?.updateFromRemote === false ? { updateFromRemote: false } : {}),
150
152
  // Only when chosen: absent is reject, as it always was.
151
153
  ...(s.vcs?.dirtyWorktree && s.vcs.dirtyWorktree.policy !== 'reject' ? { dirtyWorktree: { ...s.vcs.dirtyWorktree } } : {}),
154
+ ...(s.vcs?.userInputs?.paths.length ? { userInputs: { ...s.vcs.userInputs } } : {}),
155
+ ...(s.vcs?.artifacts?.paths.length ? { artifacts: { ...s.vcs.artifacts } } : {}),
152
156
  },
153
157
  review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
154
158
  ...(s.projectDir ? { projectDir: s.projectDir } : {}),
@@ -168,6 +172,8 @@ export function buildPlanExport(scope) {
168
172
  task.readOnly = true;
169
173
  if (t.scope && t.scope.length > 0)
170
174
  task.scope = [...t.scope];
175
+ if (t.outputs && t.outputs.length > 0)
176
+ task.outputs = [...t.outputs];
171
177
  const earlier = earlierPlans(t, level2);
172
178
  if (earlier.length > 0)
173
179
  task.earlierAttempts = earlier;
@@ -231,27 +237,32 @@ export function taskAtAttempt(task, attempt) {
231
237
  const a = task.attempts?.[attempt - 1];
232
238
  if (!a)
233
239
  return null;
240
+ /*
241
+ * By the same table `rerunTask` follows: what was archived is read from the attempt, what was
242
+ * cleared is absent. Field by field, by hand, an earlier attempt read back with the latest one's
243
+ * handoff, counts, limit and fresh-chat mark.
244
+ */
245
+ const asItWas = {};
246
+ for (const [key, what] of Object.entries(TASK_FIELD_ON_RERUN)) {
247
+ if (what === 'archived')
248
+ asItWas[key] = a[key];
249
+ if (what === 'cleared')
250
+ asItWas[key] = undefined;
251
+ }
234
252
  return {
235
253
  ...task,
254
+ ...asItWas,
236
255
  attempt,
237
256
  status: a.status,
238
- runId: a.runId,
239
- runGroup: a.runGroup,
240
- startedAt: a.startedAt,
241
- finishedAt: a.finishedAt,
242
257
  iterations: a.iterations,
243
- summary: a.summary,
244
- reason: a.reason,
245
- deviations: a.deviations,
246
- disputes: a.disputes,
258
+ // The task's own text and definition as they were when that attempt ran, where it was kept.
247
259
  title: a.title,
248
260
  prompt: a.prompt,
249
261
  level2: a.level2,
250
262
  checks: a.checks ?? task.checks,
251
263
  vcsPlan: a.vcsPlan ?? task.vcsPlan,
252
- checkResults: a.checkResults,
253
- review: a.review,
254
- vcs: a.vcs,
264
+ scope: a.scope ?? task.scope,
265
+ readOnly: a.readOnly ?? task.readOnly,
255
266
  attempts: task.attempts?.slice(0, attempt - 1),
256
267
  continuing: undefined,
257
268
  buildsOn: undefined,
@@ -436,7 +447,25 @@ async function domainTask(session, task, runsDir) {
436
447
  const started = task.startedAt ? Date.parse(task.startedAt) : NaN;
437
448
  const finished = task.finishedAt ? Date.parse(task.finishedAt) : NaN;
438
449
  const out = {
439
- session: { id: session.id, name: session.name },
450
+ /*
451
+ * Where the session started — the baseline commit, a snapshot of the operator's changes, the
452
+ * input files with their SHA-256 sums — so a reader can check the work against its inputs.
453
+ */
454
+ session: {
455
+ id: session.id,
456
+ name: session.name,
457
+ ...(session.vcsStart
458
+ ? {
459
+ start: {
460
+ kind: session.vcsStart.kind,
461
+ commit: session.vcsStart.commit,
462
+ branch: session.vcsStart.branch,
463
+ ...(session.vcsStart.snapshot ? { snapshot: session.vcsStart.snapshot } : {}),
464
+ ...(session.vcsStart.inputs ? { inputs: session.vcsStart.inputs } : {}),
465
+ },
466
+ }
467
+ : {}),
468
+ },
440
469
  task: {
441
470
  id: task.id,
442
471
  title: task.title,
@@ -444,6 +473,8 @@ async function domainTask(session, task, runsDir) {
444
473
  readOnly: task.readOnly ?? false,
445
474
  scope: task.scope ?? [],
446
475
  scopeReverted: task.scopeReverted ?? [],
476
+ inputsRestored: task.inputsRestored ?? [],
477
+ artifactsKept: task.artifactsKept ?? [],
447
478
  handoff: task.handoff ?? null,
448
479
  stopCode: task.stopCode ?? null,
449
480
  prompt: p.prompt,
@@ -526,7 +557,7 @@ async function domainTask(session, task, runsDir) {
526
557
  whatTheChatTried: aRounds,
527
558
  whatWasActuallyDone: { repository: a.vcs, deviations: a.deviations, disputes: a.disputes },
528
559
  whyItFailed: aFailed
529
- ? { status: a.status, reason: a.reason, failingChecks: (a.checkResults ?? []).filter((c) => !c.passed), lastRound: aRounds.at(-1) }
560
+ ? { status: a.status, reason: a.reason, failingChecks: (a.checkResults ?? []).filter((c) => !c.passed), lastRound: aRounds.at(-1), leftovers: a.leftovers }
530
561
  : undefined,
531
562
  });
532
563
  }
@@ -550,6 +581,8 @@ export async function buildDomainExport(scope, runsDir) {
550
581
  }
551
582
  return {
552
583
  exportedAt: new Date().toISOString(),
584
+ // Which copilot-operator made this, so a report from another machine says it without being asked.
585
+ botVersion: botVersion(),
553
586
  about: `copilot-operator, the work: ${scope.label}. What each task asked, what the chat tried, what was done to the repository, and why a task did not end done. Nothing about the runner itself; that is the bot export.`,
554
587
  counts,
555
588
  tasks,
@@ -607,6 +640,9 @@ async function botTask(session, task, runsDir) {
607
640
  steps: stepsOf(aEvents).map((s) => ({ iteration: s.iteration, id: s.id, command: s.description, outcome: s.outcome, exitCode: s.exitCode, durationMs: s.durationMs, refused: s.refused })),
608
641
  problems: aEvents.filter((e) => e.level === 'error' || e.level === 'warn').map(trimmed),
609
642
  events: aEvents.map(trimmed),
643
+ // That attempt's own: what it left running and the machine it ran on.
644
+ processes: { leftovers: a.leftovers },
645
+ environment: a.environment,
610
646
  };
611
647
  })),
612
648
  };
@@ -618,6 +654,8 @@ export async function buildBotExport(scope, runsDir, machine) {
618
654
  tasks.push(await botTask(session, task, runsDir));
619
655
  return {
620
656
  exportedAt: new Date().toISOString(),
657
+ // Which copilot-operator made this, so a report from another machine says it without being asked.
658
+ botVersion: botVersion(),
621
659
  about: `copilot-operator, the runner: ${scope.label}. The environment, every transcript event, every step with its exit code, the transport's retries, what was reaped, what the review machinery did. Read the domain export for what the task was about.`,
622
660
  machine,
623
661
  tasks,
@@ -649,6 +687,8 @@ export async function buildBundleExport(scope, runsDir, machine) {
649
687
  const pairs = tasksOf(scope);
650
688
  return {
651
689
  exportedAt: new Date().toISOString(),
690
+ // Which copilot-operator made this, so a report from another machine says it without being asked.
691
+ botVersion: botVersion(),
652
692
  about: `copilot-operator, all three views of ${pairs.length} chosen task(s): ${scope.label}. ` +
653
693
  '`plan` is what was asked, in the format that imports again; `work` is what happened to it; ' +
654
694
  '`runner` is what the machine did. Read `work` first, then `runner` when the machine is the suspect.',
@@ -21,6 +21,26 @@ export function tidyVcsPlan(plan) {
21
21
  export function isContinuable(t) {
22
22
  return t.status === 'limit-reached' || t.status === 'aborted' || (!!t.limit && (t.status === 'failed' || t.status === 'blocked'));
23
23
  }
24
+ /**
25
+ * How many times the runner ran the task again in a fresh conversation, on its own, in the run that
26
+ * produced its latest attempt (`limits.retryBlockedInFreshChat`): the attempts at the end of its
27
+ * history that it marked `freshRetry`, counted back to the one the operator started. A row that says
28
+ * "retried in a fresh chat, then done" means the chat was the cause; "still blocked after 2" means
29
+ * it was not.
30
+ *
31
+ * Derived, never stored. It was a counter on the task that the runner added to and nothing reset,
32
+ * so every Run again or Continue of a task that blocked added the run's retries to the last run's:
33
+ * the register said 2, then 4, then 6, while each run had retried twice.
34
+ */
35
+ export function freshRetriesOfLatestRun(task) {
36
+ if (!task.freshRetry)
37
+ return 0;
38
+ let n = 1;
39
+ const before = task.attempts ?? [];
40
+ for (let i = before.length - 1; i >= 0 && before[i]?.freshRetry; i -= 1)
41
+ n += 1;
42
+ return n;
43
+ }
24
44
  export function newId(prefix = '') {
25
45
  const now = new Date();
26
46
  const p = (n) => String(n).padStart(2, '0');
@@ -50,6 +50,67 @@ export function bySessionList(a, b) {
50
50
  return byTime;
51
51
  return b.id.localeCompare(a.id);
52
52
  }
53
+ /**
54
+ * What `rerunTask` does with every field of a task when a new attempt begins — Run again, Continue,
55
+ * a new prompt, the runner's own fresh-chat retry.
56
+ *
57
+ * archived belongs to the attempt that ended: copied onto its `TaskAttempt`, cleared on the task
58
+ * cleared belongs to that attempt and is not kept on the record: cleared
59
+ * reset given the new attempt's starting value (status queued, iterations 0, attempt + 1, …)
60
+ * kept the task's own, across attempts: its definition, or something every attempt inherits
61
+ *
62
+ * A record over every key of `Task`, so a field added to the task without a decision here does not
63
+ * compile. Fields that were left out of `rerunTask` by default were counted on by the next attempt:
64
+ * the fresh-chat retry count said 2, then 4, then 6 while each run had retried twice, and what an
65
+ * attempt left running was listed under every attempt after it. `npm run check:attempts` holds
66
+ * `rerunTask` to this table.
67
+ */
68
+ export const TASK_FIELD_ON_RERUN = {
69
+ id: 'kept',
70
+ title: 'kept',
71
+ level2: 'kept',
72
+ prompt: 'kept',
73
+ createdAt: 'kept',
74
+ checks: 'kept',
75
+ vcsPlan: 'kept',
76
+ reviewEnabled: 'kept',
77
+ readOnly: 'kept',
78
+ scope: 'kept',
79
+ outputs: 'kept',
80
+ // What one review noticed is arithmetic for every attempt after it.
81
+ reviewChecks: 'kept',
82
+ attempts: 'kept',
83
+ status: 'reset',
84
+ iterations: 'reset',
85
+ attempt: 'reset',
86
+ continuing: 'reset',
87
+ buildsOn: 'reset',
88
+ runId: 'archived',
89
+ runGroup: 'archived',
90
+ startedAt: 'archived',
91
+ finishedAt: 'archived',
92
+ summary: 'archived',
93
+ reason: 'archived',
94
+ deviations: 'archived',
95
+ disputes: 'archived',
96
+ checkResults: 'archived',
97
+ review: 'archived',
98
+ stats: 'archived',
99
+ freshRetry: 'archived',
100
+ stopCode: 'archived',
101
+ limit: 'archived',
102
+ scopeReverted: 'archived',
103
+ inputsRestored: 'archived',
104
+ artifactsKept: 'archived',
105
+ vcs: 'archived',
106
+ leftovers: 'archived',
107
+ environment: 'archived',
108
+ handoff: 'archived',
109
+ interruption: 'cleared',
110
+ finalReply: 'cleared',
111
+ firstMessage: 'cleared',
112
+ logFile: 'cleared',
113
+ };
53
114
  export const DEFAULT_VCS = {
54
115
  enabled: true,
55
116
  repoDir: '',
@@ -408,6 +469,7 @@ export class SessionStore {
408
469
  ...(input.readOnly === true ? { readOnly: true } : {}),
409
470
  // Stored only when it limits something: absent means anywhere, as before scopes existed.
410
471
  ...((input.scope ?? []).filter((p) => p.trim()).length > 0 ? { scope: (input.scope ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
472
+ ...((input.outputs ?? []).filter((p) => p.trim()).length > 0 ? { outputs: (input.outputs ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
411
473
  };
412
474
  await this.updateSession(sessionId, (s) => {
413
475
  s.tasks.push(task);
@@ -476,6 +538,9 @@ export class SessionStore {
476
538
  ...(current.interruption ? { interruption: current.interruption } : {}),
477
539
  });
478
540
  }
541
+ /**
542
+ * What a new attempt does with each field of a task — see `TASK_FIELD_ON_RERUN`.
543
+ */
479
544
  async rerunTask(sessionId, taskId, patch = {},
480
545
  /** Present when the new attempt carries on from the last one (see `continueTask`). */
481
546
  continuing,
@@ -495,6 +560,8 @@ export class SessionStore {
495
560
  {
496
561
  runId: t.runId,
497
562
  runGroup: t.runGroup,
563
+ leftovers: t.leftovers,
564
+ environment: t.environment,
498
565
  status: t.status,
499
566
  startedAt: t.startedAt,
500
567
  finishedAt: t.finishedAt,
@@ -518,7 +585,11 @@ export class SessionStore {
518
585
  stopCode: t.stopCode,
519
586
  limit: t.limit,
520
587
  scope: t.scope,
588
+ readOnly: t.readOnly,
521
589
  scopeReverted: t.scopeReverted,
590
+ handoff: t.handoff,
591
+ inputsRestored: t.inputsRestored,
592
+ artifactsKept: t.artifactsKept,
522
593
  vcs: t.vcs,
523
594
  },
524
595
  ];
@@ -553,9 +624,15 @@ export class SessionStore {
553
624
  t.stats = undefined;
554
625
  t.handoff = undefined;
555
626
  t.scopeReverted = undefined;
627
+ t.inputsRestored = undefined;
628
+ t.artifactsKept = undefined;
556
629
  t.freshRetry = undefined;
557
630
  t.stopCode = undefined;
558
631
  t.limit = undefined;
632
+ // What this attempt left running and the machine it ran on are this attempt's, as the rest:
633
+ // left in place, the next attempt's list was added to this one's and read as its own.
634
+ t.leftovers = undefined;
635
+ t.environment = undefined;
559
636
  // The branch of the finished attempt stays in the repository and stays on the record
560
637
  // above; the next attempt gets its own, cut from the same commit this one started at.
561
638
  t.vcs = undefined;
@@ -618,6 +695,9 @@ export class SessionStore {
618
695
  // Sessions written before the queue could be told what to do on a failure behave the
619
696
  // way they always did: the chain stops.
620
697
  s.onFailure ??= 'stop';
698
+ // A counter that added up across runs, written before it was derived (`freshRetriesOfLatestRun`).
699
+ for (const t of s.tasks ?? [])
700
+ delete t.autoRetries;
621
701
  return s;
622
702
  }
623
703
  catch {