copilot-operator 0.1.11 → 0.1.12

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 (55) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +64 -6
  3. package/dist/src/config/schema.js +14 -3
  4. package/dist/src/context/desktopMirror.js +34 -12
  5. package/dist/src/context/projectMirror.js +69 -1
  6. package/dist/src/orchestrator/contract.js +56 -1
  7. package/dist/src/orchestrator/taskRunner.js +116 -11
  8. package/dist/src/plan/brief.js +20 -8
  9. package/dist/src/plan/systemGuide.js +42 -10
  10. package/dist/src/session/compose.js +16 -1
  11. package/dist/src/session/exportRecord.js +8 -1
  12. package/dist/src/session/handoff.js +41 -3
  13. package/dist/src/session/model.js +9 -0
  14. package/dist/src/session/store.js +11 -7
  15. package/dist/src/session/story.js +74 -18
  16. package/dist/src/transport/chatTransport.js +1 -0
  17. package/dist/src/transport/copilotTransport.js +80 -61
  18. package/dist/src/vcs/git.js +15 -0
  19. package/dist/src/vcs/taskVcs.js +29 -6
  20. package/dist/web/404.html +2 -2
  21. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
  22. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
  23. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +1 -0
  24. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
  25. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +1 -0
  26. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
  27. package/dist/web/_next/static/chunks/app/sessions/view/{page-d7e1f83d13c6356b.js → page-cc99401da5173b36.js} +1 -1
  28. package/dist/web/_next/static/css/{2ad2a74b7e07dcc9.css → a0642fdb8adf9edb.css} +1 -1
  29. package/dist/web/appearance.html +2 -2
  30. package/dist/web/appearance.txt +12 -12
  31. package/dist/web/defaults.html +2 -2
  32. package/dist/web/defaults.txt +12 -12
  33. package/dist/web/history.html +2 -2
  34. package/dist/web/history.txt +12 -12
  35. package/dist/web/import.html +2 -2
  36. package/dist/web/import.txt +12 -12
  37. package/dist/web/index.html +2 -2
  38. package/dist/web/index.txt +12 -12
  39. package/dist/web/level1.html +2 -2
  40. package/dist/web/level1.txt +12 -12
  41. package/dist/web/presets.html +2 -2
  42. package/dist/web/presets.txt +12 -12
  43. package/dist/web/sessions/view.html +2 -2
  44. package/dist/web/sessions/view.txt +12 -12
  45. package/dist/web/system.html +2 -2
  46. package/dist/web/system.txt +12 -12
  47. package/package.json +6 -3
  48. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
  49. package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +0 -1
  50. package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +0 -1
  51. package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +0 -1
  52. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
  53. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
  54. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → bcBE4tY_aNh6kzLzN1luP}/_buildManifest.js +0 -0
  55. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → bcBE4tY_aNh6kzLzN1luP}/_ssgManifest.js +0 -0
@@ -13,7 +13,7 @@
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 { createTransport } from '../transport/chatTransport.js';
16
+ import { createTransport, isReplyTimeout } 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';
@@ -42,9 +42,9 @@ import { MIN_TRIED_APPROACHES } from '../protocol/replySchema.js';
42
42
  import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
43
43
  import { composeHandoff } from '../session/handoff.js';
44
44
  import { ProgressWatch } from './progress.js';
45
- import { contractConflicts } from './contract.js';
45
+ import { contractConflicts, readsTreeClean } from './contract.js';
46
46
  import { treeFingerprint } from '../vcs/git.js';
47
- import { mirrorProject, describeMirror } from '../context/projectMirror.js';
47
+ import { mirrorProject, describeMirror, MirrorSelectionError } from '../context/projectMirror.js';
48
48
  import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
49
49
  import { prepareForTask, commitTaskResult, repoDirOf } from '../vcs/taskVcs.js';
50
50
  import { exportMachine, writeAttemptRecord } from '../session/exports.js';
