copilot-operator 0.1.16 → 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 (52) hide show
  1. package/dist/src/api/operator.controller.js +23 -0
  2. package/dist/src/api/operator.service.js +55 -3
  3. package/dist/src/orchestrator/derivedChecks.js +19 -1
  4. package/dist/src/orchestrator/review.js +2 -2
  5. package/dist/src/orchestrator/taskRunner.js +81 -8
  6. package/dist/src/plan/brief.js +60 -0
  7. package/dist/src/plan/importPlan.js +35 -1
  8. package/dist/src/plan/schema.js +62 -0
  9. package/dist/src/plan/systemGuide.js +72 -0
  10. package/dist/src/protocol/replySchema.js +1 -1
  11. package/dist/src/protocol/reviewSchema.js +10 -5
  12. package/dist/src/session/exports.js +46 -13
  13. package/dist/src/session/model.js +20 -0
  14. package/dist/src/session/store.js +146 -6
  15. package/dist/src/vcs/artifacts.js +138 -0
  16. package/dist/src/vcs/inputs.js +332 -0
  17. package/dist/src/vcs/snapshot.js +395 -0
  18. package/dist/src/vcs/taskVcs.js +183 -27
  19. package/dist/web/404.html +2 -2
  20. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
  21. package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
  23. package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +1 -0
  24. package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +1 -0
  25. package/dist/web/_next/static/css/{e2a2c9eb3c226313.css → c37978b44194c37b.css} +1 -1
  26. package/dist/web/appearance.html +2 -2
  27. package/dist/web/appearance.txt +12 -12
  28. package/dist/web/defaults.html +2 -2
  29. package/dist/web/defaults.txt +12 -12
  30. package/dist/web/history.html +2 -2
  31. package/dist/web/history.txt +12 -12
  32. package/dist/web/import.html +2 -2
  33. package/dist/web/import.txt +12 -12
  34. package/dist/web/index.html +2 -2
  35. package/dist/web/index.txt +12 -12
  36. package/dist/web/level1.html +2 -2
  37. package/dist/web/level1.txt +12 -12
  38. package/dist/web/presets.html +2 -2
  39. package/dist/web/presets.txt +12 -12
  40. package/dist/web/sessions/view.html +2 -2
  41. package/dist/web/sessions/view.txt +12 -12
  42. package/dist/web/system.html +2 -2
  43. package/dist/web/system.txt +12 -12
  44. package/package.json +9 -3
  45. package/prompts/level1.md +547 -546
  46. package/dist/web/_next/static/chunks/549-7fd4fe96682299f5.js +0 -1
  47. package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +0 -1
  48. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
  49. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +0 -1
  50. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +0 -1
  51. /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → JPmlfAEvbNkbghoyvY9uC}/_buildManifest.js +0 -0
  52. /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → JPmlfAEvbNkbghoyvY9uC}/_ssgManifest.js +0 -0
