copilot-operator 0.1.42 → 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 (59) hide show
  1. package/dist/src/api/operator.controller.js +131 -0
  2. package/dist/src/api/operator.service.js +335 -3
  3. package/dist/src/api/server.js +16 -0
  4. package/dist/src/cli.js +12 -0
  5. package/dist/src/config/schema.js +10 -0
  6. package/dist/src/orchestrator/taskRunner.js +109 -38
  7. package/dist/src/plan/brief.js +57 -20
  8. package/dist/src/plan/changes.js +217 -0
  9. package/dist/src/plan/schema.js +1 -1
  10. package/dist/src/plan/systemGuide.js +597 -12
  11. package/dist/src/session/compose.js +15 -1
  12. package/dist/src/session/model.js +1 -1
  13. package/dist/src/session/store.js +60 -0
  14. package/dist/src/vcs/taskVcs.js +110 -1
  15. package/dist/web/404.html +2 -2
  16. package/dist/web/_next/static/WmzBz4QN36pcz4PL_cfEW/_buildManifest.js +1 -0
  17. package/dist/web/_next/static/chunks/499-751e0be9d4f86e2d.js +1 -0
  18. package/dist/web/_next/static/chunks/549-2415dd26f4e53d07.js +1 -0
  19. package/dist/web/_next/static/chunks/699-9d031f8c145a0465.js +1 -0
  20. package/dist/web/_next/static/chunks/867-aea30f560bc18eaf.js +1 -0
  21. package/dist/web/_next/static/chunks/897-5a34109533c3da50.js +1 -0
  22. package/dist/web/_next/static/chunks/app/defaults/page-56134fbb8cc7554f.js +1 -0
  23. package/dist/web/_next/static/chunks/app/history/{page-47973539ceff81d6.js → page-0bb537407fed5944.js} +1 -1
  24. package/dist/web/_next/static/chunks/app/import/{page-999d6cd958989723.js → page-8ef75fc08638e75f.js} +1 -1
  25. package/dist/web/_next/static/chunks/app/layout-dc602b727480a653.js +1 -0
  26. package/dist/web/_next/static/chunks/app/page-d73a8ff300862cd8.js +1 -0
  27. package/dist/web/_next/static/chunks/app/{page-e557d58476ae6383.js → sessions/page-28376d2e4a9b4738.js} +1 -1
  28. package/dist/web/_next/static/chunks/app/sessions/view/page-afc235a9171535d8.js +1 -0
  29. package/dist/web/_next/static/css/{0e55721108c300f2.css → 72c85e4322c21223.css} +1 -1
  30. package/dist/web/appearance.html +2 -2
  31. package/dist/web/appearance.txt +25 -28
  32. package/dist/web/defaults.html +2 -2
  33. package/dist/web/defaults.txt +25 -28
  34. package/dist/web/history.html +2 -2
  35. package/dist/web/history.txt +25 -28
  36. package/dist/web/import.html +2 -2
  37. package/dist/web/import.txt +25 -28
  38. package/dist/web/index.html +2 -2
  39. package/dist/web/index.txt +25 -28
  40. package/dist/web/level1.html +2 -2
  41. package/dist/web/level1.txt +25 -28
  42. package/dist/web/presets.html +3 -3
  43. package/dist/web/presets.txt +25 -28
  44. package/dist/web/sessions/view.html +2 -2
  45. package/dist/web/sessions/view.txt +25 -28
  46. package/dist/web/sessions.html +16 -0
  47. package/dist/web/sessions.txt +26 -0
  48. package/dist/web/system.html +2 -2
  49. package/dist/web/system.txt +25 -28
  50. package/package.json +4 -2
  51. package/dist/web/_next/static/chunks/204-db82ca47ff211e21.js +0 -1
  52. package/dist/web/_next/static/chunks/499-39b2c00bda3656f0.js +0 -1
  53. package/dist/web/_next/static/chunks/549-348b0a1404407e5a.js +0 -1
  54. package/dist/web/_next/static/chunks/867-7d86a692260006f1.js +0 -1
  55. package/dist/web/_next/static/chunks/app/defaults/page-a25c40436fc312db.js +0 -1
  56. package/dist/web/_next/static/chunks/app/layout-6ee612d25e78b246.js +0 -1
  57. package/dist/web/_next/static/chunks/app/sessions/view/page-872d8b9fe8eedbbf.js +0 -1
  58. package/dist/web/_next/static/lDmqS7pWU_B_Ac5idPvdS/_buildManifest.js +0 -1
  59. /package/dist/web/_next/static/{lDmqS7pWU_B_Ac5idPvdS → WmzBz4QN36pcz4PL_cfEW}/_ssgManifest.js +0 -0
@@ -39,7 +39,7 @@ 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
45
  import { PLAN_FIRST, PLAN_NOT_NOW, PLAN_NUDGE_AFTER, describePlan, planApprovedMessage, planFrom, planModeNote, planNudge, planReviseMessage } from './planMode.js';
@@ -47,6 +47,7 @@ import { composeHandoff } from '../session/handoff.js';
47
47
  import { ProgressWatch } from './progress.js';
