copilot-operator 0.1.12 → 0.1.15

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 (80) hide show
  1. package/README.md +9 -14
  2. package/dist/src/api/operator.controller.js +31 -42
  3. package/dist/src/api/operator.service.js +692 -364
  4. package/dist/src/api/saveToDesktop.js +1 -1
  5. package/dist/src/api/security.js +40 -10
  6. package/dist/src/api/server.js +75 -12
  7. package/dist/src/api/settings.js +156 -15
  8. package/dist/src/cli.js +108 -97
  9. package/dist/src/config/handWritten.js +36 -0
  10. package/dist/src/config/layout.js +13 -4
  11. package/dist/src/config/lockedPolicy.js +56 -22
  12. package/dist/src/config/schema.js +13 -84
  13. package/dist/src/context/desktopDir.js +44 -0
  14. package/dist/src/exec/authorizer.js +9 -1
  15. package/dist/src/exec/checks.js +240 -23
  16. package/dist/src/exec/dangerous.js +29 -5
  17. package/dist/src/exec/network.js +105 -28
  18. package/dist/src/exec/redaction.js +52 -9
  19. package/dist/src/exec/reportFile.js +4 -5
  20. package/dist/src/exec/runner.js +146 -13
  21. package/dist/src/exec/shells.js +41 -6
  22. package/dist/src/exec/workDir.js +4 -4
  23. package/dist/src/orchestrator/derivedChecks.js +1 -0
  24. package/dist/src/orchestrator/review.js +25 -3
  25. package/dist/src/orchestrator/taskRunner.js +482 -150
  26. package/dist/src/plan/brief.js +56 -44
  27. package/dist/src/plan/importPlan.js +18 -7
  28. package/dist/src/plan/schema.js +90 -31
  29. package/dist/src/plan/script.js +79 -90
  30. package/dist/src/plan/systemGuide.js +39 -182
  31. package/dist/src/protocol/parser.js +5 -1
  32. package/dist/src/session/debugExport.js +1 -1
  33. package/dist/src/session/exportRecord.js +1 -1
  34. package/dist/src/session/exports.js +52 -9
  35. package/dist/src/session/store.js +215 -80
  36. package/dist/src/vcs/git.js +77 -2
  37. package/dist/src/vcs/scope.js +29 -6
  38. package/dist/src/vcs/taskVcs.js +149 -23
  39. package/dist/web/404.html +2 -2
  40. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +1 -0
  41. package/dist/web/_next/static/chunks/549-9548d9a132c8c7d4.js +1 -0
  42. package/dist/web/_next/static/chunks/867-8b95ae984befc0e2.js +1 -0
  43. package/dist/web/_next/static/chunks/app/defaults/page-71a43ed69134b48d.js +1 -0
  44. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +1 -0
  45. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +1 -0
  46. package/dist/web/_next/static/css/{a0642fdb8adf9edb.css → e2a2c9eb3c226313.css} +1 -1
  47. package/dist/web/appearance.html +2 -2
  48. package/dist/web/appearance.txt +12 -12
  49. package/dist/web/defaults.html +2 -2
  50. package/dist/web/defaults.txt +12 -12
  51. package/dist/web/history.html +2 -2
  52. package/dist/web/history.txt +12 -12
  53. package/dist/web/import.html +2 -2
  54. package/dist/web/import.txt +12 -12
  55. package/dist/web/index.html +2 -2
  56. package/dist/web/index.txt +12 -12
  57. package/dist/web/level1.html +2 -2
  58. package/dist/web/level1.txt +12 -12
  59. package/dist/web/presets.html +2 -2
  60. package/dist/web/presets.txt +12 -12
  61. package/dist/web/sessions/view.html +2 -2
  62. package/dist/web/sessions/view.txt +12 -12
  63. package/dist/web/system.html +2 -2
  64. package/dist/web/system.txt +12 -12
  65. package/package.json +18 -10
  66. package/prompts/level1.md +2 -1
  67. package/prompts/organisation.bg.md +0 -1
  68. package/prompts/organisation.en.md +0 -1
  69. package/dist/src/context/contextFiles.js +0 -295
  70. package/dist/src/context/desktopMirror.js +0 -135
  71. package/dist/src/context/projectMirror.js +0 -432
  72. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +0 -1
  73. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +0 -1
  74. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +0 -1
  75. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +0 -1
  76. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +0 -1
  77. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +0 -1
  78. package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +0 -1
  79. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_buildManifest.js +0 -0
  80. /package/dist/web/_next/static/{bcBE4tY_aNh6kzLzN1luP → IwNZiBrEMAcXEUFj2miiU}/_ssgManifest.js +0 -0
