copilot-operator 0.1.41 → 0.1.43

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 (67) hide show
  1. package/dist/src/api/operator.controller.js +159 -6
  2. package/dist/src/api/operator.service.js +417 -10
  3. package/dist/src/api/server.js +16 -0
  4. package/dist/src/cli.js +12 -0
  5. package/dist/src/config/schema.js +17 -0
  6. package/dist/src/orchestrator/planMode.js +102 -0
  7. package/dist/src/orchestrator/review.js +8 -4
  8. package/dist/src/orchestrator/taskRunner.js +246 -40
  9. package/dist/src/plan/brief.js +69 -20
  10. package/dist/src/plan/changes.js +217 -0
  11. package/dist/src/plan/importPlan.js +2 -0
  12. package/dist/src/plan/schema.js +11 -1
  13. package/dist/src/plan/systemGuide.js +790 -14
  14. package/dist/src/protocol/parser.js +26 -2
  15. package/dist/src/protocol/replySchema.js +28 -4
  16. package/dist/src/session/compose.js +76 -7
  17. package/dist/src/session/exports.js +1 -0
  18. package/dist/src/session/model.js +38 -0
  19. package/dist/src/session/store.js +90 -3
  20. package/dist/src/vcs/startChoice.js +80 -8
  21. package/dist/src/vcs/taskVcs.js +126 -8
  22. package/dist/web/404.html +2 -2
  23. package/dist/web/_next/static/WmzBz4QN36pcz4PL_cfEW/_buildManifest.js +1 -0
  24. package/dist/web/_next/static/chunks/499-751e0be9d4f86e2d.js +1 -0
  25. package/dist/web/_next/static/chunks/549-2415dd26f4e53d07.js +1 -0
  26. package/dist/web/_next/static/chunks/699-9d031f8c145a0465.js +1 -0
  27. package/dist/web/_next/static/chunks/867-aea30f560bc18eaf.js +1 -0
  28. package/dist/web/_next/static/chunks/897-5a34109533c3da50.js +1 -0
  29. package/dist/web/_next/static/chunks/app/defaults/page-56134fbb8cc7554f.js +1 -0
  30. package/dist/web/_next/static/chunks/app/history/page-0bb537407fed5944.js +1 -0
  31. package/dist/web/_next/static/chunks/app/import/{page-999d6cd958989723.js → page-8ef75fc08638e75f.js} +1 -1
  32. package/dist/web/_next/static/chunks/app/layout-dc602b727480a653.js +1 -0
  33. package/dist/web/_next/static/chunks/app/page-d73a8ff300862cd8.js +1 -0
  34. package/dist/web/_next/static/chunks/app/{page-e557d58476ae6383.js → sessions/page-28376d2e4a9b4738.js} +1 -1
  35. package/dist/web/_next/static/chunks/app/sessions/view/page-afc235a9171535d8.js +1 -0
  36. package/dist/web/_next/static/css/{cea7913ba416dbb7.css → 72c85e4322c21223.css} +1 -1
  37. package/dist/web/appearance.html +2 -2
  38. package/dist/web/appearance.txt +25 -28
  39. package/dist/web/defaults.html +2 -2
  40. package/dist/web/defaults.txt +25 -28
  41. package/dist/web/history.html +2 -2
  42. package/dist/web/history.txt +25 -28
  43. package/dist/web/import.html +2 -2
  44. package/dist/web/import.txt +25 -28
  45. package/dist/web/index.html +2 -2
  46. package/dist/web/index.txt +25 -28
  47. package/dist/web/level1.html +2 -2
  48. package/dist/web/level1.txt +25 -28
  49. package/dist/web/presets.html +3 -3
  50. package/dist/web/presets.txt +25 -28
  51. package/dist/web/sessions/view.html +2 -2
  52. package/dist/web/sessions/view.txt +25 -28
  53. package/dist/web/sessions.html +16 -0
  54. package/dist/web/sessions.txt +26 -0
  55. package/dist/web/system.html +2 -2
  56. package/dist/web/system.txt +25 -28
  57. package/package.json +161 -158
  58. package/dist/web/_next/static/-htN5GTSrd3W3f5j_QC2y/_buildManifest.js +0 -1
  59. package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +0 -1
  60. package/dist/web/_next/static/chunks/549-f295c8429e83b25b.js +0 -1
  61. package/dist/web/_next/static/chunks/584-578be94a0786334b.js +0 -1
  62. package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +0 -1
  63. package/dist/web/_next/static/chunks/app/defaults/page-879158e9e550eea7.js +0 -1
  64. package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +0 -1
  65. package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +0 -1
  66. package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +0 -1
  67. /package/dist/web/_next/static/{-htN5GTSrd3W3f5j_QC2y → WmzBz4QN36pcz4PL_cfEW}/_ssgManifest.js +0 -0
