copilot-operator 0.1.8 → 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 (73) hide show
  1. package/dist/src/api/main.js +2 -104
  2. package/dist/src/api/operator.controller.js +18 -1
  3. package/dist/src/api/operator.service.js +19 -14
  4. package/dist/src/api/server.js +114 -0
  5. package/dist/src/config/schema.js +5 -0
  6. package/dist/src/exec/checks.js +17 -0
  7. package/dist/src/orchestrator/contract.js +58 -0
  8. package/dist/src/orchestrator/progress.js +106 -0
  9. package/dist/src/orchestrator/taskRunner.js +167 -11
  10. package/dist/src/plan/brief.js +2 -0
  11. package/dist/src/plan/importPlan.js +1 -0
  12. package/dist/src/plan/schema.js +28 -1
  13. package/dist/src/plan/systemGuide.js +61 -6
  14. package/dist/src/protocol/parser.js +98 -5
  15. package/dist/src/session/compose.js +1 -1
  16. package/dist/src/session/exports.js +6 -0
  17. package/dist/src/session/handoff.js +42 -0
  18. package/dist/src/session/metrics.js +95 -0
  19. package/dist/src/session/store.js +58 -10
  20. package/dist/src/transport/chatTransport.js +32 -0
  21. package/dist/src/transport/copilotTransport.js +18 -0
  22. package/dist/src/vcs/contentIntegrity.js +174 -0
  23. package/dist/src/vcs/git.js +42 -0
  24. package/dist/src/vcs/scope.js +132 -0
  25. package/dist/src/vcs/taskVcs.js +43 -2
  26. package/dist/web/404.html +4 -3
  27. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
  28. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
  29. package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +1 -0
  30. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
  31. package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +1 -0
  32. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
  33. package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
  34. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
  35. package/dist/web/_next/static/chunks/app/level1/page-dd3ebf37d29dce4f.js +1 -0
  36. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
  37. package/dist/web/_next/static/chunks/app/presets/page-93ab518db67e52a2.js +1 -0
  38. package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +1 -0
  39. package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
  40. package/dist/web/appearance.html +4 -3
  41. package/dist/web/appearance.txt +12 -12
  42. package/dist/web/defaults.html +4 -3
  43. package/dist/web/defaults.txt +12 -12
  44. package/dist/web/history.html +4 -3
  45. package/dist/web/history.txt +12 -12
  46. package/dist/web/import.html +4 -3
  47. package/dist/web/import.txt +12 -12
  48. package/dist/web/index.html +4 -3
  49. package/dist/web/index.txt +12 -12
  50. package/dist/web/level1.html +4 -3
  51. package/dist/web/level1.txt +12 -12
  52. package/dist/web/presets.html +5 -4
  53. package/dist/web/presets.txt +12 -12
  54. package/dist/web/sessions/view.html +4 -3
  55. package/dist/web/sessions/view.txt +12 -12
  56. package/dist/web/system.html +4 -3
  57. package/dist/web/system.txt +12 -12
  58. package/package.json +16 -4
  59. package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
  60. package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
  61. package/dist/web/_next/static/chunks/867-3d4ee08c5f9f0e71.js +0 -1
  62. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
  63. package/dist/web/_next/static/chunks/app/defaults/page-dfcbd748f454d2d7.js +0 -1
  64. package/dist/web/_next/static/chunks/app/history/page-49413c3a8266a0b7.js +0 -1
  65. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
  66. package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
  67. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
  68. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
  69. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
  70. package/dist/web/_next/static/chunks/app/sessions/view/page-a82e5e801dabc423.js +0 -1
  71. package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
  72. /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_buildManifest.js +0 -0
  73. /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → d6YL07r-SeksvVLrCSZ3o}/_ssgManifest.js +0 -0
@@ -13,14 +13,14 @@
13
13
  import { createHash } from 'node:crypto';
14
14
  import { readFile, mkdir, writeFile, appendFile } from 'node:fs/promises';
15
15
  import { join } from 'node:path';
