copilot-operator 0.1.11 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +64 -6
  3. package/dist/src/config/schema.js +14 -3
  4. package/dist/src/context/desktopMirror.js +34 -12
  5. package/dist/src/context/projectMirror.js +69 -1
  6. package/dist/src/orchestrator/contract.js +56 -1
  7. package/dist/src/orchestrator/taskRunner.js +116 -11
  8. package/dist/src/plan/brief.js +20 -8
  9. package/dist/src/plan/systemGuide.js +42 -10
  10. package/dist/src/session/compose.js +16 -1
  11. package/dist/src/session/exportRecord.js +8 -1
  12. package/dist/src/session/handoff.js +41 -3
  13. package/dist/src/session/model.js +9 -0
  14. package/dist/src/session/store.js +11 -7
  15. package/dist/src/session/story.js +74 -18
  16. package/dist/src/transport/chatTransport.js +1 -0
  17. package/dist/src/transport/copilotTransport.js +80 -61
  18. package/dist/src/vcs/git.js +15 -0
  19. package/dist/src/vcs/taskVcs.js +29 -6
  20. package/dist/web/404.html +2 -2
  21. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
  22. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
  23. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +1 -0
  24. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
  25. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +1 -0
  26. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
  27. package/dist/web/_next/static/chunks/app/sessions/view/{page-d7e1f83d13c6356b.js → page-cc99401da5173b36.js} +1 -1
  28. package/dist/web/_next/static/css/{2ad2a74b7e07dcc9.css → a0642fdb8adf9edb.css} +1 -1
  29. package/dist/web/appearance.html +2 -2
  30. package/dist/web/appearance.txt +12 -12
  31. package/dist/web/defaults.html +2 -2
  32. package/dist/web/defaults.txt +12 -12
  33. package/dist/web/history.html +2 -2
  34. package/dist/web/history.txt +12 -12
  35. package/dist/web/import.html +2 -2
  36. package/dist/web/import.txt +12 -12
  37. package/dist/web/index.html +2 -2
  38. package/dist/web/index.txt +12 -12
  39. package/dist/web/level1.html +2 -2
  40. package/dist/web/level1.txt +12 -12
  41. package/dist/web/presets.html +2 -2
  42. package/dist/web/presets.txt +12 -12
  43. package/dist/web/sessions/view.html +2 -2
  44. package/dist/web/sessions/view.txt +12 -12
  45. package/dist/web/system.html +2 -2
  46. package/dist/web/system.txt +12 -12
  47. package/package.json +6 -3
  48. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
  49. package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +0 -1
  50. package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +0 -1
  51. package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +0 -1
  52. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
  53. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
  54. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → bcBE4tY_aNh6kzLzN1luP}/_buildManifest.js +0 -0
  55. /package/dist/web/_next/static/{oyfXbuB_cq0sRZP7ukHlQ → bcBE4tY_aNh6kzLzN1luP}/_ssgManifest.js +0 -0
