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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/** Planning rounds after which the chat is reminded that a plan is what ends the phase. */
|
|
2
|
+
export const PLAN_NUDGE_AFTER = 12;
|
|
3
|
+
/** What the opening message says about the phase, for a task in plan mode. */
|
|
4
|
+
export function planModeNote(mode, enforced, asks) {
|
|
5
|
+
return [
|
|
6
|
+
'## Plan first (plan mode)',
|
|
7
|
+
'',
|
|
8
|
+
'Before you change anything in this task, look at the project and make a plan. In this phase you only',
|
|
9
|
+
'read: list the folders, open the files the task touches, read the git log, search for what the task',
|
|
10
|
+
'names — find what is already implemented, what is missing and what a change would affect.',
|
|
11
|
+
enforced
|
|
12
|
+
? 'Change nothing yet: the runner puts back any change made in this phase, and tells you.'
|
|
13
|
+
: 'Change nothing yet: nothing in this phase may change a file.',
|
|
14
|
+
'When you have seen enough, reply with status "plan" and no steps:',
|
|
15
|
+
'',
|
|
16
|
+
'```json',
|
|
17
|
+
'{"status": "plan", "notes": "…", "plan": {"existing": "what is already there that the task touches: files, functions, behaviour", "approach": "how you will do it", "steps": ["first stage", "second stage"], "files": ["the files you will create or change"], "verification": "how you will show the result works", "risks": "risks and open questions"}}',
|
|
18
|
+
'```',
|
|
19
|
+
'',
|
|
20
|
+
mode === 'approve' && asks
|
|
21
|
+
? 'The operator reads the plan, and approves it or asks for another with what to change.'
|
|
22
|
+
: 'The runner approves the plan and tells you so.',
|
|
23
|
+
'Begin the work only once you are told the plan is approved. Then carry it out; when it has to change,',
|
|
24
|
+
'say so in `notes` and why. The independent review checks the work against the approved plan.',
|
|
25
|
+
].join('\n');
|
|
26
|
+
}
|
|
27
|
+
/** The plan as the chat gave it, recorded. */
|
|
28
|
+
export function planFrom(reply, version, revisions = []) {
|
|
29
|
+
return {
|
|
30
|
+
existing: reply.existing.trim(),
|
|
31
|
+
approach: reply.approach.trim(),
|
|
32
|
+
steps: reply.steps.map((x) => x.trim()).filter(Boolean),
|
|
33
|
+
files: reply.files.map((x) => x.trim()).filter(Boolean),
|
|
34
|
+
verification: reply.verification.trim(),
|
|
35
|
+
...(reply.risks?.trim() ? { risks: reply.risks.trim() } : {}),
|
|
36
|
+
version,
|
|
37
|
+
at: new Date().toISOString(),
|
|
38
|
+
...(revisions.length > 0 ? { revisions } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The plan in words: for the record, the reviewer and the approval card's plain-text form. */
|
|
42
|
+
export function describePlan(plan) {
|
|
43
|
+
return [
|
|
44
|
+
`What is already there: ${plan.existing}`,
|
|
45
|
+
`Approach: ${plan.approach}`,
|
|
46
|
+
'Stages:',
|
|
47
|
+
...plan.steps.map((x, i) => `${i + 1}. ${x}`),
|
|
48
|
+
...(plan.files.length > 0 ? [`Files it will create or change: ${plan.files.join(', ')}`] : []),
|
|
49
|
+
`Verification: ${plan.verification}`,
|
|
50
|
+
...(plan.risks ? [`Risks and open questions: ${plan.risks}`] : []),
|
|
51
|
+
].join('\n');
|
|
52
|
+
}
|
|
53
|
+
/** Approved: the work begins. */
|
|
54
|
+
export function planApprovedMessage(approval) {
|
|
55
|
+
const by = approval.by === 'operator'
|
|
56
|
+
? 'The operator approved your plan.'
|
|
57
|
+
: `Your plan is approved by the runner${approval.why ? ` (${approval.why})` : ''}.`;
|
|
58
|
+
return [
|
|
59
|
+
by,
|
|
60
|
+
...(approval.note?.trim() ? ['', 'With this from the operator, which wins where it and the plan disagree:', '', approval.note.trim()] : []),
|
|
61
|
+
'',
|
|
62
|
+
'Carry it out now: reply with status "continue" and the steps of its first stage. Keep to the plan; when it',
|
|
63
|
+
'has to change, say so in `notes` and why — a change the plan did not foresee is declared, not hidden.',
|
|
64
|
+
'Verify the result as the plan says before you report done. Step numbering restarts at 1.',
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
/** The operator asked for another plan. */
|
|
68
|
+
export function planReviseMessage(comment, version) {
|
|
69
|
+
return [
|
|
70
|
+
`The operator read version ${version} of your plan and asks for another. What to change:`,
|
|
71
|
+
'',
|
|
72
|
+
comment.trim(),
|
|
73
|
+
'',
|
|
74
|
+
'You are still in the planning phase: change nothing. Look at what you need to, then reply with status',
|
|
75
|
+
'"plan" and the whole revised plan — not only what changed.',
|
|
76
|
+
].join('\n');
|
|
77
|
+
}
|
|
78
|
+
/** A plan sent when none is asked for: the phase is over, or the session is not in plan mode. */
|
|
79
|
+
export const PLAN_NOT_NOW = 'No plan is asked for now: the planning phase is over and the plan stands. Carry on with the work — reply with status ' +
|
|
80
|
+
'"continue" and steps, or end the task. When the plan has to change, say so in `notes` and why.';
|
|
81
|
+
/** "done" in the planning phase: nothing has been done yet. */
|
|
82
|
+
export const PLAN_FIRST = 'Not yet: this task is in the planning phase, and nothing has been changed. Reply with status "plan" and the plan; ' +
|
|
83
|
+
'the work begins once it is approved.';
|
|
84
|
+
/** Said after many planning rounds. */
|
|
85
|
+
export function planNudge(rounds) {
|
|
86
|
+
return `You have read the project for ${rounds} rounds in the planning phase. Send the plan now, with status "plan"; what you have not looked at yet can go under "risks".`;
|
|
87
|
+
}
|
|
88
|
+
/** The plan section of the reviewer's brief. */
|
|
89
|
+
export function planForReviewer(plan) {
|
|
90
|
+
return [
|
|
91
|
+
'## The plan the work was to follow',
|
|
92
|
+
'',
|
|
93
|
+
`The implementer made this plan before it changed anything, and it was approved${plan.approved?.by === 'operator' ? ' by the operator' : ''}.`,
|
|
94
|
+
'Check that the work follows it: a stage left out, a file changed that the plan does not name, or another',
|
|
95
|
+
'approach — none of them declared in the implementer\'s notes or deviations with a reason — is a finding',
|
|
96
|
+
'with "about": "work". A departure the implementer declared and justified is not a fault in itself; judge',
|
|
97
|
+
'whether the reason holds. The work is still judged against the task as written first.',
|
|
98
|
+
...(plan.approved?.note ? ['', `The operator added: ${plan.approved.note}`] : []),
|
|
99
|
+
'',
|
|
100
|
+
describePlan(plan),
|
|
101
|
+
].join('\n');
|
|
102
|
+
}
|
|
@@ -31,6 +31,7 @@ import { writeReport } from '../exec/reportFile.js';
|
|
|
31
31
|
import { mergeRedactions } from '../exec/redaction.js';
|
|
32
32
|
import { trackedRepoOf } from '../vcs/taskVcs.js';
|
|
33
33
|
import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
|
|
34
|
+
import { planForReviewer } from './planMode.js';
|
|
34
35
|
import { inScope } from '../vcs/scope.js';
|
|
35
36
|
/** The sentence both sides get when a review left something listening. */
|
|
36
37
|
function leftoverNote(leftovers) {
|
|
@@ -52,7 +53,9 @@ export function deliverableFor(task, summary, tracked, changedFiles) {
|
|
|
52
53
|
return { summary: text, why: 'no-files-changed' };
|
|
53
54
|
return undefined;
|
|
54
55
|
}
|
|
55
|
-
export function reviewBrief(session, task, changedFiles, cwd, deviations = [], previous, disputes = [], derivedFailing = [], earlier = [], deliverable
|
|
56
|
+
export function reviewBrief(session, task, changedFiles, cwd, deviations = [], previous, disputes = [], derivedFailing = [], earlier = [], deliverable,
|
|
57
|
+
/** The approved plan, in plan mode: see `planForReviewer`. */
|
|
58
|
+
plan) {
|
|
56
59
|
// The same repository the runner branched and read the changed files from, however it is named.
|
|
57
60
|
const repo = trackedRepoOf(session);
|
|
58
61
|
// Each file marked against the task's scope, and the scope said: the reviewer was shown side effects as
|
|
@@ -75,6 +78,7 @@ export function reviewBrief(session, task, changedFiles, cwd, deviations = [], p
|
|
|
75
78
|
'## The project instructions that applied',
|
|
76
79
|
'',
|
|
77
80
|
task.level2.trim() || '(none)',
|
|
81
|
+
...(plan ? ['', planForReviewer(plan)] : []),
|
|
78
82
|
...(earlier.length > 0
|
|
79
83
|
? [
|
|
80
84
|
'',
|
|
@@ -257,14 +261,14 @@ export async function runReview(transport, session, task, deps) {
|
|
|
257
261
|
// The machine's shells, when they are not what the contract's example assumes — the same note
|
|
258
262
|
// the implementer's conversation opens with, for a conversation working to the same example.
|
|
259
263
|
const machineNote = shellNote(undefined, defaultShell);
|
|
260
|
-
let before = await transport.sendAndConfirm(machineNote ? `${contract}
|
|
261
|
-
|
|
264
|
+
let before = await transport.sendAndConfirm(machineNote ? `${contract}
|
|
265
|
+
|
|
262
266
|
${machineNote}` : contract);
|
|
263
267
|
await transport.waitForReply(before);
|
|
264
268
|
await pacer.throttleSend();
|
|
265
269
|
// The brief is saved as sent. Until it was, whether a later round had been told what the
|
|
266
270
|
// earlier one found could not be checked from the run folder at all.
|
|
267
|
-
const brief = reviewBrief(session, task, deps.changedFiles, deps.cwd, deps.deviations, deps.previous, deps.disputes, deps.derivedFailing, deps.earlier, deps.deliverable);
|
|
271
|
+
const brief = reviewBrief(session, task, deps.changedFiles, deps.cwd, deps.deviations, deps.previous, deps.disputes, deps.derivedFailing, deps.earlier, deps.deliverable, deps.plan);
|
|
268
272
|
await saveReply('00-brief', brief);
|
|
269
273
|
await deps.record(`REVIEW ${round} BRIEF`, brief);
|
|
270
274
|
before = await transport.sendAndConfirm(brief);
|
|
@@ -39,13 +39,15 @@ import { confinementRefusal, sessionRoots } from '../exec/confinement.js';
|
|
|
39
39
|
import { writeReport } from '../exec/reportFile.js';
|
|
40
40
|
import { Pacer } from '../util/pacing.js';
|
|
41
41
|
import { RunLog } from '../log/runLog.js';
|
|
42
|
-
import { composeOpening, readOnlyNote } from '../session/compose.js';
|
|
42
|
+
import { composeOpening, operatorNotesSection, readOnlyNote } from '../session/compose.js';
|
|
43
43
|
import { MIN_TRIED_APPROACHES } from '../protocol/replySchema.js';
|
|
44
44
|
import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
|
|
45
|
+
import { PLAN_FIRST, PLAN_NOT_NOW, PLAN_NUDGE_AFTER, describePlan, planApprovedMessage, planFrom, planModeNote, planNudge, planReviseMessage } from './planMode.js';
|
|
45
46
|
import { composeHandoff } from '../session/handoff.js';
|
|
46
47
|
import { ProgressWatch } from './progress.js';
|
|
47
48
|
import { contractConflicts, readsTreeClean } from './contract.js';
|
|
48
49
|
import { treeFingerprint } from '../vcs/git.js';
|
|
50
|
+
import { isContinuable } from '../session/model.js';
|
|
49
51
|
import { prepareForTask, commitTaskResult, commitShortfall, repoDirOf, trackedRepoOf, whereLeft } from '../vcs/taskVcs.js';
|
|
50
52
|
import { protectInputs, inputsMessage, inputsAtCommit, untrackedInputs, blobOfFile } from '../vcs/inputs.js';
|
|
51
53
|
import { artifactPatterns, artifactState, keepArtifacts } from '../vcs/artifacts.js';
|
|
@@ -650,6 +652,14 @@ async function withOperatorHand(transport, wanted, first, hooks, sessionId, bus,
|
|
|
650
652
|
return { result, goOnAsItIs: false };
|
|
651
653
|
}
|
|
652
654
|
/** Runs one task to completion inside an already-open transport. */
|
|
655
|
+
/** The approved plan a task works to: its own attempt's, else, for a continuation, the latest one before it. */
|
|
656
|
+
function approvedPlanOf(task) {
|
|
657
|
+
if (task.plan?.approved)
|
|
658
|
+
return task.plan;
|
|
659
|
+
if (!task.continuing)
|
|
660
|
+
return undefined;
|
|
661
|
+
return [...(task.attempts ?? [])].reverse().find((a) => a.plan?.approved)?.plan;
|
|
662
|
+
}
|
|
653
663
|
export async function runTask(transport, session, task, deps) {
|
|
654
664
|
const { cfg, store, bus, authorizer, signal } = deps;
|
|
655
665
|
// Every attempt gets its own folder. The first keeps the original name, so nothing that
|
|
@@ -1256,6 +1266,23 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1256
1266
|
const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
|
|
1257
1267
|
if (scopeEnforced)
|
|
1258
1268
|
enforcement = { scope, readOnly: !!task.readOnly };
|
|
1269
|
+
/*
|
|
1270
|
+
* Plan mode (2026-10-13): the session's own, else Settings. A continuation carries on under the plan
|
|
1271
|
+
* its attempt already has — it is in the conversation — so only a task that starts anew plans; a new
|
|
1272
|
+
* prompt on finished work is a new instruction, and plans. The planning phase is read-only, enforced
|
|
1273
|
+
* the way a read-only task is wherever the task has a branch of its own.
|
|
1274
|
+
*/
|
|
1275
|
+
const planMode = session.planMode ?? cfg.execution.planMode ?? 'off';
|
|
1276
|
+
let planning = planMode !== 'off' && !task.continuing;
|
|
1277
|
+
const planEnforced = planning && onOwnBranch;
|
|
1278
|
+
let planVersion = 0;
|
|
1279
|
+
let planningRounds = 0;
|
|
1280
|
+
const planRevisions = [];
|
|
1281
|
+
/*
|
|
1282
|
+
* The approved plan the work follows, for the reviewer: one approved in this attempt, or the earlier
|
|
1283
|
+
* attempt's for a task carried on. Kept here, because `task` is the record as it was when the attempt began.
|
|
1284
|
+
*/
|
|
1285
|
+
let planInForce = approvedPlanOf(task);
|
|
1259
1286
|
if (watchesTree)
|
|
1260
1287
|
startState = await treeState(repoDirOf(session));
|
|
1261
1288
|
// The operator's input files, read-only on a branch of the task's own; see `vcs/inputs.ts`.
|
|
@@ -1373,6 +1400,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1373
1400
|
// --- opening messages -------------------------------------------------------------
|
|
1374
1401
|
const { content: level1 } = await store.getLevel1();
|
|
1375
1402
|
const taskNumber = session.tasks.findIndex((t) => t.id === task.id) + 1;
|
|
1403
|
+
// The operator's notes not sent yet, marked sent as they go into this opening.
|
|
1404
|
+
const openingNotes = await store.takeUnsentNotes(session.id, task.id).catch(() => []);
|
|
1376
1405
|
const opening = composeOpening({
|
|
1377
1406
|
level1,
|
|
1378
1407
|
level2: task.level2,
|
|
@@ -1384,6 +1413,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1384
1413
|
contractAlreadySent: session.contractSent && !!session.chat,
|
|
1385
1414
|
continuing,
|
|
1386
1415
|
buildsOn,
|
|
1416
|
+
// The operator's new prompt goes out whether or not the attempt it follows reached a conversation.
|
|
1417
|
+
...(task.continuing?.instruction ? { instruction: { text: task.continuing.instruction, fromAttempt: task.continuing.fromAttempt, ended: task.continuing.ended } } : {}),
|
|
1387
1418
|
workDirNote: workingDirNote(work),
|
|
1388
1419
|
vcsNote: prepared.note,
|
|
1389
1420
|
// What is said about a commit is true only where one happens (live run 2026-10-03: promised with commits off).
|
|
@@ -1409,7 +1440,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1409
1440
|
`each listed in "tried". With fewer, the runner asks you for another approach instead of accepting it.`
|
|
1410
1441
|
: undefined,
|
|
1411
1442
|
shellNote: shellNote(shells, defaultShell),
|
|
1443
|
+
...(planning && planMode !== 'off'
|
|
1444
|
+
? { planNote: planModeNote(planMode, planEnforced, planMode === 'approve' && !!deps.approvePlan && (deps.currentMode?.() ?? cfg.execution.mode) === 'confirm') }
|
|
1445
|
+
: {}),
|
|
1446
|
+
// The operator's notes about this task not sent yet: they go out with this opening (2026-10-14).
|
|
1447
|
+
...(openingNotes.length > 0 ? { operatorNotes: operatorNotesSection(openingNotes, false) } : {}),
|
|
1412
1448
|
});
|
|
1449
|
+
if (openingNotes.length > 0) {
|
|
1450
|
+
sink.event('operator-note-sent', { notes: openingNotes.length, with: 'opening' }, `${openingNotes.length} note(s) from the operator go out with the opening`);
|
|
1451
|
+
}
|
|
1413
1452
|
await setTask((t) => {
|
|
1414
1453
|
t.firstMessage = opening.firstMessage;
|
|
1415
1454
|
});
|
|
@@ -1857,6 +1896,11 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1857
1896
|
deliverable,
|
|
1858
1897
|
deviations,
|
|
1859
1898
|
disputes,
|
|
1899
|
+
/*
|
|
1900
|
+
* The approved plan, for the review to hold the work to (plan mode, 2026-10-13): this attempt's, or —
|
|
1901
|
+
* carried on in its chat — the one the attempt it continues worked to.
|
|
1902
|
+
*/
|
|
1903
|
+
...(planInForce ? { plan: planInForce } : {}),
|
|
1860
1904
|
/*
|
|
1861
1905
|
* Each earlier finding with its evidence, the correction the implementer reports, and what the
|
|
1862
1906
|
* finding's own check came to at the last gate: the next reviewer judges the finding on the evidence,
|
|
@@ -2202,7 +2246,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
2202
2246
|
await setTask((t) => {
|
|
2203
2247
|
t.iterations = iterations;
|
|
2204
2248
|
});
|
|
2205
|
-
const { reply,
|
|
2249
|
+
const { reply, blocked } = parsed;
|
|
2250
|
+
// Nothing is done while the plan is being made: the stop word in a planning round ends nothing.
|
|
2251
|
+
const done = parsed.done && !planning;
|
|
2206
2252
|
sink.event('reply-parsed', { iteration: iterations, status: reply.status, steps: reply.steps.length, notes: reply.notes }, `iteration ${iterations}: ${reply.steps.length} step(s)${reply.notes ? ` — ${reply.notes}` : ''}`);
|
|
2207
2253
|
/*
|
|
2208
2254
|
* A deviation is recorded the moment it is declared, not when the task ends.
|
|
@@ -2262,6 +2308,81 @@ export async function runTask(transport, session, task, deps) {
|
|
|
2262
2308
|
* what the reply said, at greater length and one message later. The reason carries the
|
|
2263
2309
|
* approaches that were tried, and that is what the register shows.
|
|
2264
2310
|
*/
|
|
2311
|
+
/*
|
|
2312
|
+
* Plan mode: the reply that ends the planning phase — put to the operator or approved by the runner —
|
|
2313
|
+
* and the two that do not belong in it. Answered here, in a message of its own, as a repair of the
|
|
2314
|
+
* format is: no step runs and nothing is committed in between.
|
|
2315
|
+
*/
|
|
2316
|
+
if (reply.status === 'plan' || (planning && reply.status === 'done')) {
|
|
2317
|
+
let message;
|
|
2318
|
+
let tag;
|
|
2319
|
+
if (reply.status === 'done') {
|
|
2320
|
+
message = PLAN_FIRST;
|
|
2321
|
+
tag = 'plan-first';
|
|
2322
|
+
sink.event('plan-first', { iteration: iterations }, '"done" in the planning phase: nothing has been changed yet; the chat was asked for the plan', 'warn');
|
|
2323
|
+
}
|
|
2324
|
+
else if (!planning || !reply.plan) {
|
|
2325
|
+
message = PLAN_NOT_NOW;
|
|
2326
|
+
tag = 'plan-not-asked';
|
|
2327
|
+
sink.event('plan-not-asked', { iteration: iterations }, 'a plan arrived when none is asked for; the chat was told to carry on', 'warn');
|
|
2328
|
+
}
|
|
2329
|
+
else {
|
|
2330
|
+
planVersion += 1;
|
|
2331
|
+
tag = `plan-${planVersion}`;
|
|
2332
|
+
const plan = planFrom(reply.plan, planVersion, [...planRevisions]);
|
|
2333
|
+
await setTask((t) => {
|
|
2334
|
+
t.plan = plan;
|
|
2335
|
+
});
|
|
2336
|
+
await record(`PLAN ${planVersion}`, describePlan(plan));
|
|
2337
|
+
sink.event('plan-received', { version: planVersion, steps: plan.steps.length, files: plan.files }, `plan ${planVersion} received: ${plan.steps.length} stage(s)${plan.files.length > 0 ? `, ${plan.files.length} file(s)` : ''}`);
|
|
2338
|
+
// Asked of the operator only in a run that asks before each command: one that does not, approves it itself.
|
|
2339
|
+
const asks = planMode === 'approve' && !!deps.approvePlan && (deps.currentMode?.() ?? cfg.execution.mode) === 'confirm';
|
|
2340
|
+
let answer = { decision: 'approve' };
|
|
2341
|
+
if (asks) {
|
|
2342
|
+
sink.event('plan-waiting', { version: planVersion }, `plan ${planVersion} is waiting for the operator`, 'warn');
|
|
2343
|
+
answer = await deps.approvePlan({ sessionId: session.id, taskId: task.id, title: task.title, plan });
|
|
2344
|
+
}
|
|
2345
|
+
if (signal?.aborted || answer.decision === 'stop') {
|
|
2346
|
+
return await finish('aborted', answer.decision === 'stop' ? 'stopped by the operator at its plan' : 'stopped by the operator', undefined, lastMarkdown);
|
|
2347
|
+
}
|
|
2348
|
+
if (answer.decision === 'revise') {
|
|
2349
|
+
const comment = (answer.note ?? '').trim() || 'Make the plan more concrete: name the files and the stages.';
|
|
2350
|
+
planRevisions.push({ version: planVersion, comment, at: new Date().toISOString() });
|
|
2351
|
+
await setTask((t) => {
|
|
2352
|
+
if (t.plan)
|
|
2353
|
+
t.plan.revisions = [...planRevisions];
|
|
2354
|
+
});
|
|
2355
|
+
sink.event('plan-revise', { version: planVersion, comment }, `the operator asked for another plan: ${comment}`);
|
|
2356
|
+
message = planReviseMessage(comment, planVersion);
|
|
2357
|
+
}
|
|
2358
|
+
else {
|
|
2359
|
+
// Who approved it, and why the operator was not asked when they were not.
|
|
2360
|
+
const why = asks ? undefined : planMode === 'auto' ? 'plan mode approves plans automatically' : 'the run does not ask before each command';
|
|
2361
|
+
const approved = {
|
|
2362
|
+
by: asks ? 'operator' : 'auto',
|
|
2363
|
+
at: new Date().toISOString(),
|
|
2364
|
+
...(answer.note?.trim() ? { note: answer.note.trim() } : {}),
|
|
2365
|
+
...(why ? { why } : {}),
|
|
2366
|
+
};
|
|
2367
|
+
await setTask((t) => {
|
|
2368
|
+
if (t.plan)
|
|
2369
|
+
t.plan.approved = approved;
|
|
2370
|
+
});
|
|
2371
|
+
planInForce = { ...plan, approved };
|
|
2372
|
+
sink.event('plan-approved', { version: planVersion, by: approved.by, ...(approved.note ? { note: approved.note } : {}) }, `plan ${planVersion} approved ${asks ? 'by the operator' : `by the runner — ${why}`}${approved.note ? `, with a note` : ''}`);
|
|
2373
|
+
planning = false;
|
|
2374
|
+
message = planApprovedMessage(approved);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
await record(tag.toUpperCase().replace(/-/g, ' '), message);
|
|
2378
|
+
await pacer.throttleSend();
|
|
2379
|
+
const before = await transport.sendAndConfirm(message);
|
|
2380
|
+
const next = await transport.waitForReply(before);
|
|
2381
|
+
await saveReply(tag, next);
|
|
2382
|
+
lastMarkdown = next.markdown;
|
|
2383
|
+
await pacer.settle();
|
|
2384
|
+
continue;
|
|
2385
|
+
}
|
|
2265
2386
|
if (!blocked && reply.steps.length > 0)
|
|
2266
2387
|
earlyBlocks = 0;
|
|
2267
2388
|
/*
|
|
@@ -2279,8 +2400,12 @@ export async function runTask(transport, session, task, deps) {
|
|
|
2279
2400
|
* usually lost the assignment, said "above": it is given again rather than told nothing ran (live run 2026-10-04).
|
|
2280
2401
|
*/
|
|
2281
2402
|
const message = task.continuing
|
|
2282
|
-
?
|
|
2403
|
+
? `Not yet: this task is being continued, and nothing has run since. The assignment is this one${task.continuing.instruction ? '' : ', unchanged'}:\n\n` +
|
|
2283
2404
|
`${task.prompt.length > 6000 ? `${task.prompt.slice(0, 6000)}…` : task.prompt}\n\n` +
|
|
2405
|
+
// With the operator's new prompt, when there is one: the reason the task is being carried on at all.
|
|
2406
|
+
(task.continuing.instruction
|
|
2407
|
+
? `With the operator's new instruction, which wins where the two disagree:\n\n${task.continuing.instruction.length > 6000 ? `${task.continuing.instruction.slice(0, 6000)}…` : task.continuing.instruction}\n\n`
|
|
2408
|
+
: '') +
|
|
2284
2409
|
'Carry on from where it stopped: send your next step now with status "continue", for example a command that shows ' +
|
|
2285
2410
|
'the state of the files the task works on. A task may end "blocked" only after steps that ran.'
|
|
2286
2411
|
: 'Not yet: nothing has been run in this task. You do not need to operate anything yourself — this runner executes ' +
|
|
@@ -2522,6 +2647,33 @@ export async function runTask(transport, session, task, deps) {
|
|
|
2522
2647
|
});
|
|
2523
2648
|
}
|
|
2524
2649
|
}
|
|
2650
|
+
/*
|
|
2651
|
+
* The planning phase changes nothing: what a round changed is put back before the chat reads the
|
|
2652
|
+
* results, as for a read-only task, and the chat is told. A reminder after many rounds that the
|
|
2653
|
+
* plan is what ends the phase.
|
|
2654
|
+
*/
|
|
2655
|
+
if (planning) {
|
|
2656
|
+
planningRounds += 1;
|
|
2657
|
+
if (planEnforced) {
|
|
2658
|
+
const check = await enforceScope(repoDirOf(session), [], true).catch(() => null);
|
|
2659
|
+
if (check && (check.outside.length > 0 || check.failed.length > 0)) {
|
|
2660
|
+
sink.event('plan-change-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `changed in the planning phase, put back: ${check.reverted.join(', ') || '(none)'}`, 'warn');
|
|
2661
|
+
runnerNotes.push(`This is the planning phase, and nothing may change yet: the runner put back ${check.reverted.length > 0 ? check.reverted.join(', ') : 'nothing it could'}` +
|
|
2662
|
+
`${check.failed.length > 0 ? ` and could not put back ${check.failed.map((f) => f.path).join(', ')}` : ''}. Read, then send the plan.`);
|
|
2663
|
+
}
|
|
2664
|
+
}
|
|
2665
|
+
if (planningRounds === PLAN_NUDGE_AFTER)
|
|
2666
|
+
runnerNotes.push(planNudge(planningRounds));
|
|
2667
|
+
}
|
|
2668
|
+
/*
|
|
2669
|
+
* What the operator wrote while the task works (2026-10-14: like Claude Code, the user can say more
|
|
2670
|
+
* while it runs): it goes out with this report, and is marked sent.
|
|
2671
|
+
*/
|
|
2672
|
+
const fromOperator = await store.takeUnsentNotes(session.id, task.id).catch(() => []);
|
|
2673
|
+
if (fromOperator.length > 0) {
|
|
2674
|
+
runnerNotes.push(operatorNotesSection(fromOperator, true));
|
|
2675
|
+
sink.event('operator-note-sent', { notes: fromOperator.length, with: 'report', iteration: iterations }, `${fromOperator.length} note(s) from the operator go out with this report`);
|
|
2676
|
+
}
|
|
2525
2677
|
await guardInputs(iterations);
|
|
2526
2678
|
// --- report back ---------------------------------------------------------------
|
|
2527
2679
|
const report = await writeReport(results, {
|
|
@@ -2791,52 +2943,106 @@ export async function runSession(sessionId, deps) {
|
|
|
2791
2943
|
*/
|
|
2792
2944
|
const retriesAllowed = cfg.limits.retryBlockedInFreshChat ?? 0;
|
|
2793
2945
|
let retried = 0;
|
|
2794
|
-
|
|
2795
|
-
|
|
2946
|
+
/*
|
|
2947
|
+
* Keep going until it is done (operator's request, 2026-10-14: "like Claude Code — the work does not stop
|
|
2948
|
+
* until the task is ready, and the limits keep it from looping for ever"). A task stopped by a limit from
|
|
2949
|
+
* the settings — messages, minutes, the reply wait, replies in the wrong shape, rounds of fixing checks or
|
|
2950
|
+
* review findings — is carried on at once in its own chat, from where it got to, as "Continue" would, up
|
|
2951
|
+
* to `limits.autoContinue` times; that count is the bound on the whole, so a task gets at most
|
|
2952
|
+
* (autoContinue + 1) × maxIterations messages and as many × maxRunMinutes. Not a task the operator
|
|
2953
|
+
* stopped, not one that blocked with nothing to do (that goes to a fresh chat below, then to a person),
|
|
2954
|
+
* and not when a pause or Stop was asked for.
|
|
2955
|
+
*/
|
|
2956
|
+
const autoAllowed = Math.max(0, cfg.limits.autoContinue ?? 0);
|
|
2957
|
+
let autoContinued = 0;
|
|
2958
|
+
const carriedOnAtOnce = async () => {
|
|
2959
|
+
if (autoContinued >= autoAllowed || deps.signal?.aborted || deps.shouldPause?.())
|
|
2960
|
+
return false;
|
|
2961
|
+
const now = (await store.getSession(sessionId))?.tasks.find((x) => x.id === task.id);
|
|
2962
|
+
if (!now || !now.limit || now.status === 'aborted' || !isContinuable(now))
|
|
2963
|
+
return false;
|
|
2964
|
+
autoContinued += 1;
|
|
2796
2965
|
bus.publish({
|
|
2797
2966
|
sessionId,
|
|
2798
2967
|
taskId: task.id,
|
|
2799
|
-
type: 'task-
|
|
2968
|
+
type: 'task-auto-continue',
|
|
2800
2969
|
level: 'warn',
|
|
2801
|
-
message: `"${task.title}"
|
|
2802
|
-
data: {
|
|
2970
|
+
message: `"${task.title}" stopped at ${now.limit.setting} (${now.limit.value}); carrying it on in its own chat, from where it got to (${autoContinued} of ${autoAllowed})`,
|
|
2971
|
+
data: { setting: now.limit.setting, value: now.limit.value, time: autoContinued, of: autoAllowed, status: now.status },
|
|
2803
2972
|
});
|
|
2804
|
-
await store.
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
|
|
2819
|
-
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2973
|
+
await store.continueTask(sessionId, task.id);
|
|
2974
|
+
session = (await store.getSession(sessionId));
|
|
2975
|
+
const again = session.tasks.find((x) => x.id === task.id);
|
|
2976
|
+
if (!again)
|
|
2977
|
+
return false;
|
|
2978
|
+
outcome = await runTask(chat, session, again, deps);
|
|
2979
|
+
ran += 1;
|
|
2980
|
+
return true;
|
|
2981
|
+
};
|
|
2982
|
+
for (;;) {
|
|
2983
|
+
while (outcome.status === 'blocked' && retried < retriesAllowed && !deps.signal?.aborted && !(outcome.stopCode && NOT_THE_CHATS.includes(outcome.stopCode))
|
|
2984
|
+
// A task a limit stopped is carried on where it is, with its work, not started over in a fresh chat.
|
|
2985
|
+
&& !(autoContinued < autoAllowed && !!(await store.getSession(sessionId))?.tasks.find((x) => x.id === task.id)?.limit)) {
|
|
2986
|
+
retried += 1;
|
|
2987
|
+
bus.publish({
|
|
2988
|
+
sessionId,
|
|
2989
|
+
taskId: task.id,
|
|
2990
|
+
type: 'task-retry-fresh-chat',
|
|
2991
|
+
level: 'warn',
|
|
2992
|
+
message: `"${task.title}" ended blocked; running it again in a fresh conversation (${retried} of ${retriesAllowed})`,
|
|
2993
|
+
data: { attempt: retried, of: retriesAllowed, reason: outcome.reason },
|
|
2994
|
+
});
|
|
2995
|
+
await store.rerunTask(sessionId, task.id, {});
|
|
2996
|
+
// The conversation pointer is dropped, so the next message opens a new chat, registers
|
|
2997
|
+
// it and sends the contract again. The old chat stays where it is, readable.
|
|
2829
2998
|
session = await store.updateSession(sessionId, (s) => {
|
|
2830
|
-
s.
|
|
2999
|
+
s.chat = undefined;
|
|
3000
|
+
s.contractSent = false;
|
|
3001
|
+
// Marked, not counted: how many retries this run made is read back from the marks
|
|
3002
|
+
// (`freshRetriesOfLatestRun`), so a later run cannot add to an earlier one's count.
|
|
3003
|
+
const again = s.tasks.find((x) => x.id === task.id);
|
|
3004
|
+
if (again)
|
|
3005
|
+
again.freshRetry = true;
|
|
2831
3006
|
});
|
|
3007
|
+
await chat.newChat();
|
|
3008
|
+
/*
|
|
3009
|
+
* The picker belongs to the conversation, so a new conversation has lost the choice.
|
|
3010
|
+
*
|
|
3011
|
+
* The model is applied once, before the first task, because that is where the
|
|
3012
|
+
* conversation is opened — and this is the other place one is opened, so it has to be
|
|
3013
|
+
* applied again here. Without it a session that asked for a particular model ran its
|
|
3014
|
+
* retries on whatever the chat opens on, which is "Auto", and the record went on saying
|
|
3015
|
+
* it was the model the session asked for. The review has always done this for its own
|
|
3016
|
+
* fresh conversations a few hundred lines up; this path simply never did.
|
|
3017
|
+
*/
|
|
3018
|
+
const freshModel = await applySessionModel(chat, session, bus, cfg, store, deps.models);
|
|
3019
|
+
if (effectiveModels(session, cfg).model) {
|
|
3020
|
+
session = await store.updateSession(sessionId, (s) => {
|
|
3021
|
+
s.modelInUse = freshModel.current;
|
|
3022
|
+
});
|
|
3023
|
+
}
|
|
3024
|
+
if (freshModel.refused)
|
|
3025
|
+
throw new Error(freshModel.refused);
|
|
3026
|
+
const fresh = session.tasks.find((x) => x.id === task.id);
|
|
3027
|
+
if (!fresh)
|
|
3028
|
+
break;
|
|
3029
|
+
outcome = await runTask(chat, session, fresh, deps);
|
|
3030
|
+
ran += 1;
|
|
2832
3031
|
}
|
|
2833
|
-
if (
|
|
2834
|
-
throw new Error(freshModel.refused);
|
|
2835
|
-
const fresh = session.tasks.find((x) => x.id === task.id);
|
|
2836
|
-
if (!fresh)
|
|
3032
|
+
if (!(await carriedOnAtOnce()))
|
|
2837
3033
|
break;
|
|
2838
|
-
|
|
2839
|
-
|
|
3034
|
+
}
|
|
3035
|
+
if (autoContinued > 0) {
|
|
3036
|
+
bus.publish({
|
|
3037
|
+
sessionId,
|
|
3038
|
+
taskId: task.id,
|
|
3039
|
+
type: 'task-auto-continue-ended',
|
|
3040
|
+
level: outcome.status === 'done' ? 'info' : 'warn',
|
|
3041
|
+
message: outcome.status === 'done'
|
|
3042
|
+
? `"${task.title}" done after being carried on ${autoContinued} time(s) on its own`
|
|
3043
|
+
: `"${task.title}" ended ${outcome.status} after being carried on ${autoContinued} time(s) on its own${autoContinued >= autoAllowed ? ` — limits.autoContinue (${autoAllowed}) reached` : ''}`,
|
|
3044
|
+
data: { times: autoContinued, of: autoAllowed, status: outcome.status },
|
|
3045
|
+
});
|
|
2840
3046
|
}
|
|
2841
3047
|
if (retried > 0) {
|
|
2842
3048
|
bus.publish({
|