copilot-operator 0.1.10 → 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 (58) hide show
  1. package/dist/src/api/operator.controller.js +12 -1
  2. package/dist/src/api/operator.service.js +92 -7
  3. package/dist/src/config/schema.js +20 -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 +160 -13
  8. package/dist/src/plan/brief.js +20 -8
  9. package/dist/src/plan/systemGuide.js +74 -10
  10. package/dist/src/session/compose.js +17 -2
  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 +89 -8
  18. package/dist/src/vcs/git.js +15 -0
  19. package/dist/src/vcs/syncCommand.js +105 -0
  20. package/dist/src/vcs/taskVcs.js +29 -6
  21. package/dist/web/404.html +2 -2
  22. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
  23. package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
  24. package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
  25. package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +1 -0
  26. package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
  27. package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +1 -0
  28. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
  29. package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +1 -0
  30. package/dist/web/_next/static/css/{20c6b36996df5e4c.css → a0642fdb8adf9edb.css} +1 -1
  31. package/dist/web/appearance.html +2 -2
  32. package/dist/web/appearance.txt +12 -12
  33. package/dist/web/defaults.html +2 -2
  34. package/dist/web/defaults.txt +12 -12
  35. package/dist/web/history.html +2 -2
  36. package/dist/web/history.txt +12 -12
  37. package/dist/web/import.html +2 -2
  38. package/dist/web/import.txt +12 -12
  39. package/dist/web/index.html +2 -2
  40. package/dist/web/index.txt +12 -12
  41. package/dist/web/level1.html +2 -2
  42. package/dist/web/level1.txt +12 -12
  43. package/dist/web/presets.html +2 -2
  44. package/dist/web/presets.txt +12 -12
  45. package/dist/web/sessions/view.html +2 -2
  46. package/dist/web/sessions/view.txt +12 -12
  47. package/dist/web/system.html +2 -2
  48. package/dist/web/system.txt +12 -12
  49. package/package.json +7 -3
  50. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
  51. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
  52. package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +0 -1
  53. package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +0 -1
  54. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
  55. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
  56. package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +0 -1
  57. /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → bcBE4tY_aNh6kzLzN1luP}/_buildManifest.js +0 -0
  58. /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → 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
  /**
@@ -466,6 +466,10 @@ let OperatorController = class OperatorController {
466
466
  * Separate from the session's own preflight because that one only speaks when version
467
467
  * control is already on, and the question here is whether it may be turned on.
468
468
  */
