copilot-operator 0.1.9 → 0.1.10

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 (48) hide show
  1. package/dist/src/api/operator.service.js +4 -12
  2. package/dist/src/orchestrator/contract.js +58 -0
  3. package/dist/src/orchestrator/taskRunner.js +60 -4
  4. package/dist/src/plan/schema.js +21 -1
  5. package/dist/src/plan/systemGuide.js +16 -0
  6. package/dist/src/protocol/parser.js +98 -5
  7. package/dist/src/session/exports.js +1 -0
  8. package/dist/src/session/handoff.js +1 -1
  9. package/dist/src/session/store.js +44 -10
  10. package/dist/src/transport/copilotTransport.js +18 -0
  11. package/dist/src/vcs/scope.js +10 -7
  12. package/dist/web/404.html +2 -2
  13. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
  14. package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +1 -0
  15. package/dist/web/_next/static/chunks/app/defaults/{page-78be7820772f8882.js → page-0f88c46dcd9dd52c.js} +1 -1
  16. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
  17. package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
  18. package/dist/web/_next/static/chunks/app/level1/{page-be366b35f4cc97ce.js → page-dd3ebf37d29dce4f.js} +1 -1
  19. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
  20. package/dist/web/_next/static/chunks/app/presets/{page-cabc3876a8111edd.js → page-93ab518db67e52a2.js} +1 -1
  21. package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +1 -0
  22. package/dist/web/appearance.html +2 -2
  23. package/dist/web/appearance.txt +11 -11
  24. package/dist/web/defaults.html +2 -2
  25. package/dist/web/defaults.txt +11 -11
  26. package/dist/web/history.html +2 -2
  27. package/dist/web/history.txt +11 -11
  28. package/dist/web/import.html +2 -2
  29. package/dist/web/import.txt +11 -11
  30. package/dist/web/index.html +2 -2
  31. package/dist/web/index.txt +11 -11
  32. package/dist/web/level1.html +2 -2
  33. package/dist/web/level1.txt +11 -11
  34. package/dist/web/presets.html +3 -3
  35. package/dist/web/presets.txt +11 -11
  36. package/dist/web/sessions/view.html +2 -2
  37. package/dist/web/sessions/view.txt +11 -11
  38. package/dist/web/system.html +2 -2
  39. package/dist/web/system.txt +11 -11
  40. package/package.json +5 -2
  41. package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +0 -1
  42. package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +0 -1
  43. package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +0 -1
  44. package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +0 -1
  45. package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +0 -1
  46. package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +0 -1
  47. /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → d6YL07r-SeksvVLrCSZ3o}/_buildManifest.js +0 -0
  48. /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → d6YL07r-SeksvVLrCSZ3o}/_ssgManifest.js +0 -0
@@ -17,9 +17,9 @@ import { readFile, readdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { installLayout } from '../config/layout.js';
20
- import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW } from '../session/store.js';
20
+ import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
21
21
  import { EventBus } from '../session/events.js';
22
- import { newId, tidyVcsPlan } from '../session/model.js';
22
+ import { newId } from '../session/model.js';
23
23
  import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
24
24
  import { buildExport } from '../session/exportRecord.js';
25
25
  import { buildDebugExport } from '../session/debugExport.js';
@@ -303,16 +303,7 @@ let OperatorService = class OperatorService {
303
303
  return await this.store.updateTask(sessionId, taskId, (t) => {
304
304
  if (t.status !== 'queued')
305
305
  throw new Error('Only a queued task can be edited.');
306
- if (patch.title !== undefined)
307
- t.title = patch.title;
308
- if (patch.level2 !== undefined)
309
- t.level2 = patch.level2;
310
- if (patch.prompt !== undefined)
311
- t.prompt = patch.prompt;
312
- if (patch.vcsPlan !== undefined)
313
- t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
314
- if (patch.checks !== undefined)
315
- t.checks = patch.checks.filter((c) => c.name.trim() !== '');
306
+ applyTaskPatch(t, patch);
316
307
  });
317
308
  }
