copilot-operator 0.1.23 → 0.1.25

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 (66) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +326 -33
  3. package/dist/src/exec/confinement.js +85 -7
  4. package/dist/src/exec/programs.js +12 -3
  5. package/dist/src/exec/reportFile.js +5 -3
  6. package/dist/src/exec/shells.js +13 -2
  7. package/dist/src/log/runLog.js +6 -1
  8. package/dist/src/orchestrator/contract.js +29 -7
  9. package/dist/src/orchestrator/review.js +16 -3
  10. package/dist/src/orchestrator/taskRunner.js +391 -54
  11. package/dist/src/plan/schema.js +11 -1
  12. package/dist/src/plan/systemGuide.js +12 -4
  13. package/dist/src/protocol/parser.js +33 -5
  14. package/dist/src/protocol/reporter.js +2 -0
  15. package/dist/src/session/compose.js +18 -9
  16. package/dist/src/session/exportRecord.js +12 -3
  17. package/dist/src/session/exports.js +46 -9
  18. package/dist/src/session/handoff.js +5 -2
  19. package/dist/src/session/interruption.js +3 -1
  20. package/dist/src/session/runLog.js +42 -1
  21. package/dist/src/session/runName.js +3 -1
  22. package/dist/src/session/store.js +7 -2
  23. package/dist/src/transport/copilotTransport.js +89 -62
  24. package/dist/src/transport/modelMatch.js +37 -0
  25. package/dist/src/vcs/artifacts.js +20 -7
  26. package/dist/src/vcs/commitHygiene.js +7 -2
  27. package/dist/src/vcs/contentIntegrity.js +65 -6
  28. package/dist/src/vcs/git.js +42 -7
  29. package/dist/src/vcs/prepareFromRemote.js +21 -14
  30. package/dist/src/vcs/remoteAuth.js +31 -0
  31. package/dist/src/vcs/runPreflight.js +100 -31
  32. package/dist/src/vcs/scope.js +18 -2
  33. package/dist/src/vcs/snapshot.js +61 -16
  34. package/dist/src/vcs/syncCommand.js +3 -11
  35. package/dist/src/vcs/taskVcs.js +173 -24
  36. package/dist/web/404.html +2 -2
  37. package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +1 -0
  38. package/dist/web/_next/static/chunks/867-52e371cb1ec49089.js +1 -0
  39. package/dist/web/_next/static/chunks/app/defaults/{page-0e778dafc69afbd3.js → page-62c8fbe84baa436a.js} +1 -1
  40. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +1 -0
  41. package/dist/web/_next/static/chunks/app/sessions/view/{page-9b66b729748595bb.js → page-2d3bbc68b27fed2c.js} +1 -1
  42. package/dist/web/appearance.html +2 -2
  43. package/dist/web/appearance.txt +10 -10
  44. package/dist/web/defaults.html +2 -2
  45. package/dist/web/defaults.txt +10 -10
  46. package/dist/web/history.html +2 -2
  47. package/dist/web/history.txt +10 -10
  48. package/dist/web/import.html +2 -2
  49. package/dist/web/import.txt +10 -10
  50. package/dist/web/index.html +2 -2
  51. package/dist/web/index.txt +10 -10
  52. package/dist/web/level1.html +2 -2
  53. package/dist/web/level1.txt +10 -10
  54. package/dist/web/presets.html +2 -2
  55. package/dist/web/presets.txt +10 -10
  56. package/dist/web/sessions/view.html +2 -2
  57. package/dist/web/sessions/view.txt +10 -10
  58. package/dist/web/system.html +2 -2
  59. package/dist/web/system.txt +10 -10
  60. package/package.json +3 -2
  61. package/prompts/level1.md +10 -3
  62. package/dist/web/_next/static/chunks/549-df7a4876917615da.js +0 -1
  63. package/dist/web/_next/static/chunks/867-a67ea8ba3f9b24aa.js +0 -1
  64. package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
  65. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_buildManifest.js +0 -0
  66. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_ssgManifest.js +0 -0
@@ -574,7 +574,7 @@ function readPlan(text) {
574
574
  return {
575
575
  ok: true,
576
576
  plan: parsed.data,
577
- warnings: [...warnings, ...duplicateNameWarnings(parsed.data), ...indexCheckWarnings(parsed.data)],
577
+ warnings: [...warnings, ...duplicateNameWarnings(parsed.data), ...indexCheckWarnings(parsed.data), ...commitsOffWarnings(parsed.data)],
578
578
  summary: summarise(parsed.data),
579
579
  };
580
580
  }
