copilot-operator 0.1.17 → 0.1.19

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 (56) hide show
  1. package/dist/src/api/operator.service.js +33 -2
  2. package/dist/src/cli.js +11 -29
  3. package/dist/src/config/version.js +30 -0
  4. package/dist/src/orchestrator/derivedChecks.js +19 -1
  5. package/dist/src/orchestrator/review.js +2 -2
  6. package/dist/src/orchestrator/taskRunner.js +130 -10
  7. package/dist/src/plan/brief.js +38 -4
  8. package/dist/src/plan/importPlan.js +42 -1
  9. package/dist/src/plan/schema.js +53 -0
  10. package/dist/src/plan/systemGuide.js +50 -2
  11. package/dist/src/protocol/replySchema.js +1 -1
  12. package/dist/src/protocol/reviewSchema.js +10 -5
  13. package/dist/src/session/exports.js +53 -13
  14. package/dist/src/session/model.js +20 -0
  15. package/dist/src/session/store.js +80 -0
  16. package/dist/src/vcs/artifacts.js +185 -0
  17. package/dist/src/vcs/inputs.js +449 -0
  18. package/dist/src/vcs/snapshot.js +174 -22
  19. package/dist/src/vcs/taskVcs.js +272 -37
  20. package/dist/web/404.html +2 -2
  21. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
  22. package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +1 -0
  23. package/dist/web/_next/static/chunks/app/appearance/page-a18019cdc395de3d.js +1 -0
  24. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
  25. package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +1 -0
  26. package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +1 -0
  27. package/dist/web/_next/static/chunks/app/system/page-5ae1f8054208e6eb.js +1 -0
  28. package/dist/web/appearance.html +2 -2
  29. package/dist/web/appearance.txt +25 -26
  30. package/dist/web/defaults.html +2 -2
  31. package/dist/web/defaults.txt +25 -26
  32. package/dist/web/history.html +2 -2
  33. package/dist/web/history.txt +25 -26
  34. package/dist/web/import.html +2 -2
  35. package/dist/web/import.txt +25 -26
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/index.txt +25 -26
  38. package/dist/web/level1.html +2 -2
  39. package/dist/web/level1.txt +25 -26
  40. package/dist/web/presets.html +2 -2
  41. package/dist/web/presets.txt +25 -26
  42. package/dist/web/sessions/view.html +2 -2
  43. package/dist/web/sessions/view.txt +25 -26
  44. package/dist/web/system.html +2 -2
  45. package/dist/web/system.txt +25 -26
  46. package/package.json +8 -3
  47. package/prompts/level1.md +547 -546
  48. package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +0 -1
  49. package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +0 -1
  50. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +0 -1
  51. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
  52. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +0 -1
  53. package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +0 -1
  54. package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +0 -1
  55. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_buildManifest.js +0 -0
  56. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_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,9 @@ 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';
50
+ import { coveredByArtifacts } from '../vcs/artifacts.js';
51
+ import { botVersion } from '../config/version.js';
49
52
  import { computeMetrics } from '../session/metrics.js';
50
53
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
51
54
  import { saveAndReveal } from './saveToDesktop.js';