@@ -457,7 +457,7 @@ let OperatorController = class OperatorController {
457
457
  /** Stores it. A field left out is kept; an empty `rootDir` clears the default. */
458
458
  setProject(body) {
459
459
  return this.ops
460
- .setProject({ rootDir: body?.rootDir, name: body?.name, others: body?.others, mirrorToDesktop: body?.mirrorToDesktop, mirror: body?.mirror })
460
+ .setProject({ rootDir: body?.rootDir, name: body?.name, others: body?.others, mirrorToDesktop: body?.mirrorToDesktop, mirror: body?.mirror, desktop: body?.desktop })
461
461
  .catch(fail);
462
462
  }
463
463
  /**
@@ -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, applyTaskPatch } from '../session/store.js';
21
21
  import { EventBus } from '../session/events.js';
22
- import { newId } from '../session/model.js';
22
+ import { 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';
@@ -235,9 +235,16 @@ let OperatorService = class OperatorService {
235
235
  }
236
236
  async updateSession(id, patch) {
237
237
  await this.init();
238
+ if (patch.active !== undefined && this.running.has(id)) {
239
+ throw new Error('This session is running; its status can be changed once it has stopped.');
240
+ }
238
241
  return await this.store.updateSession(id, (s) => {
239
242
  if (patch.name !== undefined)
240
243
  s.name = patch.name.trim() || s.name;
244
+ if (patch.active === true)
245
+ delete s.active;
246
+ else if (patch.active === false)
247
+ s.active = false;
241
248
  if (patch.onFailure === 'stop' || patch.onFailure === 'continue')
242
249
  s.onFailure = patch.onFailure;
243
250
  // An empty string is a real choice here: it means "leave the chat on whatever it is".
@@ -324,7 +331,7 @@ let OperatorService = class OperatorService {
324
331
  * the text it ran with, and only then is the new text applied. Editing in place would leave
325
332
  * the old attempt's summary sitting under a question that was never asked.
326
333
  */
327
- /** "Continue" on a task that stopped before it finished — its limit, the bot stopping, the operator: same chat, same branch, fresh count. */
334
+ /** "Continue" on a task that stopped before it finished — a limit from the settings, the bot stopping, the operator: same chat, same branch, fresh count. */
328
335
  async continueTask(sessionId, taskId) {
329
336
  await this.init();
330
337
  const task = await this.store.continueTask(sessionId, taskId);
@@ -749,6 +756,10 @@ let OperatorService = class OperatorService {
749
756
  const session = await this.store.getSession(sessionId);
750
757
  if (!session)
751
758
  return { started: false, reason: 'no such session', done: Promise.resolve(idle) };
759
+ // Set aside by the operator on the Sessions page: not started by anything until made active.
760
+ if (session.active === false) {
761
+ return { started: false, reason: 'the session is inactive; make it active on the Sessions page to run it', done: Promise.resolve(idle) };
762
+ }
752
763
  const queuedIds = queuedToRun(session, onlyTasks).map((t) => t.id);
753
764
  if (queuedIds.length === 0)
754
765
  return { started: false, reason: 'no queued tasks', done: Promise.resolve(idle) };
@@ -804,6 +815,8 @@ let OperatorService = class OperatorService {
804
815
  // The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
805
816
  // and the policy.json of a later task must say so.
806
817
  currentMode: () => this.running.get(sessionId)?.mode ?? mode,
818
+ // The Desktop copies follow the work while it happens, not only before the run.
819
+ onWorkChanged: () => this.requestDesktopRefresh(),
807
820
  // The session decides whether its tasks are one chain or a set of independent checks.
808
821
  continueOnFailure: session.onFailure === 'continue',
809
822
  transport,
@@ -1814,7 +1827,7 @@ let OperatorService = class OperatorService {
1814
1827
  const problem = repoUnusableReason(rootDir);
1815
1828
  const others = (cfg.project?.others ?? []).map((o) => {
1816
1829
  const p = repoUnusableReason(o.rootDir);
1817
- return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror };
1830
+ return { name: o.name, rootDir: o.rootDir, repoOk: p === null, ...(p ? { repoProblem: p } : {}), mirror: o.mirror, desktop: o.desktop !== false };
1818
1831
  });
1819
1832
  return {
1820
1833
  rootDir,
@@ -1823,10 +1836,42 @@ let OperatorService = class OperatorService {
1823
1836
  ...(problem ? { repoProblem: problem } : {}),
1824
1837
  others,
1825
1838
  mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
1839
+ desktop: cfg.project?.desktop !== false,
1826
1840
  mirror: cfg.project?.mirror,
1827
1841
  contextRoot: contextRoot(cfg),
1828
1842
  };
1829
1843
  }
1844
+ desktopRefresh = null;
1845
+ desktopRefreshAgain = false;
1846
+ /**
1847
+ * Asks for the Desktop copies to be brought up to date, while a run works.
1848
+ *
1849
+ * They used to be refreshed once, before a run, so a task that changed files left the Desktop —
1850
+ * and OneDrive, and the chat that reads from it — on the code as it was before the task. The
1851
+ * runner now calls this after every round of steps and after every task. One refresh runs at a
1852
+ * time: a request that arrives while one is running is remembered and served by one more pass
1853
+ * after it, so a burst of rounds costs two passes, not one per round, and the last pass always
1854
+ * sees the latest files. A failure is reported and never stops the run.
1855
+ */
1856
+ requestDesktopRefresh() {
1857
+ if (this.desktopRefresh) {
1858
+ this.desktopRefreshAgain = true;
1859
+ return;
1860
+ }
1861
+ this.desktopRefresh = (async () => {
1862
+ do {
1863
+ this.desktopRefreshAgain = false;
1864
+ await this.refreshDesktopMirrors();
1865
+ } while (this.desktopRefreshAgain);
1866
+ })().finally(() => {
1867
+ this.desktopRefresh = null;
1868
+ });
1869
+ }
1870
+ /** Resolves once no Desktop refresh is running or waiting. For the checks, and for a clean stop. */
1871
+ async desktopRefreshSettled() {
1872
+ while (this.desktopRefresh)
1873
+ await this.desktopRefresh;
1874
+ }
1830
1875
  /**
1831
1876
  * Refreshes every project's Desktop folder, when the switch is on. Called before a run
1832
1877
  * starts, so what OneDrive holds when the chat opens is the code as it is now, for every
@@ -1841,8 +1886,8 @@ let OperatorService = class OperatorService {
1841
1886
  this.bus.publish({
1842
1887
  sessionId: '*',
1843
1888
  type: 'desktop-mirror',
1844
- level: 'info',
1845
- message: outcomes.map((o) => `${o.name}: ${describeMirror(o.result)}`).join('; ') || 'no projects to mirror',
1889
+ level: outcomes.some((o) => o.problem) ? 'warn' : 'info',
1890
+ message: outcomes.map((o) => `${o.name}: ${o.result ? describeMirror(o.result) : `not refreshed — ${o.problem}`}`).join('; ') || 'no projects to mirror',
1846
1891
  });
1847
1892
  }
1848
1893
  catch (e) {
@@ -1871,6 +1916,8 @@ let OperatorService = class OperatorService {
1871
1916
  name: (o.name ?? '').trim(),
1872
1917
  rootDir: (o.rootDir ?? '').trim(),
1873
1918
  ...(o.mirror ? { mirror: o.mirror } : {}),
1919
+ // Stored only when it is off: absent means the project goes to the Desktop with the rest.
1920
+ ...(o.desktop === false ? { desktop: false } : {}),
1874
1921
  }));
1875
1922
  const seen = new Set();
1876
1923
  for (const o of others) {
@@ -1889,7 +1936,12 @@ let OperatorService = class OperatorService {
1889
1936
  }
1890
1937
  const mirrorToDesktop = patch.mirrorToDesktop ?? current.mirrorToDesktop ?? false;
1891
1938
  const mirror = patch.mirror ?? current.mirror;
1892
- const cfg = await this.settings.save({ ...raw, project: { ...current, rootDir, name, others, mirrorToDesktop, ...(mirror ? { mirror } : {}) } });
1939
+ const desktop = patch.desktop ?? current.desktop ?? true;
1940
+ const { desktop: _old, ...rest } = current;
1941
+ const cfg = await this.settings.save({
1942
+ ...raw,
1943
+ project: { ...rest, rootDir, name, others, mirrorToDesktop, ...(mirror ? { mirror } : {}), ...(desktop === false ? { desktop: false } : {}) },
1944
+ });
1893
1945
  /*
1894
1946
  * The switch acts at once, both ways. On: every project's folder appears on the Desktop
1895
1947
  * now, not at the next run, because "I turned it on and nothing happened" is a support
@@ -2054,6 +2106,12 @@ let OperatorService = class OperatorService {
2054
2106
  readOnly: t.readOnly || undefined,
2055
2107
  scope: t.scope?.length ? t.scope : undefined,
2056
2108
  stopCode: t.stopCode,
2109
+ limit: t.limit,
2110
+ continuable: isContinuable(t) || undefined,
2111
+ branch: t.vcs?.branch,
2112
+ commit: t.vcs?.commit,
2113
+ vcsProblem: t.vcs?.problem,
2114
+ sessionInactive: s.active === false || undefined,
2057
2115
  autoRetries: t.autoRetries || undefined,
2058
2116
  attempt: t.attempt,
2059
2117
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
@@ -47,10 +47,17 @@ const TextOrFile = z.union([z.string(), z.object({ file: z.string().min(1) })]);
47
47
  /** One of git's own options, before the subcommand: `-C <path>`, `--no-pager`, `-c k=v`. */
48
48
  const GIT_GLOBAL_OPTION = '(?:--no-pager|--paginate|--bare|--literal-pathspecs|--exec-path=\\S*|-c\\s+\\S+|' +
49
49
  '-C\\s+(?:"[^"]*"|\'[^\']*\'|\\S+)|--git-dir[= ]\\S+|--work-tree[= ]\\S+)';
50
- /** Subcommands that have no read-only form at all. */
50
+ /**
51
+ * Subcommands that have no read-only form at all.
52
+ *
53
+ * Matched as a whole subcommand, followed by neither a letter nor a hyphen: `merge` must not catch
54
+ * `merge-base`, which only reads (refused live on 2026-09-30, while a task checked that its branch
55
+ * held a checkpoint). The hyphenated ones that do write are therefore listed by their full name.
56
+ */
51
57
  const GIT_ALWAYS_WRITES = 'commit|push|reset|rebase|merge|cherry-pick|revert|clean|checkout|switch|restore|stash|' +
52
58
  'am|apply|init|clone|filter-branch|filter-repo|update-ref|update-index|gc|prune|repack|' +
53
- 'submodule|worktree|mv|rm|add|pull';
59
+ 'submodule|worktree|mv|rm|add|pull|' +
60
+ 'checkout-index|commit-tree|merge-file|merge-index|merge-one-file|read-tree|prune-packed|update-server-info';
54
61
  /** Subcommands that read by default and write with these arguments. */
55
62
  const GIT_SOMETIMES_WRITES = 'remote\\s+(?:add|remove|rm|rename|set-url|set-head|set-branches|prune)\\b|' +
56
63
  'branch\\s+(?:-[dDmMfu]\\b|--(?:delete|move|copy|force|set-upstream-to|unset-upstream))|' +
@@ -238,7 +245,7 @@ export const RunConfigSchema = z.object({
238
245
  'Disable-WindowsOptionalFeature',
239
246
  'net\\s+user\\s+\\w+\\s+/add',
240
247
  // git that changes something. See GIT_WRITE_NOTE below.
241
- `\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})\\b`,
248
+ `\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_ALWAYS_WRITES})(?![\\w-])`,
242
249
  `\\bgit\\s+(?:${GIT_GLOBAL_OPTION}\\s+)*(?:${GIT_SOMETIMES_WRITES})`,
243
250
  ]),
244
251
  })
@@ -285,6 +292,8 @@ export const RunConfigSchema = z.object({
285
292
  rootDir: z.string().trim().min(1),
286
293
  /** Which of this project's directories go to the Desktop. See `mirror` below. */
287
294
  mirror: ProjectMirrorSelection.optional(),
295
+ /** `false` keeps this project off the Desktop while the switch below is on. Absent means on. */
296
+ desktop: z.boolean().optional(),
288
297
  }))
289
298
  .default([]),
290
299
  /**
@@ -299,6 +308,8 @@ export const RunConfigSchema = z.object({
299
308
  mirrorToDesktop: z.boolean().default(false),
300
309
  /** The default project's own selection. */
301
310
  mirror: ProjectMirrorSelection.optional(),
311
+ /** `false` keeps the default project off the Desktop while the switch is on. Absent means on. */
312
+ desktop: z.boolean().optional(),
302
313
  })