469
+ /** The command that brings a project's branch back to its remote's main — shown, never run. */
470
+ repoSync(dir) {
471
+ return this.ops.syncPlan(dir ?? '').catch(fail);
472
+ }
469
473
  repo(dir) {
470
474
  const problem = this.ops.repoProblem(dir ?? '');
471
475
  return problem ? { ok: false, problem } : { ok: true };
@@ -994,6 +998,13 @@ __decorate([
994
998
  __metadata("design:paramtypes", [Object]),
995
999
  __metadata("design:returntype", Promise)
996
1000
  ], OperatorController.prototype, "setProject", null);
1001
+ __decorate([
1002
+ Get('repo/sync'),
1003
+ __param(0, Query('dir')),
1004
+ __metadata("design:type", Function),
1005
+ __metadata("design:paramtypes", [String]),
1006
+ __metadata("design:returntype", Promise)
1007
+ ], OperatorController.prototype, "repoSync", null);
997
1008
  __decorate([
998
1009
  Get('repo'),
999
1010
  __param(0, Query('dir')),
@@ -15,11 +15,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
15
15
  import { Injectable } from '@nestjs/common';
16
16
  import { readFile, readdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
- import { join } from 'node:path';
18
+ 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';
@@ -47,10 +47,17 @@ import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } fro
47
47
  import { unattendedPrecondition } from '../exec/policy.js';
48
48
  import { createTransport } from '../transport/chatTransport.js';
49
49
  import { composeHandoff } from '../session/handoff.js';
50
+ import { planSync } from '../vcs/syncCommand.js';
50
51
  import { computeMetrics } from '../session/metrics.js';
51
52
  import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
52
53
  import { saveAndReveal } from './saveToDesktop.js';
53
54
  import { Settings } from './settings.js';
55
+ /** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
56
+ function normaliseDir(dir) {
57
+ if (!dir.trim())
58
+ return '';
59
+ return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
60
+ }
54
61
  let OperatorService = class OperatorService {
55
62
  /** Where this install keeps its prompts and its records. See `config/layout.ts`. */
56
63
  layout = installLayout();
@@ -228,9 +235,16 @@ let OperatorService = class OperatorService {
228
235
  }
229
236
  async updateSession(id, patch) {
230
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
+ }
231
241
  return await this.store.updateSession(id, (s) => {
232
242
  if (patch.name !== undefined)
233
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;
234
248
  if (patch.onFailure === 'stop' || patch.onFailure === 'continue')
235
249
  s.onFailure = patch.onFailure;
236
250
  // An empty string is a real choice here: it means "leave the chat on whatever it is".
@@ -317,7 +331,7 @@ let OperatorService = class OperatorService {
317
331
  * the text it ran with, and only then is the new text applied. Editing in place would leave
318
332
  * the old attempt's summary sitting under a question that was never asked.
319
333
  */
320
- /** "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. */
321
335
  async continueTask(sessionId, taskId) {
322
336
  await this.init();
323
337
  const task = await this.store.continueTask(sessionId, taskId);
@@ -742,6 +756,10 @@ let OperatorService = class OperatorService {
742
756
  const session = await this.store.getSession(sessionId);
743
757
  if (!session)
744
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
+ }
745
763
  const queuedIds = queuedToRun(session, onlyTasks).map((t) => t.id);
746
764
  if (queuedIds.length === 0)
747
765
  return { started: false, reason: 'no queued tasks', done: Promise.resolve(idle) };
@@ -797,6 +815,8 @@ let OperatorService = class OperatorService {
797
815
  // The mode as it is now, not as it was: "run the rest without asking" changes it mid-run,
798
816
  // and the policy.json of a later task must say so.
799
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(),
800
820
  // The session decides whether its tasks are one chain or a set of independent checks.
801
821
  continueOnFailure: session.onFailure === 'continue',
802
822
  transport,
@@ -1774,6 +1794,26 @@ let OperatorService = class OperatorService {
1774
1794
  repoProblem(dir) {
1775
1795
  return repoUnusableReason(dir);
1776
1796
  }
1797
+ /**
1798
+ * What bringing a project back to its remote's main branch would lose, and the command that does
1799
+ * it — for the operator to run. See `vcs/syncCommand.ts`; nothing here writes.
1800
+ *
1801
+ * Only for a folder this bot already works in: a project in Settings or the repository of a
1802
+ * session. The answer lists file names and commit subjects, and a local page asking about any
1803
+ * folder on the disk is not a question this API should answer.
1804
+ */
1805
+ async syncPlan(dir) {
1806
+ await this.init();
1807
+ const wanted = normaliseDir(dir);
1808
+ const cfg = await this.settings.load();
1809
+ const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.mirror?.rootDir ?? ''])]
1810
+ .filter((d) => d.trim())
1811
+ .map(normaliseDir));
1812
+ if (!wanted || !known.has(wanted)) {
1813
+ throw new Error('That folder is not one of the projects in Settings or the repository of a session, so no command is offered for it.');
1814
+ }
1815
+ return await planSync(resolve(dir.trim()));
1816
+ }
1777
1817
  // --- the project being worked on ---------------------------------------------------------
1778
1818
  /**
1779
1819
  * The folder new sessions start pointed at, and whether it can carry version control.
@@ -1787,7 +1827,7 @@ let OperatorService = class OperatorService {
1787
1827
  const problem = repoUnusableReason(rootDir);
1788
1828
  const others = (cfg.project?.others ?? []).map((o) => {
1789
1829
  const p = repoUnusableReason(o.rootDir);
1790
- 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 };
1791
1831
  });
1792
1832
  return {
1793
1833
  rootDir,
@@ -1796,10 +1836,42 @@ let OperatorService = class OperatorService {
1796
1836
  ...(problem ? { repoProblem: problem } : {}),
1797
1837
  others,
1798
1838
  mirrorToDesktop: cfg.project?.mirrorToDesktop ?? false,
1839
+ desktop: cfg.project?.desktop !== false,
1799
1840
  mirror: cfg.project?.mirror,
1800
1841
  contextRoot: contextRoot(cfg),
1801
1842
  };
1802
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
+ }
1803
1875
  /**
1804
1876
  * Refreshes every project's Desktop folder, when the switch is on. Called before a run
1805
1877
  * starts, so what OneDrive holds when the chat opens is the code as it is now, for every
@@ -1814,8 +1886,8 @@ let OperatorService = class OperatorService {
1814
1886
  this.bus.publish({
1815
1887
  sessionId: '*',
1816
1888
  type: 'desktop-mirror',
1817
- level: 'info',
1818
- 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',
1819
1891
  });
1820
1892
  }
1821
1893
  catch (e) {
@@ -1844,6 +1916,8 @@ let OperatorService = class OperatorService {
1844
1916
  name: (o.name ?? '').trim(),
1845
1917
  rootDir: (o.rootDir ?? '').trim(),
1846
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 } : {}),
1847
1921
  }));
1848
1922
  const seen = new Set();
1849
1923
  for (const o of others) {
@@ -1862,7 +1936,12 @@ let OperatorService = class OperatorService {
1862
1936
  }
1863
1937
  const mirrorToDesktop = patch.mirrorToDesktop ?? current.mirrorToDesktop ?? false;
1864
1938
  const mirror = patch.mirror ?? current.mirror;
1865
- 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
+ });
1866
1945
  /*
1867
1946
  * The switch acts at once, both ways. On: every project's folder appears on the Desktop
1868
1947
  * now, not at the next run, because "I turned it on and nothing happened" is a support
@@ -2027,6 +2106,12 @@ let OperatorService = class OperatorService {
2027
2106
  readOnly: t.readOnly || undefined,
2028
2107
  scope: t.scope?.length ? t.scope : undefined,
2029
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,
2030
2115
  autoRetries: t.autoRetries || undefined,
2031
2116
  attempt: t.attempt,
2032
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
  /**
@@ -399,6 +410,12 @@ export const RunConfigSchema = z.object({
399
410
  * its record. Zero turns it off.
400
411
  */
401
412
  retryBlockedInFreshChat: z.number().int().nonnegative().default(2),
413
+ /*
414
+ * How many genuinely different approaches the chat must have tried before it may end a task
415
+ * "blocked". The reply format's own floor is 2 (replySchema.ts); this raises it. A "blocked"
416
+ * with fewer is sent back asking for another approach (see taskRunner.ts). Settings → Execution.
417
+ */
418
+ minApproachesBeforeBlocked: z.number().int().min(2).max(50).default(2),
402
419
  })
403
420
  .prefault({}),
404
421
  runsDir: z.string().default('./runs'),
@@ -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
  }