@@ -142,11 +142,33 @@ export function normaliseProse(value) {
142
142
  });
143
143
  }
144
144
  }
145
+ // A plan's prose, in plan mode: read as text whatever shape it came in, as the fields above are.
146
+ if (out.plan && typeof out.plan === 'object' && !Array.isArray(out.plan)) {
147
+ const plan = { ...out.plan };
148
+ for (const key of ['existing', 'approach', 'verification', 'risks']) {
149
+ if (key in plan && plan[key] !== undefined && typeof plan[key] !== 'string') {
150
+ plan[key] = proseOf(plan[key]);
151
+ coerced.push(`plan.${key}`);
152
+ }
153
+ }
154
+ for (const key of ['steps', 'files']) {
155
+ if (typeof plan[key] === 'string') {
156
+ // One item per line, its numbering or bullet taken off: "1. add the writer\n2. add the route".
157
+ plan[key] = plan[key]
158
+ .split(/\r?\n/)
159
+ .map((l) => l.replace(/^\s*(?:\d+[.)]|[-*•])\s+/, '').trim())
160
+ .filter(Boolean);
161
+ coerced.push(`plan.${key}`);
162
+ }
163
+ }
164
+ out.plan = plan;
165
+ }
145
166
  return { value: out, coerced };
146
167
  }
147
168
  /** What each field must look like, said in the message that asks for a reformatted reply. */
