copilot-operator 0.1.17 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/src/api/operator.service.js +23 -2
  2. package/dist/src/orchestrator/derivedChecks.js +19 -1
  3. package/dist/src/orchestrator/review.js +2 -2
  4. package/dist/src/orchestrator/taskRunner.js +81 -8
  5. package/dist/src/plan/brief.js +26 -0
  6. package/dist/src/plan/importPlan.js +28 -1
  7. package/dist/src/plan/schema.js +38 -0
  8. package/dist/src/plan/systemGuide.js +40 -0
  9. package/dist/src/protocol/replySchema.js +1 -1
  10. package/dist/src/protocol/reviewSchema.js +10 -5
  11. package/dist/src/session/exports.js +44 -13
  12. package/dist/src/session/model.js +20 -0
  13. package/dist/src/session/store.js +78 -0
  14. package/dist/src/vcs/artifacts.js +138 -0
  15. package/dist/src/vcs/inputs.js +332 -0
  16. package/dist/src/vcs/snapshot.js +59 -18
  17. package/dist/src/vcs/taskVcs.js +136 -25
  18. package/dist/web/404.html +2 -2
  19. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
  20. package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +1 -0
  21. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
  22. package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +1 -0
  23. package/dist/web/appearance.html +2 -2
  24. package/dist/web/appearance.txt +11 -11
  25. package/dist/web/defaults.html +2 -2
  26. package/dist/web/defaults.txt +11 -11
  27. package/dist/web/history.html +2 -2
  28. package/dist/web/history.txt +11 -11
  29. package/dist/web/import.html +2 -2
  30. package/dist/web/import.txt +11 -11
  31. package/dist/web/index.html +2 -2
  32. package/dist/web/index.txt +11 -11
  33. package/dist/web/level1.html +2 -2
  34. package/dist/web/level1.txt +11 -11
  35. package/dist/web/presets.html +2 -2
  36. package/dist/web/presets.txt +11 -11
  37. package/dist/web/sessions/view.html +2 -2
  38. package/dist/web/sessions/view.txt +11 -11
  39. package/dist/web/system.html +2 -2
  40. package/dist/web/system.txt +11 -11
  41. package/package.json +7 -3
  42. package/prompts/level1.md +547 -546
  43. package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +0 -1
  44. package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +0 -1
  45. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
  46. package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +0 -1
  47. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → JPmlfAEvbNkbghoyvY9uC}/_buildManifest.js +0 -0
  48. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → JPmlfAEvbNkbghoyvY9uC}/_ssgManifest.js +0 -0
@@ -19,7 +19,7 @@ import { join, resolve } from 'node:path';
19
19
  import { installLayout } from '../config/layout.js';
20
20
  import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyDefaultProject, applyTaskPatch } from '../session/store.js';
21
21
  import { EventBus } from '../session/events.js';
22
- import { isContinuable, newId } from '../session/model.js';
22
+ import { freshRetriesOfLatestRun, isContinuable, newId } from '../session/model.js';
23
23
  import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
24
24
  import { buildExport } from '../session/exportRecord.js';
25
25
  import { buildDebugExport } from '../session/debugExport.js';
@@ -46,6 +46,7 @@ import { createTransport } from '../transport/chatTransport.js';
46
46
  import { composeHandoff } from '../session/handoff.js';
47
47
  import { planSync } from '../vcs/syncCommand.js';
48
48
  import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
49
+ import { normalisePatterns } from '../vcs/inputs.js';
49
50
  import { computeMetrics } from '../session/metrics.js';
50
51
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
51
52
  import { saveAndReveal } from './saveToDesktop.js';