303
314
  .prefault({}),
304
315
  /**
@@ -31,10 +31,11 @@ export function safeProjectName(name) {
31
31
  export function knownProjects(cfg) {
32
32
  const out = [];
33
33
  const root = (cfg.project?.rootDir ?? '').trim();
34
- if (root)
35
- out.push({ name: (cfg.project?.name ?? '').trim() || basename(root) || 'project', rootDir: root, isDefault: true, mirror: cfg.project?.mirror ?? EMPTY_SELECTION });
34
+ if (root) {
35
+ out.push({ name: (cfg.project?.name ?? '').trim() || basename(root) || 'project', rootDir: root, isDefault: true, mirror: cfg.project?.mirror ?? EMPTY_SELECTION, desktop: cfg.project?.desktop !== false });
36
+ }
36
37
  for (const o of cfg.project?.others ?? [])
37
- out.push({ name: o.name, rootDir: o.rootDir, isDefault: false, mirror: o.mirror ?? EMPTY_SELECTION });
38
+ out.push({ name: o.name, rootDir: o.rootDir, isDefault: false, mirror: o.mirror ?? EMPTY_SELECTION, desktop: o.desktop !== false });
38
39
  return out;
39
40
  }
40
41
  /** What to call the folder for a session's project: its Settings name, else the folder's own. */
