copilot-operator 0.1.8 → 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 (70) 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 +15 -2
  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 +2 -0
  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 +45 -6
  13. package/dist/src/session/compose.js +1 -1
  14. package/dist/src/session/exports.js +5 -0
  15. package/dist/src/session/handoff.js +42 -0
  16. package/dist/src/session/metrics.js +95 -0
  17. package/dist/src/session/store.js +14 -0
  18. package/dist/src/transport/chatTransport.js +32 -0
  19. package/dist/src/vcs/contentIntegrity.js +174 -0
  20. package/dist/src/vcs/git.js +42 -0
  21. package/dist/src/vcs/scope.js +129 -0
  22. package/dist/src/vcs/taskVcs.js +43 -2
  23. package/dist/web/404.html +4 -3
  24. package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +1 -0
  25. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +1 -0
  26. package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +1 -0
  27. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +1 -0
  28. package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +1 -0
  29. package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +1 -0
  30. package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +1 -0
  31. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +1 -0
  32. package/dist/web/_next/static/chunks/app/level1/page-be366b35f4cc97ce.js +1 -0
  33. package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +1 -0
  34. package/dist/web/_next/static/chunks/app/presets/page-cabc3876a8111edd.js +1 -0
  35. package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +1 -0
  36. package/dist/web/_next/static/css/20c6b36996df5e4c.css +1 -0
  37. package/dist/web/appearance.html +4 -3
  38. package/dist/web/appearance.txt +12 -12
  39. package/dist/web/defaults.html +4 -3
  40. package/dist/web/defaults.txt +12 -12
  41. package/dist/web/history.html +4 -3
  42. package/dist/web/history.txt +12 -12
  43. package/dist/web/import.html +4 -3
  44. package/dist/web/import.txt +12 -12
  45. package/dist/web/index.html +4 -3
  46. package/dist/web/index.txt +12 -12
  47. package/dist/web/level1.html +4 -3
  48. package/dist/web/level1.txt +12 -12
  49. package/dist/web/presets.html +4 -3
  50. package/dist/web/presets.txt +12 -12
  51. package/dist/web/sessions/view.html +4 -3
  52. package/dist/web/sessions/view.txt +12 -12
  53. package/dist/web/system.html +4 -3
  54. package/dist/web/system.txt +12 -12
  55. package/package.json +13 -4
  56. package/dist/web/_next/static/chunks/549-33f0d3d53338b2f2.js +0 -1
  57. package/dist/web/_next/static/chunks/723-edc7b5709e5407ec.js +0 -1
  58. package/dist/web/_next/static/chunks/867-3d4ee08c5f9f0e71.js +0 -1
  59. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +0 -1
  60. package/dist/web/_next/static/chunks/app/defaults/page-dfcbd748f454d2d7.js +0 -1
  61. package/dist/web/_next/static/chunks/app/history/page-49413c3a8266a0b7.js +0 -1
  62. package/dist/web/_next/static/chunks/app/import/page-8a4bdee8e38341a9.js +0 -1
  63. package/dist/web/_next/static/chunks/app/layout-e0174d863960f109.js +0 -1
  64. package/dist/web/_next/static/chunks/app/level1/page-697d4d6ba5bba41a.js +0 -1
  65. package/dist/web/_next/static/chunks/app/page-f04d6e00182ccc76.js +0 -1
  66. package/dist/web/_next/static/chunks/app/presets/page-e2dcb924d9e2d758.js +0 -1
  67. package/dist/web/_next/static/chunks/app/sessions/view/page-a82e5e801dabc423.js +0 -1
  68. package/dist/web/_next/static/css/b67acb571615432b.css +0 -1
  69. /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → auD7lTmj-JDMJIQ4TVivR}/_buildManifest.js +0 -0
  70. /package/dist/web/_next/static/{kysrkUS5Y-ZTHsIIXgs3q → 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 знака. Кратко, латиница, с тирета.'],
@@ -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',
@@ -2571,6 +2587,13 @@ export const SYSTEM_GUIDE = [
2571
2587
  },
2572
2588
  ],
2573
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
+ },
2574
2597
  ],
2575
2598
  },