318
309
  async deleteTask(sessionId, taskId) {
@@ -2035,6 +2026,7 @@ let OperatorService = class OperatorService {
2035
2026
  disputes: t.disputes?.length || undefined,
2036
2027
  readOnly: t.readOnly || undefined,
2037
2028
  scope: t.scope?.length ? t.scope : undefined,
2029
+ stopCode: t.stopCode,
2038
2030
  autoRetries: t.autoRetries || undefined,
2039
2031
  attempt: t.attempt,
2040
2032
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Whether a task can be satisfied at all, decided before anything is sent to the chat.
3
+ *
4
+ * A task is a contract: a prompt, the checks that say when it is done, and what it may change
5
+ * (`readOnly`, `scope`). The three are written at different times — a plan, an edit, a new prompt on
6
+ * a finished task — and they can end up contradicting each other: an audit made read-only that
7
+ * still carries the old "the file now says X" check; a task scoped to its tests that must change
8
+ * the config to pass. Run as it is, such a task spends its rounds and ends blocked on a check the
9
+ * chat could never have met. This names the contradiction instead, before the first message.
10
+ *
11
+ * Only what can be known without running anything is judged: the flags against each other, and
12
+ * the file checks against the tree as it is now. A file check that already passes needs no change;
13
+ * one that fails needs its file changed, which a read-only task, or a scope that leaves the file
14
+ * out, forbids. Command checks are not judged: whether a command will pass depends on work the
15
+ * task is allowed to do elsewhere.
16
+ */
17
+ import { readFile, stat } from 'node:fs/promises';
18
+ import { isAbsolute, relative, resolve } from 'node:path';
19
+ import { inScope } from '../vcs/scope.js';
20
+ async function fileCheckPassesNow(check, cwd) {
21
+ const path = resolve(cwd, (check.cwd ?? '').trim() || '.', (check.file ?? '').trim());
22
+ const info = await stat(path).catch(() => null);
23
+ const exists = !!info?.isFile();
24
+ if (check.expect === 'file-exists')
25
+ return { passes: exists, path };
26
+ if (check.expect === 'file-missing')
27
+ return { passes: !exists, path };
28
+ const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
29
+ return { passes: exists && text.includes(check.value ?? ''), path };
30
+ }
31
+ /** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
32
+ export async function contractConflicts(task, cwd, repoDir) {
33
+ const out = [];
34
+ const scope = task.scope ?? [];
35
+ if (task.readOnly && scope.length > 0) {
36
+ out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
37
+ }
38
+ if (!task.readOnly && scope.length === 0)
39
+ return out;
40
+ const checks = [
41
+ ...(task.checks ?? []),
42
+ ...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check),
43
+ ].filter((c) => c.expect === 'file-exists' || c.expect === 'file-missing' || c.expect === 'file-contains');
44
+ for (const check of checks) {
45
+ const { passes, path } = await fileCheckPassesNow(check, cwd);
46
+ if (passes)
47
+ continue;
48
+ const rel = relative(resolve(repoDir), path).replace(/\\/g, '/');
49
+ const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
50
+ if (task.readOnly) {
51
+ out.push(`The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`);
52
+ }
53
+ else if (inside && !inScope(rel, scope)) {
54
+ out.push(`The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`);
55
+ }
56
+ }
57
+ return out;
58
+ }
@@ -41,6 +41,7 @@ import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
41
41
  import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
42
42
  import { composeHandoff } from '../session/handoff.js';
43
43
  import { ProgressWatch } from './progress.js';
44
+ import { contractConflicts } from './contract.js';
44
45
  import { treeFingerprint } from '../vcs/git.js';
45
46
  import { mirrorProject, describeMirror } from '../context/projectMirror.js';
46
47
  import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
@@ -366,6 +367,12 @@ export async function runTask(transport, session, task, deps) {
366
367
  const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
367
368
  /** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
368
369
  let noProgressReason = null;
370
+ /**
371
+ * Why the attempt stopped, when the status alone does not say: a format the chat could not keep,
372
+ * a task contract that contradicts itself, a loop, a check that could never run, a machine that
373
+ * lacks a shell. Written by `finish`. See `Task.stopCode`.
374
+ */
375
+ let stopCode;
369
376
  /**
370
377
  * Checks earlier reviews gave with their findings — carried over from every attempt before
371
378
  * this one, and grown by this one. See `derivedChecks.ts` for the three rules.
@@ -467,6 +474,8 @@ export async function runTask(transport, session, task, deps) {
467
474
  t.reviewChecks = reviewChecks;
468
475
  t.logFile = 'task-log.txt';
469
476
  t.stats = stats;
477
+ if (stopCode)
478
+ t.stopCode = stopCode;
470
479
  });
471
480
  // The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
472
481
  await setTask((t) => {
@@ -606,9 +615,31 @@ export async function runTask(transport, session, task, deps) {
606
615
  * is the task's, and the starting commit is what a change goes back to.
607
616
  */
608
617
  const scope = task.scope ?? [];
609
- const scopeEnforced = scope.length > 0 && !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
618
+ const onOwnBranch = !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
619
+ /*
620
+ * A read-only task is a scope of nothing, and is enforced the same way: whatever a round changed
621
+ * is put back before the chat reads the results. It used to be judged only at the end, so an
622
+ * audit that edited a file "to check something" kept the edit through every later round and its
623
+ * findings described a tree that was not the one it had been asked to audit.
624
+ */
625
+ const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
610
626
  /** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
611
627
  const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
628
+ /*
629
+ * The task's contract, checked before a single message is sent.
630
+ *
631
+ * A task can be written so that no answer satisfies it: read-only and still given paths to
632
+ * change, or a check that needs a file to change while the task may not change it. Sent as it
633
+ * is, the chat works for rounds, the check fails at the end, and the task goes blocked on a
634
+ * finding the chat could never have fixed. The contradiction is named instead, now, and nothing
635
+ * is run. See `orchestrator/contract.ts`.
636
+ */
637
+ const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd);
638
+ if (conflicts.length > 0) {
639
+ stopCode = 'contract-conflict';
640
+ sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
641
+ return await finish('blocked', `the task contradicts itself, so it was not started: ${conflicts.join(' ')} Change the prompt, the checks, the scope or read-only, and queue it again.`);
642
+ }
612
643
  // --- project mirror, attached to the first message of the task ---------------------
613
644
  let mirrorFiles = [];
614
645
  if (session.mirror.enabled && session.mirror.rootDir) {
@@ -819,6 +850,7 @@ export async function runTask(transport, session, task, deps) {
819
850
  if (stuck) {
820
851
  noProgressReason = stuck;
821
852
  stats.stoppedFor = 'no-progress';
853
+ stopCode = 'no-progress';
822
854
  sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
823
855
  return 'give-up';
824
856
  }
@@ -1155,9 +1187,20 @@ export async function runTask(transport, session, task, deps) {
1155
1187
  formatRetries += 1;
1156
1188
  stats.formatErrors += 1;
1157
1189
  sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
1190
+ /*
1191
+ * Out of format retries: stopped at a limit, not failed.
1192
+ *
1193
+ * Nothing about the work went wrong here — the steps that ran are recorded, their output is
1194
+ * in the run folder, and the rejected replies ran nothing. So the task ends `limit-reached`,
1195
+ * marked `format-repair-exhausted`, which is what lets "Continue in the same chat" carry it
1196
+ * on from exactly here instead of the operator having to run it again from the start. It
1197
+ * used to end `failed`, which read as a verdict on the work and offered only a fresh start.
1198
+ */
1158
1199
  if (formatRetries > cfg.limits.maxFormatRetries) {
1159
1200
  await transport.dumpFailure(log.path('failures'), 'format-error');
1160
- return await finish('failed', `Copilot did not keep the output contract: ${parsed.detail}`, undefined, lastMarkdown);
1201
+ stopCode = 'format-repair-exhausted';
1202
+ return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
1203
+ `the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
1161
1204
  }
1162
1205
  await pacer.throttleSend();
1163
1206
  const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
@@ -1167,6 +1210,9 @@ export async function runTask(transport, session, task, deps) {
1167
1210
  continue;
1168
1211
  }
1169
1212
  formatRetries = 0;
1213
+ if (parsed.coerced) {
1214
+ sink.event('format-coerced', { fields: parsed.coerced }, `read as text: ${parsed.coerced.join(', ')} arrived in another shape; nothing in them is run, so the reply was taken as it was`);
1215
+ }
1170
1216
  iterations += 1;
1171
1217
  await setTask((t) => {
1172
1218
  t.iterations = iterations;
@@ -1238,11 +1284,15 @@ export async function runTask(transport, session, task, deps) {
1238
1284
  if (done && reply.steps.length === 0) {
1239
1285
  const verdict = await gateOnChecks();
1240
1286
  if (verdict === 'environment') {
1287
+ stopCode = 'environment';
1241
1288
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1242
1289
  }
1243
1290
  if (verdict === 'give-up') {
1244
1291
  if (noProgressReason)
1245
1292
  return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1293
+ // Every failing check was refused before it ran: a verdict on the checks, not on the work.
1294
+ if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
1295
+ stopCode = 'invalid-check';
1246
1296
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1247
1297
  }
1248
1298
  if (verdict === 'accept') {
@@ -1382,6 +1432,7 @@ export async function runTask(transport, session, task, deps) {
1382
1432
  // Ended before the report is written and sent: there is nobody to send it to who could
1383
1433
  // do anything with it, and the reason on the task says what to install or set instead.
1384
1434
  if (environmentProblem) {
1435
+ stopCode = 'environment';
1385
1436
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1386
1437
  }
1387
1438
  /*
@@ -1390,7 +1441,7 @@ export async function runTask(transport, session, task, deps) {
1390
1441
  * and the chat is told in the same message.
1391
1442
  */
1392
1443
  if (scopeEnforced) {
1393
- const check = await enforceScope(repoDirOf(session), scope).catch((e) => ({
1444
+ const check = await enforceScope(repoDirOf(session), scope, !!task.readOnly).catch((e) => ({
1394
1445
  outside: [],
1395
1446
  reverted: [],
1396
1447
  failed: [{ path: '(the working tree)', why: e.message }],
@@ -1398,7 +1449,7 @@ export async function runTask(transport, session, task, deps) {
1398
1449
  if (check.outside.length > 0 || check.failed.length > 0) {
1399
1450
  sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
1400
1451
  (check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
1401
- runnerNotes.push(scopeMessage(check, scope));
1452
+ runnerNotes.push(scopeMessage(check, scope, !!task.readOnly));
1402
1453
  stats.scopeReverts += check.reverted.length;
1403
1454
  await setTask((t) => {
1404
1455
  t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
@@ -1429,6 +1480,7 @@ export async function runTask(transport, session, task, deps) {
1429
1480
  const stuck = progress.afterRound(await treeNow(), results);
1430
1481
  if (stuck) {
1431
1482
  stats.stoppedFor = 'no-progress';
1483
+ stopCode = 'no-progress';
1432
1484
  sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
1433
1485
  return await finish('blocked', stuck, reply.summary, lastMarkdown);
1434
1486
  }
@@ -1529,11 +1581,15 @@ export async function runTask(transport, session, task, deps) {
1529
1581
  if (done) {
1530
1582
  const verdict = await gateOnChecks();
1531
1583
  if (verdict === 'environment') {
1584
+ stopCode = 'environment';
1532
1585
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1533
1586
  }
1534
1587
  if (verdict === 'give-up') {
1535
1588
  if (noProgressReason)
1536
1589
  return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1590
+ // Every failing check was refused before it ran: a verdict on the checks, not on the work.
1591
+ if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
1592
+ stopCode = 'invalid-check';
1537
1593
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1538
1594
  }
1539
1595
  if (verdict === 'accept') {
@@ -432,7 +432,7 @@ export function checkPlan(text) {
432
432
  if (!parsed.success) {
433
433
  return { ok: false, warnings, issues: parsed.error.issues.map((i) => ({ path: pathOf(i.path), message: i.message })) };
434
434
  }
435
- const weak = weakCheckIssues(parsed.data);
435
+ const weak = [...weakCheckIssues(parsed.data), ...contradictionIssues(parsed.data)];
436
436
  if (weak.length)
437
437
  return { ok: false, warnings, issues: weak };
438
438
  return {
@@ -503,6 +503,26 @@ function weakCheckIssues(plan) {
503
503
  });
504
504
  return out;
505
505
  }
506
+ /**
507
+ * A task whose own flags contradict each other: read-only (change nothing) and scoped (change
508
+ * these). Refused at import, where the chat that wrote it can still be asked which it meant; the
509
+ * runner refuses it again before starting (orchestrator/contract.ts) for a task edited since.
510
+ */
511
+ function contradictionIssues(plan) {
512
+ const out = [];
513
+ plan.sessions.forEach((s, i) => {
514
+ s.tasks.forEach((t, j) => {
515
+ if (!t.readOnly || t.scope.length === 0)
516
+ return;
517
+ out.push({
518
+ path: `sessions[${i}].tasks[${j}]`,
519
+ message: `"${t.title}" is "readOnly": true, which allows no change, and has a "scope" (${t.scope.join(', ')}), which allows changes there. ` +
520
+ 'An audit that writes a report is not read-only: drop "readOnly" and scope it to the report. One that writes nothing: drop "scope".',
521
+ });
522
+ });
523
+ });
524
+ return out;
525
+ }
506
526
  /** The issues as a block the user can hand back to the chat model without editing it. */
507
527
  export function describeIssues(issues) {
508
528
  return issues.map((i) => (i.path ? `- ${i.path}: ${i.message}` : `- ${i.message}`)).join('\n');
@@ -1437,6 +1437,22 @@ export const SYSTEM_GUIDE = [
1437
1437
  doesEn: 'On a task that has already run: stores the edit and queues it again; the earlier attempt is kept. On a done task the new attempt builds on its work. It does not start it.',
1438
1438
  doesBg: 'При вече изпълнена задача: записва промяната и я връща в опашката; предишният опит се пази. При готова задача новият опит стъпва върху свършеното. Не я пуска.',
1439
1439
  },
1440
+ {
1441
+ key: 'task.readOnlyEdit',
1442
+ kind: 'checkbox',
1443
+ en: 'Read-only: this task must not change any file',
1444
+ bg: 'Само за четене: задачата не бива да променя нито един файл',
1445
+ doesEn: 'Also in the new-prompt dialog of the register. For an audit: every change a round makes is put back.',
1446
+ doesBg: 'Има го и в диалога за нов prompt в регистъра. За одит: всяка промяна от кръг се връща.',
1447
+ },
1448
+ {
1449
+ key: 'task.scopeEdit',
1450
+ kind: 'field',
1451
+ en: 'Files this task may change',
1452
+ bg: 'Файлове, които задачата може да променя',
1453
+ doesEn: 'One path or pattern per line; changes elsewhere are put back. Also in the new-prompt dialog, which lists the old checks to keep or drop.',
1454
+ doesBg: 'По един път или шаблон на ред; промени другаде се връщат. Има го и в диалога за нов prompt, който изброява старите проверки за запазване или махане.',
1455
+ },
1440
1456
  {
1441
1457
  key: 'task.branch',
1442
1458
  kind: 'field',
@@ -60,8 +60,83 @@ export function stripCitations(text) {
60
60
  return undefined;
61
61
  return text.replace(/\s*【[^】]*】/g, '').replace(/[ ]+$/gm, '').trim();
62
62
  }
63
+ /*
64
+ * Prose that arrived in another shape.
65
+ *
66
+ * `tried`, `needed`, `notes` and `summary` are sentences for a person to read; nothing in them is
67
+ * run. A chat that writes `tried` as a list of objects — `{"approach": "…", "result": "…"}` — has
68
+ * said exactly what was asked, in a shape the schema did not expect, and refusing the whole reply
69
+ * for it cost a format round each time and, three in a row, the task: it ended "failed" with the
70
+ * work done and its results already recorded. So these fields are read as text whatever shape they
71
+ * come in, and the reading is reported. The fields that are acted on — `status`, `steps` and
72
+ * everything in them — are never coerced: a step the runner would have to guess at is not run.
73
+ */
74
+ const PROSE_KEYS = ['approach', 'what', 'tried', 'attempt', 'action', 'description', 'text', 'summary', 'detail', 'result', 'outcome', 'why', 'reason', 'needed'];
75
+ function proseOf(value) {
76
+ if (typeof value === 'string')
77
+ return value;
78
+ if (value === null || value === undefined)
79
+ return '';
80
+ if (typeof value === 'number' || typeof value === 'boolean')
81
+ return String(value);
82
+ if (Array.isArray(value))
83
+ return value.map(proseOf).filter((x) => x.trim()).join('\n');
84
+ if (typeof value === 'object') {
85
+ const record = value;
86
+ const named = PROSE_KEYS.filter((k) => typeof record[k] === 'string' && record[k].trim()).map((k) => record[k]);
87
+ if (named.length > 0)
88
+ return named.join(' — ');
89
+ const strings = Object.values(record).filter((v) => typeof v === 'string' && v.trim() !== '');
90
+ return strings.length > 0 ? strings.join(' — ') : JSON.stringify(value);
91
+ }
92
+ return String(value);
93
+ }
94
+ export function normaliseProse(value) {
95
+ if (!value || typeof value !== 'object' || Array.isArray(value))
96
+ return { value, coerced: [] };
97
+ const out = { ...value };
98
+ const coerced = [];
99
+ for (const key of ['notes', 'summary', 'needed']) {
100
+ if (key in out && out[key] !== undefined && typeof out[key] !== 'string') {
101
+ out[key] = proseOf(out[key]);
102
+ coerced.push(key);
103
+ }
104
+ }
105
+ if ('tried' in out && out.tried !== undefined) {
106
+ const tried = out.tried;
107
+ if (typeof tried === 'string') {
108
+ out.tried = tried.trim() ? [tried] : [];
109
+ coerced.push('tried');
110
+ }
111
+ else if (Array.isArray(tried) && tried.some((t) => typeof t !== 'string')) {
112
+ out.tried = tried.map(proseOf).filter((t) => t.trim() !== '');
113
+ tried.forEach((t, i) => {
114
+ if (typeof t !== 'string')
115
+ coerced.push(`tried.${i}`);
116
+ });
117
+ }
118
+ }
119
+ return { value: out, coerced };
120
+ }
121
+ /** What each field must look like, said in the message that asks for a reformatted reply. */
122
+ const FIELD_SHAPES = [
123
+ [/^status$/, '"status" is one of "continue", "done", "blocked"'],
124
+ [/^steps(\.|$)/, '"steps" is a list of {"id": 1, "type": "command", "cmd": "the command"} ("shell", "expect", "timeoutSec" optional)'],
125
+ [/^tried(\.|$)/, '"tried" is a list of plain strings, one sentence per approach'],
126
+ [/^deviations(\.|$)/, '"deviations" is a list of {"instruction": "…", "did": "…", "why": "…"} with plain strings'],
127
+ [/^disputed(\.|$)/, '"disputed" is a list of {"finding": "…", "why": "…", "evidence": "…"} with plain strings'],
128
+ [/^(summary|notes|needed)$/, '"summary", "notes" and "needed" are plain strings'],
129
+ ];
130
+ const FORMAT_EXAMPLE = [
131
+ '```json',
132
+ '{"status": "continue", "notes": "what this round does", "steps": [{"id": 1, "type": "command", "cmd": "npm test"}]}',
133
+ '```',
134
+ 'or, to end: {"status": "done", "summary": "several sentences on what was done and what it shows"}',
135
+ 'or {"status": "blocked", "summary": "…", "tried": ["first approach", "second approach"], "needed": "what would unblock it"}',
136
+ ].join('\n');
63
137
  export function parseReply(markdown, opts) {
64
138
  const candidates = findJsonCandidates(markdown);
139
+ const paths = new Set();
65
140
  if (candidates.length === 0) {
66
141
  return {
67
142
  ok: false,
@@ -80,9 +155,12 @@ export function parseReply(markdown, opts) {
80
155
  errors.push(`JSON.parse failed: ${e.message}`);
81
156
  continue;
82
157
  }
83
- const result = ReplySchema.safeParse(value);
158
+ const prose = normaliseProse(value);
159
+ const result = ReplySchema.safeParse(prose.value);
84
160
  if (!result.success) {
85
161
  errors.push(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '));
162
+ for (const i of result.error.issues)
163
+ paths.add(i.path.join('.') || '(root)');
86
164
  continue;
87
165
  }
88
166
  const reply = result.data;
@@ -106,6 +184,7 @@ export function parseReply(markdown, opts) {
106
184
  done: reply.status === 'done' || (markerHit && hasSummary && !blocked),
107
185
  blocked,
108
186
  json: text.trim(),
187
+ ...(prose.coerced.length > 0 ? { coerced: prose.coerced } : {}),
109
188
  };
110
189
  }
111
190
  }
@@ -113,6 +192,7 @@ export function parseReply(markdown, opts) {
113
192
  ok: false,
114
193
  reason: 'invalid-json',
115
194
  detail: errors.slice(0, 4).join(' | '),
195
+ ...(paths.size > 0 ? { paths: [...paths] } : {}),
116
196
  };
117
197
  }
118
198
  /**
@@ -162,15 +242,28 @@ export function parseReview(markdown, opts) {
162
242
  return { ok: false, reason: 'invalid-json', detail: errors.slice(0, 4).join(' | ') };
163
243
  }
164
244
  /** The message sent back when a reply did not validate. Names the problem, nothing else. */
245
+ /**
246
+ * The message that asks for the same answer again, in the format.
247
+ *
248
+ * It names each rejected field with the shape it must have, gives a valid example, and says what
249
+ * did not happen: nothing from the rejected reply was run, so the results of the steps before it
250
+ * stand and the answer only needs reformatting. Without that last part a chat told "invalid" tends
251
+ * to start the round again, or to keep the same shape and change the wording, and the rounds run out.
252
+ */
165
253
  export function formatErrorMessage(fail, attempt, maxAttempts) {
254
+ const shapes = [...new Set((fail.paths ?? []).flatMap((p) => FIELD_SHAPES.filter(([re]) => re.test(p)).map(([, s]) => s)))];
166
255
  return [
167
- `Your last reply did not match the required format (attempt ${attempt} of ${maxAttempts}).`,
256
+ `Your last reply did not match the required format (format retry ${attempt} of ${maxAttempts}).`,
168
257
  fail.reason === 'no-json-block'
169
258
  ? 'There was no fenced json code block in it.'
170
259
  : `The json block did not validate: ${fail.detail}`,
171
- 'Resend the same answer as exactly one fenced code block tagged json, with the fields',
172
- 'status, steps and notes, and nothing else tagged json in the reply.',
173
- ].join(' ');
260
+ shapes.length > 0 ? `What those fields must be: ${shapes.join('; ')}.` : '',
261
+ 'Only the format is wrong. Nothing from that reply was run, and the results of the steps before it stand.',
262
+ 'Send the same answer again, reformatted, as exactly one fenced code block tagged json — do not start over and do not change what it asks for. For example:',
263
+ FORMAT_EXAMPLE,
264
+ ]
265
+ .filter(Boolean)
266
+ .join('\n');
174
267
  }
175
268
  /**
176
269
  * Spots a command that arrived damaged rather than badly written.
@@ -399,6 +399,7 @@ async function domainTask(session, task, runsDir) {
399
399
  scope: task.scope ?? [],
400
400
  scopeReverted: task.scopeReverted ?? [],
401
401
  handoff: task.handoff ?? null,
402
+ stopCode: task.stopCode ?? null,
402
403
  prompt: p.prompt,
403
404
  expected: p.expected,
404
405
  level2: task.level2,
@@ -29,7 +29,7 @@ export function composeHandoff(task, notRun = []) {
29
29
  if ((vcs.suspicious?.length ?? 0) > 0 || (vcs.foreignCommits?.length ?? 0) > 0)
30
30
  manual.push('look at the committed files and commits listed above');
31
31
  return {
32
- outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}) },
32
+ outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
33
33
  changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
34
34
  validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
35
35
  ...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
@@ -42,6 +42,47 @@ export const DEFAULT_MIRROR = {
42
42
  respectGitignore: true,
43
43
  includeEnvFiles: false,
44
44
  };
45
+ /**
46
+ * Applies an edit to a task: the one place the rule about a new intent lives.
47
+ *
48
+ * A task given a new prompt is a new question. The checks the reviews of its earlier attempts
49
+ * added were findings about the old question, and inheriting them gated the new one on work it
50
+ * no longer asks for: a maintenance task rewritten as an audit still had to pass "the build uses
51
+ * the new config", and went blocked on an artificial finding. So a changed prompt drops them, with
52
+ * the reason kept beside each, and they stay on the record. The plan's own checks are not guessed
53
+ * at: whoever writes the new prompt says which still apply (the edit form and the new-prompt
54
+ * dialog both show them), and `checks` in the patch is that answer. A re-run with the same prompt
55
+ * keeps everything, because it is the same question asked again.
56
+ */
57
+ export function applyTaskPatch(t, patch) {
58
+ const newPrompt = patch.prompt !== undefined && patch.prompt.trim() !== '' && patch.prompt.trim() !== t.prompt.trim();
59
+ if (patch.title !== undefined)
60
+ t.title = patch.title.trim() || t.title;
61
+ if (patch.prompt !== undefined && patch.prompt.trim())
62
+ t.prompt = patch.prompt;
63
+ if (patch.level2 !== undefined)
64
+ t.level2 = patch.level2;
65
+ if (patch.vcsPlan !== undefined)
66
+ t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
67
+ if (patch.checks !== undefined)
68
+ t.checks = patch.checks.filter((c) => c.name.trim() !== '');
69
+ if (patch.readOnly !== undefined) {
70
+ if (patch.readOnly)
71
+ t.readOnly = true;
72
+ else
73
+ delete t.readOnly;
74
+ }
75
+ if (patch.scope !== undefined) {
76
+ const scope = patch.scope.map((p) => p.trim()).filter(Boolean);
77
+ if (scope.length > 0)
78
+ t.scope = scope;
79
+ else
80
+ delete t.scope;
81
+ }
82
+ if (newPrompt && t.reviewChecks?.length) {
83
+ t.reviewChecks = t.reviewChecks.map((rc) => rc.state === 'dropped' ? rc : { ...rc, state: 'dropped', droppedBecause: 'the task was given a new prompt; this was a finding about the old one' });
84
+ }
85
+ }
45
86
  export class SessionStore {
46
87
  defaultLevel1Path;
47
88
  dir;
@@ -358,22 +399,14 @@ export class SessionStore {
358
399
  continuing: t.continuing,
359
400
  buildsOn: t.buildsOn,
360
401
  freshRetry: t.freshRetry,
402
+ stopCode: t.stopCode,
361
403
  scope: t.scope,
362
404
  scopeReverted: t.scopeReverted,
363
405
  vcs: t.vcs,
364
406
  },
365
407
  ];
366
408
  // An edit is applied only after the old attempt has been put beyond its reach.
367
- if (patch.title !== undefined)
368
- t.title = patch.title.trim() || t.title;
369
- if (patch.prompt !== undefined && patch.prompt.trim())
370
- t.prompt = patch.prompt;
371
- if (patch.level2 !== undefined)
372
- t.level2 = patch.level2;
373
- if (patch.vcsPlan !== undefined)
374
- t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
375
- if (patch.checks !== undefined)
376
- t.checks = patch.checks.filter((c) => c.name.trim() !== '');
409
+ applyTaskPatch(t, patch);
377
410
  t.attempt = (t.attempt ?? 1) + 1;
378
411
  t.status = 'queued';
379
412
  t.iterations = 0;
@@ -404,6 +437,7 @@ export class SessionStore {
404
437
  t.handoff = undefined;
405
438
  t.scopeReverted = undefined;
406
439
  t.freshRetry = undefined;
440
+ t.stopCode = undefined;
407
441
  // The branch of the finished attempt stays in the repository and stays on the record
408
442
  // above; the next attempt gets its own, cut from the same commit this one started at.
409
443
  t.vcs = undefined;
@@ -750,12 +750,30 @@ Current URL: ${url}`);
750
750
  await this.closeMenu();
751
751
  return { ok: false, current: before, reason: `The picker does not offer "${name}" any more.` };
752
752
  }
753
+ /*
754
+ * Already the one in force: close the menu and say so, without clicking.
755
+ *
756
+ * The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
757
+ * reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
758
+ * model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
759
+ * click it used to get did nothing in Copilot: the row already in force ignores it, the menu
760
+ * stayed open over the composer, and the run went on with the model list hanging on screen.
761
+ */
762
+ if (target.selected) {
763
+ await this.closeMenu();
764
+ this.emit('model-already-selected', { model: target.name, buttonShows: before });
765
+ return { ok: true, current: target.name };
766
+ }
753
767
  if (target.disabled) {
754
768
  await this.closeMenu();
755
769
  return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
756
770
  }
757
771
  await this.clickMenuRow(target.name, inSubmenu);
758
772
  await this.p.waitForTimeout(1_000);
773
+ // Whatever the click did, the menu is not left open over the chat: a build that keeps it open
774
+ // after a choice, or a click that did not land, would otherwise leave it for the next message.
775
+ if ((await this.readMenuRows()).length > 0)
776
+ await this.closeMenu();
759
777
  const after = await this.currentModel();
760
778
  // The button shows the choice, so it is the check. It also **shortens** it: picking
761
779
  // "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
@@ -65,12 +65,13 @@ export function inScope(path, scope) {
65
65
  * Puts back every change outside `scope`. Only for a repository whose tree was clean when the task
66
66
  * started, which is why the caller passes the repository only when version control is active.
67
67
  */
68
- export async function enforceScope(dir, scope) {
68
+ export async function enforceScope(dir, scope, nothing = false) {
69
69
  const out = { outside: [], reverted: [], failed: [] };
70
- if (scope.length === 0)
70
+ // An empty scope allows everything; `nothing` is the read-only task, which allows no change at all.
71
+ if (scope.length === 0 && !nothing)
71
72
  return out;
72
73
  const changed = await workingTreePaths(dir);
73
- out.outside = changed.filter((p) => !inScope(p, scope));
74
+ out.outside = nothing ? changed : changed.filter((p) => !inScope(p, scope));
74
75
  const root = resolve(dir);
75
76
  for (const rel of out.outside) {
76
77
  const abs = resolve(join(dir, rel));
@@ -107,13 +108,15 @@ export async function enforceScope(dir, scope) {
107
108
  return out;
108
109
  }
109
110
  /** What the chat is told, in the message that carries the step results. */
110
- export function scopeMessage(check, scope) {
111
+ export function scopeMessage(check, scope, readOnly = false) {
111
112
  const list = (paths) => paths.slice(0, 15).join(', ') + (paths.length > 15 ? `, and ${paths.length - 15} more` : '');
112
113
  const parts = [
113
- `Outside this task's scope, so put back by the runner: ${list(check.reverted)}.`,
114
+ `${readOnly ? 'Changed by a read-only task' : "Outside this task's scope"}, so put back by the runner: ${list(check.reverted)}.`,
114
115
  check.failed.length > 0 ? `Could not be put back: ${check.failed.map((f) => `${f.path} (${f.why})`).join('; ')}.` : '',
115
- `This task may change only: ${scope.join(', ')}. If the work truly needs a file outside that, do not work around it: ` +
116
- 'end with status "blocked", name the file in "needed" and say why.',
116
+ readOnly
117
+ ? 'This task is read-only: it may change no file at all. Read, run and report; put what you found in your summary.'
118
+ : `This task may change only: ${scope.join(', ')}. If the work truly needs a file outside that, do not work around it: ` +
119
+ 'end with status "blocked", name the file in "needed" and say why.',
117
120
  ];
118
121
  return parts.filter(Boolean).join(' ');
119
122
  }