@@ -384,6 +384,15 @@ export async function runTask(transport, session, task, deps) {
384
384
  * lacks a shell. Written by `finish`. See `Task.stopCode`.
385
385
  */
386
386
  let stopCode;
387
+ /** The setting whose limit ended the attempt, when one did. Written by `finish`. See `TaskLimit`. */
388
+ let limitHit;
389
+ /**
390
+ * Checks that the working tree is clean, decided after the runner's commit rather than at the gate.
391
+ * The chat may not commit, so before that commit such a check could only fail (2026-09-30: failed,
392
+ * and then the commit left the tree clean). Set by the gate, run by `finish`. See `readsTreeClean`.
393
+ */
394
+ let afterCommitChecks = [];
395
+ let runAfterCommit = null;
387
396
  /**
388
397
  * Checks earlier reviews gave with their findings — carried over from every attempt before
389
398
  * this one, and grown by this one. See `derivedChecks.ts` for the three rules.
@@ -456,6 +465,40 @@ export async function runTask(transport, session, task, deps) {
456
465
  sink.event('vcs-error', { error: String(e) }, `version control failed after the task: ${e.message}`, 'warn');
457
466
  return undefined;
458
467
  });
468
+ /*
469
+ * The checks about a clean tree, now that the runner has committed. A done task whose tree is
470
+ * still not clean after the commit has failed; any other ending keeps its own reason, and the
471
+ * results are recorded all the same.
472
+ */
473
+ let afterCommitResults = [];
474
+ if (afterCommitChecks.length > 0 && runAfterCommit) {
475
+ const outcomes = await runAfterCommit(afterCommitChecks).catch((e) => {
476
+ sink.event('checks-after-commit-error', { error: String(e) }, `the checks after the commit could not run: ${e.message}`, 'warn');
477
+ return [];
478
+ });
479
+ afterCommitResults = outcomes.map((o) => ({ name: `${o.check.name} (after the commit)`, passed: o.passed, detail: o.detail }));
480
+ for (const o of outcomes) {
481
+ sink.event(o.passed ? 'check-passed' : 'check-failed', { name: o.check.name, detail: o.detail, afterCommit: true }, `${o.passed ? 'passed' : 'FAILED'} after the commit: ${o.check.name} — ${o.detail}`, o.passed ? 'info' : 'warn');
482
+ }
483
+ const failedAfter = outcomes.filter((o) => !o.passed);
484
+ if (status === 'done' && failedAfter.length > 0) {
485
+ status = 'failed';
486
+ reason =
487
+ `after the runner's commit${vcsAfter?.commit ? ` (${vcsAfter.commit.slice(0, 8)})` : ''}${vcsAfter?.problem ? `, which did not happen: ${vcsAfter.problem}` : ''}, ` +
488
+ `${failedAfter.length} check(s) about the working tree still failed: ${failedAfter.map((o) => `${o.check.name} (${o.detail})`).join('; ')}`;
489
+ }
490
+ }
491
+ /*
492
+ * What the task said about the repository was written before the commit. When it did not end
493
+ * done and the runner then committed its changes, the reason says so, so "not committed, the
494
+ * tree is dirty" is not left standing beside a commit on the record.
495
+ */
496
+ if (status !== 'done' && vcsAfter?.commit && vcsAfter.commit !== task.vcs?.commit) {
497
+ reason =
498
+ `${reason ?? ''} After that, the runner committed the task's changes as ${vcsAfter.commit.slice(0, 8)} on ${vcsAfter.branch}` +
499
+ `${vcsAfter.afterCommit ? (vcsAfter.afterCommit.clean ? '; the working tree is clean' : `; still uncommitted: ${vcsAfter.afterCommit.changed.slice(0, 8).join(', ')}`) : ''}.`;
500
+ reason = reason.trim();
501
+ }
459
502
  if (vcsAfter?.branch) {
460
503
  await record('VERSION CONTROL', [
461
504
  `branch : ${vcsAfter.branch}`,
@@ -487,11 +530,26 @@ export async function runTask(transport, session, task, deps) {
487
530
  t.stats = stats;
488
531
  if (stopCode)
489
532
  t.stopCode = stopCode;
533
+ if (limitHit)
534
+ t.limit = limitHit;
535
+ if (afterCommitResults.length > 0)
536
+ t.checkResults = [...(t.checkResults ?? []), ...afterCommitResults];
537
+ // A review that was asked for and never ran says why, instead of reading as zero rounds.
538
+ if (!t.review && (task.reviewEnabled ?? session.review?.enabled ?? true) && status !== 'done') {
539
+ t.review = {
540
+ verdict: 'skipped',
541
+ rounds: 0,
542
+ stepsRun: 0,
543
+ skippedBecause: `the task ended ${status} before its work reached the review; the review runs only on work reported done whose checks pass`,
544
+ };
545
+ }
490
546
  });
491
547
  // The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
492
548
  await setTask((t) => {
493
549
  t.handoff = composeHandoff(t, notRun);
494
550
  });
551
+ // Its last changes, checks and commit included, go to the Desktop copies too.
552
+ deps.onWorkChanged?.();
495
553
  sink.event('task-finished', { status, reason, iterations }, `task "${task.title}" ${status}${reason ? `: ${reason}` : ''}`);
496
554
  await log.close();
497
555
  /*
@@ -645,7 +703,9 @@ export async function runTask(transport, session, task, deps) {
645
703
  * finding the chat could never have fixed. The contradiction is named instead, now, and nothing
646
704
  * is run. See `orchestrator/contract.ts`.
647
705
  */
648
- const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd);
706
+ const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd, {
707
+ branch: prepared.vcs.problem ? undefined : prepared.vcs.branch,
708
+ });
649
709
  if (conflicts.length > 0) {
650
710
  stopCode = 'contract-conflict';
651
711
  sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
@@ -659,8 +719,14 @@ export async function runTask(transport, session, task, deps) {
659
719
  const projectName = projectNameFor(session.mirror.rootDir, cfg);
660
720
  const targetDir = projectTargetDir(cfg, projectName);
661
721
  await removeLegacyFlatMirror(cfg).catch(() => undefined);
722
+ /*
723
+ * A selection that cannot be copied stops the task before anything is sent: the chat would
724
+ * otherwise work without the files it was promised. The copies already there are kept.
725
+ */
662
726
  const result = await mirrorProject({
663
727
  rootDir: session.mirror.rootDir,
728
+ // `rules-engine` in a plan usually means the repository's folder, wherever the root is.
729
+ alsoUnder: [repoDirOf(session)],
664
730
  includeDirs: session.mirror.includeDirs.length ? session.mirror.includeDirs : ['.'],
665
731
  excludeDirs: session.mirror.excludeDirs,
666
732
  targetDir,
@@ -673,7 +739,11 @@ export async function runTask(transport, session, task, deps) {
673
739
  ignoreDirs: cfg.projectMirror.ignoreDirs,
674
740
  includeEnvFiles: session.mirror.includeEnvFiles ?? cfg.projectMirror.includeEnvFiles,
675
741
  maxFileBytes: cfg.projectMirror.maxFileBytes,
676
- });
742
+ }).catch((e) => (e instanceof MirrorSelectionError ? e : Promise.reject(e)));
743
+ if (result instanceof MirrorSelectionError) {
744
+ sink.event('mirror-refused', { error: result.message }, result.message, 'error');
745
+ return await finish('failed', `${result.message} Nothing was sent to the chat. Fix the files to attach and queue the task again.`);
746
+ }
677
747
  sink.event('mirror', { targetDir, ...result }, `project mirror: ${describeMirror(result)}`);
678
748
  if (session.mirror.includeEnvFiles) {
679
749
  const envCount = Object.keys(result.mapping).filter((p) => /(^|\/)\.env(\.|$)/i.test(p)).length;
@@ -786,14 +856,21 @@ export async function runTask(transport, session, task, deps) {
786
856
  * happened, and a task cannot be trusted to answer that about itself.
787
857
  */
788
858
  const gateOnChecks = async () => {
789
- const checks = [...(task.checks ?? []), ...activeChecks(reviewChecks), ...(willCommit ? [COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK] : [])];
859
+ const every = [...(task.checks ?? []), ...activeChecks(reviewChecks)];
860
+ // Whether the tree is clean is decided after the runner's commit; see `afterCommitChecks`.
861
+ const deferred = willCommit ? every.filter(readsTreeClean) : [];
862
+ if (deferred.length > 0 && afterCommitChecks.length === 0) {
863
+ sink.event('checks-after-commit', { checks: deferred.map((c) => c.name) }, `${deferred.length} check(s) about a clean working tree are decided after the runner commits the work: ${deferred.map((c) => c.name).join(', ')}`);
864
+ }
865
+ afterCommitChecks = deferred;
866
+ const checks = [...every.filter((c) => !deferred.includes(c)), ...(willCommit ? [COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK] : [])];
790
867
  if (checks.length === 0)
791
868
  return 'accept';
792
869
  // Counted only once the round turns out to have been a real attempt. A round that died of
793
870
  // a missing interpreter asked nothing of the work and must not cost the task one.
794
871
  const round = checkRounds + 1;
795
872
  sink.event('checks-started', { round, count: checks.length }, `checking the task against ${checks.length} condition(s)`);
796
- const outcomes = (lastOutcomes = await runChecks(checks, {
873
+ const checkOptions = {
797
874
  cwd: work.cwd,
798
875
  tracker,
799
876
  passEnv: cfg.execution.passEnv,
@@ -811,7 +888,9 @@ export async function runTask(transport, session, task, deps) {
811
888
  // The same shell a step that named none is given, so the gate and the work it judges
812
889
  // cannot have been read by different interpreters.
813
890
  defaultShell,
814
- }));
891
+ };
892
+ runAfterCommit = (cs) => runChecks(cs, checkOptions);
893
+ const outcomes = (lastOutcomes = await runChecks(checks, checkOptions));
815
894
  /*
816
895
  * The runner's own checks are advice given once, not a gate: a second "done" with the finding
817
896
  * still there is committed and the finding stays on the task. Refusing instead would leave the
@@ -885,6 +964,7 @@ export async function runTask(transport, session, task, deps) {
885
964
  sink.event('checks-derived-deferred', { rounds: checkRounds - 1, checks: failed.map((o) => o.check.name) }, `${failed.length} check(s) from earlier reviews still fail after ${maxCheckRounds} attempt(s); the work goes to the reviewer with them named`, 'warn');
886
965
  return 'accept';
887
966
  }
967
+ limitHit = { setting: 'maxCheckRounds', value: maxCheckRounds };
888
968
  sink.event('checks-exhausted', { rounds: checkRounds - 1 }, `${failed.length} check(s) still failing after ${maxCheckRounds} attempt(s); the task is closed as failed`, 'warn');
889
969
  return 'give-up';
890
970
  }
@@ -944,7 +1024,7 @@ export async function runTask(transport, session, task, deps) {
944
1024
  */
945
1025
  const gateOnReview = async (closing) => {
946
1026
  if (!reviewWanted) {
947
- await saveReview({ verdict: 'skipped', rounds: 0, stepsRun: 0 });
1027
+ await saveReview({ verdict: 'skipped', rounds: 0, stepsRun: 0, skippedBecause: `the review is switched off for this ${task.reviewEnabled === false ? 'task' : 'session'}` });
948
1028
  return 'accept';
949
1029
  }
950
1030
  reviewRounds += 1;
@@ -1129,6 +1209,7 @@ export async function runTask(transport, session, task, deps) {
1129
1209
  * at the fix. It had been fixed.
1130
1210
  */
1131
1211
  if (reviewRounds > maxReviewRounds) {
1212
+ limitHit = { setting: 'maxReviewRounds', value: maxReviewRounds };
1132
1213
  sink.event('review-exhausted', { rounds: maxReviewRounds, findings: outcome.findings.length }, `the review still has findings after ${maxReviewRounds} round(s) of fixing; the task is closed as blocked`, 'warn');
1133
1214
  return 'give-up';
1134
1215
  }
@@ -1190,10 +1271,14 @@ export async function runTask(transport, session, task, deps) {
1190
1271
  for (;;) {
1191
1272
  if (signal?.aborted)
1192
1273
  return await finish('aborted', 'stopped by the operator');
1193
- if (Date.now() > deadline)
1274
+ if (Date.now() > deadline) {
1275
+ limitHit = { setting: 'maxRunMinutes', value: cfg.limits.maxRunMinutes };
1194
1276
  return await finish('limit-reached', `maxRunMinutes (${cfg.limits.maxRunMinutes}) reached`);
1195
- if (iterations >= cfg.limits.maxIterations)
1277
+ }
1278
+ if (iterations >= cfg.limits.maxIterations) {
1279
+ limitHit = { setting: 'maxIterations', value: cfg.limits.maxIterations };
1196
1280
  return await finish('limit-reached', `maxIterations (${cfg.limits.maxIterations}) reached`);
1281
+ }
1197
1282
  const parsed = parseReply(lastMarkdown, {
1198
1283
  stopMarker: cfg.copilot.stopMarker,
1199
1284
  defaultShell,
@@ -1214,6 +1299,7 @@ export async function runTask(transport, session, task, deps) {
1214
1299
  if (formatRetries > cfg.limits.maxFormatRetries) {
1215
1300
  await transport.dumpFailure(log.path('failures'), 'format-error');
1216
1301
  stopCode = 'format-repair-exhausted';
1302
+ limitHit = { setting: 'maxFormatRetries', value: cfg.limits.maxFormatRetries };
1217
1303
  return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
1218
1304
  `the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
1219
1305
  }
@@ -1517,6 +1603,9 @@ export async function runTask(transport, session, task, deps) {
1517
1603
  }
1518
1604
  if (aborted)
1519
1605
  return await finish('aborted', 'the operator aborted the task', undefined, lastMarkdown);
1606
+ // The files may have changed: the Desktop copies catch up, in the background (see RunDeps).
1607
+ if (results.some((r) => r.outcome !== 'refused' && r.outcome !== 'aborted'))
1608
+ deps.onWorkChanged?.();
1520
1609
  // A loop in which every round looks new: see `progress.ts`. After the report is written, so
1521
1610
  // the evidence the diagnosis points at is on disk.
1522
1611
  const stuck = progress.afterRound(await treeNow(), results);
@@ -1604,6 +1693,13 @@ export async function runTask(transport, session, task, deps) {
1604
1693
  sent = true;
1605
1694
  }
1606
1695
  catch (e) {
1696
+ /*
1697
+ * The report went out and the chat is still answering it. Sending it again would stack a
1698
+ * second copy under a reply in progress; this is the reply wait from the settings running
1699
+ * out, which ends the task at that limit (see the catch at the end), not an upload to retry.
1700
+ */
1701
+ if (isReplyTimeout(e))
1702
+ throw e;
1607
1703
  sink.event('report-send-failed', { attempt, error: String(e) }, `sending the report failed (attempt ${attempt + 1}): ${e.message}`, 'warn');
1608
1704
  if (attempt === cfg.report.uploadRetries) {
1609
1705
  const body = await readFile(report.paths[0], 'utf8');
@@ -1648,6 +1744,15 @@ export async function runTask(transport, session, task, deps) {
1648
1744
  }
1649
1745
  catch (e) {
1650
1746
  const message = e.message;
1747
+ /*
1748
+ * The chat took longer than Settings allow for a reply. A limit, not a fault: the conversation
1749
+ * and the work are where they were, and "Continue" carries the task on in that chat.
1750
+ */
1751
+ if (isReplyTimeout(e)) {
1752
+ sink.event('reply-timeout', { seconds: e.seconds }, message, 'warn');
1753
+ limitHit = { setting: 'replyTimeoutSec', value: e.seconds };
1754
+ return await finish('limit-reached', `replyTimeoutSec (${e.seconds}) reached: ${message}`);
1755
+ }
1651
1756
  sink.event('task-error', { error: message, stack: e.stack }, message, 'error');
1652
1757
  await transport.dumpFailure(log.path('failures'), 'crash').catch(() => undefined);
1653
1758
  /*
@@ -402,12 +402,12 @@ const FIELD_ROWS_BG = [
402
402
  ];
403
403
  const VCS_ROWS_EN = [
404
404
  ['vcs', 'session', 'yes, always', 'Whether the runner does version control for this session, and where. See below. A session without it is refused.'],
405
- ['vcs.branch', 'task', 'yes in per-task mode', 'The branch this task works on, without the prefix.'],
405
+ ['vcs.branch', 'task', 'yes in per-task mode', 'The branch this task works on. A name with a "/" (feature/x, recovery/y) is used as written; one without gets the prefix.'],
406
406
  ['vcs.commitMessage', 'task', 'yes', 'The commit this task ends with. Imperative, first line under 72 characters.'],
407
407
  ];
408
408
  const VCS_ROWS_BG = [
409
409
  ['vcs', 'сесия', 'да, винаги', 'Дали runner-ът прави контрол на версиите за тази сесия и къде. Виж по-долу. Сесия без него се отказва.'],
410
- ['vcs.branch', 'задача', 'да, в режим per-task', 'Клонът, по който работи задачата, без представката.'],
410
+ ['vcs.branch', 'задача', 'да, в режим per-task', 'Клонът, по който работи задачата. Име с „/“ (feature/x, recovery/y) се ползва както е; без „/“ получава представката.'],
411
411
  ['vcs.commitMessage', 'задача', 'да', 'Комитът, с който задачата завършва. В повелително наклонение, първият ред под 72 знака.'],
412
412
  ];
413
413
  function table(header, rows) {
@@ -417,7 +417,12 @@ const VCS_DETAIL_EN = `
417
417
  **vcs on a session**: \`enabled\` (true here), \`repoDir\` (absolute path to the git repository),
418
418
  \`branchMode\`, \`commitOnFinish\` (true unless the user says otherwise), \`branchPrefix\` (leave it
419
419
  "cop/"), \`branchName\` — used **only** in per-session mode, for the one branch the whole
420
- session works on, without the prefix — and \`startFrom\` with \`baseBranch\`.
420
+ session works on — and \`startFrom\` with \`baseBranch\`. A branch name with a "/" follows the
421
+ team's own convention and is used exactly as written (in per-session mode an existing branch of that
422
+ name is carried on, not copied); a name without one gets the prefix. With version control on, a check never tests which
423
+ branch is checked out — the runner chooses it; to check that earlier work is included, check the
424
+ commit: \`git merge-base --is-ancestor <commit> HEAD\`. Nor does a check test that the working tree
425
+ is clean: the runner commits after the checks, and its own commit is what leaves it clean.
421
426
 
422
427
  **startFrom** decides where each session's work begins, and it is the user's choice — ask:
423
428
 
@@ -460,7 +465,12 @@ const VCS_DETAIL_BG = `
460
465
  **vcs на сесия**: \`enabled\` (тук true), \`repoDir\` (абсолютен път до git хранилището),
461
466
  \`branchMode\`, \`commitOnFinish\` (true, освен ако потребителят не каже друго), \`branchPrefix\`
462
467
  (остави "cop/"), \`branchName\` — използва се **само** в режим per-session, за единствения клон,
463
- по който работи цялата сесия, без представката — и \`startFrom\` с \`baseBranch\`.
468
+ по който работи цялата сесия — и \`startFrom\` с \`baseBranch\`. Име на клон с „/“ следва
469
+ конвенцията на екипа и се ползва точно както е написано (в режим per-session съществуващ клон с това
470
+ име се продължава, не се копира); име без „/“ получава представката. При включен контрол на версиите проверка никога не
471
+ проверява кой клон е активен — него го избира runner-ът; за да провериш, че по-ранна работа е
472
+ включена, провери commit-а: \`git merge-base --is-ancestor <commit> HEAD\`. Проверка не проверява и
473
+ че работното дърво е чисто: runner-ът комитва след проверките и неговият commit го оставя чисто.
464
474
 
465
475
  **startFrom** решава откъде започва работата на всяка сесия, и изборът е на потребителя — питай:
466
476
 
@@ -738,8 +748,9 @@ in those words, every time:
738
748
  A check that is wrong rather than a prompt that is wrong is edited instead: **"Edit"** on the
739
749
  task card, then **"What it checks"**, **"Must be"** and **"Command"** under **"Checks"**, then
740
750
  **"Save and queue it again"** — which, like every "queue" button, starts nothing by itself.
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
742
- same chat"** on its card on the session page, then **"Run {n} task(s)"** at the top of that page.
751
+ A task stopped by a limit from the settings, or **aborted** because the bot stopped under it, is carried on in its own chat instead:
752
+ **"Continue where it stopped"** on its row in the register, which starts it, or **"Continue in the same chat"** on its card on the session page,
753
+ then **"Run {n} task(s)"** at the top of that page.
743
754
  When a failure shows up while the run is still going, **"Pause after this task"** holds the rest
744
755
  while you work it out.
745
756
  Putting the repository back to before the task is **"Restore"**; putting it back and re-running
@@ -899,8 +910,9 @@ const PHASE3_BG = `
899
910
  Когато е сгрешена проверката, а не prompt-ът, се редактира друго: **„Редактирай“** на картата
900
911
  на задачата, после **„Какво проверява“**, **„Трябва“** и **„Команда“** под **„Проверки“**, после
901
912
  **„Запази и върни в опашката“** — който, като всеки бутон за връщане в опашката, сам не пуска нищо.
902
- Задача, завършила с **limit-reached** или **aborted**, защото ботът е спрял под нея, продължава в собствения си чат: **„Продължи в същия чат“**
903
- на картата ѝ на страницата на сесията, после **„Пусни {n} задача(и)“** горе на същата страница.
913
+ Задача, спряна от лимит в настройките, или **aborted**, защото ботът е спрял под нея, продължава в собствения си чат:
914
+ **„Продължи откъдето спря“** на реда ѝ в регистъра, който я пуска, или **„Продължи в същия чат“** на картата ѝ на страницата на сесията,
915
+ после **„Пусни {n} задача(и)“** горе на същата страница.
904
916
  Когато провал се покаже, докато пускането още върви, **„Пауза след тази задача“** задържа
905
917
  останалото, докато го разбереш.
906
918
  Връщането на хранилището отпреди задачата е **„Върни“**; връщането му плюс ново изпълнение на
@@ -798,18 +798,34 @@ export const SYSTEM_GUIDE = [
798
798
  {
799
799
  key: 'home.tickForBoth',
800
800
  kind: 'note',
801
- en: 'The ticks choose what to run and what to delete. Only sessions with something queued can be run.',
802
- bg: 'Отметките избират какво да се пусне и какво да се изтрие. Пускат се само сесии с нещо в опашката.',
803
- doesEn: 'One set of tick boxes serves both the run panel above and the delete button here.',
804
- doesBg: 'Един набор отметки обслужва и панела за пускане горе, и бутона за изтриване тук.',
801
+ en: 'The first column of ticks manages the list — status and delete — and works while a run is going, except on the session running now. The second chooses what to run; only active sessions with something queued can be run.',
802
+ bg: 'Първата колона отметки управлява списъка — статус и изтриване — и работи и докато тече пускане, освен за сесията, която работи в момента. Втората избира какво да се пусне; пускат се само активни сесии с нещо в опашката.',
803
+ doesEn: 'Two sets of tick boxes: the first for status and delete, the second for the run panel above.',
804
+ doesBg: 'Два набора отметки: първият за статус и изтриване, вторият за панела за пускане горе.',
805
+ },
806
+ {
807
+ key: 'home.makeActive',
808
+ kind: 'button',
809
+ en: 'Make active',
810
+ bg: 'Направи активни',
811
+ doesEn: 'Every session ticked in the first column can be run again. New and imported sessions are active.',
812
+ doesBg: 'Всички отметнати в първата колона сесии отново могат да се пускат. Новите и импортираните са активни.',
813
+ },
814
+ {
815
+ key: 'home.makeInactive',
816
+ kind: 'button',
817
+ en: 'Make inactive',
818
+ bg: 'Направи неактивни',
819
+ doesEn: 'Sets them aside: nothing starts them — not a run of several, not "Continue" — until made active. Not for a running session.',
820
+ doesBg: 'Оставя ги настрана: нищо не ги пуска — нито пускане на няколко, нито „Продължи“ — докато не станат активни. Не и за работеща сесия.',
805
821
  },
806
822
  {
807
823
  key: 'home.deleteSelected',
808
824
  kind: 'button',
809
825
  en: 'Delete the {n} selected',
810
826
  bg: 'Изтрий избраните {n}',
811
- doesEn: 'Deletes every ticked session after one question; the run folders and the chats survive.',
812
- doesBg: 'Изтрива всички отметнати сесии след един въпрос; папките под runs/ и разговорите остават.',
827
+ doesEn: 'Deletes every session ticked in the first column after one question, also during a run; never the one running. Run folders and chats survive.',
828
+ doesBg: 'Изтрива всички отметнати в първата колона сесии след един въпрос, и по време на пускане; никога работещата. Папките и разговорите остават.',
813
829
  },
814
830
  {
815
831
  key: 'home.delete',
@@ -1474,8 +1490,8 @@ export const SYSTEM_GUIDE = [
1474
1490
  kind: 'button',
1475
1491
  en: 'Continue in the same chat',
1476
1492
  bg: 'Продължи в същия чат',
1477
- 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)".',
1478
- doesBg: 'На задача, спряла преди да приключи (лимит, спрял бот, оператор): нарежда я да продължи в същия чат и клон. После „Пусни {n} задача(и)“.',
1493
+ doesEn: 'On a task that stopped before it finished (a limit from the settings, the bot stopping, the operator): queues it to carry on in the same chat and branch. Then "Run {n} task(s)".',
1494
+ doesBg: 'На задача, спряла преди да приключи (лимит от настройките, спрял бот, оператор): нарежда я да продължи в същия чат и клон. После „Пусни {n} задача(и)“.',
1479
1495
  },
1480
1496
  {
1481
1497
  key: 'task.rerun',
@@ -2174,6 +2190,14 @@ export const SYSTEM_GUIDE = [
2174
2190
  doesEn: 'The same button once the story is open: folds it away again.',
2175
2191
  doesBg: 'Същият бутон, когато историята е отворена: сгъва я обратно.',
2176
2192
  },
2193
+ {
2194
+ key: 'reg.continueHere',
2195
+ kind: 'button',
2196
+ en: 'Continue where it stopped',
2197
+ bg: 'Продължи откъдето спря',
2198
+ doesEn: 'On a row stopped by a limit from the settings (minutes, messages, reply wait, rounds of fixing), by the bot stopping or by the operator: says what happens to the chat, branch and files, then starts that task alone in its chat.',
2199
+ doesBg: 'На ред, спрян от лимит в настройките (минути, съобщения, чакане на отговор, кръгове поправки), от спрял бот или от оператора: казва какво става с чата, клона и файловете, после пуска само тази задача в чата ѝ.',
2200
+ },
2177
2201
  {
2178
2202
  key: 'reg.fixPrompt',
2179
2203
  kind: 'button',
@@ -2685,8 +2709,16 @@ export const SYSTEM_GUIDE = [
2685
2709
  kind: 'checkbox',
2686
2710
  en: 'Keep the projects on the Desktop',
2687
2711
  bg: 'Дръж проектите на Desktop-а',
2688
- doesEn: 'Copies every project on this page to its own Desktop folder before each run, which is how the files reach the chat.',
2689
- doesBg: 'Копира всеки проект от тази страница в собствена папка на Desktop-а преди всяко пускане — така файловете стигат до чата.',
2712
+ doesEn: 'The master switch: copies the projects on this page to their own Desktop folders before a run and after every round of steps, which is how the files reach the chat.',
2713
+ doesBg: 'Главният ключ: копира проектите от тази страница в собствени папки на Desktop-а преди пускане и след всеки кръг стъпки — така файловете стигат до чата.',
2714
+ },
2715
+ {
2716
+ key: 'proj.desktopThis',
2717
+ kind: 'checkbox',
2718
+ en: 'Keep a copy of this project on the Desktop',
2719
+ bg: 'Дръж копие на този проект на Desktop-а',
2720
+ doesEn: 'Under each project, on by default: untick to keep that one off the Desktop while the switch is on. Greyed out while the switch is off.',
2721
+ doesBg: 'Под всеки проект, включено по подразбиране: махнете отметката, за да не се копира точно той, докато ключът е включен. Неактивно, докато ключът е изключен.',
2690
2722
  },
2691
2723
  {
2692
2724
  key: 'proj.folders',
@@ -54,8 +54,23 @@ function whyItStopped(c) {
54
54
  return 'It was stopped by the operator before it finished — not because anything failed. The files are as you left them.';
55
55
  }
56
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.`;
57
+ // Out of rounds of fixing: something did say no, and the chat needs to hear it was not waved through.
58
+ if (c.limit?.setting === 'maxCheckRounds' || c.limit?.setting === 'maxReviewRounds') {
59
+ return (`It stopped because the runner's limit of ${LIMIT_WORDS[c.limit.setting](c.limit.value)} was reached${why}. ` +
60
+ 'What was still failing then is still open: deal with it first. The count starts again now. The files are as you left them.');
61
+ }
62
+ const which = c.limit ? ` of ${LIMIT_WORDS[c.limit.setting](c.limit.value)}` : '';
63
+ return `It stopped because the runner's limit${which} was reached${why} — not because anything failed. The files are as you left them.`;
58
64
  }
65
+ /** Each limit from the settings, as the chat is told about it. See `TaskLimit`. */
66
+ const LIMIT_WORDS = {
67
+ maxRunMinutes: (n) => `${n} minutes per task`,
68
+ maxIterations: (n) => `${n} messages per task`,
69
+ replyTimeoutSec: (n) => `${n} seconds to wait for one reply`,
70
+ maxFormatRetries: (n) => `${n} replies in the wrong format in a row`,
71
+ maxCheckRounds: (n) => `${n} rounds of fixing failed checks`,
72
+ maxReviewRounds: (n) => `${n} rounds of fixing review findings`,
73
+ };
59
74
  export function composeOpening(input) {
60
75
  const taskBlock = `${TASK_HEADER} ${input.taskNumber}: ${input.taskTitle.trim() || 'untitled'}\n\n${input.prompt.trim()}`;
61
76
  /*
@@ -87,10 +87,17 @@ function versionControlLines(task, session) {
87
87
  if (!vcs.branch)
88
88
  return [`version control : did not run — ${vcs.problem}`];
89
89
  const commits = vcs.commits ?? [];
90
+ // In the order it happened: the tree before the runner's commit, the commit, the tree after it.
91
+ const before = vcs.beforeCommit;
92
+ const after = vcs.afterCommit;
90
93
  const lines = [
91
94
  `branch : ${vcs.branch}`,
92
95
  `started at : ${vcs.baseCommit ? vcs.baseCommit.slice(0, 8) : '—'} (the commit this task branched from)`,
93
- `commit : ${vcs.commit ? vcs.commit.slice(0, 8) : 'none — the task changed no files'}`,
96
+ ...(before ? [`before commit: ${before.changed.length} path(s) changed or new in the working tree${before.changed.length ? ` — ${before.changed.slice(0, 10).join(', ')}` : ''}`] : []),
97
+ `commit : ${vcs.commit ? vcs.commit.slice(0, 8) : vcs.problem ? 'none — see the note below' : 'none — the task changed no files'}`,
98
+ ...(after
99
+ ? [`after commit: on ${after.branch ?? '?'} at ${after.head ? after.head.slice(0, 8) : '?'}, ${after.clean ? 'working tree clean' : `still uncommitted: ${after.changed.slice(0, 10).join(', ')}`}`]
100
+ : []),
94
101
  `commits : ${commits.length} on this branch since it was cut`,
95
102
  ];
96
103
  for (const line of commits)
@@ -1,3 +1,15 @@
1
+ /**
2
+ * The end of a task in one fixed shape, put together by the runner from what it recorded.
3
+ *
4
+ * The chat's summary is the model's account, in its words and in whatever order it chose. The
5
+ * operator reading a finished run needs the same eight answers every time — what came of it, which
6
+ * files, what was checked, what is still wrong, where the evidence is, where the code is, what is
7
+ * left for a person to do, and what was never run — and until now had to piece them together from
8
+ * the summary, the check list, the review panel, the branch line and the iteration reports. This
9
+ * reads them off the task record instead, so every entry is a fact the runner saw rather than a
10
+ * claim, and nothing here is written by the model.
11
+ */
12
+ import { isContinuable } from './model.js';
1
13
  export function composeHandoff(task, notRun = []) {
2
14
  const failedChecks = (task.checkResults ?? []).filter((c) => !c.passed);
3
15
  const openFindings = task.review?.verdict === 'fail' ? (task.review.findings ?? []).filter((f) => f.about !== 'task') : [];
@@ -18,10 +30,10 @@ export function composeHandoff(task, notRun = []) {
18
30
  manual.push(`version control: ${vcs.problem}`);
19
31
  if (vcs.commit && vcs.branch)
20
32
  manual.push(`push ${vcs.branch} when you are ready — the runner never pushes`);
33
+ if (isContinuable(task))
34
+ manual.push('continue it in the same chat from the register, or run it again');
21
35
  if (task.status === 'blocked')
22
36
  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
37
  if (task.status === 'failed')
26
38
  manual.push('read why it failed, fix the prompt or the checks, and queue it again');
27
39
  if (openFindings.length > 0)
@@ -32,7 +44,33 @@ export function composeHandoff(task, notRun = []) {
32
44
  outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
33
45
  changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
34
46
  validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
35
- ...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
47
+ ...(task.review
48
+ ? { review: { verdict: task.review.verdict, open: openFindings.length, ...(task.review.skippedBecause ? { skippedBecause: task.review.skippedBecause } : {}) } }
49
+ : {}),
50
+ ...(vcs.branch || vcs.problem
51
+ ? {
52
+ lifecycle: {
53
+ ...(vcs.beforeCommit ? { preCommitState: { changed: vcs.beforeCommit.changed } } : {}),
54
+ checksResult: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
55
+ commitResult: {
56
+ ...(vcs.branch ? { branch: vcs.branch } : {}),
57
+ ...(vcs.commit ? { commit: vcs.commit } : {}),
58
+ files: vcs.files?.length ?? 0,
59
+ ...(vcs.problem ? { problem: vcs.problem } : {}),
60
+ },
61
+ ...(vcs.afterCommit
62
+ ? {
63
+ postCommitState: {
64
+ ...(vcs.afterCommit.branch ? { branch: vcs.afterCommit.branch } : {}),
65
+ ...(vcs.afterCommit.head ? { head: vcs.afterCommit.head } : {}),
66
+ clean: vcs.afterCommit.clean,
67
+ uncommitted: vcs.afterCommit.changed,
68
+ },
69
+ }
70
+ : {}),
71
+ },
72
+ }
73
+ : {}),
36
74
  knownIssues,
37
75
  evidence: { ...(task.runId ? { runId: task.runId } : {}), checks: task.checkResults?.length ?? 0, reviewRounds: task.review?.rounds ?? 0 },
38
76
  vcs: { ...(vcs.branch ? { branch: vcs.branch } : {}), ...(vcs.commit ? { commit: vcs.commit } : {}), pushed: false, ...(vcs.problem ? { problem: vcs.problem } : {}) },
@@ -12,6 +12,15 @@ export function tidyVcsPlan(plan) {
12
12
  return undefined;
13
13
  return { ...(branch ? { branch } : {}), ...(commitMessage ? { commitMessage } : {}) };
14
14
  }
15
+ /**
16
+ * Whether "Continue" may carry a task on where it stopped, in its chat and on its branch.
17
+ *
18
+ * A task that stopped before it finished qualifies (`limit-reached`, `aborted`), and so does one
19
+ * whose ending came from a limit in the settings whatever its status says. See `TaskLimit`.
20
+ */
21
+ export function isContinuable(t) {
22
+ return t.status === 'limit-reached' || t.status === 'aborted' || (!!t.limit && (t.status === 'failed' || t.status === 'blocked'));
23
+ }
15
24
  export function newId(prefix = '') {
16
25
  const now = new Date();
17
26
  const p = (n) => String(n).padStart(2, '0');
@@ -9,7 +9,7 @@
9
9
  import { mkdir, readFile, writeFile, readdir, rename, rm } from 'node:fs/promises';
10
10
  import { existsSync } from 'node:fs';
11
11
  import { join, resolve } from 'node:path';
12
- import { newId, tidyVcsPlan } from './model.js';
12
+ import { isContinuable, newId, tidyVcsPlan } from './model.js';
13
13
  /** A task the runner is inside of. It cannot be edited, deleted, or left behind at startup. */
14
14
  const ACTIVE_STATUSES = ['running', 'waiting-approval'];
15
15
  /**
@@ -341,22 +341,24 @@ export class SessionStore {
341
341
  *
342
342
  * Two kinds of stop qualify. The runner's limit (`limit-reached`), and a stop that was not the
343
343
  * task's doing (`aborted`): the bot itself stopping under it — power, a shutdown, Ctrl+C, a crash,
344
- * recorded in `interruption` at the next start — or the operator stopping it. A task that failed,
345
- * blocked or was not done has a verdict on its work, and carrying that work on would carry the
346
- * verdict's cause on with it; those are run again.
344
+ * recorded in `interruption` at the next start — or the operator stopping it. A task that failed
345
+ * or blocked has a verdict on its work, and carrying that work on would carry the verdict's cause
346
+ * on with it; those are run again — unless what ended them was a limit from the settings (out of
347
+ * rounds of fixing checks or review findings), which is a counter, not the verdict. See `TaskLimit`.
347
348
  */
348
349
  async continueTask(sessionId, taskId) {
349
350
  const current = (await this.getSession(sessionId))?.tasks.find((x) => x.id === taskId);
350
351
  if (!current)
351
352
  throw new Error(`Task ${taskId} does not exist in session ${sessionId}.`);
352
- if (current.status !== 'limit-reached' && current.status !== 'aborted') {
353
- throw new Error('Only a task that stopped before it finished can be continued; run this one again instead.');
353
+ if (!isContinuable(current)) {
354
+ throw new Error('Only a task that stopped before it finished, or at a limit from the settings, can be continued; run this one again instead.');
354
355
  }
355
- const how = current.status === 'limit-reached' ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
356
+ const how = current.status === 'limit-reached' || current.limit ? 'limit' : current.interruption ? 'interrupted' : 'stopped';
356
357
  return await this.rerunTask(sessionId, taskId, {}, {
357
358
  fromAttempt: current.attempt ?? 1,
358
359
  stoppedBecause: current.reason,
359
360
  how,
361
+ ...(current.limit ? { limit: current.limit } : {}),
360
362
  ...(current.interruption ? { interruption: current.interruption } : {}),
361
363
  });
362
364
  }
@@ -400,6 +402,7 @@ export class SessionStore {
400
402
  buildsOn: t.buildsOn,
401
403
  freshRetry: t.freshRetry,
402
404
  stopCode: t.stopCode,
405
+ limit: t.limit,
403
406
  scope: t.scope,
404
407
  scopeReverted: t.scopeReverted,
405
408
  vcs: t.vcs,
@@ -438,6 +441,7 @@ export class SessionStore {
438
441
  t.scopeReverted = undefined;
439
442
  t.freshRetry = undefined;
440
443
  t.stopCode = undefined;
444
+ t.limit = undefined;
441
445
  // The branch of the finished attempt stays in the repository and stays on the record
442
446
  // above; the next attempt gets its own, cut from the same commit this one started at.
443
447
  t.vcs = undefined;