@@ -419,6 +420,26 @@ let OperatorService = class OperatorService {
419
420
  delete merged.dirtyWorktree;
420
421
  else
421
422
  merged.dirtyWorktree = { policy: dirty.policy, ...(dirty.requireApproval ? {} : { requireApproval: false }) };
423
+ // Input files and artifacts: patterns as the matching reads them, absent when there are none.
424
+ const inputPaths = normalisePatterns(merged.userInputs?.paths ?? []);
425
+ if (inputPaths.length === 0)
426
+ delete merged.userInputs;
427
+ else {
428
+ merged.userInputs = {
429
+ paths: inputPaths,
430
+ ...(merged.userInputs?.readOnly === false ? { readOnly: false } : {}),
431
+ ...(merged.userInputs?.requireApproval === false ? { requireApproval: false } : {}),
432
+ };
433
+ }
434
+ const artifactPaths = normalisePatterns(merged.artifacts?.paths ?? []);
435
+ if (artifactPaths.length === 0)
436
+ delete merged.artifacts;
437
+ else
438
+ merged.artifacts = { paths: artifactPaths };
439
+ // New inputs are settled again at the next task: the recorded ones are no longer the whole list.
440
+ if (JSON.stringify(merged.userInputs ?? null) !== JSON.stringify(s.vcs?.userInputs ?? null) && s.vcsStart?.inputs) {
441
+ s.vcsStart = { ...s.vcsStart, inputs: undefined };
442
+ }
422
443
  // Only the program marks a name as one of its own branches, and a name changed is not that one.
423
444
  const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
424
445
  if (stillOwn)
@@ -2601,7 +2622,7 @@ let OperatorService = class OperatorService {
2601
2622
  commit: t.vcs?.commit,
2602
2623
  vcsProblem: t.vcs?.problem,
2603
2624
  sessionInactive: s.active === false || undefined,
2604
- autoRetries: t.autoRetries || undefined,
2625
+ autoRetries: freshRetriesOfLatestRun(t) || undefined,
2605
2626
  attempt: t.attempt,
2606
2627
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
2607
2628
  interrupted: t.status === 'aborted' && !!t.interruption ? true : undefined,
@@ -23,7 +23,25 @@ export function derivedCheckName(findingId, name) {
23
23
  return `review ${findingId}: ${name}`;
24
24
  }
25
25
  export function isDerivedCheck(check) {
26
- return /^review r\d+f\d+: /.test(check.name);
26
+ return /^review (?:a\d+)?r\d+f\d+: /.test(check.name);
27
+ }
28
+ /**
29
+ * Kept checks written before a finding's id carried its attempt, given the id it carries now.
30
+ *
31
+ * Those records hold `r1f1` for attempt 1's first finding and again for attempt 2's, under one check
32
+ * name, and a dispute, a deferral or a drop aimed at one acted on both. Renamed from the attempt each
33
+ * was made on (see `findingId`), check name included, so the gate and the implementer see the same
34
+ * name a dispute can then use. A check of the first attempt, and one already renamed, is left as it is.
35
+ */
36
+ export function withAttemptIds(reviewChecks) {
37
+ return reviewChecks.map((rc) => {
38
+ if ((rc.attempt ?? 1) <= 1 || !/^r\d+f\d+$/.test(rc.findingId))
39
+ return rc;
40
+ const id = `a${rc.attempt}${rc.findingId}`;
41
+ const before = derivedCheckName(rc.findingId, '');
42
+ const name = rc.check.name.startsWith(before) ? derivedCheckName(id, rc.check.name.slice(before.length)) : rc.check.name;
43
+ return { ...rc, findingId: id, check: { ...rc.check, name } };
44
+ });
27
45
  }
28
46
  /**
29
47
  * Runs each finding's check on the current state. Kept if it fails now, refused if it passes.
@@ -329,7 +329,7 @@ ${machineNote}` : contract);
329
329
  problem: `every finding of round ${round} rested on something the task never asked for, twice; the review is inconclusive`,
330
330
  };
331
331
  }
332
- const named = grounded.map((f, i) => ({ ...f, id: findingId(round, i) }));
332
+ const named = grounded.map((f, i) => ({ ...f, id: findingId(round, i, task.attempt ?? 1) }));
333
333
  /*
334
334
  * A read-only task's findings never become gate conditions.
335
335
  *
@@ -605,7 +605,7 @@ export function findingsMessage(outcome, round, maxRounds, repeated = [], leftov
605
605
  `Fix these, then verify as usual and close the task again. This is review round ${round} of ${maxRounds};`,
606
606
  'after that the task is closed as blocked with whatever is still outstanding.',
607
607
  '',
608
- 'If you believe a finding is wrong, say so in `disputed`: its id (the `[r1f2]` in front of it), why it',
608
+ 'If you believe a finding is wrong, say so in `disputed`: its id as written (the `[r1f2]` in front of it), why it',
609
609
  'is wrong, and the evidence that shows it — the command you ran and what came back. A dispute goes to',
610
610
  'the next reviewer as a claim to test; a sentence in your summary goes nowhere. Do not change the',
611
611
  'thing the reviewer looked at in order to make the objection go away.',
@@ -21,7 +21,7 @@ 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
23
  import { runChecks, failureMessage, failureRedactions, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK, RUNNER_CHECK_KINDS } from '../exec/checks.js';
24
- import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing, isDerivedCheck } from './derivedChecks.js';
24
+ import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing, isDerivedCheck, withAttemptIds } from './derivedChecks.js';
25
25
  import { workingDirFor, isWorkingDirProblem, workingDirNote } from '../exec/workDir.js';
26
26
  import { redactSecrets } from '../exec/redaction.js';
27
27
  import { snapshotProcesses, reapLeftovers, describeLeftovers, ProcessTracker } from '../exec/processes.js';
@@ -46,6 +46,8 @@ import { ProgressWatch } from './progress.js';
46
46
  import { contractConflicts, readsTreeClean } from './contract.js';
47
47
  import { treeFingerprint } from '../vcs/git.js';
48
48
  import { prepareForTask, commitTaskResult, repoDirOf, trackedRepoOf } from '../vcs/taskVcs.js';
49
+ import { protectInputs, inputsMessage } from '../vcs/inputs.js';
50
+ import { artifactPatterns, keepArtifacts } from '../vcs/artifacts.js';
49
51
  import { exportMachine, writeAttemptRecord } from '../session/exports.js';
50
52
  /** Why a task that was reported as done is being closed as failed. */
51
53
  function checksFailedReason(outcomes) {
@@ -538,9 +540,10 @@ export async function runTask(transport, session, task, deps) {
538
540
  let runAfterCommit = null;
539
541
  /**
540
542
  * Checks earlier reviews gave with their findings — carried over from every attempt before
541
- * this one, and grown by this one. See `derivedChecks.ts` for the three rules.
543
+ * this one, and grown by this one. See `derivedChecks.ts` for the three rules. Ones written before
544
+ * a finding's id carried its attempt are renamed here, before anything finds one by id or name.
542
545
  */
543
- let reviewChecks = task.reviewChecks ?? [];
546
+ let reviewChecks = withAttemptIds(task.reviewChecks ?? []);
544
547
  /** Derived checks that still failed when the rounds ran out; the reviewer is told. */
545
548
  let derivedStillFailing = [];
546
549
  /**
@@ -577,10 +580,57 @@ export async function runTask(transport, session, task, deps) {
577
580
  await record(`LEFT RUNNING BY ${by.toUpperCase()}`, describeLeftovers([...result.killed, ...result.failed]));
578
581
  return all.map((l) => ({ name: l.name, ports: l.ports }));
579
582
  };
583
+ /** The session's read-only input files and the commit they are put back from, once the task is on its own branch. */
584
+ let inputsGuard = null;
585
+ /** Puts back whatever a round did to the input files, and tells the chat and the record. */
586
+ const guardInputs = async (iteration) => {
587
+ if (!inputsGuard)
588
+ return null;
589
+ const guard = inputsGuard;
590
+ const check = await protectInputs(repoDirOf(session), guard.base, guard.inputs).catch((e) => ({
591
+ changed: ['(the input files)'],
592
+ restored: [],
593
+ failed: [{ path: '(the input files)', why: e.message }],
594
+ }));
595
+ if (check.changed.length > 0) {
596
+ sink.event('inputs-restored', { iteration, restored: check.restored, failed: check.failed }, `the operator's input files are read-only; put back: ${check.restored.join(', ') || '(none)'}` +
597
+ (check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
598
+ runnerNotes.push(inputsMessage(check));
599
+ await setTask((t) => {
600
+ t.inputsRestored = [...new Set([...(t.inputsRestored ?? []), ...check.restored])];
601
+ });
602
+ }
603
+ return check;
604
+ };
580
605
  const finish = async (status, reason, summary, finalReply) => {
581
606
  await record(`TASK ${status.toUpperCase()}`, [summary ?? '', reason ? `Reason: ${reason}` : ''].filter(Boolean).join('\n\n') || '(no details)');
582
607
  // The net under the plan's own checks: whatever the task left running is stopped and named.
583
608
  await reap(processesBefore, 'the task');
609
+ /*
610
+ * The operator's input files, once more before the commit: put back what the last round changed,
611
+ * so it is not committed as the task's work. One that cannot be put back fails a done task. See
612
+ * `vcs/inputs.ts`.
613
+ */
614
+ if (inputsGuard) {
615
+ const check = await guardInputs();
616
+ if (check && check.failed.length > 0 && status === 'done') {
617
+ status = 'failed';
618
+ reason = `the operator's input file(s) ${check.failed.map((f) => f.path).join(', ')} were changed and could not be put back as they were: ${check.failed.map((f) => f.why).join('; ')}.`;
619
+ }
620
+ }
621
+ /*
622
+ * The session's artifacts, whatever the outcome: copied into this attempt's record, so the
623
+ * evidence of each attempt is kept even after a later one overwrites it. See `vcs/artifacts.ts`.
624
+ */
625
+ const patterns = artifactPatterns(session.vcs);
626
+ const projectRoot = repoDirOf(session) || session.projectDir?.trim() || '';
627
+ if (patterns.length > 0 && projectRoot) {
628
+ const kept = await keepArtifacts(projectRoot, patterns, join(artifactsDir, 'project')).catch((e) => ({ kept: [], skipped: [`(all: ${e.message})`] }));
629
+ sink.event('artifacts-kept', { kept: kept.kept.length, skipped: kept.skipped }, `artifacts kept with the run: ${kept.kept.length} file(s)${kept.skipped.length > 0 ? `; not kept: ${kept.skipped.slice(0, 5).join('; ')}` : ''}`, kept.skipped.length > 0 ? 'warn' : 'info');
630
+ await setTask((t) => {
631
+ t.artifactsKept = kept.kept.length > 0 ? kept.kept : undefined;
632
+ });
633
+ }
584
634
  /*
585
635
  * A read-only task that changed files has failed, whatever it reported.
586
636
  *
@@ -737,6 +787,9 @@ export async function runTask(transport, session, task, deps) {
737
787
  t.runId = runId;
738
788
  t.runGroup = deps.runGroup;
739
789
  t.startedAt = new Date().toISOString();
790
+ // The kept checks under the ids this run uses (`withAttemptIds`), so the record names them as the gate does.
791
+ if (reviewChecks.length > 0)
792
+ t.reviewChecks = reviewChecks;
740
793
  });
741
794
  sink.event('task-started', { runId, title: task.title }, `task "${task.title}" starting (run ${runId})`);
742
795
  await writeFile(taskLogPath, `TASK: ${task.title}\nSESSION: ${session.name} (${session.id})\nRUN: ${runId}\nSTARTED: ${new Date().toISOString()}\n`, 'utf8');
@@ -856,6 +909,11 @@ export async function runTask(transport, session, task, deps) {
856
909
  * findings described a tree that was not the one it had been asked to audit.
857
910
  */
858
911
  const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
912
+ // The operator's input files, read-only on a branch of the task's own; see `vcs/inputs.ts`.
913
+ const startNow = (await store.getSession(session.id))?.vcsStart;
914
+ if (onOwnBranch && startNow?.inputs?.readOnly && startNow.inputs.files.length > 0) {
915
+ inputsGuard = { base: prepared.vcs.baseCommit, inputs: startNow.inputs };
916
+ }
859
917
  /** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
860
918
  const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
861
919
  /*
@@ -1409,7 +1467,7 @@ export async function runTask(transport, session, task, deps) {
1409
1467
  // `repeated` still identifies them.
1410
1468
  const named = outcome.findings.map((f, i) => ({
1411
1469
  ...f,
1412
- id: f.id ?? findingId(reviewRounds, i),
1470
+ id: f.id ?? findingId(reviewRounds, i, attempt),
1413
1471
  ...(repeated.includes(f) ? { repeated: true } : {}),
1414
1472
  }));
1415
1473
  outcome = { ...outcome, findings: named };
@@ -1936,6 +1994,7 @@ export async function runTask(transport, session, task, deps) {
1936
1994
  });
1937
1995
  }
1938
1996
  }
1997
+ await guardInputs(iterations);
1939
1998
  // --- report back ---------------------------------------------------------------
1940
1999
  const report = await writeReport(results, {
1941
2000
  runId,
@@ -2160,9 +2219,23 @@ export async function runSession(sessionId, deps) {
2160
2219
  break;
2161
2220
  }
2162
2221
  session = (await store.getSession(sessionId));
2163
- const task = session.tasks.find((t) => t.id === queuedTask.id);
2222
+ let task = session.tasks.find((t) => t.id === queuedTask.id);
2164
2223
  if (!task || task.status !== 'queued')
2165
2224
  continue;
2225
+ /*
2226
+ * A task taken from the queue was started by the operator, never by the retry loop below, which
2227
+ * runs its own. A fresh-retry mark on it is left from a retry that was queued and never ran —
2228
+ * the new chat failed to open, the program was closed — and counted as this run's, it added the
2229
+ * old run's retry to this one's (`freshRetriesOfLatestRun`).
2230
+ */
2231
+ if (task.freshRetry) {
2232
+ session = await store.updateSession(sessionId, (s) => {
2233
+ const t = s.tasks.find((x) => x.id === queuedTask.id);
2234
+ if (t)
2235
+ t.freshRetry = undefined;
2236
+ });
2237
+ task = session.tasks.find((t) => t.id === queuedTask.id);
2238
+ }
2166
2239
  let outcome = await runTask(chat, session, task, deps);
2167
2240
  ran += 1;
2168
2241
  /*
@@ -2194,11 +2267,11 @@ export async function runSession(sessionId, deps) {
2194
2267
  session = await store.updateSession(sessionId, (s) => {
2195
2268
  s.chat = undefined;
2196
2269
  s.contractSent = false;
2270
+ // Marked, not counted: how many retries this run made is read back from the marks
2271
+ // (`freshRetriesOfLatestRun`), so a later run cannot add to an earlier one's count.
2197
2272
  const again = s.tasks.find((x) => x.id === task.id);
2198
- if (again) {
2199
- again.autoRetries = (again.autoRetries ?? 0) + 1;
2273
+ if (again)
2200
2274
  again.freshRetry = true;
2201
- }
2202
2275
  });
2203
2276
  await chat.newChat();
2204
2277
  /*
@@ -464,6 +464,19 @@ taken where the changes are, so use it with \`startFrom\` \`"branch"\` only when
464
464
  that branch, and never with \`"existing-branch"\`. Leaving it out keeps \`"reject"\`: version control stays out
465
465
  of a tree with uncommitted changes.
466
466
 
467
+ **userInputs** — files the user provides for the work (schemas, test data):
468
+ \`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. They go into the commit every session starts from —
469
+ new or ignored ones in the starting snapshot, approved by the user; a later session whose start lacks them
470
+ gets them committed on top of it — and they are read-only while the tasks run (\`"readOnly": false\` only if
471
+ the user wants the work to change them). Ask the user which files are inputs; never name a whole folder of
472
+ work, and never \`.env\`. **artifacts** — evidence the work writes (reports, ZIP archives, test results):
473
+ \`{ "paths": ["rules-engine/test-results/**"] }\`. Never committed, kept with the run; tell tasks to write
474
+ evidence there instead of changing \`.gitignore\`.
475
+
476
+ A session with \`"startFrom": "previous-session"\` continues the whole work of the one before it only when
477
+ that one is \`"branchMode": "per-session"\`: per task, its work is on several branches and the next session
478
+ refuses to start from part of it.
479
+
467
480
  Put the same \`startFrom\` on every session that works in one repository unless the user wants it
468
481
  mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
469
482
  the one to avoid.
@@ -532,6 +545,19 @@ run“, на отделен клон, и сесията тръгва от нег
532
545
  този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: контролът на версиите
533
546
  не влиза в дърво с некомитнати промени.
534
547
 
548
+ **userInputs** — файлове, които потребителят дава за работата (схеми, тестови данни):
549
+ \`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. Влизат в комита, от който тръгва всяка сесия —
550
+ новите или ignored в началната снимка, с одобрение от потребителя; следваща сесия, чието начало ги няма, ги
551
+ получава комитнати върху него — и са само за четене, докато задачите работят (\`"readOnly": false\` само ако
552
+ потребителят иска работата да ги променя). Питай кои файлове са вход; никога цяла папка с работа и никога
553
+ \`.env\`. **artifacts** — доказателства, които работата пише (отчети, ZIP архиви, резултати от тестове):
554
+ \`{ "paths": ["rules-engine/test-results/**"] }\`. Никога не се комитват, пазят се към пускането; кажи на
555
+ задачите да пишат доказателствата там, вместо да променят \`.gitignore\`.
556
+
557
+ Сесия с \`"startFrom": "previous-session"\` продължава цялата работа на предната само ако тя е
558
+ \`"branchMode": "per-session"\`: при per-task работата ѝ е на няколко клона и следващата сесия отказва да
559
+ тръгне от част от нея.
560
+
535
561
  Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
536
562
  различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
537
563
  което е за избягване.
@@ -1,4 +1,5 @@
1
1
  import { DEFAULT_VCS, applyDefaultProject } from '../session/store.js';
2
+ import { normalisePatterns } from '../vcs/inputs.js';
2
3
  import { availableShells, detectShells } from '../exec/shells.js';
3
4
  /** The heading under which a task's acceptance bar reaches Copilot. English, like level 1. */
4
5
  const EXPECTED_HEADER = '### Expected result';
@@ -165,6 +166,23 @@ defaultProjectDir = '') {
165
166
  const sharedGroup = plan.conversation === 'shared'
166
167
  ? (plan.plan.trim() || `plan-${new Date().toISOString().slice(0, 16).replace(/[:T-]/g, '')}`).slice(0, 60)
167
168
  : '';
169
+ /*
170
+ * A session that continues the one before it in the same repository, when that one ran per task
171
+ * with several tasks: its work is on several branches, and the continuation is refused at its start
172
+ * (see `sessionStart`). Said now, when the plan can still be changed.
173
+ */
174
+ const lastIn = new Map();
175
+ for (const planned of plan.sessions) {
176
+ const repo = (planned.vcs?.repoDir || planned.projectDir || '').trim().replace(/[\\/]+$/, '').toLowerCase();
177
+ if (!planned.vcs?.enabled || !repo)
178
+ continue;
179
+ const before = lastIn.get(repo);
180
+ if (planned.vcs.startFrom === 'previous-session' && before?.perTask && before.tasks > 1) {
181
+ warnings.push(`Session "${planned.name}" continues "${before.name}", which runs a branch per task: its ${before.tasks} tasks' work will be on ${before.tasks} branches, ` +
182
+ `and "${planned.name}" will refuse to start from part of it. Give "${before.name}" "branchMode": "per-session" if the next session builds on all of it.`);
183
+ }
184
+ lastIn.set(repo, { name: planned.name, perTask: (planned.vcs.branchMode ?? 'per-task') === 'per-task', tasks: planned.tasks.length });
185
+ }
168
186
  for (const planned of plan.sessions) {
169
187
  // Attaching project files to the chat was removed; an old plan's root is still its project folder.
170
188
  if (planned.mirror?.enabled) {
@@ -172,8 +190,17 @@ defaultProjectDir = '') {
172
190
  'the chat reads the project by running commands. "mirror" was ignored.');
173
191
  }
174
192
  const created = await store.createSession(planned.name, planned.projectDir || planned.mirror?.rootDir || '');
175
- const { dirtyWorktree: plannedDirty, ...plannedVcs } = planned.vcs ?? {};
193
+ const { dirtyWorktree: plannedDirty, userInputs: plannedInputs, artifacts: plannedArtifacts, ...plannedVcs } = planned.vcs ?? {};
176
194
  const vcs = { ...DEFAULT_VCS, ...plannedVcs };
195
+ // Input files and artifacts in the one spelling the session has: see `UserInputs`.
196
+ const inputPaths = plannedInputs?.enabled === false ? [] : normalisePatterns(plannedInputs?.paths ?? []);
197
+ if (inputPaths.length > 0) {
198
+ const readOnly = plannedInputs?.readOnly ?? plannedInputs?.readOnlyAfterCapture;
199
+ vcs.userInputs = { paths: inputPaths, ...(readOnly === false ? { readOnly: false } : {}), ...(plannedInputs?.requireApproval === false ? { requireApproval: false } : {}) };
200
+ }
201
+ const artifactPaths = normalisePatterns(plannedArtifacts?.paths ?? []);
202
+ if (artifactPaths.length > 0)
203
+ vcs.artifacts = { paths: artifactPaths };
177
204
  // Stored in the one spelling the session has: see `DirtyWorktree`.
178
205
  const policy = plannedDirty?.policy === 'snapshot' && plannedDirty.includeUntracked === false ? 'tracked-only-snapshot' : plannedDirty?.policy;
179
206
  if (policy && policy !== 'reject')
@@ -13,6 +13,7 @@
13
13
  * sending the user back to the chat; it is reported as a warning instead.
14
14
  */
15
15
  import { z } from 'zod';
16
+ import { namesEverything } from '../vcs/inputs.js';
16
17
  export const PLAN_VERSION = 1;
17
18
  /**
18
19
  * Version control, exactly as a session stores it, and required on every session.
@@ -77,6 +78,35 @@ const VcsInput = z
77
78
  .optional(),
78
79
  })
79
80
  .optional(),
81
+ /**
82
+ * The operator's input files. See `UserInputs`. Accepts the spelling a chat model may write:
83
+ * `capture` can only be "baseline-commit", `allowUntracked`/`allowIgnored` only true (an input
84
+ * may be either), `readOnlyAfterCapture` is `readOnly`.
85
+ */
86
+ userInputs: z
87
+ .object({
88
+ enabled: z.boolean().optional(),
89
+ paths: z.array(z.string().trim()).default([]),
90
+ capture: z.literal('baseline-commit', { error: 'userInputs.capture can only be "baseline-commit": input files go into the commit the session starts from. Leave the field out.' }).optional(),
91
+ allowUntracked: z.literal(true, { error: 'userInputs.allowUntracked can only be true: an input file may be untracked. Leave the field out.' }).optional(),
92
+ allowIgnored: z.literal(true, { error: 'userInputs.allowIgnored can only be true: an input file may be ignored by git. Leave the field out.' }).optional(),
93
+ readOnly: z.boolean().optional(),
94
+ readOnlyAfterCapture: z.boolean().optional(),
95
+ requireApproval: z.boolean().optional(),
96
+ })
97
+ .optional(),
98
+ /**
99
+ * Evidence kept with the run and never committed. See `VersionControl.artifacts`. `commit` can
100
+ * only be false and `attachToRun`/`allowIgnored` only true: that is what an artifact is.
101
+ */
102
+ artifacts: z
103
+ .object({
104
+ paths: z.array(z.string().trim()).default([]),
105
+ allowIgnored: z.literal(true, { error: 'artifacts.allowIgnored can only be true. Leave the field out.' }).optional(),
106
+ attachToRun: z.literal(true, { error: 'artifacts.attachToRun can only be true: artifacts are always kept with the run. Leave the field out.' }).optional(),
107
+ commit: z.literal(false, { error: 'artifacts.commit can only be false: an artifact is never committed. Files that should be committed are the work, not artifacts.' }).optional(),
108
+ })
109
+ .optional(),
80
110
  }, {
81
111
  error: 'Every session needs a "vcs" object saying whether the runner does version control for it and, when it does, which git repository to work in. Ask the user both questions and write the answers here.',
82
112
  })
@@ -102,6 +132,14 @@ const VcsInput = z
102
132
  message: '"tracked-only-snapshot" takes tracked changes only, and includeUntracked true asks for new files too. Keep one: policy "snapshot", or leave includeUntracked out.',
103
133
  });
104
134
  }
135
+ for (const [i, p] of (vcs.userInputs?.paths ?? []).entries()) {
136
+ if (namesEverything(p)) {
137
+ ctx.addIssue({ code: 'custom', path: ['userInputs', 'paths', i], message: `"${p}" names the whole repository; an input pattern names the operator's files, such as "rules-engine/test-data/schemas/*.yaml".` });
138
+ }
139
+ }
140
+ if (vcs.userInputs && vcs.userInputs.enabled !== false && (vcs.userInputs.paths ?? []).length > 0 && !vcs.enabled) {
141
+ ctx.addIssue({ code: 'custom', path: ['userInputs'], message: 'userInputs needs version control on: the input files go into the commit the session starts from. Turn vcs.enabled on, or leave userInputs out.' });
142
+ }
105
143
  if (vcs.enabled && !vcs.repoDir.trim()) {
106
144
  ctx.addIssue({
107
145
  code: 'custom',
@@ -1224,6 +1224,46 @@ export const SYSTEM_GUIDE = [
1224
1224
  doesEn: 'Under the list of uncommitted files, each set to "Include in the starting snapshot" or "Leave out of the run": commits the included ones on the snapshot branch; left-out files go to the repository\'s .git/info/exclude. Until it is pressed a run of the session is refused and nothing changes.',
1225
1225
  doesBg: 'Под списъка с некомитнати файлове, всеки с „Включи в началната снимка“ или „Остави извън изпълнението“: комитва включените на клона на снимката; оставените извън отиват в .git/info/exclude на хранилището. Докато не е натиснат, пускане на сесията се отказва и нищо не се променя.',
1226
1226
  },
1227
+ {
1228
+ key: 'vcs.inputs',
1229
+ kind: 'note',
1230
+ en: 'Input files',
1231
+ bg: 'Входни файлове',
1232
+ doesEn: 'The heading over the operator\'s input files and whether they are read-only.',
1233
+ doesBg: 'Заглавието над входните файлове на оператора и дали са само за четене.',
1234
+ },
1235
+ {
1236
+ key: 'vcs.inputsField',
1237
+ kind: 'field',
1238
+ en: 'Your input files, one pattern per line (repository-relative)',
1239
+ bg: 'Твоите входни файлове, по един шаблон на ред (спрямо хранилището)',
1240
+ doesEn: 'Files the operator provides — schemas, test data. They go into the commit every session of the run starts from (new or ignored ones in the starting snapshot, listed for approval); a later session whose start lacks them has them committed on top of it; their SHA-256 sums are recorded. In a plan: "userInputs": { "paths": [...] }.',
1241
+ doesBg: 'Файлове, които операторът дава — схеми, тестови данни. Влизат в комита, от който тръгва всяка сесия на пускането (новите или ignored — в началната снимка, със списък за одобрение); следваща сесия, чието начало ги няма, ги получава комитнати върху него; SHA-256 сумите им се записват. В план: "userInputs": { "paths": [...] }.',
1242
+ },
1243
+ {
1244
+ key: 'vcs.inputsReadOnly',
1245
+ kind: 'checkbox',
1246
+ en: 'Read-only while the tasks run',
1247
+ bg: 'Само за четене, докато задачите работят',
1248
+ doesEn: 'On by default. A changed, deleted or added input file is put back after every round and the chat told; one that cannot be put back fails the task.',
1249
+ doesBg: 'Включено по подразбиране. Променен, изтрит или добавен входен файл се връща след всеки рунд и чатът научава; такъв, който не може да се върне, проваля задачата.',
1250
+ },
1251
+ {
1252
+ key: 'vcs.artifacts',
1253
+ kind: 'note',
1254
+ en: 'Artifacts',
1255
+ bg: 'Artifacts',
1256
+ doesEn: 'The heading over the evidence files the work writes.',
1257
+ doesBg: 'Заглавието над файловете с доказателства, които работата пише.',
1258
+ },
1259
+ {
1260
+ key: 'vcs.artifactsField',
1261
+ kind: 'field',
1262
+ en: 'Evidence the work writes, one pattern per line (repository-relative)',
1263
+ bg: 'Доказателства, които работата пише, по един шаблон на ред (спрямо хранилището)',
1264
+ doesEn: 'Reports, ZIP archives, test results: never committed (kept out through .git/info/exclude, .gitignore untouched) and copied into each attempt\'s record with their sums. In a plan: "artifacts": { "paths": [...] }.',
1265
+ doesBg: 'Отчети, ZIP архиви, резултати от тестове: никога не се комитват (стоят извън git чрез .git/info/exclude, .gitignore не се пипа) и се копират към записа на всеки опит със сумите си. В план: "artifacts": { "paths": [...] }.',
1266
+ },
1227
1267
  {
1228
1268
  key: 'vcs.ready',
1229
1269
  kind: 'note',
@@ -83,7 +83,7 @@ export const DeviationSchema = z.object({
83
83
  * claim to test, and to the record. The finding is named by the id the runner gave it.
84
84
  */
85
85
  export const DisputeSchema = z.object({
86
- /** The finding's id as it was given: `r1f2`. */
86
+ /** The finding's id as it was given: `r1f2`, or `a2r1f2` on a later attempt. */
87
87
  finding: z.string().trim().min(1),
88
88
  /** Why it is wrong. */
89
89
  why: z.string().trim().min(1),
@@ -166,13 +166,18 @@ export function isGrounded(basis, sources) {
166
166
  return sources.some((s) => groundingKey(s).includes(key));
167
167
  }
168
168
  /**
169
- * The name a finding is referred to by, once the runner has it: round and position.
169
+ * The name a finding is referred to by, once the runner has it: round and position, and from the
170
+ * task's second attempt on the attempt as well — `r1f2`, then `a2r1f2`.
170
171
  *
171
- * Given by the runner rather than the reviewer so that it is unique across rounds and cannot
172
- * be forgotten. It is what the implementer names in `disputed`, and what the record shows.
172
+ * Given by the runner rather than the reviewer so that it is unique and cannot be forgotten. It is
173
+ * what the implementer names in `disputed`, what a check given with the finding is named after, and
174
+ * what the record shows. Unique across the task's attempts, not only its rounds, because those checks
175
+ * are kept for every later attempt (`Task.reviewChecks`) and the rounds count from 1 on each: with
176
+ * round and position alone, attempt 2's first finding was `r1f1` beside attempt 1's kept `r1f1`, and
177
+ * a dispute of the one suspended the check of the other. The first attempt keeps the short form.
173
178
  */
174
- export function findingId(round, index) {
175
- return `r${round}f${index + 1}`;
179
+ export function findingId(round, index, attempt = 1) {
180
+ return `${attempt > 1 ? `a${attempt}` : ''}r${round}f${index + 1}`;
176
181
  }
177
182
  /** True when nothing the reviewer found is something the implementer could fix. */
178
183
  export function allAboutTheTask(findings) {
@@ -21,6 +21,7 @@
21
21
  import { existsSync } from 'node:fs';
22
22
  import { readFile, writeFile } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
+ import { TASK_FIELD_ON_RERUN } from './store.js';
24
25
  import { withoutPersona } from '../plan/importPlan.js';
25
26
  const EXPECTED_HEADER = '### Expected result';
26
27
  const GOAL_HEADER = '## Goal of this session';
@@ -149,6 +150,8 @@ export function buildPlanExport(scope) {
149
150
  ...(s.vcs?.updateFromRemote === false ? { updateFromRemote: false } : {}),
150
151
  // Only when chosen: absent is reject, as it always was.
151
152
  ...(s.vcs?.dirtyWorktree && s.vcs.dirtyWorktree.policy !== 'reject' ? { dirtyWorktree: { ...s.vcs.dirtyWorktree } } : {}),
153
+ ...(s.vcs?.userInputs?.paths.length ? { userInputs: { ...s.vcs.userInputs } } : {}),
154
+ ...(s.vcs?.artifacts?.paths.length ? { artifacts: { ...s.vcs.artifacts } } : {}),
152
155
  },
153
156
  review: { enabled: s.review?.enabled !== false, model: s.review?.model ?? '' },
154
157
  ...(s.projectDir ? { projectDir: s.projectDir } : {}),
@@ -231,27 +234,32 @@ export function taskAtAttempt(task, attempt) {
231
234
  const a = task.attempts?.[attempt - 1];
232
235
  if (!a)
233
236
  return null;
237
+ /*
238
+ * By the same table `rerunTask` follows: what was archived is read from the attempt, what was
239
+ * cleared is absent. Field by field, by hand, an earlier attempt read back with the latest one's
240
+ * handoff, counts, limit and fresh-chat mark.
241
+ */
242
+ const asItWas = {};
243
+ for (const [key, what] of Object.entries(TASK_FIELD_ON_RERUN)) {
244
+ if (what === 'archived')
245
+ asItWas[key] = a[key];
246
+ if (what === 'cleared')
247
+ asItWas[key] = undefined;
248
+ }
234
249
  return {
235
250
  ...task,
251
+ ...asItWas,
236
252
  attempt,
237
253
  status: a.status,
238
- runId: a.runId,
239
- runGroup: a.runGroup,
240
- startedAt: a.startedAt,
241
- finishedAt: a.finishedAt,
242
254
  iterations: a.iterations,
243
- summary: a.summary,
244
- reason: a.reason,
245
- deviations: a.deviations,
246
- disputes: a.disputes,
255
+ // The task's own text and definition as they were when that attempt ran, where it was kept.
247
256
  title: a.title,
248
257
  prompt: a.prompt,
249
258
  level2: a.level2,
250
259
  checks: a.checks ?? task.checks,
251
260
  vcsPlan: a.vcsPlan ?? task.vcsPlan,
252
- checkResults: a.checkResults,
253
- review: a.review,
254
- vcs: a.vcs,
261
+ scope: a.scope ?? task.scope,
262
+ readOnly: a.readOnly ?? task.readOnly,
255
263
  attempts: task.attempts?.slice(0, attempt - 1),
256
264
  continuing: undefined,
257
265
  buildsOn: undefined,
@@ -436,7 +444,25 @@ async function domainTask(session, task, runsDir) {
436
444
  const started = task.startedAt ? Date.parse(task.startedAt) : NaN;
437
445
  const finished = task.finishedAt ? Date.parse(task.finishedAt) : NaN;
438
446
  const out = {
439
- session: { id: session.id, name: session.name },
447
+ /*
448
+ * Where the session started — the baseline commit, a snapshot of the operator's changes, the
449
+ * input files with their SHA-256 sums — so a reader can check the work against its inputs.
450
+ */
451
+ session: {
452
+ id: session.id,
453
+ name: session.name,
454
+ ...(session.vcsStart
455
+ ? {
456
+ start: {
457
+ kind: session.vcsStart.kind,
458
+ commit: session.vcsStart.commit,
459
+ branch: session.vcsStart.branch,
460
+ ...(session.vcsStart.snapshot ? { snapshot: session.vcsStart.snapshot } : {}),
461
+ ...(session.vcsStart.inputs ? { inputs: session.vcsStart.inputs } : {}),
462
+ },
463
+ }
464
+ : {}),
465
+ },
440
466
  task: {
441
467
  id: task.id,
442
468
  title: task.title,
@@ -444,6 +470,8 @@ async function domainTask(session, task, runsDir) {
444
470
  readOnly: task.readOnly ?? false,
445
471
  scope: task.scope ?? [],
446
472
  scopeReverted: task.scopeReverted ?? [],
473
+ inputsRestored: task.inputsRestored ?? [],
474
+ artifactsKept: task.artifactsKept ?? [],
447
475
  handoff: task.handoff ?? null,
448
476
  stopCode: task.stopCode ?? null,
449
477
  prompt: p.prompt,
@@ -526,7 +554,7 @@ async function domainTask(session, task, runsDir) {
526
554
  whatTheChatTried: aRounds,
527
555
  whatWasActuallyDone: { repository: a.vcs, deviations: a.deviations, disputes: a.disputes },
528
556
  whyItFailed: aFailed
529
- ? { status: a.status, reason: a.reason, failingChecks: (a.checkResults ?? []).filter((c) => !c.passed), lastRound: aRounds.at(-1) }
557
+ ? { status: a.status, reason: a.reason, failingChecks: (a.checkResults ?? []).filter((c) => !c.passed), lastRound: aRounds.at(-1), leftovers: a.leftovers }
530
558
  : undefined,
531
559
  });
532
560
  }
@@ -607,6 +635,9 @@ async function botTask(session, task, runsDir) {
607
635
  steps: stepsOf(aEvents).map((s) => ({ iteration: s.iteration, id: s.id, command: s.description, outcome: s.outcome, exitCode: s.exitCode, durationMs: s.durationMs, refused: s.refused })),
608
636
  problems: aEvents.filter((e) => e.level === 'error' || e.level === 'warn').map(trimmed),
609
637
  events: aEvents.map(trimmed),
638
+ // That attempt's own: what it left running and the machine it ran on.
639
+ processes: { leftovers: a.leftovers },
640
+ environment: a.environment,
610
641
  };
611
642
  })),
612
643
  };