copilot-operator 0.1.18 → 0.1.20

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 (46) hide show
  1. package/dist/src/api/operator.service.js +10 -0
  2. package/dist/src/cli.js +11 -29
  3. package/dist/src/config/version.js +30 -0
  4. package/dist/src/orchestrator/taskRunner.js +56 -9
  5. package/dist/src/plan/brief.js +16 -8
  6. package/dist/src/plan/importPlan.js +14 -0
  7. package/dist/src/plan/schema.js +15 -0
  8. package/dist/src/plan/systemGuide.js +10 -2
  9. package/dist/src/session/exports.js +11 -0
  10. package/dist/src/session/store.js +2 -0
  11. package/dist/src/vcs/artifacts.js +56 -9
  12. package/dist/src/vcs/inputs.js +145 -28
  13. package/dist/src/vcs/snapshot.js +117 -6
  14. package/dist/src/vcs/taskVcs.js +156 -28
  15. package/dist/web/404.html +2 -2
  16. package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +1 -0
  17. package/dist/web/_next/static/chunks/app/appearance/page-a18019cdc395de3d.js +1 -0
  18. package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +1 -0
  19. package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +1 -0
  20. package/dist/web/_next/static/chunks/app/system/page-5ae1f8054208e6eb.js +1 -0
  21. package/dist/web/appearance.html +2 -2
  22. package/dist/web/appearance.txt +25 -26
  23. package/dist/web/defaults.html +2 -2
  24. package/dist/web/defaults.txt +25 -26
  25. package/dist/web/history.html +2 -2
  26. package/dist/web/history.txt +25 -26
  27. package/dist/web/import.html +2 -2
  28. package/dist/web/import.txt +25 -26
  29. package/dist/web/index.html +2 -2
  30. package/dist/web/index.txt +25 -26
  31. package/dist/web/level1.html +2 -2
  32. package/dist/web/level1.txt +25 -26
  33. package/dist/web/presets.html +2 -2
  34. package/dist/web/presets.txt +25 -26
  35. package/dist/web/sessions/view.html +2 -2
  36. package/dist/web/sessions/view.txt +25 -26
  37. package/dist/web/system.html +2 -2
  38. package/dist/web/system.txt +25 -26
  39. package/package.json +5 -3
  40. package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +0 -1
  41. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +0 -1
  42. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +0 -1
  43. package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +0 -1
  44. package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +0 -1
  45. /package/dist/web/_next/static/{JPmlfAEvbNkbghoyvY9uC → P0lpNIJvdSO3yuWRpIPQf}/_buildManifest.js +0 -0
  46. /package/dist/web/_next/static/{JPmlfAEvbNkbghoyvY9uC → P0lpNIJvdSO3yuWRpIPQf}/_ssgManifest.js +0 -0
@@ -47,6 +47,8 @@ import { composeHandoff } from '../session/handoff.js';
47
47
  import { planSync } from '../vcs/syncCommand.js';
48
48
  import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
49
49
  import { normalisePatterns } from '../vcs/inputs.js';
50
+ import { coveredByArtifacts } from '../vcs/artifacts.js';
51
+ import { botVersion } from '../config/version.js';
50
52
  import { computeMetrics } from '../session/metrics.js';
51
53
  import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
52
54
  import { saveAndReveal } from './saveToDesktop.js';
@@ -436,6 +438,12 @@ let OperatorService = class OperatorService {
436
438
  delete merged.artifacts;
437
439
  else
438
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
+ }
439
447
  // New inputs are settled again at the next task: the recorded ones are no longer the whole list.