48
48
  import { contractConflicts, readsTreeClean } from './contract.js';
49
49
  import { treeFingerprint } from '../vcs/git.js';
50
+ import { isContinuable } from '../session/model.js';
50
51
  import { prepareForTask, commitTaskResult, commitShortfall, repoDirOf, trackedRepoOf, whereLeft } from '../vcs/taskVcs.js';
51
52
  import { protectInputs, inputsMessage, inputsAtCommit, untrackedInputs, blobOfFile } from '../vcs/inputs.js';
52
53
  import { artifactPatterns, artifactState, keepArtifacts } from '../vcs/artifacts.js';
@@ -1399,6 +1400,8 @@ export async function runTask(transport, session, task, deps) {
1399
1400
  // --- opening messages -------------------------------------------------------------
1400
1401
  const { content: level1 } = await store.getLevel1();
1401
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(() => []);
1402
1405
  const opening = composeOpening({
1403
1406
  level1,
1404
1407
  level2: task.level2,
@@ -1440,7 +1443,12 @@ export async function runTask(transport, session, task, deps) {
1440
1443
  ...(planning && planMode !== 'off'
1441
1444
  ? { planNote: planModeNote(planMode, planEnforced, planMode === 'approve' && !!deps.approvePlan && (deps.currentMode?.() ?? cfg.execution.mode) === 'confirm') }
1442
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) } : {}),
1443
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
+ }
1444
1452
  await setTask((t) => {
1445
1453
  t.firstMessage = opening.firstMessage;
1446
1454
  });
@@ -2657,6 +2665,15 @@ export async function runTask(transport, session, task, deps) {
2657
2665
  if (planningRounds === PLAN_NUDGE_AFTER)
2658
2666
  runnerNotes.push(planNudge(planningRounds));
2659
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
+ }
2660
2677
  await guardInputs(iterations);
2661
2678
  // --- report back ---------------------------------------------------------------
2662
2679
  const report = await writeReport(results, {
@@ -2926,52 +2943,106 @@ export async function runSession(sessionId, deps) {
2926
2943
  */
2927
2944
  const retriesAllowed = cfg.limits.retryBlockedInFreshChat ?? 0;
2928
2945
  let retried = 0;
2929
- while (outcome.status === 'blocked' && retried < retriesAllowed && !deps.signal?.aborted && !(outcome.stopCode && NOT_THE_CHATS.includes(outcome.stopCode))) {
2930
- retried += 1;
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;
2931
2965
  bus.publish({
2932
2966
  sessionId,
2933
2967
  taskId: task.id,
2934
- type: 'task-retry-fresh-chat',
2968
+ type: 'task-auto-continue',
2935
2969
  level: 'warn',
2936
- message: `"${task.title}" ended blocked; running it again in a fresh conversation (${retried} of ${retriesAllowed})`,
2937
- data: { attempt: retried, of: retriesAllowed, reason: outcome.reason },
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 },
2938
2972
  });
2939
- await store.rerunTask(sessionId, task.id, {});
2940
- // The conversation pointer is dropped, so the next message opens a new chat, registers
2941
- // it and sends the contract again. The old chat stays where it is, readable.
2942
- session = await store.updateSession(sessionId, (s) => {
2943
- s.chat = undefined;
2944
- s.contractSent = false;
2945
- // Marked, not counted: how many retries this run made is read back from the marks
2946
- // (`freshRetriesOfLatestRun`), so a later run cannot add to an earlier one's count.
2947
- const again = s.tasks.find((x) => x.id === task.id);
2948
- if (again)
2949
- again.freshRetry = true;
2950
- });
2951
- await chat.newChat();
2952
- /*
2953
- * The picker belongs to the conversation, so a new conversation has lost the choice.
2954
- *
2955
- * The model is applied once, before the first task, because that is where the
2956
- * conversation is opened — and this is the other place one is opened, so it has to be
2957
- * applied again here. Without it a session that asked for a particular model ran its
2958
- * retries on whatever the chat opens on, which is "Auto", and the record went on saying
2959
- * it was the model the session asked for. The review has always done this for its own
2960
- * fresh conversations a few hundred lines up; this path simply never did.
2961
- */
2962
- const freshModel = await applySessionModel(chat, session, bus, cfg, store, deps.models);
2963
- if (effectiveModels(session, cfg).model) {
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.
2964
2998
  session = await store.updateSession(sessionId, (s) => {
2965
- s.modelInUse = freshModel.current;
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;
2966
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;
2967
3031
  }
2968
- if (freshModel.refused)
2969
- throw new Error(freshModel.refused);
2970
- const fresh = session.tasks.find((x) => x.id === task.id);
2971
- if (!fresh)
3032
+ if (!(await carriedOnAtOnce()))
2972
3033
  break;
2973
- outcome = await runTask(chat, session, fresh, deps);
2974
- ran += 1;
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
+ });
2975
3046
  }
2976
3047
  if (retried > 0) {
2977
3048
  bus.publish({
@@ -796,7 +796,16 @@ Skipped entirely when every task ended **"done"**: say so in one line and go to
796
796
 
797
797
  Otherwise: diagnose first, propose second, and say which kind of proposal it is.
798
798
 
799
- **Ask for the exports**, with the record message from the script. Every row on \`/history\` carries
799
+ **The quickest way to what happened is the operator's own report.** On \`/\`, the workbench, they choose
800
+ the task or its session, press **"Ask Kerrigan"**, write what they saw and want, and press **"Copy for
801
+ Kerrigan"**: you get every task with its id, how it ended and why, what it changed, and their words,
802
+ with the form of the answer. Answer it with **changes** to those sessions — a task carried on with your
803
+ prompt (\`continue\`), a done one given a new prompt (\`newPrompt\`), one started over (\`runAgain\`),
804
+ undone (\`undo\`), accepted as done (\`accept\`), a task added, edited or removed — not with a new plan,
805
+ which would make copies in new chats. They paste your whole answer under **"Her answer"**, press **"Check
806
+ her answer"**, read each change in words, and **"Make the {n} change(s)"**; nothing starts by itself.
807
+
808
+ **When you need more than that, ask for the exports**, with the record message from the script. Every row on \`/history\` carries
800
809
  three links, each for that one task:
801
810
 
802
811
  - **"plan"** — the sessions and tasks as they are now, in this format, with the edits made in the
@@ -812,7 +821,7 @@ three links, each for that one task:
812
821
  **"save the log"** on the same row writes the runner's log of the latest attempt onto the
813
822
  operator's Desktop and opens Explorer with the file selected, ready to drag into this chat;
814
823
  **"save this attempt's log"** does the same for an earlier one. **"What happened"** unfolds the
815
- whole story under the row without saving anything. For a whole run rather than one task, it is **"Download everything as JSON"** on \`/\`:
824
+ whole story under the row without saving anything. For a whole run rather than one task, it is **"Download everything as JSON"** on \`/sessions\`:
816
825
  one file with every step, exit code, summary, review and commit of those sessions.
817
826
 
818
827
  **Read \`whyItFailed\` first**, then the last rounds. The failure words: \`blocked\` — the chat
@@ -830,7 +839,11 @@ in those words, every time:
830
839
 
831
840
  - **A new prompt.** The task text was ambiguous, a path or a port was wrong, the level 2 forbade
832
841
  what the task needed, a check asked the git index about a file the runner had not committed yet,
833
- or the chat did the wrong thing and the checks caught it. Give the whole replacement text, then:
842
+ or the chat did the wrong thing and the checks caught it. **Carry the task on with it first**: it keeps
843
+ what the failed attempt did, in its own chat and branch, and the chat is told how it ended and why.
844
+ Give the text, as a \`continue\` change, or for the operator to write on \`/\`: choose the task, put
845
+ the text in the chat box on the right, press **"Continue with this"**, then start it from the panel that
846
+ opens. Only when the earlier work should be thrown away, give the whole replacement text, then:
834
847
  1. On \`/history\`, in the **"Flow"** view, press **"Fix the prompt and queue it again"** on that row.
835
848
  2. Replace the text with the one above and press **"Save and queue again"**.
836
849
  3. Under **"What is next"**, press **"Continue: run the {n} queued task(s) in {s} session(s)"**
@@ -850,7 +863,10 @@ in those words, every time:
850
863
  When a failure shows up while the run is still going, **"Pause after this task"** holds the rest
851
864
  while you work it out.
852
865
  Putting the repository back to before the task is **"Restore"**; putting it back and re-running
853
- that task and every task after it is **"Run again from here"**.
866
+ that task and every task after it is **"Run again from here"**. On \`/\`, **"Undo…"** on the task offers
867
+ **"Undo only this task"** — its work taken out of the session's branch, what came after it kept — and
868
+ **"Go back to before this task"** — it and the tasks after it queued again, nothing started. A task the
869
+ operator finished by hand is **"Accept as done"**.
854
870
  - **A change to the bot.** No wording fixes it: the shell the task needs is not on the machine,
855
871
  the claim cannot be written as any of the \`expect\` kinds in the checks table above, the runner
856
872
  never picked up what the chat attached, the review did not run at all. Say **this is a change
@@ -936,17 +952,20 @@ answer means, and what you do on it:
936
952
  for a criterion that is only **claimed**, because it is the cheapest: it changes nothing and turns
937
953
  "claimed only" into "proven" or into a real failure you can then fix. Write it as a plan in this
938
954
  format.
939
- 2. **Change a task and run it again** — when the work is right for the task but the task was
940
- asked wrongly. Give the whole replacement prompt. On \`/history\`:
955
+ 2. **Give a done task a new prompt** — when the work is right as far as it goes but more is needed,
956
+ or it was asked wrongly. A \`newPrompt\` change, or on \`/\`: the task, its chat box, **"Queue with
957
+ this new prompt"** — it builds on what the task did. To start it over instead: on \`/history\`,
941
958
  **"Fix the prompt and queue it again"** on that row, replace the text, **"Save and queue
942
959
  again"**, then continue the run.
943
960
  3. **Put the repository back and solve it differently** — when the approach is wrong rather than
944
- the wording, and building on it would be building on the wrong thing. **"Restore"** on the
961
+ the wording, and building on it would be building on the wrong thing. An \`undo\` change, or on
962
+ \`/\` **"Undo…"**: **"Undo only this task"** takes its work out and keeps what came after;
963
+ **"Go back to before this task"** queues it and every one after it again. **"Restore"** on the
945
964
  task card puts the code back to before that task; **"Run again from here"** puts it back and
946
965
  re-runs that task and every one after it. Say what will be lost.
947
- 4. **A new session of tasks** — when what is missing was never asked for by any task, so there
948
- is nothing to fix and something to add. Write it as a plan in this format, for
949
- **"Create the sessions and tasks"**.
966
+ 4. **More tasks** — when what is missing was never asked for by any task, so there is nothing to
967
+ fix and something to add. In the same session, as \`addTask\` changes; as a new session, a plan
968
+ in this format, for **"Create the sessions and tasks"**.
950
969
  `.trim();
951
970
  const PHASE3_BG = `
952
971
  ## Фаза 3 — задача не завърши готова
@@ -956,7 +975,16 @@ const PHASE3_BG = `
956
975
 
957
976
  Иначе: първо диагноза, после предложение, и всеки път казвай от кой вид е предложението.
958
977
 
959
- **Поискай файловете**, със съобщението за записа от сценария. Всеки ред в \`/history\` носи три
978
+ **Най-бързият път до случилото се е отчетът на самия оператор.** В \`/\`, работното място, той избира
979
+ задачата или сесията ѝ, натиска **„Попитай Кериган“**, пише какво е видял и какво иска и натиска
980
+ **„Копирай за Кериган“**: получаваш всяка задача с id-то ѝ, как е завършила и защо, какво е променила, и
981
+ думите му, с формата на отговора. Отговори с **промени** по тези сесии — задача, продължена с твоя prompt
982
+ (\`continue\`), готова с нов prompt (\`newPrompt\`), пусната отначало (\`runAgain\`), отменена (\`undo\`),
983
+ приета като готова (\`accept\`), добавена, редактирана или премахната задача — а не с нов план, който би
984
+ направил копия в нови чатове. Той поставя целия ти отговор под **„Нейният отговор“**, натиска **„Провери
985
+ отговора ѝ“**, чете всяка промяна с думи и **„Направи промените ({n})“**; нищо не тръгва само.
986
+
987
+ **Когато ти трябва повече, поискай файловете**, със съобщението за записа от сценария. Всеки ред в \`/history\` носи три
960
988
  връзки, всяка за точно тази задача:
961
989
 
962
990
  - **„план“** — сесиите и задачите, както са сега, в този формат, с редакциите от интерфейса.
@@ -973,7 +1001,7 @@ const PHASE3_BG = `
973
1001
  оператора и отваря Explorer с избран файл, готов за влачене в този чат; **„запази log-а на този
974
1002
  опит“** прави същото за по-ранен. **„Какво се случи“** разгъва цялата история под реда, без да се
975
1003
  записва нищо. За цяло пускане, а не
976
- за една задача, е **„Изтегли всичко като JSON“** в \`/\`: един файл с всяка стъпка, код на изход,
1004
+ за една задача, е **„Изтегли всичко като JSON“** в \`/sessions\`: един файл с всяка стъпка, код на изход,
977
1005
  обяснение, рецензия и комит на тези сесии.
978
1006
 
979
1007
  **Чети първо \`whyItFailed\`**, после последните кръгове. Думите за провал: \`blocked\` — чатът
@@ -992,7 +1020,10 @@ const PHASE3_BG = `
992
1020
  - **Нов prompt.** Текстът на задачата е бил двусмислен, път или порт е грешен, ниво 2 е
993
1021
  забранявало това, което задачата иска, проверка е питала git индекса за файл, който runner-ът
994
1022
  още не е комитнал, или чатът е направил грешното нещо и проверките са го хванали. Дай целия
995
- нов текст, после:
1023
+ текст. **Първо продължи задачата с него**: пази каквото е свършил провалилият се опит, в своя чат и
1024
+ клон, а чатът научава как е завършил и защо. Дай текста като промяна \`continue\` или за оператора в
1025
+ \`/\`: задачата, текстът в полето на чата вдясно, **„Продължи с това“**, после пускане от панела,
1026
+ който се отваря. Само когато по-ранната работа трябва да се изхвърли, дай целия нов текст, после:
996
1027
  1. В \`/history\`, в изгледа **„Поток“**, натисни **„Поправи prompt-а и върни в опашката“** на този ред.
997
1028
  2. Замени текста с горния и натисни **„Запази и върни в опашката“**.
998
1029
  3. Под **„Какво следва“** натисни **„Продължи: пусни {n} чакащи задачи в {s} сесии“** (пише
@@ -1012,7 +1043,10 @@ const PHASE3_BG = `
1012
1043
  Когато провал се покаже, докато пускането още върви, **„Пауза след тази задача“** задържа
1013
1044
  останалото, докато го разбереш.
1014
1045
  Връщането на хранилището отпреди задачата е **„Върни“**; връщането му плюс ново изпълнение на
1015
- тази задача и всички след нея е **„Пусни отново оттук“**.
1046
+ тази задача и всички след нея е **„Пусни отново оттук“**. В \`/\` **„Отмени…“** на задачата предлага
1047
+ **„Отмени само тази задача“** — работата ѝ се изважда от клона на сесията, каквото е след нея остава — и
1048
+ **„Върни отпреди тази задача“** — тя и задачите след нея отново на опашката, без пускане. Задача,
1049
+ довършена ръчно от оператора, е **„Приеми като готова“**.
1016
1050
  - **Промяна по бота.** Никакви думи не го оправят: обвивката, която задачата иска, я няма на
1017
1051
  машината, твърдението не се изразява с нито един от видовете \`expect\` от таблицата с
1018
1052
  проверките по-горе, runner-ът изобщо не е взел това, което чатът е прикачил, рецензията не се
@@ -1099,17 +1133,20 @@ const PHASE4_BG = `
1099
1133
  я първа за критерий, който е само **твърдение**, защото е най-евтината: не променя нищо и превръща
1100
1134
  „само твърдение“ в „доказано“ или в истински провал, който после можеш да поправиш. Напиши я като
1101
1135
  план в този формат.
1102
- 2. **Промени задача и я пусни отново** — когато работата е правилна за задачата, но задачата е
1103
- била поискана грешно. Дай целия заместващ prompt. В \`/history\`:
1136
+ 2. **Дай на готова задача нов prompt** — когато работата е правилна дотук, но трябва още, или е била
1137
+ поискана грешно. Промяна \`newPrompt\` или в \`/\`: задачата, полето на чата, **„На опашката с този
1138
+ нов prompt“** — надгражда свършеното. За да тръгне отначало: в \`/history\`
1104
1139
  **„Поправи prompt-а и върни в опашката“** на този ред, замени текста,
1105
1140
  **„Запази и върни в опашката“**, после продължи изпълнението.
1106
1141
  3. **Върни хранилището и реши иначе** — когато грешен е подходът, а не формулировката, и да се
1107
- стъпва върху него значи да се стъпва върху грешното нещо. **„Върни“** на картата на задачата
1142
+ стъпва върху него значи да се стъпва върху грешното нещо. Промяна \`undo\` или в \`/\` **„Отмени…“**:
1143
+ **„Отмени само тази задача“** изважда работата ѝ и пази каквото е след нея; **„Върни отпреди тази
1144
+ задача“** слага нея и всяка след нея отново на опашката. **„Върни“** на картата на задачата
1108
1145
  връща кода отпреди тази задача; **„Пусни отново оттук“** го връща и пуска пак нея и всяка след
1109
1146
  нея. Кажи какво ще се загуби.
1110
- 4. **Нова сесия със задачи** — когато липсващото никога не е било поискано от никоя задача, така че
1111
- няма какво да се поправя, а има какво да се добави. Напиши я като план в този формат, за
1112
- **„Създай сесиите и задачите“**.
1147
+ 4. **Още задачи** — когато липсващото никога не е било поискано от никоя задача, така че няма какво
1148
+ да се поправя, а има какво да се добави. В същата сесия — като промени \`addTask\`; като нова сесия —
1149
+ план в този формат, за **„Създай сесиите и задачите“**.
1113
1150
  `.trim();
1114
1151
  /**
1115
1152
  * The operator's own part, verbatim — or, while there is none, the interview that produces it.
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Kerrigan's answer about work that already exists (operator's request, 2026-10-14: "no dead end — the user
3
+ * tells Kerrigan what happened and she proposes how to go on; even when the tasks are done she may judge the
4
+ * implementation wrong and ask for more"). A plan only ever made new sessions, so everything she advised about
5
+ * a session that had run had to be done by hand, or became a copy of it in a new chat.
6
+ *
7
+ * Two halves, both plain text so they work with any chat: `kerriganReport` writes what happened — each session
8
+ * and task with its id, how it ended and why, what it changed — together with the operator's note and the exact
9
+ * form of the answer; `checkChanges` reads her answer, a list of changes to those sessions and tasks, against
10
+ * them as they are now, and says in words what each would do before anything is done. The service applies them
11
+ * one by one through the same paths the buttons use, so a change is held to the same rules as a click.
12
+ */
13
+ import { z } from 'zod';
14
+ import { TaskInput, extractJson } from './schema.js';
15
+ import { canFollowUp, clipText, isContinuable } from '../session/model.js';
16
+ const SessionRef = z.string({ error: 'Each change names its "session": the id from the report (s-…), or its exact name.' }).trim().min(1);
17
+ const TaskRef = z.union([z.string().trim().min(1), z.number().int().positive()], {
18
+ error: 'Each change names its "task": the id from the report (t-…), its number in the session, or its exact title.',
19
+ });
20
+ export const ChangeSchema = z.discriminatedUnion('op', [
21
+ z.object({ op: z.literal('addTask'), session: SessionRef, after: TaskRef.optional(), task: TaskInput }),
22
+ z.object({
23
+ op: z.literal('editTask'),
24
+ session: SessionRef,
25
+ task: TaskRef,
26
+ title: z.string().trim().min(3).max(120).optional(),
27
+ prompt: z.string().trim().min(30, 'A new prompt needs a real instruction, not a few words.').optional(),
28
+ checks: TaskInput.shape.checks.optional(),
29
+ scope: TaskInput.shape.scope.optional(),
30
+ }),
31
+ z.object({ op: z.literal('removeTask'), session: SessionRef, task: TaskRef }),
32
+ z.object({ op: z.literal('continue'), session: SessionRef, task: TaskRef, instruction: z.string().trim().min(10, 'Say what to do now: "instruction" is the new prompt the task carries on with.') }),
33
+ z.object({ op: z.literal('newPrompt'), session: SessionRef, task: TaskRef, prompt: z.string().trim().min(30, 'A new prompt needs a real instruction, not a few words.') }),
34
+ z.object({ op: z.literal('runAgain'), session: SessionRef, task: TaskRef, prompt: z.string().trim().min(30).optional() }),
35
+ z.object({ op: z.literal('undo'), session: SessionRef, task: TaskRef, how: z.enum(['this', 'back'], { error: '"how" is "this" (take only this task\'s work out) or "back" (go back to before it, and queue the tasks after it too).' }) }),
36
+ z.object({ op: z.literal('accept'), session: SessionRef, task: TaskRef, note: z.string().trim().optional() }),
37
+ z.object({ op: z.literal('note'), session: SessionRef, task: TaskRef, text: z.string().trim().min(1) }),
38
+ ], {
39
+ error: (i) => (i.code === 'invalid_union' ? '"op" must be one of addTask, editTask, removeTask, continue, newPrompt, runAgain, undo, accept, note.' : undefined),
40
+ });
41
+ export const ChangesSchema = z.object({
42
+ version: z.literal(1, 'Set "version": 1.'),
43
+ notes: z.string().trim().default(''),
44
+ changes: z.array(ChangeSchema).min(1, 'An answer with "changes" needs at least one.'),
45
+ });
46
+ /** Whether the pasted text is an answer with changes rather than a plan of new sessions. */
47
+ export function looksLikeChanges(text) {
48
+ try {
49
+ const raw = JSON.parse(extractJson(text ?? ''));
50
+ return !!raw && typeof raw === 'object' && Array.isArray(raw.changes);
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ const RUNNING = ['running', 'waiting-approval'];
57
+ function findSession(sessions, ref) {
58
+ return sessions.find((s) => s.id === ref) ?? sessions.find((s) => s.name === ref) ?? sessions.find((s) => s.name.toLowerCase() === ref.toLowerCase());
59
+ }
60
+ function findTask(session, ref) {
61
+ if (typeof ref === 'number' || /^\d+$/.test(ref))
62
+ return session.tasks[Number(ref) - 1];
63
+ return session.tasks.find((t) => t.id === ref) ?? session.tasks.find((t) => t.title === ref) ?? session.tasks.find((t) => t.title.toLowerCase() === ref.toLowerCase());
64
+ }
65
+ /** Reads Kerrigan's answer and finds each change against the sessions as they are now. Nothing is changed. */
66
+ export function checkChanges(text, sessions) {
67
+ let raw;
68
+ try {
69
+ raw = JSON.parse(extractJson(text ?? ''));
70
+ }
71
+ catch (e) {
72
+ return { ok: false, warnings: [], issues: [{ path: '', message: `This is not valid JSON: ${e.message}.` }] };
73
+ }
74
+ const parsed = ChangesSchema.safeParse(raw);
75
+ if (!parsed.success) {
76
+ return { ok: false, warnings: [], issues: parsed.error.issues.map((i) => ({ path: i.path.map((p) => (typeof p === 'number' ? `[${p}]` : `.${String(p)}`)).join('').replace(/^\./, ''), message: i.message })) };
77
+ }
78
+ const issues = [];
79
+ const warnings = [];
80
+ const changes = [];
81
+ parsed.data.changes.forEach((change, index) => {
82
+ const path = `changes[${index}]`;
83
+ const session = findSession(sessions, change.session);
84
+ if (!session) {
85
+ issues.push({ path: `${path}.session`, message: `No session "${change.session}". Use the id from the report (s-…) or the session's exact name.` });
86
+ return;
87
+ }
88
+ const base = { index, change, sessionId: session.id, sessionName: session.name };
89
+ if (change.op === 'addTask') {
90
+ const after = change.after !== undefined ? findTask(session, change.after) : undefined;
91
+ if (change.after !== undefined && !after) {
92
+ issues.push({ path: `${path}.after`, message: `Session "${session.name}" has no task "${change.after}" to put the new one after.` });
93
+ return;
94
+ }
95
+ changes.push({ ...base, says: `add task "${change.task.title}" to "${session.name}"${after ? `, after "${after.title}"` : ', at the end'}, queued` });
96
+ return;
97
+ }
98
+ const task = findTask(session, change.task);
99
+ if (!task) {
100
+ issues.push({ path: `${path}.task`, message: `Session "${session.name}" has no task "${change.task}". Use the id from the report (t-…), its number, or its exact title.` });
101
+ return;
102
+ }
103
+ const named = { ...base, taskId: task.id, taskTitle: task.title };
104
+ const refuse = (message) => {
105
+ issues.push({ path: `${path}.op`, message: `"${task.title}" in "${session.name}" is ${task.status}: ${message}` });
106
+ };
107
+ if (RUNNING.includes(task.status))
108
+ return refuse('it is running now; nothing can be changed on it until it stops.');
109
+ switch (change.op) {
110
+ case 'editTask':
111
+ if (task.status !== 'queued')
112
+ return refuse('only a queued task is edited; for one that ran use "runAgain" with a "prompt", "continue" or "newPrompt".');
113
+ changes.push({ ...named, says: `edit "${task.title}": ${['title', 'prompt', 'checks', 'scope'].filter((k) => change[k] !== undefined).join(', ') || 'nothing'}` });
114
+ return;
115
+ case 'removeTask':
116
+ if (task.status !== 'queued')
117
+ return refuse('only a queued task is removed; a task that ran keeps its record.');
118
+ changes.push({ ...named, says: `remove the queued task "${task.title}"` });
119
+ return;
120
+ case 'continue':
121
+ if (!isContinuable(task) && !canFollowUp(task))
122
+ return refuse('only a task that failed, blocked or stopped is carried on; for a done one use "newPrompt".');
123
+ changes.push({ ...named, says: `carry "${task.title}" on in its own chat and branch, from where it got to, with the new prompt` });
124
+ return;
125
+ case 'newPrompt':
126
+ if (task.status !== 'done')
127
+ return refuse('"newPrompt" builds on a done task\'s work; for a failed one use "continue".');
128
+ changes.push({ ...named, says: `give the done task "${task.title}" a new prompt that builds on its work, queued` });
129
+ return;
130
+ case 'runAgain':
131
+ if (task.status === 'queued')
132
+ return refuse('it is queued already; use "editTask" to change it.');
133
+ changes.push({ ...named, says: `run "${task.title}" again from where it first started${change.prompt ? ', with a new prompt' : ''}, queued` });
134
+ return;
135
+ case 'undo':
136
+ if (task.status === 'queued' || !task.startedAt)
137
+ return refuse('it has not run since it was queued; there is nothing of it to undo.');
138
+ changes.push({ ...named, says: change.how === 'this' ? `undo only "${task.title}": its work taken out, the tasks after it kept; it is queued again` : `go back to before "${task.title}": it and the tasks after it queued again` });
139
+ return;
140
+ case 'accept':
141
+ if (!['failed', 'blocked', 'limit-reached', 'aborted'].includes(task.status))
142
+ return refuse('only a task that ended without being done is accepted as done.');
143
+ changes.push({ ...named, says: `accept "${task.title}" as done${change.note ? ', with a note' : ''}` });
144
+ return;
145
+ case 'note':
146
+ if (task.status !== 'queued')
147
+ warnings.push(`The note for "${task.title}" (${task.status}) goes out only when it runs again.`);
148
+ changes.push({ ...named, says: `a note to the chat for "${task.title}"` });
149
+ return;
150
+ }
151
+ });
152
+ if (issues.length > 0)
153
+ return { ok: false, issues, warnings };
154
+ return { ok: true, notes: parsed.data.notes, changes, warnings };
155
+ }
156
+ // --- the report ------------------------------------------------------------------------------
157
+ const clip = (s, n) => (clipText(s, n) ?? '').replace(/\s+/g, ' ').trim();
158
+ function taskLines(task, index) {
159
+ const out = [`${index + 1}. "${task.title}" — id ${task.id} — ${task.status}${(task.attempt ?? 1) > 1 ? `, attempt ${task.attempt}` : ''}`];
160
+ if (task.accepted)
161
+ out.push(` accepted as done by the operator (it had ended ${task.accepted.was})${task.accepted.note ? `: ${clip(task.accepted.note, 300)}` : ''}`);
162
+ const undone = task.attempts?.at(-1)?.undone;
163
+ if (task.status === 'queued' && undone)
164
+ out.push(` its last attempt was undone by the operator (${undone.how === 'this' ? 'only its work taken out' : 'back to before it'})`);
165
+ if (task.continuing?.instruction)
166
+ out.push(` carried on with the operator's prompt: ${clip(task.continuing.instruction, 400)}`);
167
+ if (task.status === 'queued')
168
+ out.push(` prompt: ${clip(task.prompt, 500)}`);
169
+ if (task.summary && task.status !== 'queued')
170
+ out.push(` summary: ${clip(task.summary, 600)}`);
171
+ if (task.reason && task.reason !== task.summary && task.status !== 'done')
172
+ out.push(` why it stopped: ${clip(task.reason, 600)}`);
173
+ const failed = (task.checkResults ?? []).filter((c) => !c.passed);
174
+ if (failed.length > 0)
175
+ out.push(` checks that did not pass: ${failed.slice(0, 6).map((c) => `${c.name} (${clip(c.detail, 160)})`).join('; ')}`);
176
+ if (task.review?.verdict) {
177
+ const findings = (task.review.findings ?? []).slice(0, 5).map((f) => `${clip(f.what, 200)}${f.where ? ` [${clip(f.where, 80)}]` : ''}`);
178
+ out.push(` review: ${task.review.verdict}${findings.length > 0 ? ` — ${findings.join('; ')}` : ''}`);
179
+ }
180
+ const files = (task.vcs?.files ?? []).map((f) => f.path);
181
+ if (files.length > 0)
182
+ out.push(` changed: ${files.slice(0, 25).join(', ')}${files.length > 25 ? ` and ${files.length - 25} more` : ''}`);
183
+ if (task.vcs?.branch)
184
+ out.push(` branch ${task.vcs.branch}${task.vcs.commit ? ` at ${task.vcs.commit.slice(0, 8)}` : ''}`);
185
+ const notes = task.notes ?? [];
186
+ if (notes.length > 0)
187
+ out.push(` operator's notes: ${notes.slice(-3).map((n) => clip(n.text, 200)).join(' | ')}`);
188
+ return out;
189
+ }
190
+ /**
191
+ * What happened, for Kerrigan: the sessions, each task with its id, how it ended and why, and what it changed;
192
+ * the operator's note; and the exact form of the answer. Plain text, for any chat.
193
+ */
194
+ export function kerriganReport(sessions, opts = {}) {
195
+ const only = new Set(opts.taskIds ?? []);
196
+ const lines = [
197
+ '# What happened — for Kerrigan',
198
+ '',
199
+ `The operator sends you the state of their work as copilot-operator recorded it (${opts.at ?? new Date().toISOString()}), and asks you to look at it`,
200
+ 'and propose how to go on — whether the tasks failed, stopped, or are done but not what was meant.',
201
+ '',
202
+ ];
203
+ if (opts.note?.trim())
204
+ lines.push('## The operator says', '', opts.note.trim(), '');
205
+ for (const s of sessions) {
206
+ const vcs = s.vcs?.enabled ? `${s.vcs.branchMode === 'per-task' ? 'a branch per task' : 'one branch for the session, a commit per task'} in ${s.vcs.repoDir || '(no folder)'}` : 'no version control';
207
+ lines.push(`## Session "${s.name}" — id ${s.id}`, '', `${vcs}; ${s.onFailure === 'continue' ? 'independent tasks' : 'one chain: a failed task stops the ones after it'}.`, '');
208
+ s.tasks.forEach((t, i) => {
209
+ if (only.size > 0 && !only.has(t.id))
210
+ return;
211
+ lines.push(...taskLines(t, i));
212
+ });
213
+ lines.push('');
214
+ }
215
+ lines.push('## How to answer', '', 'First tell the operator, in their language and in a few sentences, what you think happened and what you propose.', 'Then give the changes as one JSON object in a ```json block, which the operator pastes on the import page;', 'it shows each change in words before anything is done. Name sessions and tasks by the ids above.', '', '```json', '{"version": 1, "notes": "why, in one or two sentences", "changes": [', ' {"op": "continue", "session": "s-…", "task": "t-…", "instruction": "what to do now — the task goes on from where it got to, in its own chat and branch"},', ' {"op": "addTask", "session": "s-…", "after": "t-…", "task": {"title": "…", "prompt": "…", "expected": "…", "checks": [], "scope": []}}', ']}', '```', '', 'The changes, one object each:', '- "continue" {task, instruction} — a task that failed, blocked or stopped goes on from where it got to, with your prompt added to its own.', '- "newPrompt" {task, prompt} — a done task gets a new prompt that builds on its work (more to do, or done wrong).', '- "runAgain" {task, prompt?} — a task that ran starts over from where it first started, optionally with a corrected prompt; its earlier work is not used.', '- "undo" {task, how} — "this": only this task\'s work is taken out of the session\'s branch, the tasks after it kept; "back": the branch goes back to before it, and it and the tasks after it are queued again. Nothing is deleted.', '- "accept" {task, note?} — a task that did not end done is accepted as done (for example, the operator finished it by hand).', '- "addTask" {task, after?} — a new task in an existing session, after the task named (else at the end); "task" has the fields of a plan\'s task.', '- "editTask" {task, title?, prompt?, checks?, scope?} — a queued task, changed before it runs.', '- "removeTask" {task} — a queued task that is no longer wanted.', '- "note" {task, text} — information for the chat about a task, sent with its next message.', '', 'Nothing runs by itself after the import: the operator starts the run. For work that belongs in a new session, answer with a plan as before.');
216
+ return lines.join('\n');
217
+ }
@@ -250,7 +250,7 @@ const TaskVcsInput = z.object({
250
250
  * ignored with a warning. See `importPlan`.
251
251
  */
252
252
  const LegacyMirrorInput = z.object({ enabled: z.boolean().optional(), rootDir: z.string().optional() }).passthrough();
253
- const TaskInput = z.object({
253
+ export const TaskInput = z.object({
254
254
  title: z
255
255
  .string()
256
256
  .trim()