@@ -92,20 +92,29 @@ ${lines.join('\n')}
92
92
  * Whether a run with nobody watching can start on this machine — and when it cannot, what to press.
93
93
  *
94
94
  * Kerrigan's phase 2 script offers the unattended button first, because on a machine that allows it
95
- * that is the ordinary way to run. On one that does not — no isolation claimed, or no allowlist —
96
- * the runner refuses it at the entrance, and a persona that recommends it has sent the operator to
97
- * a button that says no. She cannot see the machine; the app can, so it tells her, and only when it
98
- * matters. The labels are the ones on the screens, so check:guide holds them.
95
+ * that is the ordinary way to run. On one that does not — a policy lock, no isolation claimed, or no
96
+ * allowlist — the runner refuses it at the entrance, and a persona that recommends it has sent the
97
+ * operator to a button that says no. She cannot see the machine; the app can, so it tells her, and
98
+ * only when it matters. The labels are the ones on the screens, so check:guide holds them.
99
99
  */
100
100
  function machineSection(unattendedBlocked, lang) {
101
101
  if (!unattendedBlocked)
102
102
  return '';
103
- const whyBg = unattendedBlocked === 'isolation'
104
- ? '**„Къде върви ботът“** в `/defaults` още казва, че ботът върви в акаунта на оператора, без изолация'
105
- : 'списъкът с позволени програми е празен, а без него нищо не ограничава стъпка, която никой не гледа';
106
- const whyEn = unattendedBlocked === 'isolation'
107
- ? '**"Where the bot runs"** on `/defaults` still says the bot runs in the operator\'s own account, with no isolation'
108
- : 'the list of allowed programs is empty, and without it nothing limits a step that nobody is watching';
103
+ /*
104
+ * Each reason named as it is, because each sends the operator somewhere different. A lock blamed
105
+ * on the allowlist sent them to fill in a list that was already full, and the run was refused all
106
+ * the same: the lock is the machine's, set beside the install, and no page changes it.
107
+ */
108
+ const whyBg = {
109
+ lock: 'файлът `policy.lock.json` до инсталацията на тази машина забранява пускания без надзор, и никоя настройка на страниците не го променя',
110
+ isolation: '**„Къде върви ботът“** в `/defaults` още казва, че ботът върви в акаунта на оператора, без изолация',
111
+ allowlist: 'списъкът с позволени програми е празен, а без него нищо не ограничава стъпка, която никой не гледа',
112
+ }[unattendedBlocked];
113
+ const whyEn = {
114
+ lock: "this machine's `policy.lock.json`, beside the install, forbids runs with nobody watching, and no setting on any page changes it",
115
+ isolation: '**"Where the bot runs"** on `/defaults` still says the bot runs in the operator\'s own account, with no isolation',
116
+ allowlist: 'the list of allowed programs is empty, and without it nothing limits a step that nobody is watching',
117
+ }[unattendedBlocked];
109
118
  return lang === 'bg'
110
119
  ? `
111
120
  ## На тази машина пускане без надзор се отказва
@@ -248,14 +257,6 @@ export function planExample() {
248
257
  baseBranch: 'main',
249
258
  },
250
259
  review: { enabled: true, model: '' },
251
- mirror: {
252
- enabled: true,
253
- rootDir: root,
254
- includeDirs: ['src/invoices'],
255
- excludeDirs: [],
256
- respectGitignore: true,
257
- includeEnvFiles: false,
258
- },
259
260
  tasks: firstTasks,
260
261
  },
261
262
  {
@@ -308,6 +309,12 @@ const FIELD_ROWS_EN = [
308
309
  'no',
309
310
  'Overrides the setting above for this one session: give the same name to the sessions that should share a chat, and leave it "" for a session that should have its own.',
310
311
  ],
312
+ [
313
+ 'projectDir',
314
+ 'session',
315
+ 'no',
316
+ 'The project folder of a session with version control off: its commands run there. Leave it out for the default project. With version control on, `vcs.repoDir` is required and the commands run in it.',
317
+ ],
311
318
  [
312
319
  'review',
313
320
  'session',
@@ -317,7 +324,6 @@ const FIELD_ROWS_EN = [
317
324
  ['review', 'task', 'no', 'Set to `false` to skip the review for one task. Absent means the session decides.'],
318
325
  ['readOnly', 'task', 'no', '`true` for a task that must not change files — an audit, a smoke test, a report. The runner fails it if the tree changed, and still commits the change on its branch so nothing is lost.'],
319
326
  ['scope', 'task', 'no', 'The paths the task may change: files, folders (`tests/e2e/`) or `*`/`**` patterns. With version control on, the runner puts back any change outside them. Give it when the task is about one suite or module.'],
320
- ['mirror', 'session', 'no, but ask', 'Whether project files are copied and attached to the first message as context, and which: root, directories in and out, gitignore, env files. Off when left out.'],
321
327
  ['tasks', 'session', 'yes', 'At least one, in the order they must run.'],
322
328
  ['title', 'task', 'yes', '3 to 120 characters. Short, latin, hyphenated.'],
323
329
  ['prompt', 'task', 'yes', 'The task itself, at least 30 characters. This is what Copilot reads.'],
@@ -373,6 +379,12 @@ const FIELD_ROWS_BG = [
373
379
  'не',
374
380
  'Отменя настройката горе само за тази сесия: дай едно и също име на сесиите, които да споделят чат, и остави "" на онази, която да е сама.',
375
381
  ],
382
+ [
383
+ 'projectDir',
384
+ 'сесия',
385
+ 'не',
386
+ 'Папката на проекта на сесия с изключен контрол на версиите: командите ѝ се изпълняват там. Пропусни го за проекта по подразбиране. С включен контрол на версиите `vcs.repoDir` е задължително и командите се изпълняват в него.',
387
+ ],
376
388
  [
377
389
  'review',
378
390
  'сесия',
@@ -382,7 +394,6 @@ const FIELD_ROWS_BG = [
382
394
  ['review', 'задача', 'не', 'Сложи `false`, за да се пропусне рецензията само за тази задача. Липсата значи каквото казва сесията.'],
383
395
  ['readOnly', 'задача', 'не', '`true` за задача, която не бива да променя файлове — одит, smoke тест, доклад. Runner-ът я проваля, ако дървото е променено, и пак комитва промяната на клона ѝ, за да не се губи нищо.'],
384
396
  ['scope', 'задача', 'не', 'Пътищата, които задачата може да променя: файлове, папки (`tests/e2e/`) или шаблони с `*`/`**`. При включен контрол на версиите runner-ът връща всяка промяна извън тях. Давай го, когато задачата е за един suite или модул.'],
385
- ['mirror', 'сесия', 'не, но питай', 'Дали файлове от проекта се копират и прикачат към първото съобщение като контекст, и кои: корен, директории вътре и вън, gitignore, env файлове. Изключено, когато липсва.'],
386
397
  ['tasks', 'сесия', 'да', 'Поне една, в реда, в който трябва да се изпълнят.'],
387
398
  ['title', 'задача', 'да', 'От 3 до 120 знака. Кратко, латиница, с тирета.'],
388
399
  ['prompt', 'задача', 'да', 'Самата задача, поне 30 знака. Това чете Copilot.'],
@@ -432,6 +443,16 @@ is clean: the runner commits after the checks, and its own commit is what leaves
432
443
  - \`"previous-session"\` — the session carries on from the end of the branch of the session that
433
444
  ran before it **in the same repository**, so the sessions form a chain. The first session of
434
445
  such a chain starts from \`baseBranch\`. Sessions in other repositories do not count.
446
+ - \`"existing-branch"\` with \`existingBranch\` — no new branch at all: every task of the session
447
+ works on that existing local branch, exactly as named (\`"existingBranch": "recovery/apz-migration"\`),
448
+ one after another, building on what is already on it. For new work that continues a branch the
449
+ user already has. The branch must exist when the plan is imported; \`branchMode\`, \`branchName\`
450
+ and the prefix do not apply.
451
+
452
+ Before a session's first branch the runner fetches and fast-forwards the branch it starts from
453
+ (\`baseBranch\`, or \`existingBranch\`), so the work begins from the server's code; it never resets or
454
+ merges. \`"updateFromRemote": false\` turns that off — only when the user asks to work offline or from
455
+ local commits on purpose.
435
456
 
436
457
  Put the same \`startFrom\` on every session that works in one repository unless the user wants it
437
458
  mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
@@ -455,7 +476,9 @@ commit is written: imperative, short, about the change rather than about the tas
455
476
  \`"vcs": { "enabled": false, "repoDir": "", "branchMode": "per-task", "commitOnFinish": false, "branchPrefix": "cop/" }\`.
456
477
  Then give its tasks no \`vcs\` at all — no branch, no commit message — because there is nothing
457
478
  to name. Read-only work, audits and reports are the usual reason. Say in \`notes\` why you turned
458
- it off, so the operator can disagree with you.
479
+ it off, so the operator can disagree with you. Its commands run in the default project; when its
480
+ work is in another one, give that project's path as the session's \`projectDir\`, since the empty
481
+ \`repoDir\` cannot say it.
459
482
 
460
483
  **Do not leave \`vcs\` out of a session.** The system refuses the whole document and hands the
461
484
  user a line saying so, and you will have to ask the question then anyway — after wasting their
@@ -479,6 +502,15 @@ const VCS_DETAIL_BG = `
479
502
  - \`"previous-session"\` — сесията продължава от края на клона на сесията, пусната преди нея **в
480
503
  същото хранилище**, така че сесиите образуват верига. Първата сесия от веригата тръгва от
481
504
  \`baseBranch\`. Сесии в други хранилища не се броят.
505
+ - \`"existing-branch"\` с \`existingBranch\` — без нов клон: всяка задача от сесията работи на този
506
+ съществуващ локален клон, точно с това име (\`"existingBranch": "recovery/apz-migration"\`), една след
507
+ друга, върху това, което вече е там. За нова работа, която продължава клон, който потребителят вече
508
+ има. Клонът трябва да съществува при импорта; \`branchMode\`, \`branchName\` и представката не се прилагат.
509
+
510
+ Преди първия клон на сесията runner-ът прави fetch и fast-forward на клона, от който тя тръгва
511
+ (\`baseBranch\` или \`existingBranch\`), така че работата започва от кода на сървъра; никога не прави
512
+ reset или merge. \`"updateFromRemote": false\` го изключва — само ако потребителят поиска да работи
513
+ офлайн или нарочно от локални commit-и.
482
514
 
483
515
  Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
484
516
  различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
@@ -502,7 +534,9 @@ const VCS_DETAIL_BG = `
502
534
  \`"vcs": { "enabled": false, "repoDir": "", "branchMode": "per-task", "commitOnFinish": false, "branchPrefix": "cop/" }\`.
503
535
  На нейните задачи не давай \`vcs\` изобщо — нито клон, нито текст на комит — защото няма какво да
504
536
  се именува. Обичайната причина е работа само за четене, одит или отчет. Напиши в \`notes\` защо си
505
- го изключил, за да може операторът да не се съгласи с теб.
537
+ го изключил, за да може операторът да не се съгласи с теб. Командите ѝ се изпълняват в проекта по
538
+ подразбиране; когато работата ѝ е в друг, дай пътя на този проект като \`projectDir\` на сесията,
539
+ защото празното \`repoDir\` не може да го каже.
506
540
 
507
541
  **Не пропускай \`vcs\` в сесия.** Системата отказва целия документ и връща на потребителя ред,
508
542
  който го казва, а ти пак ще трябва да зададеш въпроса — след като си му загубил времето. Задай
@@ -606,16 +640,6 @@ const CHECKS_BG = `
606
640
  трите минават без свършена работа.
607
641
  Всеки критерий от заданието е покрит поне от една проверка, която пада, докато работата не е свършена.
608
642
  `.trim();
609
- const MIRROR_EN = `
610
- **mirror**: \`enabled\`, \`rootDir\` (absolute), \`includeDirs\` and \`excludeDirs\` (paths relative to
611
- the root, forward slashes), \`respectGitignore\` (leave it true), \`includeEnvFiles\` (leave it false
612
- unless the user insists — it copies secrets into a chat).
613
- `.trim();
614
- const MIRROR_BG = `
615
- **mirror**: \`enabled\`, \`rootDir\` (абсолютен), \`includeDirs\` и \`excludeDirs\` (пътища спрямо корена,
616
- с наклонени черти напред), \`respectGitignore\` (остави го true), \`includeEnvFiles\` (остави го false,
617
- освен ако потребителят не настоява — копира тайни в чат).
618
- `.trim();
619
643
  const VCS_RULE_EN = '- **Version control is yours to ask about, and there is no default.** With it on, the runner makes a branch\n' +
620
644
  ' before each task and a commit after it, using the names you give, and it never pushes; Copilot must not touch\n' +
621
645
  ' git itself. With it off, files are changed where they lie and there is no way back. Ask the user which it is,\n' +
@@ -1504,10 +1528,6 @@ asking for both — the first is what makes the next plan quicker to write than
1504
1528
  judges it. Ask whether it stays on and on which model; a model different from the working
1505
1529
  one catches more. \`model\` for the work itself only if the user names one from the chat's
1506
1530
  own picker.
1507
- - \`mirror\` (optional, off by default): whether project files are copied and attached to the
1508
- first message as context — from which root, which directories in and out, whether
1509
- \`.gitignore\` is respected (yes), whether \`.env\` files go in (no: they are secrets). Ask;
1510
- never assume.
1511
1531
  3. **The tasks.** For each: \`title\` (required), \`prompt\` (required — what Copilot reads:
1512
1532
  precise, with paths, ports and commands), \`expected\` (the bar, one sentence), \`checks\`
1513
1533
  (the same bar written so the runner can decide it; write one wherever a command or a file can
@@ -1592,8 +1612,6 @@ ${table(['Field', 'Where', 'Required', 'What it is'], rows)}
1592
1612
 
1593
1613
  ${VCS_DETAIL_EN}
1594
1614
 
1595
- ${MIRROR_EN}
1596
-
1597
1615
  ${CHECKS_EN}
1598
1616
 
1599
1617
  ### A filled-in example
@@ -1727,10 +1745,6 @@ ${systemGuideSection('bg')}
1727
1745
  работата и я оценява. Питай дали остава включен и на кой модел; модел, различен от
1728
1746
  работния, хваща повече. \`model\` за самата работа — само ако потребителят назове такъв
1729
1747
  от менюто на чата.
1730
- - \`mirror\` (незадължително, изключено по подразбиране): дали файлове от проекта се копират
1731
- и прикачат към първото съобщение като контекст — от кой корен, кои директории вътре и вън,
1732
- дали се спазва \`.gitignore\` (да), дали влизат \`.env\` файлове (не: те са тайни). Питай;
1733
- никога не предполагай.
1734
1748
  3. **Задачите.** За всяка: \`title\` (задължително), \`prompt\` (задължително — това чете
1735
1749
  Copilot: точно, с пътища, портове и команди), \`expected\` (летвата, едно изречение),
1736
1750
  \`checks\` (същата летва, написана така, че runner-ът да я решава сам; пиши по една навсякъде,
@@ -1816,8 +1830,6 @@ ${table(['Поле', 'Къде', 'Задължително', 'Какво е'], r
1816
1830
 
1817
1831
  ${VCS_DETAIL_BG}
1818
1832
 
1819
- ${MIRROR_BG}
1820
-
1821
1833
  ${CHECKS_BG}
1822
1834
 
1823
1835
  ### Попълнен пример
@@ -1,4 +1,4 @@
1
- import { DEFAULT_MIRROR, DEFAULT_VCS } from '../session/store.js';
1
+ import { DEFAULT_VCS, applyDefaultProject } from '../session/store.js';
2
2
  import { availableShells, detectShells } from '../exec/shells.js';
3
3
  /** The heading under which a task's acceptance bar reaches Copilot. English, like level 1. */
4
4
  const EXPECTED_HEADER = '### Expected result';
@@ -145,7 +145,13 @@ function unavailableShellWarnings(plan) {
145
145
  */
146
146
  export async function importPlan(store, plan, defaultModel = '', defaultReviewModel = '',
147
147
  /** The operator's persona at the moment of import, written into every task's level 2. */
148
- persona = '') {
148
+ persona = '',
149
+ /**
150
+ * The default project on the Project page, for a session that names no folder of its own. The
151
+ * service passes it, so an imported session works where one made by hand would; see
152
+ * `applyDefaultProject`. Empty leaves such a session without a folder.
153
+ */
154
+ defaultProjectDir = '') {
149
155
  const sessions = [];
150
156
  const warnings = [...unavailableShellWarnings(plan)];
151
157
  /*
@@ -160,15 +166,18 @@ persona = '') {
160
166
  ? (plan.plan.trim() || `plan-${new Date().toISOString().slice(0, 16).replace(/[:T-]/g, '')}`).slice(0, 60)
161
167
  : '';
162
168
  for (const planned of plan.sessions) {
163
- const mirror = { ...DEFAULT_MIRROR, ...(planned.mirror ?? {}) };
164
- if (mirror.enabled && !mirror.rootDir.trim()) {
165
- mirror.enabled = false;
166
- warnings.push(`Session "${planned.name}" asked for project files without a root folder, so file attachment was left off.`);
169
+ // Attaching project files to the chat was removed; an old plan's root is still its project folder.
170
+ if (planned.mirror?.enabled) {
171
+ warnings.push(`Session "${planned.name}" asks for project files to be attached to the chat. That no longer exists: ` +
172
+ 'the chat reads the project by running commands. "mirror" was ignored.');
167
173
  }
168
- const created = await store.createSession(planned.name, mirror);
174
+ const created = await store.createSession(planned.name, planned.projectDir || planned.mirror?.rootDir || '');
169
175
  const vcs = { ...DEFAULT_VCS, ...(planned.vcs ?? {}) };
170
176
  vcs.branchPrefix = vcs.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
171
177
  vcs.repoDir = vcs.repoDir.trim();
178
+ // A branch to carry on, given alone, is that choice: see `VersionControl.startFrom`.
179
+ if (vcs.existingBranch?.trim())
180
+ vcs.startFrom = 'existing-branch';
172
181
  const group = planned.conversationGroup.trim() || sharedGroup;
173
182
  await store.updateSession(created.id, (s) => {
174
183
  s.onFailure = planned.onFailure;
@@ -180,6 +189,8 @@ persona = '') {
180
189
  s.model = (planned.model || defaultModel).trim() || undefined;
181
190
  s.planName = plan.plan.trim() || undefined;
182
191
  s.vcs = vcs;
192
+ // After the plan's own folders are in place, so only a session that named none is pointed at it.
193
+ applyDefaultProject(s, defaultProjectDir);
183
194
  });
184
195
  const titles = [];
185
196
  for (const task of planned.tasks) {
@@ -44,16 +44,40 @@ const VcsInput = z
44
44
  * Absent keeps the old behaviour, wherever the repository is. See `VersionControl.startFrom`.
45
45
  */
46
46
  startFrom: z
47
- .enum(['branch', 'previous-session', 'head'], {
48
- error: 'startFrom must be "branch" (every session starts from the local branch in baseBranch, "main" unless said otherwise) or "previous-session" (each session carries on from the branch of the session before it in the same repository).',
47
+ .enum(['branch', 'previous-session', 'head', 'existing-branch'], {
48
+ error: 'startFrom must be "branch" (every session starts from the local branch in baseBranch, "main" unless said otherwise), "previous-session" (each session carries on from the branch of the session before it in the same repository) or "existing-branch" (the session works on the existing branch named in existingBranch).',
49
49
  })
50
50
  .optional(),
51
51
  /** The local branch `startFrom: "branch"` starts from, and `previous-session`'s fallback. */
52
52
  baseBranch: z.string().trim().optional(),
53
+ /**
54
+ * The existing local branch the whole session works on, exactly as named: no new branch, no
55
+ * prefix. Given alone it means `startFrom: "existing-branch"`. See `VersionControl.startFrom`.
56
+ */
57
+ existingBranch: z.string().trim().optional(),
58
+ /**
59
+ * Fetch and fast-forward the branch the session starts from before its first branch is cut.
60
+ * Absent means true. See `VersionControl.updateFromRemote`.
61
+ */
62
+ updateFromRemote: z.boolean().optional(),
53
63
  }, {
54
64
  error: 'Every session needs a "vcs" object saying whether the runner does version control for it and, when it does, which git repository to work in. Ask the user both questions and write the answers here.',
55
65
  })
56
66
  .superRefine((vcs, ctx) => {
67
+ if (vcs.startFrom === 'existing-branch' && !vcs.existingBranch) {
68
+ ctx.addIssue({
69
+ code: 'custom',
70
+ path: ['existingBranch'],
71
+ message: 'startFrom is "existing-branch", so "existingBranch" must name the local branch to carry on, exactly as it is in git (for example "recovery/apz-migration").',
72
+ });
73
+ }
74
+ if (vcs.existingBranch && vcs.startFrom && vcs.startFrom !== 'existing-branch') {
75
+ ctx.addIssue({
76
+ code: 'custom',
77
+ path: ['existingBranch'],
78
+ message: `"existingBranch" carries on a branch that exists, and startFrom "${vcs.startFrom}" cuts a new one. Keep one: startFrom "existing-branch" with existingBranch, or leave existingBranch out.`,
79
+ });
80
+ }
57
81
  if (vcs.enabled && !vcs.repoDir.trim()) {
58
82
  ctx.addIssue({
59
83
  code: 'custom',
@@ -143,15 +167,12 @@ const TaskVcsInput = z.object({
143
167
  branch: z.string().trim().optional(),
144
168
  commitMessage: z.string().trim().optional(),
145
169
  });
146
- /** The project files copied into the chat. Absent means none, which is the default. */
147
- const MirrorInput = z.object({
148
- enabled: z.boolean().optional(),
149
- rootDir: z.string().optional(),
150
- includeDirs: z.array(z.string()).optional(),
151
- excludeDirs: z.array(z.string()).optional(),
152
- respectGitignore: z.boolean().optional(),
153
- includeEnvFiles: z.boolean().optional(),
154
- });
170
+ /**
171
+ * What was once the project files copied into the chat, removed on 2026-09-30. Still read, so a plan
172
+ * written before then imports: its `rootDir` becomes the session's project folder, and the rest is
173
+ * ignored with a warning. See `importPlan`.
174
+ */
175
+ const LegacyMirrorInput = z.object({ enabled: z.boolean().optional(), rootDir: z.string().optional() }).passthrough();
155
176
  const TaskInput = z.object({
156
177
  title: z
157
178
  .string()
@@ -247,7 +268,14 @@ const SessionInput = z.object({
247
268
  conversationGroup: z.string().trim().default(''),
248
269
  vcs: VcsInput,
249
270
  review: ReviewInput.optional(),
250
- mirror: MirrorInput.optional(),
271
+ /**
272
+ * The project folder of a session with version control off: where its commands run. Absent means
273
+ * the default project on the Project page. With version control on, `vcs.repoDir` is required (see
274
+ * `checkPlan`) and the commands run there, so this folder decides nothing for such a session.
275
+ */
276
+ projectDir: z.string().trim().optional(),
277
+ /** Read only for old plans; see `LegacyMirrorInput`. */
278
+ mirror: LegacyMirrorInput.optional(),
251
279
  tasks: z.array(TaskInput).min(1, 'A session with no tasks would import as an empty queue.'),
252
280
  });
253
281
  export const PlanSchema = z.object({
@@ -326,7 +354,6 @@ const KNOWN_KEYS = {
326
354
  vcs: keysOf(VcsInput),
327
355
  taskVcs: keysOf(TaskVcsInput),
328
356
  review: keysOf(ReviewInput),
329
- mirror: keysOf(MirrorInput),
330
357
  };
331
358
  function unknownKeys(value, kind, where, out) {
332
359
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -337,31 +364,46 @@ function unknownKeys(value, kind, where, out) {
337
364
  }
338
365
  }
339
366
  }
367
+ /**
368
+ * A value of the raw document as an object whose fields can be read, or nothing.
369
+ *
370
+ * The warning walk runs on the document as it arrived, before the schema has said anything about
371
+ * it, so every level of it may be null, a number or a list where an object belongs. A cast does
372
+ * not make it one: `sessions: [null]` read `null.vcs` and threw, where the schema would have
373
+ * refused it by its path. What is not an object has no fields to warn about, and is left for the
374
+ * schema to refuse.
375
+ */
376
+ function fieldsOf(value) {
377
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
378
+ }
340
379
  /** Fields a model invented, walked by hand because the schema drops them silently. */
341
380
  function collectWarnings(raw) {
342
381
  const out = [];
343
- if (!raw || typeof raw !== 'object')
382
+ const plan = fieldsOf(raw);
383
+ if (!plan)
344
384
  return out;
345
- unknownKeys(raw, 'plan', 'plan', out);
346
- const sessions = raw.sessions;
347
- if (!Array.isArray(sessions))
385
+ unknownKeys(plan, 'plan', 'plan', out);
386
+ if (!Array.isArray(plan.sessions))
348
387
  return out;
349
- sessions.forEach((s, i) => {
350
- unknownKeys(s, 'session', `sessions[${i}]`, out);
351
- const session = s;
388
+ plan.sessions.forEach((s, i) => {
389
+ const session = fieldsOf(s);
390
+ if (!session)
391
+ return;
392
+ unknownKeys(session, 'session', `sessions[${i}]`, out);
352
393
  unknownKeys(session.vcs, 'vcs', `sessions[${i}].vcs`, out);
353
394
  unknownKeys(session.review, 'review', `sessions[${i}].review`, out);
354
- unknownKeys(session.mirror, 'mirror', `sessions[${i}].mirror`, out);
355
- if (Array.isArray(session.tasks)) {
356
- session.tasks.forEach((t, j) => {
357
- unknownKeys(t, 'task', `sessions[${i}].tasks[${j}]`, out);
358
- unknownKeys(t?.vcs, 'taskVcs', `sessions[${i}].tasks[${j}].vcs`, out);
359
- const checks = t?.checks;
360
- if (Array.isArray(checks)) {
361
- checks.forEach((c, k) => unknownKeys(c, 'check', `sessions[${i}].tasks[${j}].checks[${k}]`, out));
362
- }
363
- });
364
- }
395
+ if (!Array.isArray(session.tasks))
396
+ return;
397
+ session.tasks.forEach((t, j) => {
398
+ const task = fieldsOf(t);
399
+ if (!task)
400
+ return;
401
+ unknownKeys(task, 'task', `sessions[${i}].tasks[${j}]`, out);
402
+ unknownKeys(task.vcs, 'taskVcs', `sessions[${i}].tasks[${j}].vcs`, out);
403
+ if (Array.isArray(task.checks)) {
404
+ task.checks.forEach((c, k) => unknownKeys(c, 'check', `sessions[${i}].tasks[${j}].checks[${k}]`, out));
405
+ }
406
+ });
365
407
  });
366
408
  return out;
367
409
  }
@@ -404,8 +446,25 @@ export function summarise(plan) {
404
446
  *
405
447
  * Never throws. Every way this can fail — not JSON at all, the wrong version, a task with no
406
448
  * prompt — is an answer the user is meant to see and hand back to the chat model.
449
+ *
450
+ * That promise is kept here, around the whole reading, and not only by each walk being careful:
451
+ * the walks run on whatever a chat produced, and one of them reading a field of null once turned
452
+ * a malformed plan into a request error carrying a JavaScript message, which neither the page nor
453
+ * the chat that wrote the plan can do anything with. A walk that throws again is an answer too.
407
454
  */
408
455
  export function checkPlan(text) {
456
+ try {
457
+ return readPlan(text);
458
+ }
459
+ catch (e) {
460
+ return {
461
+ ok: false,
462
+ warnings: [],
463
+ issues: [{ path: '', message: `This plan could not be read: ${e.message}. Check that every session, task and check is a JSON object.` }],
464
+ };
465
+ }
466
+ }
467
+ function readPlan(text) {
409
468
  const source = extractJson(text ?? '');
410
469
  if (!source.trim()) {
411
470
  return { ok: false, warnings: [], issues: [{ path: '', message: 'There is nothing here to import.' }] };