148
169
  const FIELD_SHAPES = [
149
- [/^status$/, '"status" is one of "continue", "done", "blocked"'],
170
+ [/^status$/, '"status" is one of "continue", "done", "blocked" — and "plan", in plan mode only'],
171
+ [/^plan(\.|$)/, '"plan" is {"existing": "…", "approach": "…", "steps": ["…"], "files": ["…"], "verification": "…", "risks": "…"} with plain strings'],
150
172
  [/^steps(\.|$)/, '"steps" is a list of {"id": 1, "type": "command", "cmd": "the command"} ("shell", "expect", "timeoutSec" optional)'],
151
173
  [/^tried(\.|$)/, '"tried" is a list of plain strings, one sentence per approach'],
152
174
  [/^deviations(\.|$)/, '"deviations" is a list of {"instruction": "…", "did": "…", "why": "…"} with plain strings'],
@@ -210,10 +232,12 @@ export function parseReply(markdown, opts) {
210
232
  // model that writes "Край" under an explanation of why it cannot finish has ended the
211
233
  // task, not completed it, and reading that as `done` would file a failure as a success.
212
234
  const blocked = reply.status === 'blocked';
235
+ // A plan waits for its approval: the stop word in it does not end the task.
236
+ const plan = reply.status === 'plan';
213
237
  return {
214
238
  ok: true,
215
239
  reply: { ...reply, steps },
216
- done: reply.status === 'done' || (markerHit && hasSummary && !blocked),
240
+ done: reply.status === 'done' || (markerHit && hasSummary && !blocked && !plan),
217
241
  blocked,
218
242
  json: text.trim(),
219
243
  ...(prose.coerced.length > 0 ? { coerced: prose.coerced } : {}),
@@ -90,6 +90,21 @@ export const DisputeSchema = z.object({
90
90
  /** What shows it: the command run and what came back, quoted. */
91
91
  evidence: z.string().trim().min(1),
92
92
  });
93
+ /** A list item that arrived as an object or a number: read as its text, since nothing in a plan is run. */
94
+ const asText = (v) => typeof v === 'string' ? v : v && typeof v === 'object' ? Object.values(v).map((x) => (typeof x === 'string' ? x : JSON.stringify(x))).join(' — ') : String(v ?? '');
95
+ /**
96
+ * The plan of a task in plan mode (see `PlanMode` in session/model.ts): what is already there, how the
97
+ * task will be done, its stages, the files it touches, how the result will be shown to work, and the
98
+ * risks. Prose, never run; a list item in another shape is read as its text.
99
+ */
100
+ export const PlanSchema = z.object({
101
+ existing: z.string().trim().min(1, 'plan.existing must say what is already in the project that the task touches: the files, functions and behaviour you found.'),
102
+ approach: z.string().trim().min(1, 'plan.approach must say how you mean to do the task.'),
103
+ steps: z.array(z.preprocess(asText, z.string().trim().min(1))).min(1, 'plan.steps must list the stages, in order.'),
104
+ files: z.array(z.preprocess(asText, z.string().trim().min(1))).default([]),
105
+ verification: z.string().trim().min(1, 'plan.verification must say how you will show the result works.'),
106
+ risks: z.string().optional(),
107
+ });
93
108
  export const ReplySchema = z
94
109
  .object({
95
110
  /**
@@ -105,8 +120,13 @@ export const ReplySchema = z
105
120
  * costing a message, a reply and a report, and ending in a timeout that says nothing about
106
121
  * why. A task that stops with "I tried these three things and this is in the way" is a
107
122
  * result. A task that runs out of iterations is not.
123
+ *
124
+ * `plan` — in plan mode only: here is the plan, made from what I read; no steps with it, and no change
125
+ * made yet. See `PlanSchema`.
108
126
  */
109
- status: z.enum(['continue', 'done', 'blocked']),
127
+ status: z.enum(['continue', 'done', 'blocked', 'plan']),
128
+ /** Required when `status` is `plan`. */
129
+ plan: PlanSchema.optional(),
110
130
  steps: z.array(StepSchema).default([]),
111
131
  notes: z.string().optional(),
112
132
  /**
@@ -138,8 +158,12 @@ export const ReplySchema = z
138
158
  'Send at least one step, or set status to "done" or "blocked".',
139
159
  })
140
160
  .refine((r) => r.status === 'continue' || r.steps.length === 0, {
141
- message: 'status "done" and status "blocked" both end the task, so they cannot carry steps. ' +
142
- 'Send the steps with status "continue" first, then end.',
161
+ message: 'status "done", "blocked" and "plan" cannot carry steps: "done" and "blocked" end the task, and "plan" waits for the plan to be approved. ' +
162
+ 'Send the steps with status "continue".',
163
+ })
164
+ .refine((r) => r.status !== 'plan' || !!r.plan, {
165
+ message: 'status "plan" requires a "plan" object: { "existing": what is already there, "approach": how, "steps": [the stages], ' +
166
+ '"files": [the files you will create or change], "verification": how you will show it works, "risks": optional }.',
143
167
  })
144
168
  .refine((r) => r.status !== 'done' || (r.summary ?? '').trim().length >= MIN_SUMMARY_CHARS, {
145
169
  message: 'status "done" requires a real summary: several sentences explaining what was done, ' +
@@ -190,7 +214,7 @@ export function describeDeviations(deviations) {
190
214
  * because forgetting to repeat is more common than meaning to retract.
191
215
  */
192
216
  export function resolveDeviations(existing, status, incoming) {
193
- if (status !== 'continue' && incoming.length > 0)
217
+ if (status !== 'continue' && status !== 'plan' && incoming.length > 0)
194
218
  return mergeDeviations([], incoming);
195
219
  return mergeDeviations(existing, incoming);
196
220
  }
@@ -1,4 +1,18 @@
1
1
  import { describeInterruption } from './interruption.js';
2
+ /** The operator's notes about a task, as the chat is given them: with the opening, or with a report while it runs. */
3
+ export function operatorNotesSection(notes, whileWorking) {
4
+ if (notes.length === 0)
5
+ return '';
6
+ return [
7
+ whileWorking
8
+ ? `## ${notes.length > 1 ? 'Notes' : 'A note'} from the operator, while you work`
9
+ : `## ${notes.length > 1 ? 'Notes' : 'A note'} from the operator about this task`,
10
+ '',
11
+ 'Take this into account from now on; where it and the task disagree, this wins.',
12
+ '',
13
+ ...notes.map((n) => (notes.length > 1 ? `- ${n.text.trim().replace(/\n/g, '\n ')}` : n.text.trim())),
14
+ ].join('\n');
15
+ }
2
16
  const LEVEL2_HEADER = '## Project instructions (level 2)';
3
17
  const TASK_HEADER = '## Task';
4
18
  /**
@@ -11,7 +25,7 @@ const TASK_HEADER = '## Task';
11
25
  * and a note that is empty simply leaves nothing behind.
12
26
  */
13
27
  function runnerBlock(input) {
14
- const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.approachesNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
28
+ const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.approachesNote, input.vcsNote, input.shellNote, input.planNote, input.operatorNotes].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
15
29
  return notes.length > 0 ? `${notes.join('\n\n')}\n\n` : '';
16
30
  }
17
31
  /**
@@ -52,6 +66,8 @@ function level2Block(level2) {
52
66
  * the steps it had just asked for actually ran.
53
67
  */
54
68
  function whyItStopped(c) {
69
+ if (c.how === 'failed')
70
+ return describeEnding(c.fromAttempt, c.ended, c.stoppedBecause);
55
71
  if (c.how === 'interrupted') {
56
72
  const where = c.interruption ? `\n\n${describeInterruption(c.interruption)}\n\n` : ' ';
57
73
  return ('The runner stopped unexpectedly while this task was in progress — the program was closed, the machine went off or it ' +
@@ -71,6 +87,33 @@ function whyItStopped(c) {
71
87
  const which = c.limit ? ` of ${LIMIT_WORDS[c.limit.setting](c.limit.value)}` : '';
72
88
  return `It stopped because the runner's limit${which} was reached${why} — not because anything failed. The files are as you left them.`;
73
89
  }
90
+ /**
91
+ * How an attempt ended without the work being done, in the words the chat is sent: its status and the
92
+ * runner's reason, the checks that did not pass, what the independent review found, and the attempt's
93
+ * own summary. The chat that carries it on needs the why as much as the what (2026-10-13).
94
+ */
95
+ export function describeEnding(fromAttempt, ended, reason) {
96
+ const status = ended?.status ?? 'failed';
97
+ const why = (ended?.reason ?? reason ?? '').trim();
98
+ const lines = [`The previous attempt (attempt ${fromAttempt}) did not succeed: it ended ${status}${why ? ` — ${why}` : '.'}`];
99
+ if (ended?.failedChecks?.length) {
100
+ lines.push('', 'The checks that did not pass:');
101
+ for (const c of ended.failedChecks)
102
+ lines.push(`- ${c.name}: ${c.detail}`);
103
+ }
104
+ if (ended?.findings?.length) {
105
+ lines.push('', 'What the independent review found:');
106
+ for (const f of ended.findings)
107
+ lines.push(`- ${f.what}${f.where ? ` (${f.where})` : ''}: ${f.evidence}`);
108
+ }
109
+ if (ended?.summary)
110
+ lines.push('', `That attempt's own summary: ${ended.summary}`);
111
+ return lines.join('\n');
112
+ }
113
+ /** The operator's new prompt, as its own section of the message. */
114
+ function instructionSection(text) {
115
+ return `## New instruction from the operator\n\nAdded to the task above, not in place of it; where the two disagree, this wins.\n\n${text.trim()}`;
116
+ }
74
117
  /** Each limit from the settings, as the chat is told about it. See `TaskLimit`. */
75
118
  const LIMIT_WORDS = {
76
119
  maxRunMinutes: (n) => `${n} minutes per task`,
@@ -87,23 +130,49 @@ export function composeOpening(input) {
87
130
  * again — sending it would read as a new task and start the work over. Only when the
88
131
  * conversation was lost does the task go out in full, with a line saying it is a continuation.
89
132
  */
133
+ const instruction = input.instruction?.text.trim() ? input.instruction : undefined;
90
134
  if (input.continuing && input.contractAlreadySent) {
91
- const message = `Continue task ${input.taskNumber}: ${input.taskTitle.trim() || 'untitled'}, in this same conversation. ` +
135
+ const title = input.taskTitle.trim() || 'untitled';
136
+ /*
137
+ * After a failure, with the operator's new prompt: told how it ended and why, that its files are
138
+ * there, and what to do now — not "carry on from your plan", which is the plan that failed.
139
+ */
140
+ if (input.continuing.how === 'failed' && instruction) {
141
+ const message = `Continue task ${input.taskNumber}: ${title}, in this same conversation, with a new instruction from the operator.\n\n` +
142
+ `${whyItStopped(input.continuing)}\n\n` +
143
+ `The files are as that attempt left them, and what it changed is in your working tree. Do not start over: look at ` +
144
+ `what is there first, keep what is right, and do what the instruction below says. The assignment is the one you ` +
145
+ `were given above, with the instruction added to it. Verify the result as for any task. Step numbering restarts at 1.\n\n` +
146
+ `${instructionSection(instruction.text)}\n\n` +
147
+ `${runnerBlock(input)}`.trimEnd();
148
+ return { messages: [message], firstMessage: message };
149
+ }
150
+ const message = `Continue task ${input.taskNumber}: ${title}, in this same conversation. ` +
92
151
  `${whyItStopped(input.continuing)} ` +
93
152
  `Do not start over and do not repeat work that is done: start from ` +
94
153
  `your plan, say in \`notes\` which stages are done and which one you are on, and carry on. ` +
95
- `The assignment is the one you were given above; it has not changed. Step numbering restarts at 1.\n\n` +
154
+ (instruction
155
+ ? `The assignment is the one you were given above, with the operator's instruction below added to it. Step numbering restarts at 1.\n\n${instructionSection(instruction.text)}\n\n`
156
+ : `The assignment is the one you were given above; it has not changed. Step numbering restarts at 1.\n\n`) +
96
157
  `${runnerBlock(input)}`.trimEnd();
97
158
  return { messages: [message], firstMessage: message };
98
159
  }
99
160
  const continuationLine = input.continuing
100
- ? `\n\nThis continues an earlier attempt at this task. ${whyItStopped(input.continuing)} ` +
101
- `Look at what is already done before changing anything, and carry on from there.`
161
+ ? `\n\nThis continues an earlier attempt at this task. ${whyItStopped(input.continuing)}` +
162
+ (input.continuing.how === 'failed'
163
+ ? `\n\nWhat that attempt changed is in your working tree. Look at it before changing anything, keep what is right, and do what the instruction below says.`
164
+ : ` Look at what is already done before changing anything, and carry on from there.`) +
165
+ (instruction ? `\n\n${instructionSection(instruction.text)}` : '')
102
166
  : input.buildsOn
103
167
  ? `\n\nThis task was done once already (attempt ${input.buildsOn.fromAttempt}), and that work is in your ` +
104
168
  `working tree. The text above is a new instruction for it: look at what is there first, build on it, and ` +
105
- `change or undo it only where the new instruction asks. Verify the result as for any task.`
106
- : '';
169
+ `change or undo it only where the new instruction asks. Verify the result as for any task.` +
170
+ (instruction ? `\n\n${describeEnding(instruction.fromAttempt, instruction.ended)}\n\n${instructionSection(instruction.text)}` : '')
171
+ : instruction
172
+ ? // The attempt it follows never reached a conversation: what is known of its ending, then the prompt.
173
+ `\n\nAn earlier attempt at this task never reached this conversation. ${describeEnding(instruction.fromAttempt, instruction.ended)}` +
174
+ `\n\n${instructionSection(instruction.text)}`
175
+ : '';
107
176
  if (!input.contractAlreadySent) {
108
177
  const taskMessage = `${runnerBlock(input)}${level2Block(input.level2)}\n\n${taskBlock}${continuationLine}`;
109
178
  return {
@@ -137,6 +137,7 @@ export function buildPlanExport(scope) {
137
137
  onFailure: s.onFailure ?? 'stop',
138
138
  level2: split.level2,
139
139
  conversationGroup: shared ? '' : (s.conversationGroup ?? ''),
140
+ ...(s.planMode ? { planMode: s.planMode } : {}),
140
141
  vcs: {
141
142
  enabled: s.vcs?.enabled ?? false,
142
143
  repoDir: s.vcs?.repoDir ?? '',
@@ -12,6 +12,44 @@ export function tidyVcsPlan(plan) {
12
12
  return undefined;
13
13
  return { ...(branch ? { branch } : {}), ...(commitMessage ? { commitMessage } : {}) };
14
14
  }
15
+ export const clipText = (s, n) => {
16
+ const t = (s ?? '').trim();
17
+ if (!t)
18
+ return undefined;
19
+ return t.length > n ? `${t.slice(0, n)}…` : t;
20
+ };
21
+ /**
22
+ * How a task's latest attempt ended, for the next one to be told: its status and reason, its own
23
+ * summary, the checks that did not pass and what the independent review found — clipped, since it
24
+ * goes into a chat message.
25
+ */
26
+ export function endingOf(t) {
27
+ const failedChecks = (t.checkResults ?? []).filter((c) => !c.passed).slice(0, 10).map((c) => ({ name: c.name, detail: clipText(c.detail, 400) ?? '' }));
28
+ const findings = t.review?.verdict === 'fail'
29
+ ? (t.review.findings ?? []).slice(0, 10).map((f) => ({ what: clipText(f.what, 400) ?? '', ...(f.where ? { where: clipText(f.where, 200) } : {}), evidence: clipText(f.evidence, 400) ?? '' }))
30
+ : [];
31
+ const reason = clipText(t.reason, 2000);
32
+ const summary = clipText(t.summary, 2000);
33
+ return {
34
+ status: t.status,
35
+ ...(reason ? { reason } : {}),
36
+ ...(summary && summary !== reason ? { summary } : {}),
37
+ ...(t.stopCode ? { stopCode: t.stopCode } : {}),
38
+ ...(failedChecks.length > 0 ? { failedChecks } : {}),
39
+ ...(findings.length > 0 ? { findings } : {}),
40
+ };
41
+ }
42
+ /**
43
+ * Whether a task can be carried on with a new prompt after it failed: it ended failed or blocked on its
44
+ * work. Not one that contradicted itself — it never started its work, and its way on is the fix of the
45
+ * contradiction. A task that stopped at a limit or was stopped is continued anyway (`isContinuable`),
46
+ * with a new prompt or without.
47
+ */
48
+ export function canFollowUp(t) {
49
+ return (t.status === 'failed' || t.status === 'blocked') && t.stopCode !== 'contract-conflict';
50
+ }
51
+ /** The longest new prompt taken: it goes into one chat message beside the task. */
52
+ export const MAX_INSTRUCTION_CHARS = 20_000;
15
53
  /**
16
54
  * Whether "Continue" may carry a task on where it stopped, in its chat and on its branch.
17
55
  *
@@ -11,7 +11,7 @@ import { mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promise
11
11
  import { existsSync } from 'node:fs';
12
12
  import { randomUUID } from 'node:crypto';
13
13
  import { join, resolve } from 'node:path';
14
- import { isContinuable, newId, tidyVcsPlan } from './model.js';
14
+ import { endingOf, isContinuable, MAX_INSTRUCTION_CHARS, newId, tidyVcsPlan } from './model.js';
15
15
  /** A task the runner is inside of. It cannot be edited, deleted, or left behind at startup. */
16
16
  const ACTIVE_STATUSES = ['running', 'waiting-approval'];
17
17
  /**
@@ -107,6 +107,10 @@ export const TASK_FIELD_ON_RERUN = {
107
107
  leftovers: 'archived',
108
108
  environment: 'archived',
109
109
  handoff: 'archived',
110
+ plan: 'archived',
111
+ accepted: 'archived',
112
+ // Every note stays with the task; the ones not sent yet go out with the next attempt's opening.
113
+ notes: 'kept',
110
114
  interruption: 'cleared',
111
115
  finalReply: 'cleared',
112
116
  firstMessage: 'cleared',
@@ -525,12 +529,35 @@ export class SessionStore {
525
529
  */
526
530
  async continueTask(sessionId, taskId,
527
531
  /** Where an attempt the operator stopped had got to, read from its run folder by the caller. */
528
- stoppedAt) {
532
+ stoppedAt,
533
+ /**
534
+ * The operator's new prompt (2026-10-13): what carries a task that failed or blocked on from where
535
+ * it got to, and may come with any other Continue. See `TaskContinuation.instruction`.
536
+ */
537
+ instruction) {
529
538
  const current = (await this.getSession(sessionId))?.tasks.find((x) => x.id === taskId);
530
539
  if (!current)
531
540
  throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
541
+ const text = instruction?.trim() || undefined;
542
+ if (text && text.length > MAX_INSTRUCTION_CHARS)
543
+ throw new Error(`The new prompt is ${text.length} characters; it can be at most ${MAX_INSTRUCTION_CHARS}.`);
532
544
  if (!isContinuable(current)) {
533
- throw new Error('Only a task that stopped before it finished, or at a limit from the settings, can be continued; run this one again instead.');
545
+ if (current.status === 'failed' || current.status === 'blocked') {
546
+ if (current.stopCode === 'contract-conflict') {
547
+ throw new Error('This task never started its work: it contradicts itself. Fix the contradiction ("Fix the contradiction"), then run it.');
548
+ }
549
+ if (!text)
550
+ throw new Error('A task that failed is carried on with a new prompt: say what to do now. To run it again as it is, use "Run again".');
551
+ // The task as it ended, before the attempt is archived: what the chat is told about it.
552
+ return await this.rerunTask(sessionId, taskId, {}, {
553
+ fromAttempt: current.attempt ?? 1,
554
+ stoppedBecause: current.reason,
555
+ how: 'failed',
556
+ instruction: text,
557
+ ended: endingOf(current),
558
+ });
559
+ }
560
+ throw new Error('Only a task that stopped before it finished, at a limit from the settings, or that failed can be continued; run this one again instead.');
534
561
  }
535
562
  const how = current.status === 'limit-reached' || current.limit ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
536
563
  return await this.rerunTask(sessionId, taskId, {}, {
@@ -539,7 +566,63 @@ export class SessionStore {
539
566
  how,
540
567
  ...(current.limit ? { limit: current.limit } : {}),
541
568
  ...(current.interruption ? { interruption: current.interruption } : how === 'stopped' && stoppedAt ? { interruption: stoppedAt } : {}),
569
+ ...(text ? { instruction: text } : {}),
570
+ });
571
+ }
572
+ /**
573
+ * A note from the operator to the chat about this task (2026-10-14): it goes out with the next message the
574
+ * task sends — the opening of its next attempt, or the next report while it runs. See `Task.notes`.
575
+ */
576
+ async addTaskNote(sessionId, taskId, text) {
577
+ const note = text.trim();
578
+ if (!note)
579
+ throw new Error('The note is empty.');
580
+ if (note.length > MAX_INSTRUCTION_CHARS)
581
+ throw new Error(`The note is ${note.length} characters; it can be at most ${MAX_INSTRUCTION_CHARS}.`);
582
+ let result;
583
+ await this.updateSession(sessionId, (s) => {
584
+ const t = s.tasks.find((x) => x.id === taskId);
585
+ if (!t)
586
+ throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
587
+ t.notes = [...(t.notes ?? []), { id: newId('n-'), text: note, at: new Date().toISOString() }];
588
+ result = t;
542
589
  });
590
+ return result;
591
+ }
592
+ /** Marks notes as sent with this attempt's message; returns the ones it marked. */
593
+ async takeUnsentNotes(sessionId, taskId) {
594
+ let taken = [];
595
+ await this.updateSession(sessionId, (s) => {
596
+ const t = s.tasks.find((x) => x.id === taskId);
597
+ if (!t?.notes?.some((n) => !n.sentAt))
598
+ return;
599
+ const at = new Date().toISOString();
600
+ taken = t.notes.filter((n) => !n.sentAt);
601
+ t.notes = t.notes.map((n) => (n.sentAt ? n : { ...n, sentAt: at, attempt: t.attempt ?? 1 }));
602
+ });
603
+ return taken;
604
+ }
605
+ /**
606
+ * The operator accepts a task as done though it did not end so (2026-10-14): what it was is kept beside it,
607
+ * and the run's chain goes on past it as past any done task.
608
+ */
609
+ async acceptTask(sessionId, taskId, note) {
610
+ const text = note?.trim() || undefined;
611
+ if (text && text.length > MAX_INSTRUCTION_CHARS)
612
+ throw new Error(`The note is ${text.length} characters; it can be at most ${MAX_INSTRUCTION_CHARS}.`);
613
+ let result;
614
+ await this.updateSession(sessionId, (s) => {
615
+ const t = s.tasks.find((x) => x.id === taskId);
616
+ if (!t)
617
+ throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
618
+ if (!['failed', 'blocked', 'limit-reached', 'aborted'].includes(t.status)) {
619
+ throw new Error(t.status === 'done' ? 'This task is done already.' : 'Only a task that ended without being done can be accepted as done.');
620
+ }
621
+ t.accepted = { at: new Date().toISOString(), was: t.status, ...(t.reason ? { reason: t.reason } : {}), ...(text ? { note: text } : {}) };
622
+ t.status = 'done';
623
+ result = t;
624
+ });
625
+ return result;
543
626
  }
544
627
  /**
545
628
  * What a new attempt does with each field of a task — see `TASK_FIELD_ON_RERUN`.
@@ -595,6 +678,8 @@ export class SessionStore {
595
678
  inputsRestored: t.inputsRestored,
596
679
  artifactsKept: t.artifactsKept,
597
680
  vcs: t.vcs,
681
+ plan: t.plan,
682
+ accepted: t.accepted,
598
683
  },
599
684
  ];
600
685
  // An edit is applied only after the old attempt has been put beyond its reach.
@@ -627,6 +712,8 @@ export class SessionStore {
627
712
  // marks itself again after this, in the runner.
628
713
  t.stats = undefined;
629
714
  t.handoff = undefined;
715
+ t.plan = undefined;
716
+ t.accepted = undefined;
630
717
  t.scopeReverted = undefined;
631
718
  t.treeChanged = undefined;
632
719
  t.inputsRestored = undefined;
@@ -1,6 +1,6 @@
1
- import { git, isAncestor, isValidBranchName, lastFetchedAt } from './git.js';
1
+ import { freeBranchName, git, isAncestor, isValidBranchName, lastFetchedAt } from './git.js';
2
2
  import { fetchAsOperator, fetchCommandFor } from './remoteAuth.js';
3
- import { describeStart, remoteTip, repoDirOf } from './taskVcs.js';
3
+ import { describeStart, remoteTip, repoDirOf, sessionBranchName } from './taskVcs.js';
4
4
  const lines = (s) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
5
5
  const norm = (dir) => dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
6
6
  /** A task of the session has run: a start recorded since then is the one its work stands on. */
@@ -109,10 +109,17 @@ export async function startOptions(sessions) {
109
109
  repoDir: repo.repoDir,
110
110
  previousOffered: s.vcs?.startFrom === 'previous-session' || earlierHere,
111
111
  };
112
- if (startIsFixed(s) && s.vcsStart)
113
- entry.fixed = describeStart(s.vcsStart);
114
- else if (startIsFixed(s))
115
- entry.fixed = `it started from ${s.vcsBaseCommit?.slice(0, 8)}`;
112
+ if (startIsFixed(s)) {
113
+ entry.fixed = s.vcsStart ? describeStart(s.vcsStart) : `it started from ${s.vcsBaseCommit?.slice(0, 8)}`;
114
+ if (!repo.problem) {
115
+ entry.started = await startedOn(s, repo);
116
+ // What "another branch" offers first: the session's own setting, as for a session that has not run.
117
+ entry.choice = defaultChoice(s, repo);
118
+ const carries = carriedOn(s);
119
+ if (carries.length > 0)
120
+ entry.rechooseBlocked = carries.join(', ');
121
+ }
122
+ }
116
123
  else {
117
124
  if (s.vcsStart)
118
125
  entry.prepared = describeStart(s.vcsStart);
@@ -123,6 +130,32 @@ export async function startOptions(sessions) {
123
130
  }
124
131
  return { repos: [...repos.values()], sessions: out };
125
132
  }
133
+ /** The queued tasks that carry an earlier attempt on, on that attempt's branch: by title, quoted. */
134
+ function carriedOn(s) {
135
+ return s.tasks.filter((t) => t.status === 'queued' && (t.continuing || t.buildsOn)).map((t) => `"${t.title}"`);
136
+ }
137
+ /** Where a session that has run stands: see `StartOptionSession.started`. */
138
+ async function startedOn(s, repo) {
139
+ const dir = repo.repoDir;
140
+ const mode = s.vcs?.startFrom === 'existing-branch' ? 'existing-branch' : s.vcs?.branchMode === 'per-session' ? 'per-session' : 'per-task';
141
+ const branch = mode === 'existing-branch' ? s.vcs?.existingBranch?.trim() : mode === 'per-session' ? sessionBranchName(s) : s.vcsStart?.branch;
142
+ const recorded = s.vcsStart?.commit ?? s.vcsBaseCommit;
143
+ let tipNow = null;
144
+ let remote;
145
+ if (branch) {
146
+ if (mode === 'per-task' && s.vcsStart?.remote && branch.startsWith(`${s.vcsStart.remote}/`)) {
147
+ tipNow = await remoteTip(dir, s.vcsStart.remote, branch.slice(s.vcsStart.remote.length + 1));
148
+ }
149
+ else {
150
+ tipNow = repo.local.find((b) => b.name === branch)?.commit ?? null;
151
+ const r = s.vcs?.baseRemote?.trim() || defaultRemote(repo);
152
+ const theirs = r ? repo.remotes.find((x) => x.name === r)?.branches.find((b) => b.name === branch) : undefined;
153
+ if (r && theirs)
154
+ remote = { name: `${r}/${branch}`, commit: theirs.commit };
155
+ }
156
+ }
157
+ return { mode, ...(branch ? { branch } : {}), ...(recorded ? { recorded } : {}), tipNow, ...(remote ? { remote } : {}) };
158
+ }
126
159
  /**
127
160
  * Checks a choice against the repository and works out what it sets. Nothing is written to the session
128
161
  * here; for `remote` with `onBranch` the local branch is made, or moved forward, since that is the
@@ -130,8 +163,47 @@ export async function startOptions(sessions) {
130
163
  * only says whether it could be done (every session of a run is checked so before any branch moves).
131
164
  */
132
165
  export async function applyStartChoice(session, choice, opts = {}) {
133
- // Nothing to choose without version control, or once the start is fixed: see `startIsFixed`.
134
- if (!session.vcs?.enabled || startIsFixed(session))
166
+ // Nothing to choose without version control; a start that is fixed is kept unless chosen again.
167
+ if (!session.vcs?.enabled)
168
+ return { ok: true };
169
+ if (!startIsFixed(session))
170
+ return await chooseStart(session, choice, opts);
171
+ if (!choice.rechoose)
172
+ return { ok: true };
173
+ /*
174
+ * Chosen again after the session ran (operator's report, 2026-10-13: "the retry remains permanently bound
175
+ * to the session's original branch and commit"). Not while a queued task carries an earlier attempt on:
176
+ * that attempt's work is on its own branch, and the task goes on there.
177
+ */
178
+ const carries = carriedOn(session);
179
+ if (carries.length > 0) {
180
+ return {
181
+ ok: false,
182
+ problem: `${carries.join(', ')} ${carries.length === 1 ? 'carries' : 'carry'} an earlier attempt on, on its own branch, so where "${session.name}" starts stays as it is. ` +
183
+ 'To start it from another branch, queue it with "Fix the prompt and queue it again" instead.',
184
+ };
185
+ }
186
+ const done = await chooseStart(session, choice, opts);
187
+ if (!done.ok || !done.applied)
188
+ return done;
189
+ const applied = done.applied;
190
+ // A branch carried on is the new one itself; the one branch of a per-session session goes on under a new name.
191
+ const perSession = session.vcs.branchMode === 'per-session' && (applied.vcs.startFrom ?? session.vcs.startFrom) !== 'existing-branch';
192
+ const freshBranch = perSession && !opts.dry ? await freeBranchName(repoDirOf(session), sessionBranchName(session)) : undefined;
193
+ return {
194
+ ok: true,
195
+ applied: {
196
+ ...applied,
197
+ clearStart: true,
198
+ restart: freshBranch ? { freshBranch } : {},
199
+ said: `${applied.said} — chosen again after the session had run: the tasks that run next start from it, a task run again included` +
200
+ (freshBranch ? `, on the new branch ${freshBranch} (${sessionBranchName(session)} keeps what was done on it)` : ''),
201
+ },
202
+ };
203
+ }
204
+ /** `applyStartChoice` for a session whose start is not fixed: see there. */
205
+ async function chooseStart(session, choice, opts) {
206
+ if (!session.vcs?.enabled)
135
207
  return { ok: true };
136
208
  const dir = repoDirOf(session);
137
209
  const before = pointOf(session.vcs);