2576
2599
  {
@@ -3075,29 +3098,45 @@ export const SYSTEM_GUIDE = [
3075
3098
  en: 'Text size',
3076
3099
  bg: 'Размер на текста',
3077
3100
  controls: [
3101
+ {
3102
+ key: 'ap.sizeSmall',
3103
+ kind: 'button',
3104
+ en: 'Small',
3105
+ bg: 'Малък',
3106
+ doesEn: 'One step below normal.',
3107
+ doesBg: 'Една степен под нормалния.',
3108
+ },
3078
3109
  {
3079
3110
  key: 'ap.sizeNormal',
3080
3111
  kind: 'button',
3081
3112
  en: 'Normal',
3082
3113
  bg: 'Нормален',
3083
- doesEn: 'Normal text size.',
3084
- doesBg: 'Нормален размер на текста.',
3114
+ doesEn: 'Normal size.',
3115
+ doesBg: 'Нормален размер.',
3085
3116
  },
3086
3117
  {
3087
3118
  key: 'ap.sizeLarge',
3088
3119
  kind: 'button',
3089
3120
  en: 'Large',
3090
3121
  bg: 'Голям',
3091
- doesEn: 'Larger text.',
3092
- doesBg: 'По-голям текст.',
3122
+ doesEn: 'One step above normal.',
3123
+ doesBg: 'Една степен над нормалния.',
3093
3124
  },
3094
3125
  {
3095
3126
  key: 'ap.sizeHuge',
3096
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',
3097
3136
  en: 'Largest',
3098
3137
  bg: 'Най-голям',
3099
- doesEn: 'The largest text.',
3100
- doesBg: 'Най-големият текст.',
3138
+ doesEn: 'Three steps above normal.',
3139
+ doesBg: 'Три степени над нормалния.',
3101
3140
  },
3102
3141
  ],
3103
3142
  },
@@ -11,7 +11,7 @@ const TASK_HEADER = '## Task';
11
11
  * and a note that is empty simply leaves nothing behind.
12
12
  */
13
13
  function runnerBlock(input) {
14
- 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);
15
15
  return notes.length > 0 ? `${notes.join('\n\n')}\n\n` : '';
16
16
  }