@@ -419,6 +422,32 @@ let OperatorService = class OperatorService {
419
422
  delete merged.dirtyWorktree;
420
423
  else
421
424
  merged.dirtyWorktree = { policy: dirty.policy, ...(dirty.requireApproval ? {} : { requireApproval: false }) };
425
+ // Input files and artifacts: patterns as the matching reads them, absent when there are none.
426
+ const inputPaths = normalisePatterns(merged.userInputs?.paths ?? []);
427
+ if (inputPaths.length === 0)
428
+ delete merged.userInputs;
429
+ else {
430
+ merged.userInputs = {
431
+ paths: inputPaths,
432
+ ...(merged.userInputs?.readOnly === false ? { readOnly: false } : {}),
433
+ ...(merged.userInputs?.requireApproval === false ? { requireApproval: false } : {}),
434
+ };
435
+ }
436
+ const artifactPaths = normalisePatterns(merged.artifacts?.paths ?? []);
437
+ if (artifactPaths.length === 0)
438
+ delete merged.artifacts;
439
+ else
440
+ merged.artifacts = { paths: artifactPaths };
441
+ // Artifacts are kept out of git; one that covers the input files would keep them out too.
442
+ const hidden = coveredByArtifacts(merged.userInputs?.paths ?? [], artifactPaths);
443
+ if (hidden.length > 0) {
444
+ throw new Error(`An artifacts pattern covers the input files ${hidden.map((h) => `"${h}"`).join(', ')}: artifacts are kept out of git, so the inputs would be too. ` +
445
+ 'Name only the folders the evidence is written to, not a whole project folder.');
446
+ }
447
+ // New inputs are settled again at the next task: the recorded ones are no longer the whole list.
448
+ if (JSON.stringify(merged.userInputs ?? null) !== JSON.stringify(s.vcs?.userInputs ?? null) && s.vcsStart?.inputs) {
449
+ s.vcsStart = { ...s.vcsStart, inputs: undefined };
450
+ }
422
451
  // Only the program marks a name as one of its own branches, and a name changed is not that one.
423
452
  const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
424
453
  if (stillOwn)
@@ -2601,7 +2630,7 @@ let OperatorService = class OperatorService {
2601
2630
  commit: t.vcs?.commit,
2602
2631
  vcsProblem: t.vcs?.problem,
2603
2632
  sessionInactive: s.active === false || undefined,
2604
- autoRetries: t.autoRetries || undefined,
2633
+ autoRetries: freshRetriesOfLatestRun(t) || undefined,
2605
2634
  attempt: t.attempt,
2606
2635
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
2607
2636
  interrupted: t.status === 'aborted' && !!t.interruption ? true : undefined,
@@ -2637,6 +2666,8 @@ let OperatorService = class OperatorService {
2637
2666
  const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
2638
2667
  const holders = findEdgeUsingProfile(cfg.resolved.profileDir);
2639
2668
  return {
2669
+ // Which copilot-operator this is: `cop --version` without a terminal.
2670
+ version: botVersion(),
2640
2671
  node: process.versions.node,
2641
2672
  edge: edge ?? null,
2642
2673
  profileDir: cfg.resolved.profileDir,
package/dist/src/cli.js CHANGED
@@ -24,32 +24,14 @@ import { PolicyLockUnusableError } from './config/lockedPolicy.js';
24
24
  import { EventBus } from './session/events.js';
25
25
  import { terminalAuthorizer, unattendedAuthorizer } from './exec/authorizer.js';
26
26
  import { unattendedPrecondition } from './exec/policy.js';
27
- import { botRootDir, isOwnCheckout } from './exec/workDir.js';
27
+ import { isOwnCheckout } from './exec/workDir.js';
28
28
  import { desktopIsSynced, resolveDesktopDir } from './context/desktopDir.js';
29
29
  import { Url } from './transport/locators.js';
30
+ import { botVersion } from './config/version.js';
30
31
  import { findEdgeUsingProfile } from './transport/profileLock.js';
31
32
  import { assessIsolation, readIsolationSignals } from './exec/isolation.js';
32
- /**
33
- * The version of the copilot-operator that is running, from its own package.json.
34
- *
35
- * It was a literal, '0.1.0', and stayed that through thirteen releases, so a bug report's
36
- * `cop --version` named a version that never had the bug. Read where the code is — `src/` under
37
- * tsx, `dist/src/` in a clone, `node_modules/copilot-operator/dist/src/` once installed — so it
38
- * cannot drift from what npm installed. botRootDir() falls back to the working directory when it
39
- * finds no package.json of ours, and that one is the user's project's: its version is not ours to
40
- * print, so the name is checked and anything else says "unknown".
41
- */
42
- function ownVersion() {
43
- try {
44
- const pkg = JSON.parse(readFileSync(join(botRootDir(), 'package.json'), 'utf8'));
45
- if (pkg.name === 'copilot-operator' && typeof pkg.version === 'string' && pkg.version)
46
- return pkg.version;
47
- }
48
- catch {
49
- // No readable package.json: say so rather than guess.
50
- }
51
- return 'unknown';
52
- }
33
+ /** The running version; see `config/version.ts`. */
34
+ const ownVersion = botVersion;
53
35
  const program = new Command();
54
36
  program
55
37
  .name('cop')
@@ -213,7 +195,7 @@ program
213
195
  replyTimeoutMs: 60_000,
214
196
  signInTimeoutMs: 15 * 60_000,
215
197
  humanWaitMs: 15 * 60_000,
216
- onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
198
+ onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
217
199
  `),
218
200
  });
219
201
  await transport.open();
@@ -506,14 +488,14 @@ program
506
488
  console.log('');
507
489
  console.log(`task "${done?.title}": ${done?.status} after ${done?.iterations ?? 0} iteration(s)`);
508
490
  if (done?.summary)
509
- console.log(`
510
- Summary:
491
+ console.log(`
492
+ Summary:
511
493
  ${done.summary}`);
512
494
  if (done?.reason)
513
- console.log(`
495
+ console.log(`
514
496
  Reason: ${done.reason}`);
515
497
  if (final?.chat)
516
- console.log(`
498
+ console.log(`
517
499
  chat: ${final.chat.name} — ${final.chat.url}`);
518
500
  if (done?.runId)
519
501
  console.log(`log: ${join(cfg.resolved.runsDir, done.runId, 'task-log.txt')}`);
@@ -533,8 +515,8 @@ program
533
515
  return;
534
516
  }
535
517
  const c = withChat.chat;
536
- console.log(`${c.name}
537
- ${c.url}
518
+ console.log(`${c.name}
519
+ ${c.url}
538
520
  session ${withChat.id}, ${withChat.tasks.length} task(s), created ${c.createdAt}`);
539
521
  });
540
522
  program.parseAsync(process.argv).catch((e) => {
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The version of the copilot-operator that is running, from its own package.json.
3
+ *
4
+ * It was a literal, '0.1.0', and stayed that through thirteen releases, so a bug report's
5
+ * `cop --version` named a version that never had the bug. Read where the code is — `src/` under
6
+ * tsx, `dist/src/` in a clone, `node_modules/copilot-operator/dist/src/` once installed — so it
7
+ * cannot drift from what npm installed. botRootDir() falls back to the working directory when it
8
+ * finds no package.json of ours, and that one is the user's project's: its version is not ours to
9
+ * print, so the name is checked and anything else says "unknown".
10
+ *
11
+ * Written into every export and attempt record too: a report sent as a screenshot from another
12
+ * machine could not say which version made it, and the operator had to be asked.
13
+ */
14
+ import { readFileSync } from 'node:fs';
15
+ import { join } from 'node:path';
16
+ import { botRootDir } from '../exec/workDir.js';
17
+ let cached;
18
+ export function botVersion() {
19
+ if (cached)
20
+ return cached;
21
+ try {
22
+ const pkg = JSON.parse(readFileSync(join(botRootDir(), 'package.json'), 'utf8'));
23
+ if (pkg.name === 'copilot-operator' && typeof pkg.version === 'string' && pkg.version)
24
+ return (cached = pkg.version);
25
+ }
26
+ catch {
27
+ // No readable package.json: say so rather than guess.
28
+ }
29
+ return 'unknown';
30
+ }
@@ -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';
@@ -45,7 +45,9 @@ import { composeHandoff } from '../session/handoff.js';
45
45
  import { ProgressWatch } from './progress.js';
46
46
  import { contractConflicts, readsTreeClean } from './contract.js';
47
47
  import { treeFingerprint } from '../vcs/git.js';
48
- import { prepareForTask, commitTaskResult, repoDirOf, trackedRepoOf } from '../vcs/taskVcs.js';
48
+ import { prepareForTask, commitTaskResult, commitShortfall, repoDirOf, trackedRepoOf } from '../vcs/taskVcs.js';
49
+ import { protectInputs, inputsMessage, inputsAtCommit, untrackedInputs } from '../vcs/inputs.js';
50
+ import { artifactPatterns, artifactState, 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,68 @@ 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
+ /** The artifact files as they were when the task started; what is kept afterwards is what changed. */
586
+ let artifactsBefore;
587
+ /** Puts back whatever a round did to the input files, and tells the chat and the record. */
588
+ const guardInputs = async (iteration) => {
589
+ if (!inputsGuard)
590
+ return null;
591
+ const guard = inputsGuard;
592
+ const check = await protectInputs(repoDirOf(session), guard.base, guard.inputs, guard.keep).catch((e) => ({
593
+ changed: ['(the input files)'],
594
+ restored: [],
595
+ failed: [{ path: '(the input files)', why: e.message }],
596
+ }));
597
+ if (check.changed.length > 0) {
598
+ 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)'}` +
599
+ (check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
600
+ runnerNotes.push(inputsMessage(check));
601
+ await setTask((t) => {
602
+ t.inputsRestored = [...new Set([...(t.inputsRestored ?? []), ...check.restored])];
603
+ });
604
+ }
605
+ return check;
606
+ };
580
607
  const finish = async (status, reason, summary, finalReply) => {
581
608
  await record(`TASK ${status.toUpperCase()}`, [summary ?? '', reason ? `Reason: ${reason}` : ''].filter(Boolean).join('\n\n') || '(no details)');
582
609
  // The net under the plan's own checks: whatever the task left running is stopped and named.
583
610
  await reap(processesBefore, 'the task');
611
+ /*
612
+ * The operator's input files, once more before the commit: put back what the last round changed,
613
+ * so it is not committed as the task's work. One that cannot be put back fails a done task. See
614
+ * `vcs/inputs.ts`.
615
+ */
616
+ if (inputsGuard) {
617
+ const check = await guardInputs();
618
+ if (check && check.failed.length > 0 && status === 'done') {
619
+ status = 'failed';
620
+ 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('; ')}.`;
621
+ }
622
+ // Said on the record either way: the inputs as the task leaves them, against their recorded sums.
623
+ if (check && check.failed.length === 0) {
624
+ sink.event('inputs-verified', { files: inputsGuard.inputs.files.length }, `${inputsGuard.inputs.files.length} input file(s) are as the session's start recorded them: same sums`);
625
+ }
626
+ }
627
+ /*
628
+ * The session's artifacts, whatever the outcome: copied into this attempt's record, so the
629
+ * evidence of each attempt is kept even after a later one overwrites it. See `vcs/artifacts.ts`.
630
+ */
631
+ const patterns = artifactPatterns(session.vcs);
632
+ const projectRoot = repoDirOf(session) || session.projectDir?.trim() || '';
633
+ if ((patterns.length > 0 || (task.outputs?.length ?? 0) > 0) && projectRoot) {
634
+ // Only what this task made or changed, inside its scope, or declared as its outputs.
635
+ const kept = await keepArtifacts(projectRoot, patterns, join(artifactsDir, 'project'), { before: artifactsBefore, scope: task.scope, outputs: task.outputs })
636
+ .catch((e) => ({ kept: [], skipped: [`(all: ${e.message})`], unchanged: 0, outsideScope: [] }));
637
+ sink.event('artifacts-kept', { kept: kept.kept.length, skipped: kept.skipped, unchanged: kept.unchanged, outsideScope: kept.outsideScope.slice(0, 20) }, `artifacts kept with the run: ${kept.kept.length} file(s) this task made or changed` +
638
+ `${kept.unchanged > 0 ? `; ${kept.unchanged} already there and untouched, not kept` : ''}` +
639
+ `${kept.outsideScope.length > 0 ? `; ${kept.outsideScope.length} outside the task's scope, not kept` : ''}` +
640
+ `${kept.skipped.length > 0 ? `; not kept: ${kept.skipped.slice(0, 5).join('; ')}` : ''}`, kept.skipped.length > 0 ? 'warn' : 'info');
641
+ await setTask((t) => {
642
+ t.artifactsKept = kept.kept.length > 0 ? kept.kept : undefined;
643
+ });
644
+ }
584
645
  /*
585
646
  * A read-only task that changed files has failed, whatever it reported.
586
647
  *
@@ -604,10 +665,26 @@ export async function runTask(transport, session, task, deps) {
604
665
  // The task is re-read first, because the branch was recorded on it after this closure
605
666
  // was created.
606
667
  const fresh = (await store.getSession(session.id))?.tasks.find((x) => x.id === task.id);
668
+ let vcsError;
607
669
  const vcsAfter = await commitTaskResult(session, { ...task, vcs: fresh?.vcs }, { status, summary, reason, deviations }, bus).catch((e) => {
670
+ vcsError = e.message;
608
671
  sink.event('vcs-error', { error: String(e) }, `version control failed after the task: ${e.message}`, 'warn');
609
672
  return undefined;
610
673
  });
674
+ /*
675
+ * With version control on, a task is done only when its work is committed. The commit used to
676
+ * fail — on the branch it was moved off, on a git error, on uncommitted files left behind — and
677
+ * the task still ended done, its work loose in the tree and the next task refusing to start over
678
+ * it. Now it ends failed with why; the work stays in the working tree, nothing is lost.
679
+ */
680
+ if (status === 'done') {
681
+ const uncommitted = await commitShortfall(session, fresh?.vcs ?? task.vcs, vcsAfter, vcsError);
682
+ if (uncommitted) {
683
+ status = 'failed';
684
+ reason = uncommitted;
685
+ sink.event('commit-failed', { problem: vcsAfter?.problem ?? vcsError, uncommitted: vcsAfter?.afterCommit?.changed?.slice(0, 20) }, uncommitted, 'error');
686
+ }
687
+ }
611
688
  /*
612
689
  * The checks about a clean tree, now that the runner has committed. A done task whose tree is
613
690
  * still not clean after the commit has failed; any other ending keeps its own reason, and the
@@ -737,6 +814,9 @@ export async function runTask(transport, session, task, deps) {
737
814
  t.runId = runId;
738
815
  t.runGroup = deps.runGroup;
739
816
  t.startedAt = new Date().toISOString();
817
+ // The kept checks under the ids this run uses (`withAttemptIds`), so the record names them as the gate does.
818
+ if (reviewChecks.length > 0)
819
+ t.reviewChecks = reviewChecks;
740
820
  });
741
821
  sink.event('task-started', { runId, title: task.title }, `task "${task.title}" starting (run ${runId})`);
742
822
  await writeFile(taskLogPath, `TASK: ${task.title}\nSESSION: ${session.name} (${session.id})\nRUN: ${runId}\nSTARTED: ${new Date().toISOString()}\n`, 'utf8');
@@ -856,6 +936,26 @@ export async function runTask(transport, session, task, deps) {
856
936
  * findings described a tree that was not the one it had been asked to audit.
857
937
  */
858
938
  const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
939
+ // The operator's input files, read-only on a branch of the task's own; see `vcs/inputs.ts`.
940
+ const startNow = (await store.getSession(session.id))?.vcsStart;
941
+ if (onOwnBranch && startNow?.inputs?.readOnly && startNow.inputs.files.length > 0) {
942
+ /*
943
+ * Held to the inputs as this task's own starting commit has them, not the session's record: a
944
+ * re-run is cut from where the task first started, before any inputs changed since. And what is
945
+ * under the patterns untracked now is the operator's, never removed by the guard.
946
+ */
947
+ const baseCommit = prepared.vcs.baseCommit;
948
+ inputsGuard = {
949
+ base: baseCommit,
950
+ inputs: await inputsAtCommit(repoDirOf(session), baseCommit, startNow.inputs),
951
+ keep: await untrackedInputs(repoDirOf(session), startNow.inputs.patterns),
952
+ };
953
+ }
954
+ // Before the first step: what is under the artifact patterns now is not this task's evidence.
955
+ const artifactRoot = repoDirOf(session) || session.projectDir?.trim() || '';
956
+ const artifactPatternsNow = [...artifactPatterns(session.vcs), ...(task.outputs ?? [])];
957
+ if (artifactRoot && artifactPatternsNow.length > 0)
958
+ artifactsBefore = await artifactState(artifactRoot, artifactPatternsNow).catch(() => undefined);
859
959
  /** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
860
960
  const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
861
961
  /*
@@ -946,7 +1046,12 @@ export async function runTask(transport, session, task, deps) {
946
1046
  workDirNote: workingDirNote(work),
947
1047
  vcsNote: prepared.note,
948
1048
  readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
949
- scopeNote: scopeNote(scope, scopeEnforced),
1049
+ scopeNote: [
1050
+ scopeNote(scope, scopeEnforced),
1051
+ (task.outputs?.length ?? 0) > 0
1052
+ ? `## Outputs\n\nThis task is to produce: ${(task.outputs ?? []).map((o) => `\`${o}\``).join(', ')}. What it creates or changes there is kept with the run's record as its evidence.`
1053
+ : '',
1054
+ ].filter(Boolean).join('\n\n'),
950
1055
  approachesNote: minApproaches > MIN_TRIED_APPROACHES
951
1056
  ? `## Giving up\n\nThis task may end with status "blocked" only after at least ${minApproaches} genuinely different approaches, ` +
952
1057
  `each listed in "tried". With fewer, the runner asks you for another approach instead of accepting it.`
@@ -1409,7 +1514,7 @@ export async function runTask(transport, session, task, deps) {
1409
1514
  // `repeated` still identifies them.
1410
1515
  const named = outcome.findings.map((f, i) => ({
1411
1516
  ...f,
1412
- id: f.id ?? findingId(reviewRounds, i),
1517
+ id: f.id ?? findingId(reviewRounds, i, attempt),
1413
1518
  ...(repeated.includes(f) ? { repeated: true } : {}),
1414
1519
  }));
1415
1520
  outcome = { ...outcome, findings: named };
@@ -1936,6 +2041,7 @@ export async function runTask(transport, session, task, deps) {
1936
2041
  });
1937
2042
  }
1938
2043
  }
2044
+ await guardInputs(iterations);
1939
2045
  // --- report back ---------------------------------------------------------------
1940
2046
  const report = await writeReport(results, {
1941
2047
  runId,
@@ -2160,9 +2266,23 @@ export async function runSession(sessionId, deps) {
2160
2266
  break;
2161
2267
  }
2162
2268
  session = (await store.getSession(sessionId));
2163
- const task = session.tasks.find((t) => t.id === queuedTask.id);
2269
+ let task = session.tasks.find((t) => t.id === queuedTask.id);
2164
2270
  if (!task || task.status !== 'queued')
2165
2271
  continue;
2272
+ /*
2273
+ * A task taken from the queue was started by the operator, never by the retry loop below, which
2274
+ * runs its own. A fresh-retry mark on it is left from a retry that was queued and never ran —
2275
+ * the new chat failed to open, the program was closed — and counted as this run's, it added the
2276
+ * old run's retry to this one's (`freshRetriesOfLatestRun`).
2277
+ */
2278
+ if (task.freshRetry) {
2279
+ session = await store.updateSession(sessionId, (s) => {
2280
+ const t = s.tasks.find((x) => x.id === queuedTask.id);
2281
+ if (t)
2282
+ t.freshRetry = undefined;
2283
+ });
2284
+ task = session.tasks.find((t) => t.id === queuedTask.id);
2285
+ }
2166
2286
  let outcome = await runTask(chat, session, task, deps);
2167
2287
  ran += 1;
2168
2288
  /*
@@ -2194,11 +2314,11 @@ export async function runSession(sessionId, deps) {
2194
2314
  session = await store.updateSession(sessionId, (s) => {
2195
2315
  s.chat = undefined;
2196
2316
  s.contractSent = false;
2317
+ // Marked, not counted: how many retries this run made is read back from the marks
2318
+ // (`freshRetriesOfLatestRun`), so a later run cannot add to an earlier one's count.
2197
2319
  const again = s.tasks.find((x) => x.id === task.id);
2198
- if (again) {
2199
- again.autoRetries = (again.autoRetries ?? 0) + 1;
2320
+ if (again)
2200
2321
  again.freshRetry = true;
2201
- }
2202
2322
  });
2203
2323
  await chat.newChat();
2204
2324
  /*
@@ -324,6 +324,7 @@ const FIELD_ROWS_EN = [
324
324
  ['review', 'task', 'no', 'Set to `false` to skip the review for one task. Absent means the session decides.'],
325
325
  ['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.'],
326
326
  ['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.'],
327
+ ['outputs', 'task', 'no', 'Evidence the task is to produce (a report, an archive): patterns. What it creates or changes there is kept with the run\'s record.'],
327
328
  ['tasks', 'session', 'yes', 'At least one, in the order they must run.'],
328
329
  ['title', 'task', 'yes', '3 to 120 characters. Short, latin, hyphenated.'],
329
330
  ['prompt', 'task', 'yes', 'The task itself, at least 30 characters. This is what Copilot reads.'],
@@ -394,6 +395,7 @@ const FIELD_ROWS_BG = [
394
395
  ['review', 'задача', 'не', 'Сложи `false`, за да се пропусне рецензията само за тази задача. Липсата значи каквото казва сесията.'],
395
396
  ['readOnly', 'задача', 'не', '`true` за задача, която не бива да променя файлове — одит, smoke тест, доклад. Runner-ът я проваля, ако дървото е променено, и пак комитва промяната на клона ѝ, за да не се губи нищо.'],
396
397
  ['scope', 'задача', 'не', 'Пътищата, които задачата може да променя: файлове, папки (`tests/e2e/`) или шаблони с `*`/`**`. При включен контрол на версиите runner-ът връща всяка промяна извън тях. Давай го, когато задачата е за един suite или модул.'],
398
+ ['outputs', 'задача', 'не', 'Доказателства, които задачата трябва да създаде (отчет, архив): шаблони. Каквото създаде или промени там, се пази към записа на пускането.'],
397
399
  ['tasks', 'сесия', 'да', 'Поне една, в реда, в който трябва да се изпълнят.'],
398
400
  ['title', 'задача', 'да', 'От 3 до 120 знака. Кратко, латиница, с тирета.'],
399
401
  ['prompt', 'задача', 'да', 'Самата задача, поне 30 знака. Това чете Copilot.'],
@@ -461,8 +463,24 @@ its own, and the session starts from it; the user approves the exact list on the
461
463
  \`"tracked-only-snapshot"\` takes tracked changes only. Secrets (\`.env\`), tool output and files outside
462
464
  the project are never taken, and an ignored file only when a task's \`scope\` names it. The snapshot is
463
465
  taken where the changes are, so use it with \`startFrom\` \`"branch"\` only when the repository is already on
464
- that branch, and never with \`"existing-branch"\`. Leaving it out keeps \`"reject"\`: version control stays out
465
- of a tree with uncommitted changes.
466
+ that branch, and never with \`"existing-branch"\`. Leaving it out keeps \`"reject"\`: a tree with uncommitted
467
+ changes refuses the task, and nothing runs.
468
+
469
+ **userInputs** — files the user provides for the work (schemas, test data):
470
+ \`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. They go into the commit every session starts from —
471
+ new or ignored ones in the starting snapshot, approved by the user; a later session whose start lacks them
472
+ gets them committed on top of it — and they are read-only while the tasks run (\`"readOnly": false\` only if
473
+ the user wants the work to change them). Ask the user which files are inputs; never name a whole folder of
474
+ work, and never \`.env\`. **artifacts** — evidence the work writes (reports, ZIP archives, test results):
475
+ \`{ "paths": ["rules-engine/test-results/**"] }\`. Never committed, kept with the run; tell tasks to write
476
+ evidence there instead of changing \`.gitignore\`. Only what a task creates or changes there is kept, and an
477
+ artifacts pattern must never cover the input files or a task's work ("rules-engine/**" would hide both): name
478
+ the evidence folders only.
479
+
480
+ A session with \`"startFrom": "previous-session"\` continues the whole work of the one before it only when
481
+ that one is \`"branchMode": "per-session"\`: per task, its work is on several branches and the next session
482
+ refuses to start from part of it. It also refuses when the session before has not finished (a task failed or
483
+ blocked): finish it first.
466
484
 
467
485
  Put the same \`startFrom\` on every session that works in one repository unless the user wants it
468
486
  mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
@@ -529,8 +547,24 @@ run“, на отделен клон, и сесията тръгва от нег
529
547
  сесията. \`"tracked-only-snapshot"\` взема само tracked промени. Тайни (\`.env\`), изход от инструменти и
530
548
  файлове извън проекта никога не се вземат, а ignored файл — само когато \`scope\` на задача го посочва.
531
549
  Снимката се прави там, където са промените, затова с \`startFrom\` \`"branch"\` само ако хранилището вече е на
532
- този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: контролът на версиите
533
- не влиза в дърво с некомитнати промени.
550
+ този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: дърво с некомитнати
551
+ промени отказва задачата и нищо не се изпълнява.
552
+
553
+ **userInputs** — файлове, които потребителят дава за работата (схеми, тестови данни):
554
+ \`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. Влизат в комита, от който тръгва всяка сесия —
555
+ новите или ignored в началната снимка, с одобрение от потребителя; следваща сесия, чието начало ги няма, ги
556
+ получава комитнати върху него — и са само за четене, докато задачите работят (\`"readOnly": false\` само ако
557
+ потребителят иска работата да ги променя). Питай кои файлове са вход; никога цяла папка с работа и никога
558
+ \`.env\`. **artifacts** — доказателства, които работата пише (отчети, ZIP архиви, резултати от тестове):
559
+ \`{ "paths": ["rules-engine/test-results/**"] }\`. Никога не се комитват, пазят се към пускането; кажи на
560
+ задачите да пишат доказателствата там, вместо да променят \`.gitignore\`. Пази се само каквото задачата създаде
561
+ или промени там, и шаблон за artifacts никога не покрива входните файлове или работата на задача
562
+ ("rules-engine/**" би скрил и двете): посочвай само папките с доказателства.
563
+
564
+ Сесия с \`"startFrom": "previous-session"\` продължава цялата работа на предната само ако тя е
565
+ \`"branchMode": "per-session"\`: при per-task работата ѝ е на няколко клона и следващата сесия отказва да
566
+ тръгне от част от нея. Отказва и когато предната сесия не е завършила (задача е провалена или блокирана):
567
+ първо я завърши.
534
568
 
535
569
  Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
536
570
  различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
@@ -1,4 +1,6 @@
1
1
  import { DEFAULT_VCS, applyDefaultProject } from '../session/store.js';
2
+ import { normalisePatterns } from '../vcs/inputs.js';
3
+ import { coveredByArtifacts } from '../vcs/artifacts.js';
2
4
  import { availableShells, detectShells } from '../exec/shells.js';
3
5
  /** The heading under which a task's acceptance bar reaches Copilot. English, like level 1. */
4
6
  const EXPECTED_HEADER = '### Expected result';
@@ -165,6 +167,35 @@ defaultProjectDir = '') {
165
167
  const sharedGroup = plan.conversation === 'shared'
166
168
  ? (plan.plan.trim() || `plan-${new Date().toISOString().slice(0, 16).replace(/[:T-]/g, '')}`).slice(0, 60)
167
169
  : '';
170
+ /*
171
+ * A session that continues the one before it in the same repository, when that one ran per task
172
+ * with several tasks: its work is on several branches, and the continuation is refused at its start
173
+ * (see `sessionStart`). Said now, when the plan can still be changed.
174
+ */
175
+ const lastIn = new Map();
176
+ for (const planned of plan.sessions) {
177
+ const repo = (planned.vcs?.repoDir || planned.projectDir || '').trim().replace(/[\\/]+$/, '').toLowerCase();
178
+ if (!planned.vcs?.enabled || !repo)
179
+ continue;
180
+ const before = lastIn.get(repo);
181
+ if (planned.vcs.startFrom === 'previous-session' && before?.perTask && before.tasks > 1) {
182
+ 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, ` +
183
+ `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.`);
184
+ }
185
+ lastIn.set(repo, { name: planned.name, perTask: (planned.vcs.branchMode ?? 'per-task') === 'per-task', tasks: planned.tasks.length });
186
+ }
187
+ // A task whose whole scope is under the artifact patterns would have its work kept out of git.
188
+ for (const planned of plan.sessions) {
189
+ const arts = planned.vcs?.artifacts?.paths ?? [];
190
+ if (!planned.vcs?.enabled || arts.length === 0)
191
+ continue;
192
+ for (const task of planned.tasks) {
193
+ if (task.scope.length > 0 && coveredByArtifacts(task.scope, arts).length === task.scope.length) {
194
+ warnings.push(`Task "${task.title}" in session "${planned.name}" may change only ${task.scope.join(', ')}, and the session's artifacts patterns cover all of it: ` +
195
+ 'whatever it writes is kept with the run and never committed. If that work is meant to be committed, narrow "artifacts".');
196
+ }
197
+ }
198
+ }
168
199
  for (const planned of plan.sessions) {
169
200
  // Attaching project files to the chat was removed; an old plan's root is still its project folder.
170
201
  if (planned.mirror?.enabled) {
@@ -172,8 +203,17 @@ defaultProjectDir = '') {
172
203
  'the chat reads the project by running commands. "mirror" was ignored.');
173
204
  }
174
205
  const created = await store.createSession(planned.name, planned.projectDir || planned.mirror?.rootDir || '');
175
- const { dirtyWorktree: plannedDirty, ...plannedVcs } = planned.vcs ?? {};
206
+ const { dirtyWorktree: plannedDirty, userInputs: plannedInputs, artifacts: plannedArtifacts, ...plannedVcs } = planned.vcs ?? {};
176
207
  const vcs = { ...DEFAULT_VCS, ...plannedVcs };
208
+ // Input files and artifacts in the one spelling the session has: see `UserInputs`.
209
+ const inputPaths = plannedInputs?.enabled === false ? [] : normalisePatterns(plannedInputs?.paths ?? []);
210
+ if (inputPaths.length > 0) {
211
+ const readOnly = plannedInputs?.readOnly ?? plannedInputs?.readOnlyAfterCapture;
212
+ vcs.userInputs = { paths: inputPaths, ...(readOnly === false ? { readOnly: false } : {}), ...(plannedInputs?.requireApproval === false ? { requireApproval: false } : {}) };
213
+ }
214
+ const artifactPaths = normalisePatterns(plannedArtifacts?.paths ?? []);
215
+ if (artifactPaths.length > 0)
216
+ vcs.artifacts = { paths: artifactPaths };
177
217
  // Stored in the one spelling the session has: see `DirtyWorktree`.
178
218
  const policy = plannedDirty?.policy === 'snapshot' && plannedDirty.includeUntracked === false ? 'tracked-only-snapshot' : plannedDirty?.policy;
179
219
  if (policy && policy !== 'reject')
@@ -211,6 +251,7 @@ defaultProjectDir = '') {
211
251
  reviewEnabled: task.review,
212
252
  readOnly: task.readOnly || undefined,
213
253
  scope: task.scope.length > 0 ? task.scope : undefined,
254
+ outputs: task.outputs.length > 0 ? task.outputs : undefined,
214
255
  });
215
256
  titles.push(added.title);
216
257
  }