440
448
  if (JSON.stringify(merged.userInputs ?? null) !== JSON.stringify(s.vcs?.userInputs ?? null) && s.vcsStart?.inputs) {
441
449
  s.vcsStart = { ...s.vcsStart, inputs: undefined };
@@ -2658,6 +2666,8 @@ let OperatorService = class OperatorService {
2658
2666
  const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
2659
2667
  const holders = findEdgeUsingProfile(cfg.resolved.profileDir);
2660
2668
  return {
2669
+ // Which copilot-operator this is: `cop --version` without a terminal.
2670
+ version: botVersion(),
2661
2671
  node: process.versions.node,
2662
2672
  edge: edge ?? null,
2663
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
+ }
@@ -45,9 +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';
49
- import { protectInputs, inputsMessage } from '../vcs/inputs.js';
50
- import { artifactPatterns, keepArtifacts } from '../vcs/artifacts.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';
51
51
  import { exportMachine, writeAttemptRecord } from '../session/exports.js';
52
52
  /** Why a task that was reported as done is being closed as failed. */
53
53
  function checksFailedReason(outcomes) {
@@ -582,12 +582,14 @@ export async function runTask(transport, session, task, deps) {
582
582
  };
583
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
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;
585
587
  /** Puts back whatever a round did to the input files, and tells the chat and the record. */
586
588
  const guardInputs = async (iteration) => {
587
589
  if (!inputsGuard)
588
590
  return null;
589
591
  const guard = inputsGuard;
590
- const check = await protectInputs(repoDirOf(session), guard.base, guard.inputs).catch((e) => ({
592
+ const check = await protectInputs(repoDirOf(session), guard.base, guard.inputs, guard.keep).catch((e) => ({
591
593
  changed: ['(the input files)'],
592
594
  restored: [],
593
595
  failed: [{ path: '(the input files)', why: e.message }],
@@ -617,6 +619,10 @@ export async function runTask(transport, session, task, deps) {
617
619
  status = 'failed';
618
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('; ')}.`;
619
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
+ }
620
626
  }
621
627
  /*
622
628
  * The session's artifacts, whatever the outcome: copied into this attempt's record, so the
@@ -624,9 +630,14 @@ export async function runTask(transport, session, task, deps) {
624
630
  */
625
631
  const patterns = artifactPatterns(session.vcs);
626
632
  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');
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');
630
641
  await setTask((t) => {
631
642
  t.artifactsKept = kept.kept.length > 0 ? kept.kept : undefined;
632
643
  });
@@ -654,10 +665,26 @@ export async function runTask(transport, session, task, deps) {
654
665
  // The task is re-read first, because the branch was recorded on it after this closure
655
666
  // was created.
656
667
  const fresh = (await store.getSession(session.id))?.tasks.find((x) => x.id === task.id);
668
+ let vcsError;
657
669
  const vcsAfter = await commitTaskResult(session, { ...task, vcs: fresh?.vcs }, { status, summary, reason, deviations }, bus).catch((e) => {
670
+ vcsError = e.message;
658
671
  sink.event('vcs-error', { error: String(e) }, `version control failed after the task: ${e.message}`, 'warn');
659
672
  return undefined;
660
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
+ }
661
688
  /*
662
689
  * The checks about a clean tree, now that the runner has committed. A done task whose tree is
663
690
  * still not clean after the commit has failed; any other ending keeps its own reason, and the
@@ -912,8 +939,23 @@ export async function runTask(transport, session, task, deps) {
912
939
  // The operator's input files, read-only on a branch of the task's own; see `vcs/inputs.ts`.
913
940
  const startNow = (await store.getSession(session.id))?.vcsStart;
914
941
  if (onOwnBranch && startNow?.inputs?.readOnly && startNow.inputs.files.length > 0) {
915
- inputsGuard = { base: prepared.vcs.baseCommit, inputs: startNow.inputs };
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
+ };
916
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);
917
959
  /** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
918
960
  const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
919
961
  /*
@@ -1004,7 +1046,12 @@ export async function runTask(transport, session, task, deps) {
1004
1046
  workDirNote: workingDirNote(work),
1005
1047
  vcsNote: prepared.note,
1006
1048
  readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
1007
- 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'),
1008
1055
  approachesNote: minApproaches > MIN_TRIED_APPROACHES
1009
1056
  ? `## Giving up\n\nThis task may end with status "blocked" only after at least ${minApproaches} genuinely different approaches, ` +
1010
1057
  `each listed in "tried". With fewer, the runner asks you for another approach instead of accepting it.`
@@ -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,8 @@ 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.
466
468
 
467
469
  **userInputs** — files the user provides for the work (schemas, test data):
468
470
  \`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. They go into the commit every session starts from —
@@ -471,11 +473,14 @@ gets them committed on top of it — and they are read-only while the tasks run
471
473
  the user wants the work to change them). Ask the user which files are inputs; never name a whole folder of
472
474
  work, and never \`.env\`. **artifacts** — evidence the work writes (reports, ZIP archives, test results):
473
475
  \`{ "paths": ["rules-engine/test-results/**"] }\`. Never committed, kept with the run; tell tasks to write
474
- evidence there instead of changing \`.gitignore\`.
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.
475
479
 
476
480
  A session with \`"startFrom": "previous-session"\` continues the whole work of the one before it only when
477
481
  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.
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.
479
484
 
480
485
  Put the same \`startFrom\` on every session that works in one repository unless the user wants it
481
486
  mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
@@ -542,8 +547,8 @@ run“, на отделен клон, и сесията тръгва от нег
542
547
  сесията. \`"tracked-only-snapshot"\` взема само tracked промени. Тайни (\`.env\`), изход от инструменти и
543
548
  файлове извън проекта никога не се вземат, а ignored файл — само когато \`scope\` на задача го посочва.
544
549
  Снимката се прави там, където са промените, затова с \`startFrom\` \`"branch"\` само ако хранилището вече е на
545
- този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: контролът на версиите
546
- не влиза в дърво с некомитнати промени.
550
+ този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: дърво с некомитнати
551
+ промени отказва задачата и нищо не се изпълнява.
547
552
 
548
553
  **userInputs** — файлове, които потребителят дава за работата (схеми, тестови данни):
549
554
  \`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. Влизат в комита, от който тръгва всяка сесия —
@@ -552,11 +557,14 @@ run“, на отделен клон, и сесията тръгва от нег
552
557
  потребителят иска работата да ги променя). Питай кои файлове са вход; никога цяла папка с работа и никога
553
558
  \`.env\`. **artifacts** — доказателства, които работата пише (отчети, ZIP архиви, резултати от тестове):
554
559
  \`{ "paths": ["rules-engine/test-results/**"] }\`. Никога не се комитват, пазят се към пускането; кажи на
555
- задачите да пишат доказателствата там, вместо да променят \`.gitignore\`.
560
+ задачите да пишат доказателствата там, вместо да променят \`.gitignore\`. Пази се само каквото задачата създаде
561
+ или промени там, и шаблон за artifacts никога не покрива входните файлове или работата на задача
562
+ ("rules-engine/**" би скрил и двете): посочвай само папките с доказателства.
556
563
 
557
564
  Сесия с \`"startFrom": "previous-session"\` продължава цялата работа на предната само ако тя е
558
565
  \`"branchMode": "per-session"\`: при per-task работата ѝ е на няколко клона и следващата сесия отказва да
559
- тръгне от част от нея.
566
+ тръгне от част от нея. Отказва и когато предната сесия не е завършила (задача е провалена или блокирана):
567
+ първо я завърши.
560
568
 
561
569
  Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
562
570
  различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
@@ -1,5 +1,6 @@
1
1
  import { DEFAULT_VCS, applyDefaultProject } from '../session/store.js';
2
2
  import { normalisePatterns } from '../vcs/inputs.js';
3
+ import { coveredByArtifacts } from '../vcs/artifacts.js';
3
4
  import { availableShells, detectShells } from '../exec/shells.js';
4
5
  /** The heading under which a task's acceptance bar reaches Copilot. English, like level 1. */
5
6
  const EXPECTED_HEADER = '### Expected result';
@@ -183,6 +184,18 @@ defaultProjectDir = '') {
183
184
  }
184
185
  lastIn.set(repo, { name: planned.name, perTask: (planned.vcs.branchMode ?? 'per-task') === 'per-task', tasks: planned.tasks.length });
185
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
+ }
186
199
  for (const planned of plan.sessions) {
187
200
  // Attaching project files to the chat was removed; an old plan's root is still its project folder.
188
201
  if (planned.mirror?.enabled) {
@@ -238,6 +251,7 @@ defaultProjectDir = '') {
238
251
  reviewEnabled: task.review,
239
252
  readOnly: task.readOnly || undefined,
240
253
  scope: task.scope.length > 0 ? task.scope : undefined,
254
+ outputs: task.outputs.length > 0 ? task.outputs : undefined,
241
255
  });
242
256
  titles.push(added.title);
243
257
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import { z } from 'zod';
16
16
  import { namesEverything } from '../vcs/inputs.js';
17
+ import { coveredByArtifacts } from '../vcs/artifacts.js';
17
18
  export const PLAN_VERSION = 1;
18
19
  /**
19
20
  * Version control, exactly as a session stores it, and required on every session.
@@ -140,6 +141,15 @@ const VcsInput = z
140
141
  if (vcs.userInputs && vcs.userInputs.enabled !== false && (vcs.userInputs.paths ?? []).length > 0 && !vcs.enabled) {
141
142
  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
143
  }
144
+ const hidden = coveredByArtifacts(vcs.userInputs?.paths ?? [], vcs.artifacts?.paths ?? []);
145
+ if (hidden.length > 0) {
146
+ ctx.addIssue({
147
+ code: 'custom',
148
+ path: ['artifacts', 'paths'],
149
+ message: `An artifacts pattern covers the input files ${hidden.map((h) => `"${h}"`).join(', ')}: artifacts are kept out of git, so the inputs would be too. ` +
150
+ 'Name only the folders the evidence is written to (for example "rules-engine/test-results/**"), not a whole project folder.',
151
+ });
152
+ }
143
153
  if (vcs.enabled && !vcs.repoDir.trim()) {
144
154
  ctx.addIssue({
145
155
  code: 'custom',
@@ -297,6 +307,11 @@ const TaskInput = z.object({
297
307
  * chat told — which a sentence in the prompt cannot be. See `vcs/scope.ts`.
298
308
  */
299
309
  scope: z.array(z.string().trim().min(1, 'A scope entry needs a path or a pattern.')).default([]),
310
+ /**
311
+ * Files the task is to produce as evidence (reports, archives), repository-relative patterns.
312
+ * Kept with the run's record when the task creates or changes them. See `Task.outputs`.
313
+ */
314
+ outputs: z.array(z.string().trim().min(1, 'An outputs entry needs a path or a pattern.')).default([]),
300
315
  /**
301
316
  * What earlier attempts of this task were asked, written by the plan export for a task that has
302
317
  * been run more than once (`earlierPlans` in `session/exports.ts`). History, not instruction:
@@ -1213,8 +1213,8 @@ export const SYSTEM_GUIDE = [
1213
1213
  kind: 'note',
1214
1214
  en: 'Uncommitted changes',
1215
1215
  bg: 'Некомитнати промени',
1216
- doesEn: 'What the first task does when the repository has uncommitted changes: keep version control out (as until now), or take them — all, or tracked only — as a starting snapshot: one commit on a branch of its own that the session starts from. In a plan: "dirtyWorktree": { "policy": "snapshot" }. Not shown for "Carry on an existing branch".',
1217
- doesBg: 'Какво прави първата задача, когато хранилището има некомитнати промени: без контрол на версиите (както досега) или ги взема — всички или само tracked — като начална снимка: един комит на отделен клон, от който сесията тръгва. В план: "dirtyWorktree": { "policy": "snapshot" }. Не се показва при „Продължи съществуващ клон“.',
1216
+ doesEn: 'What the first task does when the repository has uncommitted changes: refuse to start until they are committed, or take them — all, or tracked only — as a starting snapshot: one commit on a branch of its own that the session starts from. In a plan: "dirtyWorktree": { "policy": "snapshot" }. Not shown for "Carry on an existing branch".',
1217
+ doesBg: 'Какво прави първата задача, когато хранилището има некомитнати промени: отказва старта, докато не са комитнати, или ги взема — всички или само tracked — като начална снимка: един комит на отделен клон, от който сесията тръгва. В план: "dirtyWorktree": { "policy": "snapshot" }. Не се показва при „Продължи съществуващ клон“.',
1218
1218
  },
1219
1219
  {
1220
1220
  key: 'vcs.snapshotTake',
@@ -3241,6 +3241,14 @@ export const SYSTEM_GUIDE = [
3241
3241
  en: 'This machine',
3242
3242
  bg: 'Тази машина',
3243
3243
  controls: [
3244
+ {
3245
+ key: 'sys.version',
3246
+ kind: 'note',
3247
+ en: 'copilot-operator version',
3248
+ bg: 'Версия на copilot-operator',
3249
+ doesEn: 'The first row: which version of the bot is running, as `cop --version` says. Name it when reporting a problem.',
3250
+ doesBg: 'Първият ред: коя версия на бота работи, както я казва `cop --version`. Кажи я, когато съобщаваш за проблем.',
3251
+ },
3244
3252
  {
3245
3253
  key: 'sys.signin',
3246
3254
  kind: 'note',
@@ -22,6 +22,7 @@ import { existsSync } from 'node:fs';
22
22
  import { readFile, writeFile } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
24
  import { TASK_FIELD_ON_RERUN } from './store.js';
25
+ import { botVersion } from '../config/version.js';
25
26
  import { withoutPersona } from '../plan/importPlan.js';
26
27
  const EXPECTED_HEADER = '### Expected result';
27
28
  const GOAL_HEADER = '## Goal of this session';
@@ -171,6 +172,8 @@ export function buildPlanExport(scope) {
171
172
  task.readOnly = true;
172
173
  if (t.scope && t.scope.length > 0)
173
174
  task.scope = [...t.scope];
175
+ if (t.outputs && t.outputs.length > 0)
176
+ task.outputs = [...t.outputs];
174
177
  const earlier = earlierPlans(t, level2);
175
178
  if (earlier.length > 0)
176
179
  task.earlierAttempts = earlier;
@@ -451,6 +454,8 @@ async function domainTask(session, task, runsDir) {
451
454
  session: {
452
455
  id: session.id,
453
456
  name: session.name,
457
+ // The commit the session's tasks are cut from, by the name a report looks for.
458
+ ...(session.vcsBaseCommit ? { baselineCommit: session.vcsBaseCommit } : {}),
454
459
  ...(session.vcsStart
455
460
  ? {
456
461
  start: {
@@ -578,6 +583,8 @@ export async function buildDomainExport(scope, runsDir) {
578
583
  }
579
584
  return {
580
585
  exportedAt: new Date().toISOString(),
586
+ // Which copilot-operator made this, so a report from another machine says it without being asked.
587
+ botVersion: botVersion(),
581
588
  about: `copilot-operator, the work: ${scope.label}. What each task asked, what the chat tried, what was done to the repository, and why a task did not end done. Nothing about the runner itself; that is the bot export.`,
582
589
  counts,
583
590
  tasks,
@@ -649,6 +656,8 @@ export async function buildBotExport(scope, runsDir, machine) {
649
656
  tasks.push(await botTask(session, task, runsDir));
650
657
  return {
651
658
  exportedAt: new Date().toISOString(),
659
+ // Which copilot-operator made this, so a report from another machine says it without being asked.
660
+ botVersion: botVersion(),
652
661
  about: `copilot-operator, the runner: ${scope.label}. The environment, every transcript event, every step with its exit code, the transport's retries, what was reaped, what the review machinery did. Read the domain export for what the task was about.`,
653
662
  machine,
654
663
  tasks,
@@ -680,6 +689,8 @@ export async function buildBundleExport(scope, runsDir, machine) {
680
689
  const pairs = tasksOf(scope);
681
690
  return {
682
691
  exportedAt: new Date().toISOString(),
692
+ // Which copilot-operator made this, so a report from another machine says it without being asked.
693
+ botVersion: botVersion(),
683
694
  about: `copilot-operator, all three views of ${pairs.length} chosen task(s): ${scope.label}. ` +
684
695
  '`plan` is what was asked, in the format that imports again; `work` is what happened to it; ' +
685
696
  '`runner` is what the machine did. Read `work` first, then `runner` when the machine is the suspect.',
@@ -76,6 +76,7 @@ export const TASK_FIELD_ON_RERUN = {
76
76
  reviewEnabled: 'kept',
77
77
  readOnly: 'kept',
78
78
  scope: 'kept',
79
+ outputs: 'kept',
79
80
  // What one review noticed is arithmetic for every attempt after it.
80
81
  reviewChecks: 'kept',
81
82
  attempts: 'kept',
@@ -468,6 +469,7 @@ export class SessionStore {
468
469
  ...(input.readOnly === true ? { readOnly: true } : {}),
469
470
  // Stored only when it limits something: absent means anywhere, as before scopes existed.
470
471
  ...((input.scope ?? []).filter((p) => p.trim()).length > 0 ? { scope: (input.scope ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
472
+ ...((input.outputs ?? []).filter((p) => p.trim()).length > 0 ? { outputs: (input.outputs ?? []).map((p) => p.trim()).filter(Boolean) } : {}),
471
473
  };
472
474
  await this.updateSession(sessionId, (s) => {
473
475
  s.tasks.push(task);
@@ -22,6 +22,21 @@ import { looksGenerated } from './commitHygiene.js';
22
22
  import { git } from './git.js';
23
23
  import { normalisePatterns } from './inputs.js';
24
24
  import { inScope } from './scope.js';
25
+ /**
26
+ * The patterns among `patterns` whose files an artifact pattern would cover, and so keep out of git.
27
+ * Judged by a path the pattern stands for (`**` and `*` filled in). Found in a run on 0.1.18: an
28
+ * artifact pattern of a whole project folder covered the input files and the task's own work, every
29
+ * file the task wrote was ignored, and "nothing to commit" ended it done.
30
+ */
31
+ export function coveredByArtifacts(patterns, artifacts) {
32
+ const arts = normalisePatterns(artifacts);
33
+ if (arts.length === 0)
34
+ return [];
35
+ return normalisePatterns(patterns).filter((p) => {
36
+ const sample = p.replace(/\/+$/, '/f').replace(/\*\*/g, 'a/b').replace(/[*?]/g, 'x');
37
+ return inScope(sample, arts);
38
+ });
39
+ }
25
40
  /** The session's artifact patterns, without ones that name the whole project. */
26
41
  export function artifactPatterns(vcs) {
27
42
  return normalisePatterns(vcs?.artifacts?.paths ?? []);
@@ -59,7 +74,23 @@ export async function excludeArtifacts(dir, session) {
59
74
  const tracked = (await git(resolve(top), ['ls-files'])).stdout.split('\n').filter((p) => p && inScope(p, patterns));
60
75
  return { tracked };
61
76
  }
62
- const LIMITS = { files: 1000, fileBytes: 100 * 1024 * 1024, totalBytes: 500 * 1024 * 1024 };
77
+ const LIMITS = { files: 1000, fileBytes: 100 * 1024 * 1024, totalBytes: 500 * 1024 * 1024, listed: 20_000 };
78
+ /**
79
+ * The artifact files as they are before a task, so that what is kept afterwards is what the task
80
+ * made or changed. Found in a real run: a broad pattern kept every file already under it, evidence
81
+ * of earlier sessions included, as if this task had produced it.
82
+ */
83
+ export async function artifactState(root, patterns) {
84
+ const state = new Map();
85
+ if (patterns.length === 0 || !root)
86
+ return state;
87
+ for (const rel of await matching(root, patterns)) {
88
+ const info = await stat(join(root, rel)).catch(() => null);
89
+ if (info)
90
+ state.set(rel, `${info.size}:${info.mtimeMs}`);
91
+ }
92
+ return state;
93
+ }
63
94
  /** Files under `root` matching the patterns, found on disk: git's view does not matter for evidence. */
64
95
  async function matching(root, patterns) {
65
96
  const out = [];
@@ -73,7 +104,7 @@ async function matching(root, patterns) {
73
104
  return fixed.join('/');
74
105
  }));
75
106
  const walk = async (rel) => {
76
- if (out.length > LIMITS.files * 2)
107
+ if (out.length > LIMITS.listed)
77
108
  return;
78
109
  const abs = join(root, rel);
79
110
  const info = await stat(abs).catch(() => null);
@@ -97,17 +128,25 @@ async function matching(root, patterns) {
97
128
  return [...new Set(out)].sort();
98
129
  }
99
130
  /**
100
- * Copies the artifact files into the attempt's record, `<dest>/<path>`. Secrets are never copied;
101
- * past the limits the rest is skipped and said.
131
+ * Copies into the attempt's record, `<dest>/<path>`, the artifact files this task made or changed:
132
+ * under the session's artifact patterns or the task's own `outputs`, new or changed since `before`
133
+ * (`artifactState` at the task's start), and inside the task's scope when it has one (its outputs
134
+ * always count). A file already there and untouched is not this task's evidence and is not kept.
135
+ * Secrets are never copied; past the limits the rest is skipped and said.
102
136
  */
103
- export async function keepArtifacts(root, patterns, dest) {
137
+ export async function keepArtifacts(root, patterns, dest, opts = {}) {
104
138
  const kept = [];
105
139
  const skipped = [];
106
- if (patterns.length === 0 || !root)
107
- return { kept, skipped };
140
+ const outsideScope = [];
141
+ let unchanged = 0;
142
+ const outputs = normalisePatterns(opts.outputs ?? []);
143
+ const scope = opts.scope ?? [];
144
+ const all = [...new Set([...patterns, ...outputs])];
145
+ if (all.length === 0 || !root)
146
+ return { kept, skipped, unchanged, outsideScope };
108
147
  let total = 0;
109
148
  const base = resolve(root);
110
- for (const rel of await matching(root, patterns)) {
149
+ for (const rel of await matching(root, all)) {
111
150
  const from = resolve(join(root, rel));
112
151
  if (!from.startsWith(base + sep))
113
152
  continue;
@@ -118,6 +157,14 @@ export async function keepArtifacts(root, patterns, dest) {
118
157
  const info = await stat(from).catch(() => null);
119
158
  if (!info)
120
159
  continue;
160
+ if (opts.before && opts.before.get(rel) === `${info.size}:${info.mtimeMs}`) {
161
+ unchanged += 1;
162
+ continue;
163
+ }
164
+ if (scope.length > 0 && !inScope(rel, scope) && !(outputs.length > 0 && inScope(rel, outputs))) {
165
+ outsideScope.push(rel);
166
+ continue;
167
+ }
121
168
  if (kept.length >= LIMITS.files || info.size > LIMITS.fileBytes || total + info.size > LIMITS.totalBytes) {
122
169
  skipped.push(`${rel} (past the limit of ${LIMITS.files} files, ${LIMITS.fileBytes / 1048576} MB a file, ${LIMITS.totalBytes / 1048576} MB in all)`);
123
170
  continue;
@@ -134,5 +181,5 @@ export async function keepArtifacts(root, patterns, dest) {
134
181
  skipped.push(`${rel} (${e.message})`);
135
182
  }
136
183
  }
137
- return { kept, skipped };
184
+ return { kept, skipped, unchanged, outsideScope };
138
185
  }