17
17
  /**
@@ -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,
@@ -0,0 +1,42 @@
1
+ export function composeHandoff(task, notRun = []) {
2
+ const failedChecks = (task.checkResults ?? []).filter((c) => !c.passed);
3
+ const openFindings = task.review?.verdict === 'fail' ? (task.review.findings ?? []).filter((f) => f.about !== 'task') : [];
4
+ const vcs = task.vcs ?? {};
5
+ const knownIssues = [
6
+ ...failedChecks.map((c) => `check failed: ${c.name} — ${c.detail}`),
7
+ ...openFindings.map((f) => `review finding${f.id ? ` ${f.id}` : ''}: ${f.what}${f.where ? ` (${f.where})` : ''}`),
8
+ ...(task.checkResults ?? [])
9
+ .filter((c) => c.passed && /still there after being pointed out once/.test(c.detail))
10
+ .map((c) => `committed with a finding left in place: ${c.name}`),
11
+ ...(vcs.suspicious ?? []).map((s) => `committed although it looks like ${s.reason}: ${s.path}`),
12
+ ...(vcs.foreignCommits ?? []).map((c) => `a commit on the branch the runner did not make: ${c}`),
13
+ ...(task.scopeReverted?.length ? [`put back because outside the task's scope: ${task.scopeReverted.join(', ')}`] : []),
14
+ ...(task.deviations ?? []).map((d) => `did not follow "${d.instruction}" as written: ${d.did}`),
15
+ ];
16
+ const manual = [];
17
+ if (vcs.problem)
18
+ manual.push(`version control: ${vcs.problem}`);
19
+ if (vcs.commit && vcs.branch)
20
+ manual.push(`push ${vcs.branch} when you are ready — the runner never pushes`);
21
+ if (task.status === 'blocked')
22
+ manual.push(`unblock it: ${task.reason ?? 'see the reason'}`);
23
+ if (task.status === 'limit-reached' || task.status === 'aborted')
24
+ manual.push('continue it in the same chat from the register, or run it again');
25
+ if (task.status === 'failed')
26
+ manual.push('read why it failed, fix the prompt or the checks, and queue it again');
27
+ if (openFindings.length > 0)
28
+ manual.push(`${openFindings.length} review finding(s) are open`);
29
+ if ((vcs.suspicious?.length ?? 0) > 0 || (vcs.foreignCommits?.length ?? 0) > 0)
30
+ manual.push('look at the committed files and commits listed above');
31
+ return {
32
+ outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}) },
33
+ changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
34
+ validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
35
+ ...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
36
+ knownIssues,
37
+ evidence: { ...(task.runId ? { runId: task.runId } : {}), checks: task.checkResults?.length ?? 0, reviewRounds: task.review?.rounds ?? 0 },
38
+ vcs: { ...(vcs.branch ? { branch: vcs.branch } : {}), ...(vcs.commit ? { commit: vcs.commit } : {}), pushed: false, ...(vcs.problem ? { problem: vcs.problem } : {}) },
39
+ manual,
40
+ notExecuted: notRun.slice(0, 30).map((n) => `${n.command} — ${n.why}`),
41
+ };
42
+ }
@@ -0,0 +1,95 @@
1
+ const ENDED = ['done', 'failed', 'blocked', 'aborted', 'limit-reached'];
2
+ function attemptsOf(task) {
3
+ const all = [...(task.attempts ?? [])];
4
+ if (ENDED.includes(task.status))
5
+ all.push(task);
6
+ return all.filter((a) => ENDED.includes(a.status));
7
+ }
8
+ function modelOf(session) {
9
+ return (session.modelInUse ?? '').trim() || (session.model ?? '').trim() || '(the chat default)';
10
+ }
11
+ function row(group, tasks) {
12
+ const r = {
13
+ group,
14
+ tasks: 0,
15
+ attempts: 0,
16
+ firstPass: { n: 0, of: 0 },
17
+ doneInTheEnd: { n: 0, of: 0 },
18
+ ended: {},
19
+ freshRetries: 0,
20
+ manualInterventions: 0,
21
+ falseCompletion: { n: 0, of: 0 },
22
+ reviewRejection: { n: 0, of: 0 },
23
+ repeatedCommands: { n: 0, of: 0 },
24
+ scopeViolation: { n: 0, of: 0 },
25
+ noProgress: { n: 0, of: 0 },
26
+ unrelatedDiff: { n: 0, of: 0 },
27
+ resumed: { n: 0, of: 0 },
28
+ withStats: 0,
29
+ };
30
+ for (const task of tasks) {
31
+ const list = attemptsOf(task);
32
+ if (list.length === 0)
33
+ continue;
34
+ r.tasks += 1;
35
+ r.attempts += list.length;
36
+ r.firstPass.of += 1;
37
+ if (list[0].status === 'done')
38
+ r.firstPass.n += 1;
39
+ const last = list[list.length - 1];
40
+ r.doneInTheEnd.of += 1;
41
+ if (last.status === 'done')
42
+ r.doneInTheEnd.n += 1;
43
+ r.ended[last.status] = (r.ended[last.status] ?? 0) + 1;
44
+ list.forEach((a, i) => {
45
+ if (a.freshRetry)
46
+ r.freshRetries += 1;
47
+ else if (i > 0)
48
+ r.manualInterventions += 1;
49
+ r.unrelatedDiff.of += 1;
50
+ if ((a.vcs?.suspicious?.length ?? 0) > 0 || (a.vcs?.foreignCommits?.length ?? 0) > 0)
51
+ r.unrelatedDiff.n += 1;
52
+ if ((a.scope?.length ?? 0) > 0) {
53
+ r.scopeViolation.of += 1;
54
+ if ((a.scopeReverted?.length ?? 0) > 0)
55
+ r.scopeViolation.n += 1;
56
+ }
57
+ if (a.continuing) {
58
+ r.resumed.of += 1;
59
+ if (a.status === 'done')
60
+ r.resumed.n += 1;
61
+ }
62
+ if (a.review && a.review.verdict !== 'skipped') {
63
+ r.reviewRejection.of += 1;
64
+ if ((a.stats?.reviewRejections ?? 0) > 0 || a.review.verdict === 'fail' || (a.review.rounds ?? 0) > 1)
65
+ r.reviewRejection.n += 1;
66
+ }
67
+ if (!a.stats)
68
+ return;
69
+ r.withStats += 1;
70
+ r.manualInterventions += a.stats.operatorStops;
71
+ r.falseCompletion.of += 1;
72
+ if (a.stats.doneRejected > 0)
73
+ r.falseCompletion.n += 1;
74
+ r.repeatedCommands.of += 1;
75
+ if (a.stats.repeatsRefused > 0)
76
+ r.repeatedCommands.n += 1;
77
+ r.noProgress.of += 1;
78
+ if (a.stats.stoppedFor === 'no-progress')
79
+ r.noProgress.n += 1;
80
+ });
81
+ }
82
+ return r;
83
+ }
84
+ /** All tasks, then one row per model the sessions ran on, busiest first. */
85
+ export function computeMetrics(sessions, now = new Date()) {
86
+ const all = sessions.flatMap((s) => s.tasks);
87
+ const byModel = new Map();
88
+ for (const s of sessions)
89
+ byModel.set(modelOf(s), [...(byModel.get(modelOf(s)) ?? []), ...s.tasks]);
90
+ const models = [...byModel.entries()]
91
+ .map(([group, tasks]) => row(group, tasks))
92
+ .filter((r) => r.tasks > 0)
93
+ .sort((a, b) => b.tasks - a.tasks);
94
+ return { rows: [row('all', all), ...(models.length > 1 ? models : [])], computedAt: now.toISOString() };
95
+ }
@@ -251,6 +251,8 @@ export class SessionStore {
251
251
  ...(input.reviewEnabled === false ? { reviewEnabled: false } : {}),
252
252
  // Only `true` is worth storing: a task may change files unless a plan said otherwise.
253
253
  ...(input.readOnly === true ? { readOnly: true } : {}),
254
+ // Stored only when it limits something: absent means anywhere, as before scopes existed.
255
+ ...((input.scope ?? []).filter((p) => p.trim()).length > 0 ? { scope: (input.scope ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
254
256
  };
255
257
  await this.updateSession(sessionId, (s) => {
256
258
  s.tasks.push(task);
@@ -352,6 +354,12 @@ export class SessionStore {
352
354
  checks: t.checks,
353
355
  vcsPlan: t.vcsPlan,
354
356
  review: t.review,
357
+ stats: t.stats,
358
+ continuing: t.continuing,
359
+ buildsOn: t.buildsOn,
360
+ freshRetry: t.freshRetry,
361
+ scope: t.scope,
362
+ scopeReverted: t.scopeReverted,
355
363
  vcs: t.vcs,
356
364
  },
357
365
  ];
@@ -390,6 +398,12 @@ export class SessionStore {
390
398
  // the one thing a new attempt should inherit.
391
399
  t.deviations = undefined;
392
400
  t.disputes = undefined;
401
+ // Counts, the ending and what was put back belong to the attempt above too. A fresh-chat retry
402
+ // marks itself again after this, in the runner.
403
+ t.stats = undefined;
404
+ t.handoff = undefined;
405
+ t.scopeReverted = undefined;
406
+ t.freshRetry = undefined;
393
407
  // The branch of the finished attempt stays in the repository and stays on the record
394
408
  // above; the next attempt gets its own, cut from the same commit this one started at.
395
409
  t.vcs = undefined;
@@ -0,0 +1,32 @@
1
+ /**
2
+ * What the runner, the reviewer and the API need from a chat, and where that chat comes from.
3
+ *
4
+ * Everything that talks to Copilot goes through `CopilotTransport`, which drives a real Edge window
5
+ * signed in to Microsoft 365. That is the product, and it is also why nothing above the transport
6
+ * could be tested end to end: every run opened a browser, signed in and spent the operator's hourly
7
+ * message allowance. The loop itself — send, read the reply, run the steps, report back, stop at the
8
+ * limit, carry on after an interruption, commit on the right branch — never touches the page, only
9
+ * these few methods. So the rest of the code asks for a `ChatTransport` and gets one from
10
+ * `createTransport`, and the automated checks put a scripted chat in its place (test/support/fakeChat.ts)
11
+ * that answers from a script and never opens a window.
12
+ *
13
+ * The type is a `Pick` of the real class rather than an interface written out by hand, so the two
14
+ * cannot drift apart: a method that changes its signature in `CopilotTransport` changes it here, and
15
+ * the scripted chat stops compiling instead of passing checks against a shape the product no longer has.
16
+ *
17
+ * Deliberately, nothing outside the code can swap the chat: no environment variable, no setting, no
18
+ * command-line flag. A scripted chat hands the runner steps to execute, and a switch that a file or
19
+ * an environment could flip would be a way to make the bot run commands nobody reviewed. Only code
20
+ * running in this process — a check script — can call `setTransportFactory`.
21
+ */
22
+ import { CopilotTransport } from './copilotTransport.js';
23
+ const realChat = (opts) => new CopilotTransport(opts);
24
+ let factory = realChat;
25
+ /** A chat for these options: the real Copilot window, unless a check has put a scripted one in. */
26
+ export function createTransport(opts) {
27
+ return factory(opts);
28
+ }
29
+ /** For checks only: every chat opened from now on comes from `f`. `null` puts the real one back. */
30
+ export function setTransportFactory(f) {
31
+ factory = f ?? realChat;
32
+ }