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.
- package/dist/src/api/operator.controller.js +159 -6
- package/dist/src/api/operator.service.js +417 -10
- package/dist/src/api/server.js +16 -0
- package/dist/src/cli.js +12 -0
- package/dist/src/config/schema.js +17 -0
- package/dist/src/orchestrator/planMode.js +102 -0
- package/dist/src/orchestrator/review.js +8 -4
- package/dist/src/orchestrator/taskRunner.js +246 -40
- package/dist/src/plan/brief.js +69 -20
- package/dist/src/plan/changes.js +217 -0
- package/dist/src/plan/importPlan.js +2 -0
- package/dist/src/plan/schema.js +11 -1
- package/dist/src/plan/systemGuide.js +790 -14
- package/dist/src/protocol/parser.js +26 -2
- package/dist/src/protocol/replySchema.js +28 -4
- package/dist/src/session/compose.js +76 -7
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/model.js +38 -0
- package/dist/src/session/store.js +90 -3
- package/dist/src/vcs/startChoice.js +80 -8
- package/dist/src/vcs/taskVcs.js +126 -8
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/WmzBz4QN36pcz4PL_cfEW/_buildManifest.js +1 -0
- package/dist/web/_next/static/chunks/499-751e0be9d4f86e2d.js +1 -0
- package/dist/web/_next/static/chunks/549-2415dd26f4e53d07.js +1 -0
- package/dist/web/_next/static/chunks/699-9d031f8c145a0465.js +1 -0
- package/dist/web/_next/static/chunks/867-aea30f560bc18eaf.js +1 -0
- package/dist/web/_next/static/chunks/897-5a34109533c3da50.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-56134fbb8cc7554f.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-0bb537407fed5944.js +1 -0
- package/dist/web/_next/static/chunks/app/import/{page-999d6cd958989723.js → page-8ef75fc08638e75f.js} +1 -1
- package/dist/web/_next/static/chunks/app/layout-dc602b727480a653.js +1 -0
- package/dist/web/_next/static/chunks/app/page-d73a8ff300862cd8.js +1 -0
- package/dist/web/_next/static/chunks/app/{page-e557d58476ae6383.js → sessions/page-28376d2e4a9b4738.js} +1 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-afc235a9171535d8.js +1 -0
- package/dist/web/_next/static/css/{cea7913ba416dbb7.css → 72c85e4322c21223.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +25 -28
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +25 -28
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +25 -28
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +25 -28
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +25 -28
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +25 -28
- package/dist/web/presets.html +3 -3
- package/dist/web/presets.txt +25 -28
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +25 -28
- package/dist/web/sessions.html +16 -0
- package/dist/web/sessions.txt +26 -0
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +25 -28
- package/package.json +161 -158
- package/dist/web/_next/static/-htN5GTSrd3W3f5j_QC2y/_buildManifest.js +0 -1
- package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +0 -1
- package/dist/web/_next/static/chunks/549-f295c8429e83b25b.js +0 -1
- package/dist/web/_next/static/chunks/584-578be94a0786334b.js +0 -1
- package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-879158e9e550eea7.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +0 -1
- /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
|
|
142
|
-
'Send the steps with status "continue"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
113
|
-
entry.fixed = describeStart(s.vcsStart)
|
|
114
|
-
|
|
115
|
-
|
|
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
|
|
134
|
-
if (!session.vcs?.enabled
|
|
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);
|