@@ -88,26 +89,47 @@ export async function mirrorKnownProject(cfg, project) {
88
89
  });
89
90
  return { name, rootDir: project.rootDir, targetDir, result };
90
91
  }
91
- /** Every known project, when the switch is on. Nothing when it is off. */
92
+ /**
93
+ * Every known project that is kept on the Desktop, when the switch is on; nothing when it is off.
94
+ *
95
+ * The switch is the master control and each project has its own tick under it. A project whose
96
+ * tick is off has its Desktop folder removed here, for the same reason the switch going off
97
+ * removes them all: a stale copy of a code base in the cloud is worse than none.
98
+ */
92
99
  export async function mirrorAllProjects(cfg) {
93
100
  if (!cfg.project?.mirrorToDesktop)
94
101
  return [];
95
102
  await removeLegacyFlatMirror(cfg);
96
103
  const out = [];
97
- for (const p of knownProjects(cfg))
98
- out.push(await mirrorKnownProject(cfg, p));
104
+ for (const p of knownProjects(cfg)) {
105
+ // One project whose selection is wrong keeps its old copies and says so; the others are still refreshed.
106
+ if (p.desktop) {
107
+ out.push(await mirrorKnownProject(cfg, p).catch((e) => ({
108
+ name: safeProjectName(p.name),
109
+ rootDir: p.rootDir,
110
+ targetDir: projectTargetDir(cfg, p.name),
111
+ problem: e.message,
112
+ })));
113
+ }
114
+ else
115
+ await removeProjectFolder(cfg, p);
116
+ }
99
117
  return out;
100
118
  }
