copilot-operator 0.1.7 → 0.1.9

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 (72) 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 +30 -11
  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/progress.js +106 -0
  8. package/dist/src/orchestrator/taskRunner.js +110 -10
  9. package/dist/src/plan/brief.js +6 -4
  10. package/dist/src/plan/importPlan.js +1 -0
  11. package/dist/src/plan/schema.js +7 -0
  12. package/dist/src/plan/systemGuide.js +67 -12
  13. package/dist/src/session/compose.js +25 -20
  14. package/dist/src/session/exports.js +5 -0
  15. package/dist/src/session/handoff.js +42 -0
  16. package/dist/src/session/interruption.js +104 -0
  17. package/dist/src/session/metrics.js +95 -0
  18. package/dist/src/session/store.js +35 -5
  19. package/dist/src/session/story.js +1 -1
  20. package/dist/src/transport/chatTransport.js +32 -0
  21. package/dist/src/vcs/contentIntegrity.js +174 -0
  22. package/dist/src/vcs/git.js +42 -0
  23. package/dist/src/vcs/scope.js +129 -0
  24. package/dist/src/vcs/taskVcs.js +43 -2
  25. package/dist/web/404.html +4 -3
  26. package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +1 -0
  27. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
  28. package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +1 -0
  29. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
  30. package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +1 -0
  31. package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +1 -0
  32. package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +1 -0
  33. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
  34. package/dist/web/_next/static/chunks/app/level1/page-be366b35f4cc97ce.js +1 -0
  35. package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +1 -0
  36. package/dist/web/_next/static/chunks/app/presets/page-cabc3876a8111edd.js +1 -0
  37. package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +1 -0
  38. package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
  39. package/dist/web/appearance.html +4 -3
  40. package/dist/web/appearance.txt +12 -12
  41. package/dist/web/defaults.html +4 -3
  42. package/dist/web/defaults.txt +12 -12
  43. package/dist/web/history.html +4 -3
  44. package/dist/web/history.txt +12 -12
  45. package/dist/web/import.html +4 -3
  46. package/dist/web/import.txt +12 -12
  47. package/dist/web/index.html +4 -3
  48. package/dist/web/index.txt +12 -12
  49. package/dist/web/level1.html +4 -3
  50. package/dist/web/level1.txt +12 -12
  51. package/dist/web/presets.html +4 -3
  52. package/dist/web/presets.txt +12 -12
  53. package/dist/web/sessions/view.html +4 -3
  54. package/dist/web/sessions/view.txt +12 -12
  55. package/dist/web/system.html +4 -3
  56. package/dist/web/system.txt +12 -12
  57. package/package.json +13 -4
  58. package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
  59. package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
  60. package/dist/web/_next/static/chunks/867-4aa80f5a32311bce.js +0 -1
  61. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
  62. package/dist/web/_next/static/chunks/app/defaults/page-1747c931adc746fd.js +0 -1
  63. package/dist/web/_next/static/chunks/app/history/page-2251664c0abe3f11.js +0 -1
  64. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
  65. package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
  66. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
  67. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
  68. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
  69. package/dist/web/_next/static/chunks/app/sessions/view/page-9517dd74c6d00f85.js +0 -1
  70. package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
  71. /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_buildManifest.js +0 -0
  72. /package/dist/web/_next/static/{DCpEMjhrg9APkRwBBw5Do → auD7lTmj-JDMJIQ4TVivR}/_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,10 @@ 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 { treeFingerprint } from '../vcs/git.js';
41
45
  import { mirrorProject, describeMirror } from '../context/projectMirror.js';
42
46
  import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
43
47
  import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
