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.
- package/dist/src/api/operator.controller.js +12 -1
- package/dist/src/api/operator.service.js +92 -7
- package/dist/src/config/schema.js +20 -3
- package/dist/src/context/desktopMirror.js +34 -12
- package/dist/src/context/projectMirror.js +69 -1
- package/dist/src/orchestrator/contract.js +56 -1
- package/dist/src/orchestrator/taskRunner.js +160 -13
- package/dist/src/plan/brief.js +20 -8
- package/dist/src/plan/systemGuide.js +74 -10
- package/dist/src/session/compose.js +17 -2
- package/dist/src/session/exportRecord.js +8 -1
- package/dist/src/session/handoff.js +41 -3
- package/dist/src/session/model.js +9 -0
- package/dist/src/session/store.js +11 -7
- package/dist/src/session/story.js +74 -18
- package/dist/src/transport/chatTransport.js +1 -0
- package/dist/src/transport/copilotTransport.js +89 -8
- package/dist/src/vcs/git.js +15 -0
- package/dist/src/vcs/syncCommand.js +105 -0
- package/dist/src/vcs/taskVcs.js +29 -6
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
- package/dist/web/_next/static/chunks/36-c3dd06c6a459c114.js +1 -0
- package/dist/web/_next/static/chunks/549-a5a59a94f92bb3eb.js +1 -0
- package/dist/web/_next/static/chunks/867-b64c436f5b25f39e.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-5f180a88398be4c9.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-854e3dde672e38a2.js +1 -0
- package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-cc99401da5173b36.js +1 -0
- package/dist/web/_next/static/css/{20c6b36996df5e4c.css → a0642fdb8adf9edb.css} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +12 -12
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +12 -12
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +12 -12
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +12 -12
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +12 -12
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +12 -12
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +12 -12
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +12 -12
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +12 -12
- package/package.json +7 -3
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +0 -1
- package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
- package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +0 -1
- package/dist/web/_next/static/chunks/app/defaults/page-0f88c46dcd9dd52c.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +0 -1
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +0 -1
- /package/dist/web/_next/static/{d6YL07r-SeksvVLrCSZ3o → bcBE4tY_aNh6kzLzN1luP}/_buildManifest.js +0 -0
- /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 —
|
|
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
|
|
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
|
-
/**
|
|
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})\\
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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
|
}
|