16
- import { CopilotTransport } from '../transport/copilotTransport.js';
16
+ import { createTransport } from '../transport/chatTransport.js';
17
17
  import { buildChatName, chatCode, savePointer } from '../transport/chatSession.js';
18
18
  import { parseReply, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
19
19
  import { resolveDeviations, describeDeviations, mergeDisputes, describeDisputes } from '../protocol/replySchema.js';
20
20
  import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
21
21
  import { runStep } from '../exec/runner.js';
22
22
  import { availableShells, detectShells, effectiveShell, preferredShell, refusalForChat, resolveShell, shellNote } from '../exec/shells.js';
23
- import { runChecks, failureMessage, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK } from '../exec/checks.js';
23
+ import { runChecks, failureMessage, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK, RUNNER_CHECK_KINDS } from '../exec/checks.js';
24
24
  import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing } from './derivedChecks.js';
25
25
  import { workingDirFor, isWorkingDirProblem, workingDirNote } from '../exec/workDir.js';
26
26
  import { redactSecrets } from '../exec/redaction.js';
@@ -38,6 +38,11 @@ import { writeReport } from '../exec/reportFile.js';
38
38
  import { Pacer } from '../util/pacing.js';
39
39
  import { RunLog } from '../log/runLog.js';
40
40
  import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
41
+ import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
42
+ import { composeHandoff } from '../session/handoff.js';
43
+ import { ProgressWatch } from './progress.js';
44
+ import { contractConflicts } from './contract.js';
45
+ import { treeFingerprint } from '../vcs/git.js';
41
46
  import { mirrorProject, describeMirror } from '../context/projectMirror.js';
42
47
  import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
43
48
  import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