@@ -126,6 +126,10 @@ let OperatorController = class OperatorController {
126
126
  sessions() {
127
127
  return this.ops.listSessions();
128
128
  }
129
+ /** The sessions list in the operator's order: every session id, top to bottom. */
130
+ reorderSessions(body) {
131
+ return this.ops.reorderSessions(body?.ids).catch(fail);
132
+ }
129
133
  createSession(body) {
130
134
  return this.ops.createSession(body?.name ?? '', body?.projectDir).catch(fail);
131
135
  }
@@ -500,6 +504,10 @@ let OperatorController = class OperatorController {
500
504
  vcsStatus(id) {
501
505
  return this.ops.vcsStatus(id).catch(fail);
502
506
  }
507
+ /** The operator's approval of the starting snapshot: one choice per file of the list they saw. */
508
+ vcsSnapshot(id, body) {
509
+ return this.ops.vcsSnapshot(id, body?.choices ?? {}).catch(fail);
510
+ }
503
511
  // --- the model picker ---------------------------------------------------------------------
504
512
  /** The cached list. Returns null when the picker has never been read on this machine. */
505
513
  models() {
@@ -632,6 +640,13 @@ __decorate([
632
640
  __metadata("design:paramtypes", []),
633
641
  __metadata("design:returntype", Promise)
634
642
  ], OperatorController.prototype, "sessions", null);
643
+ __decorate([
644
+ Put('sessions-order'),
645
+ __param(0, Body()),
646
+ __metadata("design:type", Function),
647
+ __metadata("design:paramtypes", [Object]),
648
+ __metadata("design:returntype", Promise)
649
+ ], OperatorController.prototype, "reorderSessions", null);
635
650
  __decorate([
636
651
  Post('sessions'),
637
652
  __param(0, Body()),
@@ -1027,6 +1042,14 @@ __decorate([
1027
1042
  __metadata("design:paramtypes", [String]),
1028
1043
  __metadata("design:returntype", Promise)
1029
1044
  ], OperatorController.prototype, "vcsStatus", null);
1045
+ __decorate([
1046
+ Post('sessions/:id/vcs/snapshot'),
1047
+ __param(0, Param('id')),
1048
+ __param(1, Body()),
1049
+ __metadata("design:type", Function),
1050
+ __metadata("design:paramtypes", [String, Object]),
1051
+ __metadata("design:returntype", Promise)
1052
+ ], OperatorController.prototype, "vcsSnapshot", null);
1030
1053
  __decorate([
1031
1054
  Get('models'),
1032
1055
  __metadata("design:type", Function),
@@ -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';
@@ -45,6 +45,8 @@ import { unattendedPrecondition } from '../exec/policy.js';
45
45
  import { createTransport } from '../transport/chatTransport.js';
46
46
  import { composeHandoff } from '../session/handoff.js';
47
47
  import { planSync } from '../vcs/syncCommand.js';
48
+ import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
49
+ import { normalisePatterns } from '../vcs/inputs.js';
48
50
  import { computeMetrics } from '../session/metrics.js';
49
51
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
50
52
  import { saveAndReveal } from './saveToDesktop.js';
@@ -320,6 +322,14 @@ let OperatorService = class OperatorService {
320
322
  await this.store.deletePersona(name);
321
323
  }
322
324
  // --- sessions and tasks ---------------------------------------------------------------
325
+ /** The operator's order for the sessions list, top to bottom. See `SessionStore.reorderSessions`. */
326
+ async reorderSessions(ids) {
327
+ await this.init();
328
+ if (!Array.isArray(ids) || ids.some((x) => typeof x !== 'string'))
329
+ throw new Error('ids must be a list of session ids.');
330
+ await this.store.reorderSessions(ids);
331
+ return await this.listSessions();
332
+ }
323
333
  async listSessions() {
324
334
  await this.init();
325
335
  const all = await this.store.listSessions();
@@ -404,6 +414,32 @@ let OperatorService = class OperatorService {
404
414
  merged.baseBranch = merged.baseBranch.trim();
405
415
  if (merged.existingBranch !== undefined)
406
416
  merged.existingBranch = merged.existingBranch.trim();
417
+ // Only the two known fields, and absent when it says what absent says: reject, asking first.
418
+ const dirty = dirtyPolicy(merged);
419
+ if (dirty.policy === 'reject')
420
+ delete merged.dirtyWorktree;
421
+ else
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
+ }
407
443
  // Only the program marks a name as one of its own branches, and a name changed is not that one.
408
444
  const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
409
445
  if (stillOwn)
@@ -1822,11 +1858,27 @@ let OperatorService = class OperatorService {
1822
1858
  return { ok: false, repoDir: '', problem: "git is not installed, or not on this machine's PATH.", git: null };
1823
1859
  // `branch` is where HEAD is; `work` is where the session's work is. After a per-task run
1824
1860
  // those differ, and the second is the one the operator is asking about.
1825
- const preflight = await vcsPreflight(session);
1861
+ const preflight = await vcsPreflight(session, () => this.store.listSessions());
1826
1862
  // The local branches, so "carry on an existing branch" can offer them rather than be typed blind.
1827
1863
  const branches = preflight.repoDir ? await localBranches(preflight.repoDir) : [];
1828
1864
  return { ...preflight, git, work: sessionBranches(session), branches };
1829
1865
  }
1866
+ /**
1867
+ * The operator's approval of the starting snapshot: their uncommitted changes become the commit the
1868
+ * session starts from, each file taken or left out as they chose on the list. See `vcs/snapshot.ts`.
1869
+ * Refused while anything could be working in the repository, as a restore is: it changes branches.
1870
+ */
1871
+ async vcsSnapshot(sessionId, choices) {
1872
+ await this.init();
1873
+ const session = await this.store.getSession(sessionId);
1874
+ if (!session)
1875
+ throw new Error('No such session.');
1876
+ const refused = await this.restoreRefusal(session);
1877
+ if (refused)
1878
+ return { ok: false, problem: refused };
1879
+ const taken = await takeSnapshot(session, { approved: true, choices: choices && typeof choices === 'object' ? choices : {} }, this.bus, async (mutate) => void (await this.store.updateSession(sessionId, mutate)), () => this.store.listSessions());
1880
+ return taken.ok ? { ok: true, branch: taken.start.branch, commit: taken.start.commit } : { ok: false, problem: taken.problem };
1881
+ }
1830
1882
  /**
1831
1883
  * What going back to before a task would do, and then doing it.
1832
1884
  *
@@ -2570,7 +2622,7 @@ let OperatorService = class OperatorService {
2570
2622
  commit: t.vcs?.commit,
2571
2623
  vcsProblem: t.vcs?.problem,
2572
2624
  sessionInactive: s.active === false || undefined,
2573
- autoRetries: t.autoRetries || undefined,
2625
+ autoRetries: freshRetriesOfLatestRun(t) || undefined,
2574
2626
  attempt: t.attempt,
2575
2627
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
2576
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
  /*
@@ -454,6 +454,29 @@ Before a session's first branch the runner fetches and fast-forwards the branch
454
454
  merges. \`"updateFromRemote": false\` turns that off — only when the user asks to work offline or from
455
455
  local commits on purpose.
456
456
 
457
+ **dirtyWorktree** — what happens when the repository has uncommitted changes (the user's own, such as
458
+ input files a task needs) before a session's first task. Ask only when the user mentions such files:
459
+ \`{ "policy": "snapshot" }\` makes them one commit, "Capture operator baseline before run", on a branch of
460
+ its own, and the session starts from it; the user approves the exact list on the session's page first.
461
+ \`"tracked-only-snapshot"\` takes tracked changes only. Secrets (\`.env\`), tool output and files outside
462
+ the project are never taken, and an ignored file only when a task's \`scope\` names it. The snapshot is
463
+ 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
+
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
+
457
480
  Put the same \`startFrom\` on every session that works in one repository unless the user wants it
458
481
  mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
459
482
  the one to avoid.
@@ -512,6 +535,29 @@ const VCS_DETAIL_BG = `
512
535
  reset или merge. \`"updateFromRemote": false\` го изключва — само ако потребителят поиска да работи
513
536
  офлайн или нарочно от локални commit-и.
514
537
 
538
+ **dirtyWorktree** — какво става, когато хранилището има некомитнати промени (на потребителя, например
539
+ входни файлове, които задача използва) преди първата задача на сесията. Питай само ако потребителят
540
+ спомене такива файлове: \`{ "policy": "snapshot" }\` ги прави един commit, „Capture operator baseline before
541
+ run“, на отделен клон, и сесията тръгва от него; потребителят първо одобрява точния списък на страницата на
542
+ сесията. \`"tracked-only-snapshot"\` взема само tracked промени. Тайни (\`.env\`), изход от инструменти и
543
+ файлове извън проекта никога не се вземат, а ignored файл — само когато \`scope\` на задача го посочва.
544
+ Снимката се прави там, където са промените, затова с \`startFrom\` \`"branch"\` само ако хранилището вече е на
545
+ този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: контролът на версиите
546
+ не влиза в дърво с некомитнати промени.
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
+
515
561
  Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
516
562
  различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
517
563
  което е за избягване.
@@ -1438,6 +1484,13 @@ bot actually does, because it changes what a good task looks like:
1438
1484
  reads it and decides the next step. This repeats until Copilot writes a final summary.
1439
1485
  - Nothing is interactive. A command that waits for a keypress, opens an editor or needs a
1440
1486
  browser login will hang the task.
1487
+ - **How the working chat learns the project: by running commands.** No file of the project is
1488
+ copied, attached or uploaded to it. In each task it reads what it needs itself, with terminal
1489
+ commands in the project folder — it lists the folders, opens the files, looks at the git log —
1490
+ and the output comes back to it. So never ask the user how the chat will get the project's files,
1491
+ how it will see the code or whether to attach anything; that is settled. If the organisation
1492
+ document still says how the chat reaches a project (attached files, a Desktop copy), ignore it:
1493
+ that way no longer exists. Write prompts that name the paths to read, not files to attach.
1441
1494
  - **Only the project folders.** The runner refuses any command, check or file path that reaches
1442
1495
  outside the session's folder and the projects listed below — reading as well as writing — and
1443
1496
  anything that manages the machine: the registry, services, the network, users, installs that
@@ -1655,6 +1708,13 @@ function buildBg(projects, ctx = {}) {
1655
1708
  чете и решава следващата стъпка. Това се повтаря, докато Copilot не напише финално резюме.
1656
1709
  - Нищо не е интерактивно. Команда, която чака клавиш, отваря редактор или иска вход през
1657
1710
  браузър, ще увисне.
1711
+ - **Как работният чат опознава проекта: с команди.** Никой файл от проекта не се копира, прикачва
1712
+ или качва в него. Във всяка задача той сам чете каквото му трябва, с терминални команди в папката
1713
+ на проекта — изброява папките, отваря файловете, гледа git историята — и изходът се връща при него.
1714
+ Затова никога не питай потребителя как чатът ще получи файловете на проекта, как ще види кода или
1715
+ дали да прикачи нещо; това е решено. Ако документът за организацията още казва как чатът стига до
1716
+ проект (прикачени файлове, копие на Desktop-а), не го слушай: такъв начин вече няма. Пиши prompt-ове,
1717
+ които назовават пътищата за четене, а не файлове за прикачване.
1658
1718
  - **Само папките на проектите.** Runner-ът отказва всяка команда, проверка или път към файл, който
1659
1719
  излиза извън папката на сесията и проектите, изброени по-долу — и за четене, и за запис — и всичко,
1660
1720
  което управлява машината: регистъра, услугите, мрежата, потребителите, инсталации извън проекта.
@@ -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,7 +190,21 @@ 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 vcs = { ...DEFAULT_VCS, ...(planned.vcs ?? {}) };
193
+ const { dirtyWorktree: plannedDirty, userInputs: plannedInputs, artifacts: plannedArtifacts, ...plannedVcs } = planned.vcs ?? {};
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 };
204
+ // Stored in the one spelling the session has: see `DirtyWorktree`.
205
+ const policy = plannedDirty?.policy === 'snapshot' && plannedDirty.includeUntracked === false ? 'tracked-only-snapshot' : plannedDirty?.policy;
206
+ if (policy && policy !== 'reject')
207
+ vcs.dirtyWorktree = { policy, ...(plannedDirty?.requireApproval === false ? { requireApproval: false } : {}) };
176
208
  vcs.branchPrefix = vcs.branchPrefix.trim() || DEFAULT_VCS.branchPrefix;
177
209
  vcs.repoDir = vcs.repoDir.trim();
178
210
  // A branch to carry on, given alone, is that choice: see `VersionControl.startFrom`.
@@ -211,5 +243,7 @@ defaultProjectDir = '') {
211
243
  }
212
244
  sessions.push({ id: created.id, name: created.name, tasks: titles.length, titles });
213
245
  }
246
+ // The plan's sessions on top of the list, in the order the plan gives them, not newest first.
247
+ await store.placeOnTop(sessions.map((s) => s.id));
214
248
  return { sessions, taskCount: sessions.reduce((n, s) => n + s.tasks, 0), warnings };
215
249
  }
@@ -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.
@@ -60,6 +61,52 @@ const VcsInput = z
60
61
  * Absent means true. See `VersionControl.updateFromRemote`.
61
62
  */
62
63
  updateFromRemote: z.boolean().optional(),
64
+ /**
65
+ * What the session's first run does with uncommitted changes. See `DirtyWorktree`. Accepts the
66
+ * longer spelling a chat model may write — `includeUntracked: false` is `tracked-only-snapshot`,
67
+ * and `includeIgnoredOnlyWhenScoped` can only be true, since that is the rule, not a choice.
68
+ */
69
+ dirtyWorktree: z
70
+ .object({
71
+ policy: z.enum(['reject', 'snapshot', 'tracked-only-snapshot'], {
72
+ error: 'dirtyWorktree.policy must be "reject" (uncommitted changes keep version control out, as until now), "snapshot" (they become the commit the session starts from) or "tracked-only-snapshot" (the same, tracked changes only). "discard" is not offered: the runner never throws work away.',
73
+ }),
74
+ requireApproval: z.boolean().optional(),
75
+ includeUntracked: z.boolean().optional(),
76
+ includeIgnoredOnlyWhenScoped: z
77
+ .literal(true, { error: 'dirtyWorktree.includeIgnoredOnlyWhenScoped can only be true: an ignored file goes into a snapshot only when a task\'s scope names it. Leave the field out.' })
78
+ .optional(),
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(),
63
110
  }, {
64
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.',
65
112
  })
@@ -78,6 +125,21 @@ const VcsInput = z
78
125
  message: `"existingBranch" carries on a branch that exists, and startFrom "${vcs.startFrom}" cuts a new one. Keep one: startFrom "existing-branch" with existingBranch, or leave existingBranch out.`,
79
126
  });
80
127
  }
128
+ if (vcs.dirtyWorktree?.policy === 'tracked-only-snapshot' && vcs.dirtyWorktree.includeUntracked === true) {
129
+ ctx.addIssue({
130
+ code: 'custom',
131
+ path: ['dirtyWorktree', 'includeUntracked'],
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.',
133
+ });
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
+ }
81
143
  if (vcs.enabled && !vcs.repoDir.trim()) {
82
144
  ctx.addIssue({
83
145
  code: 'custom',