@@ -588,6 +588,16 @@ function readPlan(text) {
588
588
  * that was meant; the negative form (`output-omits` on `ls-files`, "node_modules is not
589
589
  * tracked") is fine and stays silent.
590
590
  */
591
+ /**
592
+ * A branch per task with commits off isolates only the first task: nothing is committed, so the next
593
+ * task finds the first one's files in the tree and runs on top of them without a branch of its own
594
+ * (live run 2026-10-03). Said at import, where it can still be changed.
595
+ */
596
+ function commitsOffWarnings(plan) {
597
+ return plan.sessions.flatMap((s, i) => s.vcs?.enabled !== false && s.vcs?.commitOnFinish === false && (s.vcs?.branchMode ?? 'per-task') === 'per-task' && s.tasks.length > 1
598
+ ? [`sessions[${i}] ("${s.name}"): a branch per task with commits off gives only the first task a branch of its own — the others run on top of its uncommitted files. Use "per-session", or let the runner commit.`]
599
+ : []);
600
+ }
591
601
  function indexCheckWarnings(plan) {
592
602
  const out = [];
593
603
  plan.sessions.forEach((s, i) => {
@@ -717,6 +717,14 @@ export const SYSTEM_GUIDE = [
717
717
  doesEn: 'The branch the plan asked the sessions to start from, and its tip.',
718
718
  doesBg: 'Клонът, от който планът иска сесиите да тръгнат, и върхът му.',
719
719
  },
720
+ {
721
+ key: 'runvcs.workBranch',
722
+ kind: 'note',
723
+ en: 'Branch the run works on',
724
+ bg: 'Клон, на който ще се работи',
725
+ doesEn: 'Shown when it is not the branch the repository is on: the session’s own branch (after a Restore, the restore branch) or the existing branch it carries on, and its tip.',
726
+ doesBg: 'Показва се, когато не е клонът, на който е хранилището: собственият клон на сесията (след Restore — клонът от възстановяването) или съществуващият клон, който продължава, и върхът му.',
727
+ },
720
728
  {
721
729
  key: 'runvcs.sessions',
722
730
  kind: 'note',
@@ -746,8 +754,8 @@ export const SYSTEM_GUIDE = [
746
754
  kind: 'button',
747
755
  en: 'Create starting snapshot on {branch}',
748
756
  bg: 'Създай начална снимка върху {branch}',
749
- doesEn: 'Commits the approved input files on top of the base branch, on a new cop/baseline/ branch, without switching branches or touching the checkout; every session of the run starting from that branch starts from it, chained ones carry it on. Recommended when the plan names that branch. Not offered while other files are uncommitted.',
750
- doesBg: 'Комитва одобрените входни файлове върху базовия клон, на нов клон cop/baseline/, без да сменя клона и без да пипа работното копие; всяка сесия от пускането, тръгваща от този клон, тръгва от снимката, а свързаните във верига я продължават. Препоръчано, когато планът посочва този клон. Не се предлага, докато има други некомитнати файлове.',
757
+ doesEn: 'Commits the approved input files on top of the base branch, on a new cop/baseline/ branch, without switching branches; when the session updates from the remote, the base branch is first fast-forwarded to it, which moves the checkout too if that branch is checked out. Every session of the run starting from that branch starts from it, chained ones carry it on. Recommended when the plan names that branch. Not offered while other files are uncommitted.',
758
+ doesBg: 'Комитва одобрените входни файлове върху базовия клон, на нов клон cop/baseline/, без да сменя клона; когато сесията се обновява от remote, базовият клон първо се придвижва напред до него, което мести и работното копие, ако този клон е изтеглен. Всяка сесия от пускането, тръгваща от този клон, тръгва от снимката, а свързаните във верига я продължават. Препоръчано, когато планът посочва този клон. Не се предлага, докато има други некомитнати файлове.',
751
759
  },
752
760
  {
753
761
  key: 'runvcs.snapshotHere',
@@ -762,8 +770,8 @@ export const SYSTEM_GUIDE = [
762
770
  kind: 'button',
763
771
  en: 'Use the current branch instead of {branch}',
764
772
  bg: 'Използвай текущия branch вместо {branch}',
765
- doesEn: 'The sessions start from the branch the repository is on instead of the base branch: their "Start from" becomes "From wherever the repository is". Nothing is committed.',
766
- doesBg: 'Сесиите тръгват от клона, на който е хранилището, вместо от базовия: „Откъде тръгва“ им става „Откъдето е хранилището“. Нищо не се комитва.',
773
+ doesEn: 'The sessions start from the branch the repository is on instead of the base branch: that branch becomes their base branch, so each starts from it even after HEAD has moved. Nothing is committed.',
774
+ doesBg: 'Сесиите тръгват от клона, на който е хранилището, вместо от базовия: той им става базов клон, така че всяка тръгва от него, дори HEAD да се е преместил. Нищо не се комитва.',
767
775
  },
768
776
  {
769
777
  key: 'runvcs.allowSnapshot',
@@ -54,6 +54,32 @@ function findJsonCandidates(markdown) {
54
54
  }
55
55
  return out;
56
56
  }
57
+ /**
58
+ * The stop word taken out of a text wherever it stands as a word of its own, and the spacing tidied
59
+ * only where it stood: a text without it comes back untouched, and a command such as "Get-Content .\src"
60
+ * keeps its space (live run 2026-10-04).
61
+ */
62
+ export function withoutMarker(text, marker) {
63
+ if (text === undefined || !marker)
64
+ return text;
65
+ const escaped = marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
66
+ const edge = String.raw `\s.,;:!?"'«»„“”()\[\]-`;
67
+ const word = new RegExp(`(^|[${edge}])${escaped}(?=$|[${edge}])`, 'gu');
68
+ if (!word.test(text))
69
+ return text;
70
+ word.lastIndex = 0;
71
+ const HOLE = '\u0000';
72
+ const out = text
73
+ .replace(word, `$1${HOLE}`)
74
+ .replace(/\([ \t]*\u0000[ \t]*\)|\[[ \t]*\u0000[ \t]*\]/g, HOLE)
75
+ .replace(/([.!?])[ \t]*\u0000[ \t]*\.+(?=\s|$)/g, '$1')
76
+ .replace(/[ \t]*\u0000[ \t]*([.,;:!?])(?=\s|$)/g, '$1')
77
+ .replace(/[ \t]+\u0000[ \t]+/g, ' ')
78
+ .replace(/\u0000/g, '')
79
+ .trim();
80
+ // Nothing but the stop word: kept as it was rather than leaving a done reply with no summary.
81
+ return out === '' ? text : out;
82
+ }
57
83
  /** Removes Copilot's 【n-hash】 citation markers and the whitespace they leave behind. */
58
84
  export function stripCitations(text) {
59
85
  if (text === undefined)
@@ -164,15 +190,17 @@ export function parseReply(markdown, opts) {
164
190
  continue;
165
191
  }
166
192
  const reply = result.data;
167
- // Copilot appends citation markers such as 【1-8313d0】 to prose it grounded in a file.
168
- // They mean nothing outside the chat and would otherwise end up in the UI and the log.
169
- reply.notes = stripCitations(reply.notes);
170
- reply.summary = stripCitations(reply.summary);
171
- const steps = reply.steps.map((s) => ({ ...s, shell: s.shell ?? opts.defaultShell }));
172
193
  // A blank stop word is no stop word. The setting is free text, and every reply contains the
173
194
  // empty string and nearly every one a space, so searching for either would read each
174
195
  // `continue` that carries a summary as done and close the task after one round.
175
196
  const marker = opts.stopMarker.trim();
197
+ // Copilot appends citation markers such as 【1-8313d0】 to prose it grounded in a file.
198
+ // They mean nothing outside the chat and would otherwise end up in the UI and the log.
199
+ // The stop word too, which the contract asks for "somewhere in the reply": it is a signal to the
200
+ // runner, and written into the summary it ended up in commit messages and exports (live run 2026-10-03).
201
+ reply.notes = withoutMarker(stripCitations(reply.notes), marker);
202
+ reply.summary = withoutMarker(stripCitations(reply.summary), marker);
203
+ const steps = reply.steps.map((s) => ({ ...s, shell: s.shell ?? opts.defaultShell }));
176
204
  const markerHit = marker !== '' && markdown.includes(marker);
177
205
  const hasSummary = (reply.summary ?? '').trim().length > 0;
178
206
  // The stop word alone is not enough to end a task any more: the summary is the
@@ -25,6 +25,8 @@ function outcomeSummary(r) {
25
25
  // not here — and it never reached a process, so it has no exit code to report. Said as a
26
26
  // refusal, apart from a step that ran and exited non-zero: the move after one is to write
27
27
  // the step differently, after the other it is to read the output and fix the work.
28
+ if (r.skippedAfter !== undefined)
29
+ return `step ${r.id} was not run, because step ${r.skippedAfter} was refused; send it again after that one is replaced`;
28
30
  return `step ${r.id} was refused by the runner and never ran (no exit code); the reason is under it`;
29
31
  case 'aborted':
30
32
  // A person stopped or skipped it — a reason to carry on without it, not to rewrite it.
@@ -18,14 +18,22 @@ function runnerBlock(input) {
18
18
  * What a read-only task is told. The rule is enforced by the runner from the working tree,
19
19
  * so the note is a warning about a fact, not a request.
20
20
  */
21
- export const READ_ONLY_NOTE = [
22
- '## Read-only task',
23
- '',
24
- 'This task must not change any file: it reads, runs and reports. The runner fails it if the',
25
- 'working tree has changed when it ends, whatever the summary says, and commits the change on',
26
- "the task's branch so it is not lost. If a review finding asks you to change something, that",
27
- 'is a finding about the task, not about the work: dispute it rather than comply.',
28
- ].join('\n');
21
+ export function readOnlyNote(commits, artifacts = []) {
22
+ return [
23
+ '## Read-only task',
24
+ '',
25
+ 'This task must not change any file: it reads, runs and reports. The runner fails it if it',
26
+ 'changed the working tree, whatever the summary says' +
27
+ (commits ? ", and commits the change on the task's branch so it is not lost." : '. Nothing is committed in this session, so a change would stay in the working tree.'),
28
+ 'Helper scripts may go in `.cop-tmp`, which is not part of the project.' +
29
+ (artifacts.length > 0 ? ` A report or other evidence goes under ${artifacts.join(', ')}: kept with the run, never committed, and the one place this task may write.` : ''),
30
+ "This rule wins over any instruction above — the project's or the persona's — to change code. If a review",
31
+ 'finding asks you to change something, that is a finding about the task, not about the work: dispute it',
32
+ 'rather than comply.',
33
+ ].join('\n');
34
+ }
35
+ /** The note for a session that commits: see `readOnlyNote`. */
36
+ export const READ_ONLY_NOTE = readOnlyNote(true);
29
37
  function level2Block(level2) {
30
38
  const body = level2.trim();
31
39
  return body.length > 0
@@ -51,7 +59,8 @@ function whyItStopped(c) {
51
59
  `changed is committed on this branch.${where}`.trimEnd());
52
60
  }
53
61
  if (c.how === 'stopped') {
54
- return 'It was stopped by the operator before it finished — not because anything failed. The files are as you left them.';
62
+ const where = c.interruption ? `\n\n${describeInterruption(c.interruption).replace(/when the runner stopped/g, 'when it was stopped')}\n\n` : ' ';
63
+ return `It was stopped by the operator before it finished — not because anything failed. The files are as you left them.${where}`.trimEnd();
55
64
  }
56
65
  const why = c.stoppedBecause ? ` (${c.stoppedBecause})` : '';
57
66
  // Out of rounds of fixing: something did say no, and the chat needs to hear it was not waved through.
@@ -78,10 +78,11 @@ async function conversationOf(task, runsDir) {
78
78
  * list the runner read back from the repository after committing, so the count is what is
79
79
  * actually on that branch since it was cut, not what anybody assumed.
80
80
  */
81
- function versionControlLines(task, session) {
81
+ export function versionControlLines(task, session) {
82
82
  const vcs = task.vcs;
83
+ const loose = task.treeChanged?.length ? [`changed, not committed: ${task.treeChanged.length} file(s) — ${task.treeChanged.slice(0, 10).join(', ')}${task.treeChanged.length > 10 ? ', …' : ''}`] : [];
83
84
  if (!session.vcs?.enabled)
84
- return ['version control : off for this session'];
85
+ return ['version control : off for this session', ...loose];
85
86
  if (!vcs || (!vcs.branch && !vcs.problem))
86
87
  return ['version control : nothing was recorded for this task'];
87
88
  if (!vcs.branch)
@@ -94,7 +95,13 @@ function versionControlLines(task, session) {
94
95
  `branch : ${vcs.branch}`,
95
96
  `started at : ${vcs.baseCommit ? vcs.baseCommit.slice(0, 8) : '—'} (the commit this task branched from)`,
96
97
  ...(before ? [`before commit: ${before.changed.length} path(s) changed or new in the working tree${before.changed.length ? ` — ${before.changed.slice(0, 10).join(', ')}` : ''}`] : []),
97
- `commit : ${vcs.commit ? vcs.commit.slice(0, 8) : vcs.problem ? 'none — see the note below' : 'none — the task changed no files'}`,
98
+ `commit : ${vcs.commit
99
+ ? vcs.commit.slice(0, 8)
100
+ : vcs.problem
101
+ ? 'none — see the note below'
102
+ : session.vcs?.commitOnFinish === false
103
+ ? `none — commits are off for this session${after && !after.clean ? ', so the changes stay in the working tree' : ''}`
104
+ : 'none — the task changed no files'}`,
98
105
  ...(after
99
106
  ? [`after commit: on ${after.branch ?? '?'} at ${after.head ? after.head.slice(0, 8) : '?'}, ${after.clean ? 'working tree clean' : `still uncommitted: ${after.changed.slice(0, 10).join(', ')}`}`]
100
107
  : []),
@@ -102,6 +109,8 @@ function versionControlLines(task, session) {
102
109
  ];
103
110
  for (const line of commits)
104
111
  lines.push(` ${line}`);
112
+ if (!vcs.commit)
113
+ lines.push(...loose);
105
114
  const files = vcs.files ?? [];
106
115
  if (files.length > 0) {
107
116
  lines.push(`files : ${files.length} changed by this task's commit`);
@@ -21,7 +21,7 @@
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 { browserAfterPreflight, readRunLog } from './runLog.js';
24
+ import { browserAfterPreflight, readRunLog, readSessionLog, refusedRunsOf } from './runLog.js';
25
25
  import { TASK_FIELD_ON_RERUN } from './store.js';
26
26
  import { botVersion } from '../config/version.js';
27
27
  import { withoutPersona } from '../plan/importPlan.js';
@@ -446,7 +446,7 @@ async function domainTask(session, task, runsDir) {
446
446
  iteration: e.iteration,
447
447
  status: e.status,
448
448
  notes: typeof e.notes === 'string' ? clip(e.notes, 1000) : undefined,
449
- steps: steps.filter((s) => s.iteration === e.iteration).map((s) => ({ id: s.id, ran: s.description, outcome: s.refused ? `refused: ${s.refused}` : s.outcome, exitCode: s.exitCode })),
449
+ steps: steps.filter((s) => s.iteration === e.iteration).map((s) => ({ id: s.id, ran: s.description, outcome: s.refused ? (/^refused\b/i.test(s.refused) ? s.refused : `refused: ${s.refused}`) : s.outcome, exitCode: s.exitCode })),
450
450
  }));
451
451
  const p = splitPrompt(task.prompt);
452
452
  const failed = task.status !== 'done' && task.status !== 'queued' && task.status !== 'running' && task.status !== 'waiting-approval';
@@ -493,6 +493,10 @@ async function domainTask(session, task, runsDir) {
493
493
  kind: session.vcsStart.kind,
494
494
  commit: session.vcsStart.commit,
495
495
  branch: session.vcsStart.branch,
496
+ // What bringing the branch up to its remote did, why it started where it did, whose work it carries on.
497
+ ...(session.vcsStart.update ? { update: session.vcsStart.update } : {}),
498
+ ...(session.vcsStart.note ? { note: session.vcsStart.note } : {}),
499
+ ...(session.vcsStart.fromSession ? { fromSession: session.vcsStart.fromSession } : {}),
496
500
  ...(session.vcsStart.snapshot ? { snapshot: session.vcsStart.snapshot } : {}),
497
501
  ...(session.vcsStart.inputs ? { inputs: session.vcsStart.inputs } : {}),
498
502
  },
@@ -574,7 +578,7 @@ async function domainTask(session, task, runsDir) {
574
578
  iteration: e.iteration,
575
579
  status: e.status,
576
580
  notes: typeof e.notes === 'string' ? clip(e.notes, 1000) : undefined,
577
- steps: aSteps.filter((s) => s.iteration === e.iteration).map((s) => ({ id: s.id, ran: s.description, outcome: s.refused ? `refused: ${s.refused}` : s.outcome, exitCode: s.exitCode })),
581
+ steps: aSteps.filter((s) => s.iteration === e.iteration).map((s) => ({ id: s.id, ran: s.description, outcome: s.refused ? (/^refused\b/i.test(s.refused) ? s.refused : `refused: ${s.refused}`) : s.outcome, exitCode: s.exitCode })),
578
582
  }));
579
583
  const aFailed = a.status !== 'done';
580
584
  earlier.push({
@@ -586,7 +590,15 @@ async function domainTask(session, task, runsDir) {
586
590
  iterations: a.iterations,
587
591
  startedAt: a.startedAt,
588
592
  finishedAt: a.finishedAt,
589
- prompt: a.prompt !== task.prompt ? splitPrompt(a.prompt).prompt : undefined,
593
+ // The whole text it ran with, when it differs: a correction added under the expected result was cut off.
594
+ prompt: a.prompt !== task.prompt ? a.prompt : undefined,
595
+ ...(a.stopCode ? { stopCode: a.stopCode } : {}),
596
+ ...(a.freshRetry ? { freshRetry: true } : {}),
597
+ ...(a.vcs?.commit ? { commit: a.vcs.commit } : {}),
598
+ ...(JSON.stringify(a.scope ?? []) !== JSON.stringify(task.scope ?? []) ? { scope: a.scope ?? [] } : {}),
599
+ ...((a.readOnly ?? false) !== (task.readOnly ?? false) ? { readOnly: a.readOnly ?? false } : {}),
600
+ ...(a.checks && JSON.stringify(a.checks) !== JSON.stringify(task.checks ?? []) ? { checks: a.checks } : {}),
601
+ run: a.runGroup ? { id: a.runGroup.id, name: a.runGroup.name } : undefined,
590
602
  whatTheChatTried: aRounds,
591
603
  whatWasActuallyDone: { repository: a.vcs, deviations: a.deviations, disputes: a.disputes },
592
604
  whyItFailed: aFailed
@@ -653,7 +665,8 @@ async function botTask(session, task, runsDir) {
653
665
  const byType = new Map();
654
666
  for (const e of events)
655
667
  byType.set(String(e.type), (byType.get(String(e.type)) ?? 0) + 1);
656
- const problems = events.filter((e) => e.level === 'error' || e.level === 'warn').map(trimmed);
668
+ // A step waiting for the operator's answer is routine in a step-by-step run, not a problem (live run 2026-10-04).
669
+ const problems = events.filter((e) => (e.level === 'error' || e.level === 'warn') && e.type !== 'approval-requested').map(trimmed);
657
670
  const reviewEvents = events.filter((e) => String(e.type).startsWith('review-') || String(e.type).startsWith('finding-'));
658
671
  return {
659
672
  session: { id: session.id, name: session.name, model: session.model, modelInUse: session.modelInUse, reviewModel: session.review?.model },
@@ -694,6 +707,10 @@ async function botTask(session, task, runsDir) {
694
707
  attempt: i + 1,
695
708
  runId: a.runId,
696
709
  runFolder: a.runId ? join(runsDir, a.runId) : undefined,
710
+ run: a.runGroup ? { id: a.runGroup.id, name: a.runGroup.name } : undefined,
711
+ policy: await policyOf(session, a, runsDir),
712
+ ...(a.stopCode ? { stopCode: a.stopCode } : {}),
713
+ ...(a.freshRetry ? { freshRetry: true } : {}),
697
714
  status: a.status,
698
715
  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 })),
699
716
  problems: aEvents.filter((e) => e.level === 'error' || e.level === 'warn').map(trimmed),
@@ -714,13 +731,32 @@ export async function buildBotExport(scope, runsDir, machine) {
714
731
  * What each run did before its tasks: the version control preflight and the moment the browser was
715
732
  * asked for, in the order they happened (`session/runLog.ts`), with the order checked rather than claimed.
716
733
  */
734
+ /*
735
+ * Every run the exported tasks took part in — the current attempt's and every earlier attempt's — and
736
+ * the runs of their sessions that were refused before a task began. Only the current attempt's run was
737
+ * listed, so a re-run made the first run's record disappear from the export (live run 2026-10-03).
738
+ */
717
739
  const runs = {};
718
- for (const { task } of pairs) {
719
- const id = task.runGroup?.id;
740
+ const add = async (id, name) => {
720
741
  if (!id || id in runs)
721
- continue;
742
+ return;
722
743
  const preflight = await readRunLog(runsDir, id);
723
- runs[id] = { name: task.runGroup?.name, preflight, order: browserAfterPreflight(preflight) };
744
+ const refused = preflight.some((e) => e.type === 'run-preflight-refused') && !preflight.some((e) => e.type === 'browser-launch-requested');
745
+ // Sessions refused at their turn in a run that went ahead (see `refusedRunsOf`).
746
+ const atTurn = preflight.filter((e) => e.type === 'run-preflight-refused' && e.data?.atTurn).flatMap((e) => (Array.isArray(e.data?.sessions) ? e.data.sessions : []));
747
+ runs[id] = { name, ...(refused ? { refused: true } : {}), ...(atTurn.length > 0 ? { refusedAtTurn: atTurn } : {}), preflight, order: browserAfterPreflight(preflight) };
748
+ };
749
+ /** What the operator did to each session's repository or start outside a run: see `appendSessionLog`. */
750
+ const operatorActions = {};
751
+ for (const { session, task } of pairs) {
752
+ if (!(session.id in operatorActions))
753
+ operatorActions[session.id] = await readSessionLog(runsDir, session.id);
754
+ await add(task.runGroup?.id, task.runGroup?.name);
755
+ for (const a of task.attempts ?? [])
756
+ await add(a.runGroup?.id, a.runGroup?.name);
757
+ await add(session.runGroup?.id, session.runGroup?.name);
758
+ for (const id of await refusedRunsOf(runsDir, session.id))
759
+ await add(id);
724
760
  }
725
761
  return {
726
762
  exportedAt: new Date().toISOString(),
@@ -729,6 +765,7 @@ export async function buildBotExport(scope, runsDir, machine) {
729
765
  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.`,
730
766
  machine,
731
767
  runs,
768
+ operatorActions,
732
769
  tasks,
733
770
  };
734
771
  }
@@ -22,7 +22,7 @@ export function composeHandoff(task, notRun = []) {
22
22
  .map((c) => `committed with a finding left in place: ${c.name}`),
23
23
  ...(vcs.suspicious ?? []).map((s) => `committed although it looks like ${s.reason}: ${s.path}`),
24
24
  ...(vcs.foreignCommits ?? []).map((c) => `a commit on the branch the runner did not make: ${c}`),
25
- ...(task.scopeReverted?.length ? [`put back because outside the task's scope: ${task.scopeReverted.join(', ')}`] : []),
25
+ ...(task.scopeReverted?.length ? [`put back because ${task.readOnly ? 'the task is read-only' : "outside the task's scope"}: ${task.scopeReverted.join(', ')}`] : []),
26
26
  ...(task.deviations ?? []).map((d) => `did not follow "${d.instruction}" as written: ${d.did}`),
27
27
  ];
28
28
  const manual = [];
@@ -34,7 +34,8 @@ export function composeHandoff(task, notRun = []) {
34
34
  manual.push('continue it in the same chat from the register, or run it again');
35
35
  if (task.status === 'blocked')
36
36
  manual.push(`unblock it: ${task.reason ?? 'see the reason'}`);
37
- if (task.status === 'failed')
37
+ // A refusal of version control's own is fixed there, not in the prompt (live run 2026-10-03).
38
+ if (task.status === 'failed' && !vcs.problem)
38
39
  manual.push('read why it failed, fix the prompt or the checks, and queue it again');
39
40
  if (openFindings.length > 0)
40
41
  manual.push(`${openFindings.length} review finding(s) are open`);
@@ -43,6 +44,8 @@ export function composeHandoff(task, notRun = []) {
43
44
  return {
44
45
  outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
45
46
  changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
47
+ // With no commit, what changed is read from the tree: the paths, without line counts.
48
+ ...(task.treeChanged?.length ? { uncommittedFiles: task.treeChanged } : {}),
46
49
  validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
47
50
  ...(task.review
48
51
  ? { review: { verdict: task.review.verdict, open: openFindings.length, ...(task.review.skippedBecause ? { skippedBecause: task.review.skippedBecause } : {}) } }
@@ -50,7 +50,9 @@ export async function readInterruption(runDir) {
50
50
  const skipped = after.find((e) => e.type === 'step-skipped' && e.id === id);
51
51
  const started = after.some((e) => e.type === 'step-started' && e.id === id);
52
52
  const proposed = after.find((e) => e.type === 'step-proposed' && e.id === id);
53
- const state = finished ? 'finished' : skipped ? 'skipped' : started ? 'cut' : 'not-run';
53
+ // A step killed by a Stop is recorded as finished with outcome "aborted": it was cut off, not run to the end.
54
+ const cutShort = finished?.outcome === 'aborted';
55
+ const state = finished && !cutShort ? 'finished' : skipped ? 'skipped' : started || cutShort ? 'cut' : 'not-run';
54
56
  const step = { id, state };
55
57
  if (typeof proposed?.description === 'string')
56
58
  step.command = proposed.description;
@@ -9,7 +9,7 @@
9
9
  * browser opens and read back by the runner export, which can then show that `browser-launch-requested`
10
10
  * came after `run-preflight-passed` — or that a refused run never asked for a browser at all.
11
11
  */
12
- import { appendFile, mkdir, readFile } from 'node:fs/promises';
12
+ import { appendFile, mkdir, readFile, readdir } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
14
  /** A run id as a folder name: what the service makes (`r-…`, `b-…`), and nothing that walks out of `_runs`. */
15
15
  const safe = (runId) => runId.replace(/[^A-Za-z0-9_-]/g, '_').slice(0, 80);
@@ -58,3 +58,44 @@ export function browserAfterPreflight(entries) {
58
58
  browserOnlyAfterPreflight: browser < 0 ? null : passed >= 0 && passed < browser,
59
59
  };
60
60
  }
61
+ /**
62
+ * The runs refused for a session before any task of it began: their logs are the only record of them,
63
+ * since a refused run is written on no task. Found by reading the run logs under `_runs`.
64
+ */
65
+ export async function refusedRunsOf(runsDir, sessionId) {
66
+ const out = [];
67
+ const ids = await readdir(join(runsDir, '_runs')).catch(() => []);
68
+ for (const id of ids) {
69
+ const entries = await readRunLog(runsDir, id);
70
+ const refused = entries.some((e) => e.type === 'run-preflight-refused');
71
+ const browser = entries.some((e) => e.type === 'browser-launch-requested');
72
+ const mine = entries.some((e) => e.type === 'run-preflight-started' && Array.isArray(e.data?.sessions) && e.data.sessions.includes(sessionId));
73
+ // And a run that went ahead without this session, refused at its turn (`atTurn`).
74
+ const refusedMe = entries.some((e) => e.type === 'run-preflight-refused' && Array.isArray(e.data?.sessions) && e.data.sessions.includes(sessionId));
75
+ if ((refused && !browser && mine) || refusedMe)
76
+ out.push(id);
77
+ }
78
+ return out.sort();
79
+ }
80
+ /**
81
+ * A durable record of what the operator did to a session outside any run — a fix pressed on the run
82
+ * screen, the folder prepared from the remote. These change the repository or where the session starts,
83
+ * and were in nothing but the live event stream and git's reflog (live run 2026-10-03). One file per
84
+ * session, `<runs>/_sessions/<session id>/actions.jsonl`, read back by the runner export.
85
+ */
86
+ export async function appendSessionLog(runsDir, sessionId, entry) {
87
+ const dir = join(runsDir, '_sessions', safe(sessionId));
88
+ await mkdir(dir, { recursive: true });
89
+ await appendFile(join(dir, 'actions.jsonl'), JSON.stringify({ at: new Date().toISOString(), ...entry }) + '\n', 'utf8');
90
+ }
91
+ export async function readSessionLog(runsDir, sessionId) {
92
+ const text = await readFile(join(runsDir, '_sessions', safe(sessionId), 'actions.jsonl'), 'utf8').catch(() => '');
93
+ return text.split('\n').filter((l) => l.trim()).flatMap((l) => {
94
+ try {
95
+ return [JSON.parse(l)];
96
+ }
97
+ catch {
98
+ return [];
99
+ }
100
+ });
101
+ }
@@ -20,7 +20,9 @@ export function suggestRunName(all, sessionIds, about) {
20
20
  }
21
21
  theirs.sort((a, b) => b.at.localeCompare(a.at));
22
22
  const chosen = all.filter((s) => wanted.has(s.id));
23
- const base = stripNumber(theirs[0]?.name ?? '') ||
23
+ // A name that only counted sessions is not carried on: "3 sessions #2" for a run of two (live run 2026-10-03).
24
+ const carried = stripNumber(theirs[0]?.name ?? '');
25
+ const base = (/^\d+ sessions$/.test(carried) ? '' : carried) ||
24
26
  (chosen.length === 1 ? chosen[0].name.trim() : '') ||
25
27
  (about ?? '').trim() ||
26
28
  // Nothing to go on: say what it is rather than leave it blank again.
@@ -100,6 +100,7 @@ export const TASK_FIELD_ON_RERUN = {
100
100
  stopCode: 'archived',
101
101
  limit: 'archived',
102
102
  scopeReverted: 'archived',
103
+ treeChanged: 'archived',
103
104
  inputsRestored: 'archived',
104
105
  artifactsKept: 'archived',
105
106
  vcs: 'archived',
@@ -522,7 +523,9 @@ export class SessionStore {
522
523
  * on with it; those are run again — unless what ended them was a limit from the settings (out of
523
524
  * rounds of fixing checks or review findings), which is a counter, not the verdict. See `TaskLimit`.
524
525
  */
525
- async continueTask(sessionId, taskId) {
526
+ async continueTask(sessionId, taskId,
527
+ /** Where an attempt the operator stopped had got to, read from its run folder by the caller. */
528
+ stoppedAt) {
526
529
  const current = (await this.getSession(sessionId))?.tasks.find((x) => x.id === taskId);
527
530
  if (!current)
528
531
  throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
@@ -535,7 +538,7 @@ export class SessionStore {
535
538
  stoppedBecause: current.reason,
536
539
  how,
537
540
  ...(current.limit ? { limit: current.limit } : {}),
538
- ...(current.interruption ? { interruption: current.interruption } : {}),
541
+ ...(current.interruption ? { interruption: current.interruption } : how === 'stopped' && stoppedAt ? { interruption: stoppedAt } : {}),
539
542
  });
540
543
  }
541
544
  /**
@@ -587,6 +590,7 @@ export class SessionStore {
587
590
  scope: t.scope,
588
591
  readOnly: t.readOnly,
589
592
  scopeReverted: t.scopeReverted,
593
+ treeChanged: t.treeChanged,
590
594
  handoff: t.handoff,
591
595
  inputsRestored: t.inputsRestored,
592
596
  artifactsKept: t.artifactsKept,
@@ -624,6 +628,7 @@ export class SessionStore {
624
628
  t.stats = undefined;
625
629
  t.handoff = undefined;
626
630
  t.scopeReverted = undefined;
631
+ t.treeChanged = undefined;
627
632
  t.inputsRestored = undefined;
628
633
  t.artifactsKept = undefined;
629
634
  t.freshRetry = undefined;