@@ -162,7 +167,7 @@ class Sink {
162
167
  * holding the profile.
163
168
  */
164
169
  export async function openBrowser(cfg, bus, transportDir, sessionId) {
165
- const transport = new CopilotTransport({
170
+ const transport = createTransport({
166
171
  profileDir: cfg.resolved.profileDir,
167
172
  transportDir,
168
173
  chatUrl: cfg.copilot.url,
@@ -319,6 +324,13 @@ export async function runTask(transport, session, task, deps) {
319
324
  const taskLogPath = log.path('task-log.txt');
320
325
  await mkdir(artifactsDir, { recursive: true });
321
326
  await mkdir(repliesDir, { recursive: true });
327
+ /*
328
+ * Made here, not left to the first step report. The report writer creates it as a side effect, so
329
+ * a chat that answered "done" before running a single step reached the failed-checks report with
330
+ * no folder to write it into, and the task ended "failed" on an ENOENT about checks-1.txt instead
331
+ * of telling the chat which check failed. Found by the end-to-end checks (test/e2e-run.check.ts).
332
+ */
333
+ await mkdir(reportsDir, { recursive: true });
322
334
  /** The consolidated, human-readable record of the whole task, appended as it happens. */
323
335
  const record = async (heading, body) => {
324
336
  await appendFile(taskLogPath, `\n${'='.repeat(78)}\n${heading}\n${'='.repeat(78)}\n${body.trimEnd()}\n`, 'utf8');
@@ -347,6 +359,20 @@ export async function runTask(transport, session, task, deps) {
347
359
  let disputes = [];
348
360
  /** What the runner tells the model in its next message about the reply just processed. */
349
361
  let runnerNotes = [];
362
+ /** Steps the chat sent that were not run, for the handoff at the end. */
363
+ const notRun = [];
364
+ /** What went wrong on the way, counted for the register's figures. See `TaskStats`. */
365
+ const stats = { formatErrors: 0, doneRejected: 0, repeatsRefused: 0, stepsRefused: 0, operatorStops: 0, reviewRejections: 0, scopeReverts: 0 };
366
+ /** Loops the older guards do not see; see `progress.ts`. */
367
+ const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
368
+ /** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
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;
350
376
  /**
351
377
  * Checks earlier reviews gave with their findings — carried over from every attempt before
352
378
  * this one, and grown by this one. See `derivedChecks.ts` for the three rules.
@@ -447,6 +473,13 @@ export async function runTask(transport, session, task, deps) {
447
473
  if (reviewChecks.length > 0)
448
474
  t.reviewChecks = reviewChecks;
449
475
  t.logFile = 'task-log.txt';
476
+ t.stats = stats;
477
+ if (stopCode)
478
+ t.stopCode = stopCode;
479
+ });
480
+ // The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
481
+ await setTask((t) => {
482
+ t.handoff = composeHandoff(t, notRun);
450
483
  });
451
484
  sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
452
485
  await log.close();
@@ -576,6 +609,37 @@ export async function runTask(transport, session, task, deps) {
576
609
  ? `not active: ${prepared.vcs.problem}`
577
610
  : `branch ${prepared.vcs.branch}, from ${prepared.vcs.baseCommit ?? '(no commits yet)'}`);
578
611
  }
612
+ /*
613
+ * The paths this task may change. Enforced only on a branch of the task's own, which is what
614
+ * makes "put back" safe: the tree was clean when the branch was made, so every change in it
615
+ * is the task's, and the starting commit is what a change goes back to.
616
+ */
617
+ const scope = task.scope ?? [];
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;
626
+ /** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
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
+ }
579
643
  // --- project mirror, attached to the first message of the task ---------------------
580
644
  let mirrorFiles = [];
581
645
  if (session.mirror.enabled && session.mirror.rootDir) {
@@ -630,6 +694,7 @@ export async function runTask(transport, session, task, deps) {
630
694
  workDirNote: workingDirNote(work),
631
695
  vcsNote: prepared.note,
632
696
  readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
697
+ scopeNote: scopeNote(scope, scopeEnforced),
633
698
  shellNote: shellNote(shells, defaultShell),
634
699
  });
635
700
  await setTask((t) => {
@@ -694,7 +759,8 @@ export async function runTask(transport, session, task, deps) {
694
759
  * task refusing to start over it.
695
760
  */
696
761
  const willCommit = !!(session.vcs?.enabled && session.vcs.commitOnFinish && repoDirOf(session));
697
- let generatedPointedOut = false;
762
+ /** The runner's own checks that have been pointed out to the chat once already. */
763
+ const pointedOut = new Set();
698
764
  /**
699
765
  * Whether "done" is accepted, decided by the operator's checks rather than by the reply.
700
766
  *
@@ -705,7 +771,7 @@ export async function runTask(transport, session, task, deps) {
705
771
  * happened, and a task cannot be trusted to answer that about itself.
706
772
  */
707
773
  const gateOnChecks = async () => {
708
- const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK] : [])];
774
+ const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK] : [])];
709
775
  if (checks.length === 0)
710
776
  return 'accept';
711
777
  // Counted only once the round turns out to have been a real attempt. A round that died of
@@ -726,19 +792,26 @@ export async function runTask(transport, session, task, deps) {
726
792
  deny: (command, shell, cwd) => checkCommandRefusal(command, shell, cfg.execution, { roots: confinement.roots, cwd }),
727
793
  roots: confinement.roots,
728
794
  repoDir: willCommit ? repoDirOf(session) : undefined,
795
+ baseCommit: prepared.vcs.baseCommit,
729
796
  // The same shell a step that named none is given, so the gate and the work it judges
730
797
  // cannot have been read by different interpreters.
731
798
  defaultShell,
732
799
  }));
800
+ /*
801
+ * The runner's own checks are advice given once, not a gate: a second "done" with the finding
802
+ * still there is committed and the finding stays on the task. Refusing instead would leave the
803
+ * tree dirty and the next task unable to start. Each check is pointed out once on its own, so a
804
+ * content problem found after the paths were settled still gets its one mention.
805
+ */
733
806
  for (const o of outcomes) {
734
- if (o.check.expect !== 'commit-clean' || o.passed)
807
+ if (!RUNNER_CHECK_KINDS.has(o.check.expect) || o.passed)
735
808
  continue;
736
- if (generatedPointedOut) {
809
+ if (pointedOut.has(o.check.expect)) {
737
810
  o.passed = true;
738
- o.detail = `still there after being pointed out once; committed and marked as suspicious on the task — ${o.detail}`;
811
+ o.detail = `still there after being pointed out once; committed and kept on the task — ${o.detail}`;
739
812
  }
740
813
  else {
741
- generatedPointedOut = true;
814
+ pointedOut.add(o.check.expect);
742
815
  }
743
816
  }
744
817
  await setTask((t) => {
@@ -769,8 +842,18 @@ export async function runTask(transport, session, task, deps) {
769
842
  sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
770
843
  return 'accept';
771
844
  }
845
+ stats.doneRejected += 1;
772
846
  if (deps.signal?.aborted)
773
847
  return 'give-up';
848
+ // The same failures as last time, and not a file changed since they were reported.
849
+ const stuck = progress.afterFailedChecks(await treeNow(), failed.map((o) => ({ name: o.check.name, detail: o.detail })));
850
+ if (stuck) {
851
+ noProgressReason = stuck;
852
+ stats.stoppedFor = 'no-progress';
853
+ stopCode = 'no-progress';
854
+ sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
855
+ return 'give-up';
856
+ }
774
857
  if (checkRounds > maxCheckRounds) {
775
858
  /*
776
859
  * Only checks from earlier reviews are failing. A reviewer's check is outranked by the
@@ -941,6 +1024,8 @@ export async function runTask(transport, session, task, deps) {
941
1024
  * task; the ones suspended by a dispute or by spent rounds come back if the finding was
942
1025
  * raised again, and go if it was not.
943
1026
  */
1027
+ if (outcome.verdict === 'fail')
1028
+ stats.reviewRejections += 1;
944
1029
  if (outcome.verdict === 'pass' || outcome.verdict === 'fail') {
945
1030
  for (const d of outcome.derivedChecks ?? []) {
946
1031
  reviewChecks = [...reviewChecks, { check: d.check, findingId: d.findingId, what: d.what, where: d.where, round: reviewRounds, attempt, state: 'active' }];
@@ -1100,10 +1185,22 @@ export async function runTask(transport, session, task, deps) {
1100
1185
  });
1101
1186
  if (!parsed.ok) {
1102
1187
  formatRetries += 1;
1188
+ stats.formatErrors += 1;
1103
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
+ */
1104
1199
  if (formatRetries > cfg.limits.maxFormatRetries) {
1105
1200
  await transport.dumpFailure(log.path('failures'), 'format-error');
1106
- 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);
1107
1204
  }
1108
1205
  await pacer.throttleSend();
1109
1206
  const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
@@ -1113,6 +1210,9 @@ export async function runTask(transport, session, task, deps) {
1113
1210
  continue;
1114
1211
  }
1115
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
+ }
1116
1216
  iterations += 1;
1117
1217
  await setTask((t) => {
1118
1218
  t.iterations = iterations;
@@ -1184,9 +1284,15 @@ export async function runTask(transport, session, task, deps) {
1184
1284
  if (done && reply.steps.length === 0) {
1185
1285
  const verdict = await gateOnChecks();
1186
1286
  if (verdict === 'environment') {
1287
+ stopCode = 'environment';
1187
1288
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1188
1289
  }
1189
1290
  if (verdict === 'give-up') {
1291
+ if (noProgressReason)
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';
1190
1296
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1191
1297
  }
1192
1298
  if (verdict === 'accept') {
@@ -1246,6 +1352,7 @@ export async function runTask(transport, session, task, deps) {
1246
1352
  const ran = seen.get(key)?.sameInARow ?? 0;
1247
1353
  if (ran >= maxCommandRepeats) {
1248
1354
  repeatsRefused += 1;
1355
+ stats.repeatsRefused += 1;
1249
1356
  sink.event('step-repeated', { id: step.id, count: ran, limit: maxCommandRepeats }, `step ${step.id} refused: already run ${ran} time(s) in this task with the same result`, 'warn');
1250
1357
  results.push(refusedResult(step, repeatRefusal(ran, maxCommandRepeats)));
1251
1358
  continue;
@@ -1312,11 +1419,43 @@ export async function runTask(transport, session, task, deps) {
1312
1419
  }
1313
1420
  await pacer.settle();
1314
1421
  }
1422
+ for (const r of results) {
1423
+ if (r.outcome !== 'refused' && r.outcome !== 'aborted')
1424
+ continue;
1425
+ if (r.outcome === 'refused')
1426
+ stats.stepsRefused += 1;
1427
+ else
1428
+ stats.operatorStops += 1;
1429
+ const why = (r.stderr.split('\n')[0] ?? '').replace(/^\[policy\] step not executed: /, '').trim();
1430
+ notRun.push({ command: r.command.slice(0, 200), why: why.slice(0, 300) || r.outcome });
1431
+ }
1315
1432
  // Ended before the report is written and sent: there is nobody to send it to who could
1316
1433
  // do anything with it, and the reason on the task says what to install or set instead.
1317
1434
  if (environmentProblem) {
1435
+ stopCode = 'environment';
1318
1436
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1319
1437
  }
1438
+ /*
1439
+ * The task's scope, as a rule rather than a sentence (see `vcs/scope.ts`): whatever this round
1440
+ * changed outside it is put back now, before the chat reads the results and builds on them,
1441
+ * and the chat is told in the same message.
1442
+ */
1443
+ if (scopeEnforced) {
1444
+ const check = await enforceScope(repoDirOf(session), scope, !!task.readOnly).catch((e) => ({
1445
+ outside: [],
1446
+ reverted: [],
1447
+ failed: [{ path: '(the working tree)', why: e.message }],
1448
+ }));
1449
+ if (check.outside.length > 0 || check.failed.length > 0) {
1450
+ sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
1451
+ (check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
1452
+ runnerNotes.push(scopeMessage(check, scope, !!task.readOnly));
1453
+ stats.scopeReverts += check.reverted.length;
1454
+ await setTask((t) => {
1455
+ t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
1456
+ });
1457
+ }
1458
+ }
1320
1459
  // --- report back ---------------------------------------------------------------
1321
1460
  const report = await writeReport(results, {
1322
1461
  runId,
@@ -1336,6 +1475,15 @@ export async function runTask(transport, session, task, deps) {
1336
1475
  }
1337
1476
  if (aborted)
1338
1477
  return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
1478
+ // A loop in which every round looks new: see `progress.ts`. After the report is written, so
1479
+ // the evidence the diagnosis points at is on disk.
1480
+ const stuck = progress.afterRound(await treeNow(), results);
1481
+ if (stuck) {
1482
+ stats.stoppedFor = 'no-progress';
1483
+ stopCode = 'no-progress';
1484
+ sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
1485
+ return await finish('blocked', stuck, reply.summary, lastMarkdown);
1486
+ }
1339
1487
  /*
1340
1488
  * Nothing this iteration did anything.
1341
1489
  *
@@ -1433,9 +1581,15 @@ export async function runTask(transport, session, task, deps) {
1433
1581
  if (done) {
1434
1582
  const verdict = await gateOnChecks();
1435
1583
  if (verdict === 'environment') {
1584
+ stopCode = 'environment';
1436
1585
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1437
1586
  }
1438
1587
  if (verdict === 'give-up') {
1588
+ if (noProgressReason)
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';
1439
1593
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1440
1594
  }
1441
1595
  if (verdict === 'accept') {
@@ -1562,8 +1716,10 @@ export async function runSession(sessionId, deps) {
1562
1716
  s.chat = undefined;
1563
1717
  s.contractSent = false;
1564
1718
  const again = s.tasks.find((x) => x.id === task.id);
1565
- if (again)
1719
+ if (again) {
1566
1720
  again.autoRetries = (again.autoRetries ?? 0) + 1;
1721
+ again.freshRetry = true;
1722
+ }
1567
1723
  });
1568
1724
  await chat.newChat();
1569
1725
  /*
@@ -316,6 +316,7 @@ const FIELD_ROWS_EN = [
316
316
  ],
317
317
  ['review', 'task', 'no', 'Set to `false` to skip the review for one task. Absent means the session decides.'],
318
318
  ['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
+ ['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.'],
319
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.'],
320
321
  ['tasks', 'session', 'yes', 'At least one, in the order they must run.'],
321
322
  ['title', 'task', 'yes', '3 to 120 characters. Short, latin, hyphenated.'],
@@ -380,6 +381,7 @@ const FIELD_ROWS_BG = [
380
381
  ],
381
382
  ['review', 'задача', 'не', 'Сложи `false`, за да се пропусне рецензията само за тази задача. Липсата значи каквото казва сесията.'],
382
383
  ['readOnly', 'задача', 'не', '`true` за задача, която не бива да променя файлове — одит, smoke тест, доклад. Runner-ът я проваля, ако дървото е променено, и пак комитва промяната на клона ѝ, за да не се губи нищо.'],
384
+ ['scope', 'задача', 'не', 'Пътищата, които задачата може да променя: файлове, папки (`tests/e2e/`) или шаблони с `*`/`**`. При включен контрол на версиите runner-ът връща всяка промяна извън тях. Давай го, когато задачата е за един suite или модул.'],
383
385
  ['mirror', 'сесия', 'не, но питай', 'Дали файлове от проекта се копират и прикачат към първото съобщение като контекст, и кои: корен, директории вътре и вън, gitignore, env файлове. Изключено, когато липсва.'],
384
386
  ['tasks', 'сесия', 'да', 'Поне една, в реда, в който трябва да се изпълнят.'],
385
387
  ['title', 'задача', 'да', 'От 3 до 120 знака. Кратко, латиница, с тирета.'],
@@ -194,6 +194,7 @@ persona = '') {
194
194
  checks: task.checks,
195
195
  reviewEnabled: task.review,
196
196
  readOnly: task.readOnly || undefined,
197
+ scope: task.scope.length > 0 ? task.scope : undefined,
197
198
  });
198
199
  titles.push(added.title);
199
200
  }
@@ -207,6 +207,13 @@ const TaskInput = z.object({
207
207
  * branch so nothing is lost and the next task starts clean.
208
208
  */
209
209
  readOnly: z.boolean().default(false),
210
+ /**
211
+ * The paths this task may change, repository-relative: a file, a folder (`tests/e2e/`), or a
212
+ * pattern with `*` and `**`. Absent or empty means anywhere in the project. Enforced by the runner
213
+ * after every round of steps when version control is on — a change outside is put back and the
214
+ * chat told — which a sentence in the prompt cannot be. See `vcs/scope.ts`.
215
+ */
216
+ scope: z.array(z.string().trim().min(1, 'A scope entry needs a path or a pattern.')).default([]),
210
217
  /**
211
218
  * What earlier attempts of this task were asked, written by the plan export for a task that has
212
219
  * been run more than once (`earlierPlans` in `session/exports.ts`). History, not instruction:
@@ -425,7 +432,7 @@ export function checkPlan(text) {
425
432
  if (!parsed.success) {
426
433
  return { ok: false, warnings, issues: parsed.error.issues.map((i) => ({ path: pathOf(i.path), message: i.message })) };
427
434
  }
428
- const weak = weakCheckIssues(parsed.data);
435
+ const weak = [...weakCheckIssues(parsed.data), ...contradictionIssues(parsed.data)];
429
436
  if (weak.length)
430
437
  return { ok: false, warnings, issues: weak };
431
438
  return {
@@ -496,6 +503,26 @@ function weakCheckIssues(plan) {
496
503
  });
497
504
  return out;
498
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
+ }
499
526
  /** The issues as a block the user can hand back to the chat model without editing it. */
500
527
  export function describeIssues(issues) {
501
528
  return issues.map((i) => (i.path ? `- ${i.path}: ${i.message}` : `- ${i.message}`)).join('\n');
@@ -278,6 +278,22 @@ export const SYSTEM_GUIDE = [
278
278
  doesEn: 'Puts the whole brief on the clipboard; paste it into a fresh chat conversation.',
279
279
  doesBg: 'Слага цялото задание в клипборда; поставете го в нов разговор с чат модел.',
280
280
  },
281
+ {
282
+ key: 'plan.saveBrief',
283
+ kind: 'button',
284
+ en: 'Save the brief as a file',
285
+ bg: 'Запази заданието като файл',
286
+ doesEn: 'Saves the brief as a .txt to attach to a fresh chat; the way when it is over about 116 000 characters.',
287
+ doesBg: 'Запазва заданието като .txt за прикачване в нов разговор; начинът, когато е над около 116 000 знака.',
288
+ },
289
+ {
290
+ key: 'plan.copyBriefNote',
291
+ kind: 'button',
292
+ en: 'Copy the message for the file',
293
+ bg: 'Копирай съобщението към файла',
294
+ doesEn: 'Only when the brief is too long to paste: the one line to send with the attached file.',
295
+ doesBg: 'Само когато заданието е твърде дълго за поставяне: едното изречение, което се праща с прикачения файл.',
296
+ },
281
297
  {
282
298
  key: 'plan.softwarePart',
283
299
  kind: 'disclosure',
@@ -1421,6 +1437,22 @@ export const SYSTEM_GUIDE = [
1421
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.',
1422
1438
  doesBg: 'При вече изпълнена задача: записва промяната и я връща в опашката; предишният опит се пази. При готова задача новият опит стъпва върху свършеното. Не я пуска.',
1423
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
+ },
1424
1456
  {
1425
1457
  key: 'task.branch',
1426
1458
  kind: 'field',
@@ -2571,6 +2603,13 @@ export const SYSTEM_GUIDE = [
2571
2603
  },
2572
2604
  ],
2573
2605
  },
2606
+ {
2607
+ headingKey: 'metrics.title',
2608
+ en: 'How the bot is doing',
2609
+ bg: 'Как се справя ботът',
2610
+ // A table of figures at the foot of the register, read-only; the heading says what it is.
2611
+ controls: [],
2612
+ },
2574
2613
  ],
2575
2614
  },
2576
2615
  {
@@ -3075,29 +3114,45 @@ export const SYSTEM_GUIDE = [
3075
3114
  en: 'Text size',
3076
3115
  bg: 'Размер на текста',
3077
3116
  controls: [
3117
+ {
3118
+ key: 'ap.sizeSmall',
3119
+ kind: 'button',
3120
+ en: 'Small',
3121
+ bg: 'Малък',
3122
+ doesEn: 'One step below normal.',
3123
+ doesBg: 'Една степен под нормалния.',
3124
+ },
3078
3125
  {
3079
3126
  key: 'ap.sizeNormal',
3080
3127
  kind: 'button',
3081
3128
  en: 'Normal',
3082
3129
  bg: 'Нормален',
3083
- doesEn: 'Normal text size.',
3084
- doesBg: 'Нормален размер на текста.',
3130
+ doesEn: 'Normal size.',
3131
+ doesBg: 'Нормален размер.',
3085
3132
  },
3086
3133
  {
3087
3134
  key: 'ap.sizeLarge',
3088
3135
  kind: 'button',
3089
3136
  en: 'Large',
3090
3137
  bg: 'Голям',
3091
- doesEn: 'Larger text.',
3092
- doesBg: 'По-голям текст.',
3138
+ doesEn: 'One step above normal.',
3139
+ doesBg: 'Една степен над нормалния.',
3093
3140
  },
3094
3141
  {
3095
3142
  key: 'ap.sizeHuge',
3096
3143
  kind: 'button',
3144
+ en: 'Larger',
3145
+ bg: 'По-голям',
3146
+ doesEn: 'Two steps above normal.',
3147
+ doesBg: 'Две степени над нормалния.',
3148
+ },
3149
+ {
3150
+ key: 'ap.sizeGiant',
3151
+ kind: 'button',
3097
3152
  en: 'Largest',
3098
3153
  bg: 'Най-голям',
3099
- doesEn: 'The largest text.',
3100
- doesBg: 'Най-големият текст.',
3154
+ doesEn: 'Three steps above normal.',
3155
+ doesBg: 'Три степени над нормалния.',
3101
3156
  },
3102
3157
  ],
3103
3158
  },