119
+ async function removeProjectFolder(cfg, p) {
120
+ const dir = projectTargetDir(cfg, p.name);
121
+ const info = await stat(dir).catch(() => null);
122
+ if (!info?.isDirectory())
123
+ return false;
124
+ await rm(dir, { recursive: true, force: true });
125
+ return true;
126
+ }
101
127
  /** Removes every known project's folder from the Desktop. The switch going off. */
102
128
  export async function removeProjectMirrors(cfg) {
103
129
  const removed = [];
104
130
  for (const p of knownProjects(cfg)) {
105
- const dir = projectTargetDir(cfg, p.name);
106
- const info = await stat(dir).catch(() => null);
107
- if (!info?.isDirectory())
108
- continue;
109
- await rm(dir, { recursive: true, force: true });
110
- removed.push(dir);
131
+ if (await removeProjectFolder(cfg, p))
132
+ removed.push(projectTargetDir(cfg, p.name));
111
133
  }
112
134
  return removed;
113
135
  }
@@ -49,6 +49,55 @@ export const DEFAULT_IGNORE_DIRS = [
49
49
  ];
50
50
  export const DEFAULT_SEPARATOR = '--';
51
51
  export const DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024;
52
+ /** A selection that cannot be copied as asked. Thrown before the target folder is touched. */
53
+ export class MirrorSelectionError extends Error {
54
+ constructor(message) {
55
+ super(message);
56
+ this.name = 'MirrorSelectionError';
57
+ }
58
+ }
59
+ /**
60
+ * Each include directory as a path under `rootDir`, looked for in `alsoUnder` when it is not there.
61
+ *
62
+ * `found` are the paths to copy; `missing` are the ones that are nowhere, with why. A directory found
63
+ * in a folder outside `rootDir` is missing too: its files could not be named as the project's.
64
+ */
65
+ export async function resolveIncludeDirs(rootDir, includeDirs, alsoUnder = []) {
66
+ const root = resolve(rootDir);
67
+ const found = [];
68
+ const moved = [];
69
+ const missing = [];
70
+ const isDir = async (p) => !!(await stat(p).catch(() => null))?.isDirectory();
71
+ for (const dir of includeDirs) {
72
+ const rel = normalizeDirPath(dir);
73
+ if (await isDir(join(root, rel))) {
74
+ found.push(rel);
75
+ continue;
76
+ }
77
+ let reason = `not a folder under the project root ${root}`;
78
+ let used = null;
79
+ for (const other of alsoUnder.filter((o) => o.trim() !== '')) {
80
+ const abs = resolve(other, rel);
81
+ if (!(await isDir(abs)))
82
+ continue;
83
+ const underRoot = relative(root, abs);
84
+ if (underRoot.startsWith('..') || resolve(root, underRoot) !== abs) {
85
+ reason = `found under ${resolve(other)}, which is not inside the project root ${root}: make the root that folder`;
86
+ continue;
87
+ }
88
+ used = underRoot.split(osSep).join('/') || '.';
89
+ break;
90
+ }
91
+ if (used) {
92
+ found.push(used);
93
+ moved.push({ asked: rel, used });
94
+ }
95
+ else {
96
+ missing.push({ dir: rel, reason });
97
+ }
98
+ }
99
+ return { found, moved, missing };
100
+ }
52
101
  /** `.\Src\Foo\` and `src/foo` are the same directory. This is the form both lists compare in. */