@@ -162,7 +166,7 @@ class Sink {
162
166
  * holding the profile.
163
167
  */
164
168
  export async function openBrowser(cfg, bus, transportDir, sessionId) {
165
- const transport = new CopilotTransport({
169
+ const transport = createTransport({
166
170
  profileDir: cfg.resolved.profileDir,
167
171
  transportDir,
168
172
  chatUrl: cfg.copilot.url,
@@ -319,6 +323,13 @@ export async function runTask(transport, session, task, deps) {
319
323
  const taskLogPath = log.path('task-log.txt');
320
324
  await mkdir(artifactsDir, { recursive: true });
321
325
  await mkdir(repliesDir, { recursive: true });
326
+ /*
327
+ * Made here, not left to the first step report. The report writer creates it as a side effect, so
328
+ * a chat that answered "done" before running a single step reached the failed-checks report with
329
+ * no folder to write it into, and the task ended "failed" on an ENOENT about checks-1.txt instead
330
+ * of telling the chat which check failed. Found by the end-to-end checks (test/e2e-run.check.ts).
331
+ */
332
+ await mkdir(reportsDir, { recursive: true });
322
333
  /** The consolidated, human-readable record of the whole task, appended as it happens. */
323
334
  const record = async (heading, body) => {
324
335
  await appendFile(taskLogPath, `\n${'='.repeat(78)}\n${heading}\n${'='.repeat(78)}\n${body.trimEnd()}\n`, 'utf8');
@@ -347,6 +358,14 @@ export async function runTask(transport, session, task, deps) {
347
358
  let disputes = [];
348
359
  /** What the runner tells the model in its next message about the reply just processed. */
349
360
  let runnerNotes = [];
361
+ /** Steps the chat sent that were not run, for the handoff at the end. */
362
+ const notRun = [];
363
+ /** What went wrong on the way, counted for the register's figures. See `TaskStats`. */
364
+ const stats = { formatErrors: 0, doneRejected: 0, repeatsRefused: 0, stepsRefused: 0, operatorStops: 0, reviewRejections: 0, scopeReverts: 0 };
365
+ /** Loops the older guards do not see; see `progress.ts`. */
366
+ const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
367
+ /** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
368
+ let noProgressReason = null;
350
369
  /**
351
370
  * Checks earlier reviews gave with their findings — carried over from every attempt before
352
371
  * this one, and grown by this one. See `derivedChecks.ts` for the three rules.
@@ -447,6 +466,11 @@ export async function runTask(transport, session, task, deps) {
447
466
  if (reviewChecks.length > 0)
448
467
  t.reviewChecks = reviewChecks;
449
468
  t.logFile = 'task-log.txt';
469
+ t.stats = stats;
470
+ });
471
+ // The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
472
+ await setTask((t) => {
473
+ t.handoff = composeHandoff(t, notRun);
450
474
  });
451
475
  sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
452
476
  await log.close();
@@ -576,6 +600,15 @@ export async function runTask(transport, session, task, deps) {
576
600
  ? `not active: ${prepared.vcs.problem}`
577
601
  : `branch ${prepared.vcs.branch}, from ${prepared.vcs.baseCommit ?? '(no commits yet)'}`);
578
602
  }
603
+ /*
604
+ * The paths this task may change. Enforced only on a branch of the task's own, which is what
605
+ * makes "put back" safe: the tree was clean when the branch was made, so every change in it
606
+ * is the task's, and the starting commit is what a change goes back to.
607
+ */
608
+ const scope = task.scope ?? [];
609
+ const scopeEnforced = scope.length > 0 && !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
610
+ /** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
611
+ const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
579
612
  // --- project mirror, attached to the first message of the task ---------------------
580
613
  let mirrorFiles = [];
581
614
  if (session.mirror.enabled && session.mirror.rootDir) {
@@ -630,6 +663,7 @@ export async function runTask(transport, session, task, deps) {
630
663
  workDirNote: workingDirNote(work),
631
664
  vcsNote: prepared.note,
632
665
  readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
666
+ scopeNote: scopeNote(scope, scopeEnforced),
633
667
  shellNote: shellNote(shells, defaultShell),
634
668
  });
635
669
  await setTask((t) => {
@@ -694,7 +728,8 @@ export async function runTask(transport, session, task, deps) {
694
728
  * task refusing to start over it.
695
729
  */
696
730
  const willCommit = !!(session.vcs?.enabled && session.vcs.commitOnFinish && repoDirOf(session));
697
- let generatedPointedOut = false;
731
+ /** The runner's own checks that have been pointed out to the chat once already. */
732
+ const pointedOut = new Set();
698
733
  /**
699
734
  * Whether "done" is accepted, decided by the operator's checks rather than by the reply.
700
735
  *
@@ -705,7 +740,7 @@ export async function runTask(transport, session, task, deps) {
705
740
  * happened, and a task cannot be trusted to answer that about itself.
706
741
  */
707
742
  const gateOnChecks = async () => {
708
- const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK] : [])];
743
+ const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK] : [])];
709
744
  if (checks.length === 0)
710
745
  return 'accept';
711
746
  // Counted only once the round turns out to have been a real attempt. A round that died of
@@ -726,19 +761,26 @@ export async function runTask(transport, session, task, deps) {
726
761
  deny: (command, shell, cwd) => checkCommandRefusal(command, shell, cfg.execution, { roots: confinement.roots, cwd }),
727
762
  roots: confinement.roots,
728
763
  repoDir: willCommit ? repoDirOf(session) : undefined,
764
+ baseCommit: prepared.vcs.baseCommit,
729
765
  // The same shell a step that named none is given, so the gate and the work it judges
730
766
  // cannot have been read by different interpreters.
731
767
  defaultShell,
732
768
  }));
769
+ /*
770
+ * The runner's own checks are advice given once, not a gate: a second "done" with the finding
771
+ * still there is committed and the finding stays on the task. Refusing instead would leave the
772
+ * tree dirty and the next task unable to start. Each check is pointed out once on its own, so a
773
+ * content problem found after the paths were settled still gets its one mention.
774
+ */
733
775
  for (const o of outcomes) {
734
- if (o.check.expect !== 'commit-clean' || o.passed)
776
+ if (!RUNNER_CHECK_KINDS.has(o.check.expect) || o.passed)
735
777
  continue;
736
- if (generatedPointedOut) {
778
+ if (pointedOut.has(o.check.expect)) {
737
779
  o.passed = true;
738
- o.detail = `still there after being pointed out once; committed and marked as suspicious on the task — ${o.detail}`;
780
+ o.detail = `still there after being pointed out once; committed and kept on the task — ${o.detail}`;
739
781
  }
740
782
  else {
741
- generatedPointedOut = true;
783
+ pointedOut.add(o.check.expect);
742
784
  }
743
785
  }
744
786
  await setTask((t) => {
@@ -769,8 +811,17 @@ export async function runTask(transport, session, task, deps) {
769
811
  sink.event('checks-passed', { count: outcomes.length }, `all ${outcomes.length} check(s) passed`);
770
812
  return 'accept';
771
813
  }
814
+ stats.doneRejected += 1;
772
815
  if (deps.signal?.aborted)
773
816
  return 'give-up';
817
+ // The same failures as last time, and not a file changed since they were reported.
818
+ const stuck = progress.afterFailedChecks(await treeNow(), failed.map((o) => ({ name: o.check.name, detail: o.detail })));
819
+ if (stuck) {
820
+ noProgressReason = stuck;
821
+ stats.stoppedFor = 'no-progress';
822
+ sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
823
+ return 'give-up';
824
+ }
774
825
  if (checkRounds > maxCheckRounds) {
775
826
  /*
776
827
  * Only checks from earlier reviews are failing. A reviewer's check is outranked by the
@@ -941,6 +992,8 @@ export async function runTask(transport, session, task, deps) {
941
992
  * task; the ones suspended by a dispute or by spent rounds come back if the finding was
942
993
  * raised again, and go if it was not.
943
994
  */
995
+ if (outcome.verdict === 'fail')
996
+ stats.reviewRejections += 1;
944
997
  if (outcome.verdict === 'pass' || outcome.verdict === 'fail') {
945
998
  for (const d of outcome.derivedChecks ?? []) {
946
999
  reviewChecks = [...reviewChecks, { check: d.check, findingId: d.findingId, what: d.what, where: d.where, round: reviewRounds, attempt, state: 'active' }];
@@ -1100,6 +1153,7 @@ export async function runTask(transport, session, task, deps) {
1100
1153
  });
1101
1154
  if (!parsed.ok) {
1102
1155
  formatRetries += 1;
1156
+ stats.formatErrors += 1;
1103
1157
  sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
1104
1158
  if (formatRetries > cfg.limits.maxFormatRetries) {
1105
1159
  await transport.dumpFailure(log.path('failures'), 'format-error');
@@ -1187,6 +1241,8 @@ export async function runTask(transport, session, task, deps) {
1187
1241
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1188
1242
  }
1189
1243
  if (verdict === 'give-up') {
1244
+ if (noProgressReason)
1245
+ return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1190
1246
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1191
1247
  }
1192
1248
  if (verdict === 'accept') {
@@ -1246,6 +1302,7 @@ export async function runTask(transport, session, task, deps) {
1246
1302
  const ran = seen.get(key)?.sameInARow ?? 0;
1247
1303
  if (ran >= maxCommandRepeats) {
1248
1304
  repeatsRefused += 1;
1305
+ stats.repeatsRefused += 1;
1249
1306
  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
1307
  results.push(refusedResult(step, repeatRefusal(ran, maxCommandRepeats)));
1251
1308
  continue;
@@ -1312,11 +1369,42 @@ export async function runTask(transport, session, task, deps) {
1312
1369
  }
1313
1370
  await pacer.settle();
1314
1371
  }
1372
+ for (const r of results) {
1373
+ if (r.outcome !== 'refused' && r.outcome !== 'aborted')
1374
+ continue;
1375
+ if (r.outcome === 'refused')
1376
+ stats.stepsRefused += 1;
1377
+ else
1378
+ stats.operatorStops += 1;
1379
+ const why = (r.stderr.split('\n')[0] ?? '').replace(/^\[policy\] step not executed: /, '').trim();
1380
+ notRun.push({ command: r.command.slice(0, 200), why: why.slice(0, 300) || r.outcome });
1381
+ }
1315
1382
  // Ended before the report is written and sent: there is nobody to send it to who could
1316
1383
  // do anything with it, and the reason on the task says what to install or set instead.
1317
1384
  if (environmentProblem) {
1318
1385
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1319
1386
  }
1387
+ /*
1388
+ * The task's scope, as a rule rather than a sentence (see `vcs/scope.ts`): whatever this round
1389
+ * changed outside it is put back now, before the chat reads the results and builds on them,
1390
+ * and the chat is told in the same message.
1391
+ */
1392
+ if (scopeEnforced) {
1393
+ const check = await enforceScope(repoDirOf(session), scope).catch((e) => ({
1394
+ outside: [],
1395
+ reverted: [],
1396
+ failed: [{ path: '(the working tree)', why: e.message }],
1397
+ }));
1398
+ if (check.outside.length > 0 || check.failed.length > 0) {
1399
+ sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
1400
+ (check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
1401
+ runnerNotes.push(scopeMessage(check, scope));
1402
+ stats.scopeReverts += check.reverted.length;
1403
+ await setTask((t) => {
1404
+ t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
1405
+ });
1406
+ }
1407
+ }
1320
1408
  // --- report back ---------------------------------------------------------------
1321
1409
  const report = await writeReport(results, {
1322
1410
  runId,
@@ -1336,6 +1424,14 @@ export async function runTask(transport, session, task, deps) {
1336
1424
  }
1337
1425
  if (aborted)
1338
1426
  return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
1427
+ // A loop in which every round looks new: see `progress.ts`. After the report is written, so
1428
+ // the evidence the diagnosis points at is on disk.
1429
+ const stuck = progress.afterRound(await treeNow(), results);
1430
+ if (stuck) {
1431
+ stats.stoppedFor = 'no-progress';
1432
+ sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
1433
+ return await finish('blocked', stuck, reply.summary, lastMarkdown);
1434
+ }
1339
1435
  /*
1340
1436
  * Nothing this iteration did anything.
1341
1437
  *
@@ -1436,6 +1532,8 @@ export async function runTask(transport, session, task, deps) {
1436
1532
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1437
1533
  }
1438
1534
  if (verdict === 'give-up') {
1535
+ if (noProgressReason)
1536
+ return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1439
1537
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1440
1538
  }
1441
1539
  if (verdict === 'accept') {
@@ -1562,8 +1660,10 @@ export async function runSession(sessionId, deps) {
1562
1660
  s.chat = undefined;
1563
1661
  s.contractSent = false;
1564
1662
  const again = s.tasks.find((x) => x.id === task.id);
1565
- if (again)
1663
+ if (again) {
1566
1664
  again.autoRetries = (again.autoRetries ?? 0) + 1;
1665
+ again.freshRetry = true;
1666
+ }
1567
1667
  });
1568
1668
  await chat.newChat();
1569
1669
  /*
@@ -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 знака. Кратко, латиница, с тирета.'],
@@ -726,7 +728,7 @@ in those words, every time:
726
728
  1. On \`/history\`, in the **"Flow"** view, press **"Fix the prompt and queue it again"** on that row.
727
729
  2. Replace the text with the one above and press **"Save and queue again"**.
728
730
  3. Under **"What is next"**, press **"Continue: run the {n} queued task(s) in {s} session(s)"**
729
- (it reads **"Continue: run {f} failed and {n} queued task(s) in {s} session(s)"** when a chain
731
+ (it reads **"Continue: run {f} unfinished and {n} queued task(s) in {s} session(s)"** when a chain
730
732
  has a failed task).
731
733
  4. In **"Before it continues"** every task is ticked. To run only the fixed one, press
732
734
  **"only this one"** beside it.
@@ -736,7 +738,7 @@ in those words, every time:
736
738
  A check that is wrong rather than a prompt that is wrong is edited instead: **"Edit"** on the
737
739
  task card, then **"What it checks"**, **"Must be"** and **"Command"** under **"Checks"**, then
738
740
  **"Save and queue it again"** — which, like every "queue" button, starts nothing by itself.
739
- A task that ended **limit-reached** is carried on in its own chat instead: **"Continue in the
741
+ A task that ended **limit-reached**, or **aborted** because the bot stopped under it, is carried on in its own chat instead: **"Continue in the
740
742
  same chat"** on its card on the session page, then **"Run {n} task(s)"** at the top of that page.
741
743
  When a failure shows up while the run is still going, **"Pause after this task"** holds the rest
742
744
  while you work it out.
@@ -887,7 +889,7 @@ const PHASE3_BG = `
887
889
  1. В \`/history\`, в изгледа **„Поток“**, натисни **„Поправи prompt-а и върни в опашката“** на този ред.
888
890
  2. Замени текста с горния и натисни **„Запази и върни в опашката“**.
889
891
  3. Под **„Какво следва“** натисни **„Продължи: пусни {n} чакащи задачи в {s} сесии“** (пише
890
- **„Продължи: пусни {f} провалили се и {n} чакащи задачи в {s} сесии“**, когато във верига има
892
+ **„Продължи: пусни {f} недовършени и {n} чакащи задачи в {s} сесии“**, когато във верига има
891
893
  провалена задача).
892
894
  4. В **„Преди да продължи“** всички задачи са отметнати. За да пуснеш само поправената, натисни
893
895
  **„само тази“** до нея.
@@ -897,7 +899,7 @@ const PHASE3_BG = `
897
899
  Когато е сгрешена проверката, а не prompt-ът, се редактира друго: **„Редактирай“** на картата
898
900
  на задачата, после **„Какво проверява“**, **„Трябва“** и **„Команда“** под **„Проверки“**, после
899
901
  **„Запази и върни в опашката“** — който, като всеки бутон за връщане в опашката, сам не пуска нищо.
900
- Задача, завършила с **limit-reached**, продължава в собствения си чат: **„Продължи в същия чат“**
902
+ Задача, завършила с **limit-reached** или **aborted**, защото ботът е спрял под нея, продължава в собствения си чат: **„Продължи в същия чат“**
901
903
  на картата ѝ на страницата на сесията, после **„Пусни {n} задача(и)“** горе на същата страница.
902
904
  Когато провал се покаже, докато пускането още върви, **„Пауза след тази задача“** задържа
903
905
  останалото, докато го разбереш.
@@ -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:
@@ -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',
@@ -1442,8 +1458,8 @@ export const SYSTEM_GUIDE = [
1442
1458
  kind: 'button',
1443
1459
  en: 'Continue in the same chat',
1444
1460
  bg: 'Продължи в същия чат',
1445
- doesEn: 'Only on a task stopped at its limit: queues it to carry on in the same chat and branch with a fresh count. Then press "Run {n} task(s)".',
1446
- doesBg: 'Само на задача, спряла на лимита: нарежда я да продължи в същия чат и клон с нов брояч. После натиснете „Пусни {n} задача(и)“.',
1461
+ doesEn: 'On a task that stopped before it finished (its limit, the bot stopping, the operator): queues it to carry on in the same chat and branch. Then "Run {n} task(s)".',
1462
+ doesBg: 'На задача, спряла преди да приключи (лимит, спрял бот, оператор): нарежда я да продължи в същия чат и клон. После „Пусни {n} задача(и)“.',
1447
1463
  },
1448
1464
  {
1449
1465
  key: 'task.rerun',
@@ -2316,6 +2332,14 @@ export const SYSTEM_GUIDE = [
2316
2332
  en: 'What is next',
2317
2333
  bg: 'Какво следва',
2318
2334
  controls: [
2335
+ {
2336
+ key: 'reg.interruptedTitle',
2337
+ kind: 'note',
2338
+ en: 'The bot stopped in the middle of the work.',
2339
+ bg: 'Ботът спря посред работата.',
2340
+ doesEn: 'After the bot stopped under a run (power, Ctrl+C, a crash): the stopped tasks\' work is kept, and "Continue" carries them on where they stopped.',
2341
+ doesBg: 'След като ботът е спрял посред пускане (ток, Ctrl+C, срив): свършеното е запазено, а „Продължи“ продължава спрелите задачи оттам, където са спрели.',
2342
+ },
2319
2343
  {
2320
2344
  key: 'batch.pause',
2321
2345
  kind: 'button',
@@ -2351,8 +2375,8 @@ export const SYSTEM_GUIDE = [
2351
2375
  {
2352
2376
  key: 'reg.continueWithFailed',
2353
2377
  kind: 'button',
2354
- en: 'Continue: run {f} failed and {n} queued task(s) in {s} session(s)',
2355
- bg: 'Продължи: пусни {f} провалили се и {n} чакащи задачи в {s} сесии',
2378
+ en: 'Continue: run {f} unfinished and {n} queued task(s) in {s} session(s)',
2379
+ bg: 'Продължи: пусни {f} недовършени и {n} чакащи задачи в {s} сесии',
2356
2380
  doesEn: 'The same button when a failed task of a chained session would go back into the queue with the queued ones.',
2357
2381
  doesBg: 'Същият бутон, когато провалена задача от сесия-верига би се върнала в опашката заедно с чакащите.',
2358
2382
  },
@@ -2417,8 +2441,8 @@ export const SYSTEM_GUIDE = [
2417
2441
  kind: 'button',
2418
2442
  en: 'Continue without asking',
2419
2443
  bg: 'Продължи без да пита',
2420
- doesEn: 'Starts exactly the ticked tasks and runs every command without asking (asks "are you sure" first unless Settings say otherwise). Ticked failed tasks start afresh.',
2421
- doesBg: 'Пуска точно отметнатите задачи и изпълнява всяка команда без питане (първо пита „сигурен ли си“, освен ако Настройките казват друго). Отметнатите провалени задачи тръгват отначало.',
2444
+ doesEn: 'Starts exactly the ticked tasks without asking (after "are you sure" unless Settings say otherwise). Stopped ones (aborted, limit reached) continue where they stopped; failed or blocked ones start afresh.',
2445
+ doesBg: 'Пуска точно отметнатите задачи без питане (след „сигурен ли си“, освен ако Настройките казват друго). Спрелите (прекратени, с лимит) продължават откъдето спряха; провалените и блокираните тръгват отначало.',
2422
2446
  },
2423
2447
  {
2424
2448
  key: 'reg.continueGo',
@@ -2563,6 +2587,13 @@ export const SYSTEM_GUIDE = [
2563
2587
  },
2564
2588
  ],
2565
2589
  },
2590
+ {
2591
+ headingKey: 'metrics.title',
2592
+ en: 'How the bot is doing',
2593
+ bg: 'Как се справя ботът',
2594
+ // A table of figures at the foot of the register, read-only; the heading says what it is.
2595
+ controls: [],
2596
+ },
2566
2597
  ],
2567
2598
  },
2568
2599
  {
@@ -2789,6 +2820,14 @@ export const SYSTEM_GUIDE = [
2789
2820
  doesEn: 'How many minutes one task may run (default 240) before it stops as limit reached; saved as you type.',
2790
2821
  doesBg: 'Колко минути може да върви една задача (по подразбиране 240), преди да спре като достигнат лимит; запазва се, докато пишете.',
2791
2822
  },
2823
+ {
2824
+ key: 'exec.replyTimeout',
2825
+ kind: 'field',
2826
+ en: 'How long to wait for a reply from the chat',
2827
+ bg: 'Колко да се чака отговор от чата',
2828
+ doesEn: 'Seconds to wait for a chat reply (default 900); a reply not finished by then fails the task with "Copilot did not finish a reply within …".',
2829
+ doesBg: 'Секунди чакане на отговор от чата (по подразбиране 900); незавършен дотогава отговор проваля задачата с „Copilot did not finish a reply within …“.',
2830
+ },
2792
2831
  {
2793
2832
  key: 'exec.retryBlocked',
2794
2833
  kind: 'field',
@@ -3059,29 +3098,45 @@ export const SYSTEM_GUIDE = [
3059
3098
  en: 'Text size',
3060
3099
  bg: 'Размер на текста',
3061
3100
  controls: [
3101
+ {
3102
+ key: 'ap.sizeSmall',
3103
+ kind: 'button',
3104
+ en: 'Small',
3105
+ bg: 'Малък',
3106
+ doesEn: 'One step below normal.',
3107
+ doesBg: 'Една степен под нормалния.',
3108
+ },
3062
3109
  {
3063
3110
  key: 'ap.sizeNormal',
3064
3111
  kind: 'button',
3065
3112
  en: 'Normal',
3066
3113
  bg: 'Нормален',
3067
- doesEn: 'Normal text size.',
3068
- doesBg: 'Нормален размер на текста.',
3114
+ doesEn: 'Normal size.',
3115
+ doesBg: 'Нормален размер.',
3069
3116
  },
3070
3117
  {
3071
3118
  key: 'ap.sizeLarge',
3072
3119
  kind: 'button',
3073
3120
  en: 'Large',
3074
3121
  bg: 'Голям',
3075
- doesEn: 'Larger text.',
3076
- doesBg: 'По-голям текст.',
3122
+ doesEn: 'One step above normal.',
3123
+ doesBg: 'Една степен над нормалния.',
3077
3124
  },
3078
3125
  {
3079
3126
  key: 'ap.sizeHuge',
3080
3127
  kind: 'button',
3128
+ en: 'Larger',
3129
+ bg: 'По-голям',
3130
+ doesEn: 'Two steps above normal.',
3131
+ doesBg: 'Две степени над нормалния.',
3132
+ },
3133
+ {
3134
+ key: 'ap.sizeGiant',
3135
+ kind: 'button',
3081
3136
  en: 'Largest',
3082
3137
  bg: 'Най-голям',
3083
- doesEn: 'The largest text.',
3084
- doesBg: 'Най-големият текст.',
3138
+ doesEn: 'Three steps above normal.',
3139
+ doesBg: 'Три степени над нормалния.',
3085
3140
  },
3086
3141
  ],
3087
3142
  },
@@ -1,17 +1,4 @@
1
- /**
2
- * Builds the messages that open a task in the chat.
3
- *
4
- * Two shapes, because a conversation is opened once and then reused:
5
- *
6
- * first task in a session level 1 (the contract, sent on its own and acknowledged),
7
- * then level 2 plus the task in one message
8
- * later tasks a short reminder that the contract still applies,
9
- * then level 2 plus the task
10
- *
11
- * Level 2 is always sent with the task, even when it did not change, because it is cheap
12
- * and it keeps each task self-contained in the transcript. The priority of level 1 over
13
- * level 2 is stated inside level 1 itself; the composition only makes the boundary visible.
14
- */
1
+ import { describeInterruption } from './interruption.js';
15
2
  const LEVEL2_HEADER = '## Project instructions (level 2)';
16
3
  const TASK_HEADER = '## Task';
17
4
  /**
@@ -24,7 +11,7 @@ const TASK_HEADER = '## Task';
24
11
  * and a note that is empty simply leaves nothing behind.
25
12
  */
26
13
  function runnerBlock(input) {
27
- const notes = [input.workDirNote, input.readOnlyNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
14
+ const notes = [input.workDirNote, input.readOnlyNote, input.scopeNote, input.vcsNote, input.shellNote].map((n) => (n ?? '').trim()).filter((n) => n.length > 0);
28
15
  return notes.length > 0 ? `${notes.join('\n\n')}\n\n` : '';
29
16
  }
30
17
  /**
@@ -50,6 +37,25 @@ function level2Block(level2) {
50
37
  * The messages to send, in order. The last one is the one whose reply starts the loop.
51
38
  * `firstMessage` is what the UI shows as "the prompt that opened this task".
52
39
  */
40
+ /**
41
+ * Why the attempt being continued stopped, and where it was, in the words the chat is sent. The
42
+ * chat was mid-conversation when it stopped, so what it most needs is to know that nothing it did
43
+ * failed, that the files are as its work left them, and — after the bot stopped under it — which of
44
+ * the steps it had just asked for actually ran.
45
+ */
46
+ function whyItStopped(c) {
47
+ if (c.how === 'interrupted') {
48
+ const where = c.interruption ? `\n\n${describeInterruption(c.interruption)}\n\n` : ' ';
49
+ return ('The runner stopped unexpectedly while this task was in progress — the program was closed, the machine went off or it ' +
50
+ 'crashed; nothing you did failed — and it has now started again. The files are as your work left them, and what had ' +
51
+ `changed is committed on this branch.${where}`.trimEnd());
52
+ }
53
+ if (c.how === 'stopped') {
54
+ return 'It was stopped by the operator before it finished — not because anything failed. The files are as you left them.';
55
+ }
56
+ const why = c.stoppedBecause ? ` (${c.stoppedBecause})` : '';
57
+ return `It stopped because the runner's limit was reached${why} — not because anything failed. The files are as you left them.`;
58
+ }
53
59
  export function composeOpening(input) {
54
60
  const taskBlock = `${TASK_HEADER} ${input.taskNumber}: ${input.taskTitle.trim() || 'untitled'}\n\n${input.prompt.trim()}`;
55
61
  /*
@@ -58,18 +64,17 @@ export function composeOpening(input) {
58
64
  * conversation was lost does the task go out in full, with a line saying it is a continuation.
59
65
  */
60
66
  if (input.continuing && input.contractAlreadySent) {
61
- const why = input.continuing.stoppedBecause ? ` (${input.continuing.stoppedBecause})` : '';
62
67
  const message = `Continue task ${input.taskNumber}: ${input.taskTitle.trim() || 'untitled'}, in this same conversation. ` +
63
- `It stopped because the runner's limit was reached${why} — not because anything failed. ` +
64
- `The files are as you left them. Do not start over and do not repeat work that is done: start from ` +
68
+ `${whyItStopped(input.continuing)} ` +
69
+ `Do not start over and do not repeat work that is done: start from ` +
65
70
  `your plan, say in \`notes\` which stages are done and which one you are on, and carry on. ` +
66
71
  `The assignment is the one you were given above; it has not changed. Step numbering restarts at 1.\n\n` +
67
72
  `${runnerBlock(input)}`.trimEnd();
68
73
  return { messages: [message], firstMessage: message };
69
74
  }
70
75
  const continuationLine = input.continuing
71
- ? `\n\nThis continues an earlier attempt at this task that stopped at the runner's limit. The files are as ` +
72
- `that attempt left them: look at what is already done before changing anything, and carry on from there.`
76
+ ? `\n\nThis continues an earlier attempt at this task. ${whyItStopped(input.continuing)} ` +
77
+ `Look at what is already done before changing anything, and carry on from there.`
73
78
  : input.buildsOn
74
79
  ? `\n\nThis task was done once already (attempt ${input.buildsOn.fromAttempt}), and that work is in your ` +
75
80
  `working tree. The text above is a new instruction for it: look at what is there first, build on it, and ` +
@@ -168,6 +168,8 @@ export function buildPlanExport(scope) {
168
168
  task.review = false;
169
169
  if (t.readOnly)
170
170
  task.readOnly = true;
171
+ if (t.scope && t.scope.length > 0)
172
+ task.scope = [...t.scope];
171
173
  const earlier = earlierPlans(t, level2);
172
174
  if (earlier.length > 0)
173
175
  task.earlierAttempts = earlier;
@@ -394,6 +396,9 @@ async function domainTask(session, task, runsDir) {
394
396
  title: task.title,
395
397
  attempt: task.attempt ?? 1,
396
398
  readOnly: task.readOnly ?? false,
399
+ scope: task.scope ?? [],
400
+ scopeReverted: task.scopeReverted ?? [],
401
+ handoff: task.handoff ?? null,
397
402
  prompt: p.prompt,
398
403
  expected: p.expected,
399
404
  level2: task.level2,