53
102
  export function normalizeDirPath(dir) {
54
103
  const cleaned = dir
@@ -293,7 +342,26 @@ export async function mirrorProject(cfg) {
293
342
  const targetDir = resolve(cfg.targetDir);
294
343
  const separator = cfg.separator ?? DEFAULT_SEPARATOR;
295
344
  const txtMode = cfg.txtMode ?? 'append';
296
- const { files, skipped } = await collectFiles(cfg);
345
+ /*
346
+ * Checked before the target folder is touched.
347
+ *
348
+ * The folder is brought in line with the selection, which deletes whatever the selection does
349
+ * not hold. A directory that could not be found made the selection empty, and every copy the
350
+ * chat had been working from was deleted (seen live on 2026-09-30: `rules-engine` not found,
351
+ * the whole context folder gone, totalBytes 0). A selection that is wrong is now an error that
352
+ * leaves the copies as they were; so is one that finds no file at all.
353
+ */
354
+ const dirs = await resolveIncludeDirs(rootDir, cfg.includeDirs, cfg.alsoUnder);
355
+ if (dirs.missing.length > 0) {
356
+ throw new MirrorSelectionError(`The project files could not be copied: ${dirs.missing.map((m) => `"${m.dir}" is ${m.reason}`).join('; ')}. ` +
357
+ `Nothing was copied, and the copies already in ${targetDir} were left as they are.`);
358
+ }
359
+ const { files, skipped } = await collectFiles({ ...cfg, includeDirs: dirs.found });
360
+ if (files.length === 0) {
361
+ throw new MirrorSelectionError(`The selection (${cfg.includeDirs.join(', ')}) holds no file that can be copied` +
362
+ `${skipped.length > 0 ? ` (${skipped.length} skipped, for example ${skipped[0].relPath}: ${skipped[0].reason})` : ''}. ` +
363
+ `Nothing was copied, and the copies already in ${targetDir} were left as they are.`);
364
+ }
297
365
  await mkdir(targetDir, { recursive: true });
298
366
  const used = new Set();
299
367
  const desired = new Set();
@@ -28,10 +28,65 @@ async function fileCheckPassesNow(check, cwd) {
28
28
  const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
29
29
  return { passes: exists && text.includes(check.value ?? ''), path };
30
30
  }
31
+ /** `git` with the global options that may come before its subcommand. */
32
+ const GIT = String.raw `\bgit(?:\s+(?:-C\s+(?:"[^"]*"|'[^']*'|\S+)|--no-pager|-c\s+\S+))*\s+`;
33
+ /** A check whose command asks which branch is checked out. */
34
+ export function readsCurrentBranch(check) {
35
+ return !!check.run && new RegExp(`${GIT}(?:branch\\s+--show-current|rev-parse\\s+--abbrev-ref\\s+HEAD|symbolic-ref\\s+(?:-q\\s+)?(?:--short\\s+)?HEAD|name-rev\\s+--name-only\\s+HEAD)`, 'i').test(check.run);
36
+ }
37
+ /**
38
+ * A check whose command asks whether the working tree is clean: `git status --porcelain`/`--short`,
39
+ * `git diff --quiet`/`--exit-code`. With version control on, the runner commits the task's changes
40
+ * after its checks — and the chat may not commit — so such a check is decided after that commit.
41
+ */
42
+ export function readsTreeClean(check) {
43
+ return !!check.run && new RegExp(`${GIT}(?:status\\b[^|;&\\n]*(?:--porcelain|--short|\\s-s\\b)|diff\\b[^|;&\\n]*(?:--quiet|--exit-code))`, 'i').test(check.run);
44
+ }
45
+ /**
46
+ * Whether a check about the checked-out branch can pass on `branch`, the one the runner chose.
47
+ * `null` when it cannot be told without running it.
48
+ */
49
+ function branchCheckPasses(check, branch) {
50
+ const value = check.value ?? '';
51
+ if (check.expect === 'output-contains')
52
+ return value === '' || branch.includes(value);
53
+ if (check.expect === 'output-omits')
54
+ return value === '' || !branch.includes(value);
55
+ if (check.expect === 'output-matches') {
56
+ try {
57
+ return new RegExp(value).test(branch);
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ // A comparison written into the command: `(git branch --show-current) -eq 'recovery/x'`.
64
+ const named = [...(check.run ?? '').matchAll(/['"]([\w.-]+(?:\/[\w.-]+)+)['"]/g)].map((m) => m[1]);
65
+ if (named.length > 0)
66
+ return named.includes(branch);
67
+ return null;
68
+ }
31
69
  /** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
32
- export async function contractConflicts(task, cwd, repoDir) {
70
+ export async function contractConflicts(task, cwd, repoDir,
71
+ /** The branch version control put the task on, when it is on. */
72
+ vcs = {}) {
33
73
  const out = [];
34
74
  const scope = task.scope ?? [];
75
+ /*
76
+ * A check that the task is on a branch the runner did not choose can never pass: the chat may not
77
+ * switch branches. Seen on 2026-09-30, a check still expecting the recovery branch the session had
78
+ * been started from, while the runner worked on its own. Named before the start, with the check
79
+ * that does what was meant: that the earlier work is in this branch.
80
+ */
81
+ if (vcs.branch) {
82
+ const all = [...(task.checks ?? []), ...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check)];
83
+ for (const check of all.filter(readsCurrentBranch)) {
84
+ if (branchCheckPasses(check, vcs.branch) !== false)
85
+ continue;
86
+ out.push(`The check "${check.name}" expects another branch, but version control put this task on ${vcs.branch}, and the chat may not switch branches. ` +
87
+ 'To check that earlier work is included, check its commit instead: git merge-base --is-ancestor <commit> HEAD.');
88
+ }
89
+ }
35
90
  if (task.readOnly && scope.length > 0) {
36
91
  out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
37
92
  }