copilot-operator 0.1.38 → 0.1.40

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 +128 -2
  2. package/dist/src/api/operator.service.js +473 -39
  3. package/dist/src/api/server.js +7 -0
  4. package/dist/src/log/terminal.js +145 -0
  5. package/dist/src/orchestrator/contract.js +77 -6
  6. package/dist/src/plan/brief.js +1375 -1369
  7. package/dist/src/plan/schema.js +5 -0
  8. package/dist/src/plan/systemGuide.js +218 -20
  9. package/dist/src/session/exports.js +1 -0
  10. package/dist/src/session/startFailure.js +91 -0
  11. package/dist/src/vcs/prepareFromRemote.js +7 -20
  12. package/dist/src/vcs/remoteAuth.js +26 -1
  13. package/dist/src/vcs/runPreflight.js +31 -14
  14. package/dist/src/vcs/snapshot.js +3 -1
  15. package/dist/src/vcs/startChoice.js +295 -0
  16. package/dist/src/vcs/taskVcs.js +61 -5
  17. package/dist/web/404.html +2 -2
  18. package/dist/web/_next/static/chunks/499-28e118e33ce07935.js +1 -0
  19. package/dist/web/_next/static/chunks/{549-7e35e4fdefadb05d.js → 549-f295c8429e83b25b.js} +1 -1
  20. package/dist/web/_next/static/chunks/584-578be94a0786334b.js +1 -0
  21. package/dist/web/_next/static/chunks/867-8dc5bbb092ca2c5c.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-9f69ccde726c9be2.js +1 -0
  23. package/dist/web/_next/static/chunks/app/layout-73f28bc065a46ed3.js +1 -0
  24. package/dist/web/_next/static/chunks/app/page-e557d58476ae6383.js +1 -0
  25. package/dist/web/_next/static/chunks/app/sessions/view/page-0485ee7f781156ec.js +1 -0
  26. package/dist/web/_next/static/chunks/app/system/{page-5ae1f8054208e6eb.js → page-227a259e383ca2a0.js} +1 -1
  27. package/dist/web/_next/static/css/cea7913ba416dbb7.css +1 -0
  28. package/dist/web/appearance.html +2 -2
  29. package/dist/web/appearance.txt +28 -26
  30. package/dist/web/defaults.html +2 -2
  31. package/dist/web/defaults.txt +28 -26
  32. package/dist/web/history.html +2 -2
  33. package/dist/web/history.txt +28 -26
  34. package/dist/web/import.html +2 -2
  35. package/dist/web/import.txt +28 -26
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/index.txt +28 -26
  38. package/dist/web/level1.html +2 -2
  39. package/dist/web/level1.txt +28 -26
  40. package/dist/web/presets.html +2 -2
  41. package/dist/web/presets.txt +28 -26
  42. package/dist/web/sessions/view.html +2 -2
  43. package/dist/web/sessions/view.txt +28 -26
  44. package/dist/web/system.html +2 -2
  45. package/dist/web/system.txt +28 -26
  46. package/package.json +158 -156
  47. package/dist/web/_next/static/chunks/36-94660f1cde2da529.js +0 -1
  48. package/dist/web/_next/static/chunks/867-d4f5bd8c5805bffb.js +0 -1
  49. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +0 -1
  50. package/dist/web/_next/static/chunks/app/layout-630b663626cebe4a.js +0 -1
  51. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +0 -1
  52. package/dist/web/_next/static/chunks/app/sessions/view/page-ce99494b1997ee25.js +0 -1
  53. package/dist/web/_next/static/css/c37978b44194c37b.css +0 -1
  54. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_buildManifest.js +0 -0
  55. /package/dist/web/_next/static/{8C2D7RnTerPO3g_WyLEC3 → Y9tuV5LV-O0XyCREkeGc5}/_ssgManifest.js +0 -0
@@ -27,7 +27,7 @@ import { join, resolve } from 'node:path';
27
27
  import { branchNameFrom, describeUpdate, freeBranchName, git, updateFromRemote } from './git.js';
28
28
  import { commitOnto, dirtyBesidesInputs, inputSettings, inputSumsAt, missingInputPatterns } from './inputs.js';
29
29
  import { approvalMismatch, dirtyPolicy, takeSnapshot } from './snapshot.js';
30
- import { repoDirOf, sessionBranchName, vcsPreflight } from './taskVcs.js';
30
+ import { baseRefOf, repoDirOf, sessionBranchName, vcsPreflight } from './taskVcs.js';
31
31
  import { artifactPatterns } from './artifacts.js';
32
32
  import { inScope } from './scope.js';
33
33
  import { findSuspicious } from './commitHygiene.js';
@@ -58,8 +58,15 @@ async function groupFor(list, allSessions) {
58
58
  const branch = branchR.ok && branchR.stdout !== 'HEAD' ? branchR.stdout : null;
59
59
  const head = headR.ok && headR.stdout ? headR.stdout : null;
60
60
  const startFrom = first.vcs?.startFrom ?? 'head';
61
- const baseBranch = startFrom === 'branch' || startFrom === 'previous-session' ? first.vcs?.baseBranch?.trim() || 'main' : undefined;
62
- const baseHead = baseBranch ? (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${baseBranch}^{commit}`])).stdout || null : undefined;
61
+ /*
62
+ * The branch the sessions start from, by its name on screen (`origin/x` for a remote branch chosen in
63
+ * the start window) and the ref git reads for it: a remote-tracking branch is never the local one of
64
+ * the same name, and is not brought up to anything here — the start window fetched it.
65
+ */
66
+ const base = startFrom === 'branch' || startFrom === 'previous-session' ? baseRefOf(first.vcs) : undefined;
67
+ const baseBranch = base?.name;
68
+ const baseHead = base ? (await git(repoDir, ['rev-parse', '--verify', '--quiet', `${base.ref}^{commit}`])).stdout || null : undefined;
69
+ const updates = first.vcs?.updateFromRemote !== false && !base?.remote;
63
70
  const settings = inputSettings(first.vcs);
64
71
  const workName = first.vcs?.startFrom === 'existing-branch'
65
72
  ? first.vcs.existingBranch?.trim()
@@ -69,7 +76,7 @@ async function groupFor(list, allSessions) {
69
76
  const workBranch = workName ? { name: workName, head: (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${workName}^{commit}`])).stdout || null } : undefined;
70
77
  const sessions = list.map((s, i) => {
71
78
  const sf = s.vcs?.startFrom ?? 'head';
72
- const role = i === 0 ? 'first' : sf === 'previous-session' ? 'inherits' : sf === 'branch' && (s.vcs?.baseBranch?.trim() || 'main') === baseBranch && !s.vcsBaseCommit ? 'shares' : 'own';
79
+ const role = i === 0 ? 'first' : sf === 'previous-session' ? 'inherits' : sf === 'branch' && baseRefOf(s.vcs).name === baseBranch && !s.vcsBaseCommit ? 'shares' : 'own';
73
80
  // Started means a task of it ran; a start recorded by the panel (a snapshot) is not that (live run 2026-10-03).
74
81
  const started = s.tasks.some((t) => !!t.startedAt || (t.attempts ?? []).some((a) => !!a.startedAt));
75
82
  return { id: s.id, name: s.name, startFrom: sf, started, hasStart: !!s.vcsBaseCommit, role };
@@ -111,16 +118,21 @@ async function groupFor(list, allSessions) {
111
118
  * The branches the sessions name, before the browser opens rather than at their first task: a
112
119
  * branch to carry on that is not there, a base branch that is not there.
113
120
  */
121
+ const otherThanStart = (!!problem && !pre.startProblem) || blocking;
122
+ let branchMissing = false;
114
123
  for (const s of list) {
115
124
  if (s.vcsBaseCommit)
116
125
  continue;
117
- const named = s.vcs?.startFrom === 'existing-branch' ? s.vcs.existingBranch?.trim() : s.vcs?.startFrom === 'branch' ? s.vcs.baseBranch?.trim() || 'main' : '';
118
- if (!named)
126
+ const named = s.vcs?.startFrom === 'existing-branch' ? { ref: `refs/heads/${s.vcs.existingBranch?.trim() ?? ''}`, name: s.vcs.existingBranch?.trim() ?? '', remote: undefined } : s.vcs?.startFrom === 'branch' ? baseRefOf(s.vcs) : null;
127
+ if (!named?.name)
119
128
  continue;
120
- const there = (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${named}^{commit}`])).stdout;
129
+ const there = (await git(repoDir, ['rev-parse', '--verify', '--quiet', `${named.ref}^{commit}`])).stdout;
121
130
  if (!there) {
122
131
  blocking = true;
123
- problem = `${problem ? `${problem} ` : ''}"${s.name}" is set to ${s.vcs?.startFrom === 'existing-branch' ? 'carry on' : 'start from'} the local branch "${named}", and there is no such branch: create it, or change "Start from" on the session's page.`;
132
+ branchMissing = true;
133
+ problem =
134
+ `${problem ? `${problem} ` : ''}"${s.name}" is set to ${s.vcs?.startFrom === 'existing-branch' ? 'carry on' : 'start from'} the ${named.remote ? 'remote' : 'local'} branch "${named.name}", and there is no such branch` +
135
+ (named.remote ? ' here: start the run again and choose it in the start window, which fetches it.' : ': create it, or choose another branch when you start the run.');
124
136
  }
125
137
  }
126
138
  const noCommits = first.vcs?.commitOnFinish === false;
@@ -136,7 +148,7 @@ async function groupFor(list, allSessions) {
136
148
  }
137
149
  if (baseBranch && !first.vcsBaseCommit && inputs.length > 0) {
138
150
  const why = !baseHead
139
- ? `there is no local branch "${baseBranch}"`
151
+ ? `there is no ${base?.remote ? 'fetched copy of the remote' : 'local'} branch "${baseBranch}"`
140
152
  : unrelated.length > 0
141
153
  ? `besides the input files there are other uncommitted changes (${unrelated.slice(0, 5).map((u) => u.path).join(', ')}${unrelated.length > 5 ? '…' : ''}): commit or stash them first${branch !== baseBranch ? ', or use the current branch instead' : ', or take them as a starting snapshot'}`
142
154
  : blockedInputs.length > 0
@@ -151,11 +163,11 @@ async function groupFor(list, allSessions) {
151
163
  recommended: !why,
152
164
  ...(why ? { why } : {}),
153
165
  // The update from the remote said first: it moves the base branch, and the checkout with it when that is the branch checked out (live run 2026-10-04).
154
- result: (first.vcs?.updateFromRemote !== false
166
+ result: (updates
155
167
  ? `First brings "${baseBranch}" up to its remote, fast-forward only${branch === baseBranch ? ', which moves your checkout with it' : ''}; then commits`
156
168
  : 'Commits') +
157
- ` the ${inputs.length} input file(s) on top of "${baseBranch}" (${short(baseHead)}${first.vcs?.updateFromRemote !== false ? ' now, or where the update takes it' : ''}) as "Capture user-provided inputs", on the new branch ${baselineName}, ` +
158
- `without switching branches: your checkout${branch ? ` of "${branch}"` : ''} and the files stay as they are${first.vcs?.updateFromRemote !== false && branch === baseBranch ? ', apart from that update' : ''}. ` +
169
+ ` the ${inputs.length} input file(s) on top of "${baseBranch}" (${short(baseHead)}${updates ? ' now, or where the update takes it' : ''}) as "Capture user-provided inputs", on the new branch ${baselineName}, ` +
170
+ `without switching branches: your checkout${branch ? ` of "${branch}"` : ''} and the files stay as they are${updates && branch === baseBranch ? ', apart from that update' : ''}. ` +
159
171
  `${names(sharing)} ${verb(sharing, 'starts', 'start')} from it${inheriting.length > 0 ? `; ${names(inheriting)} ${verb(inheriting, 'carries', 'carry')} it on through the chain` : ''}.`,
160
172
  });
161
173
  }
@@ -218,6 +230,7 @@ async function groupFor(list, allSessions) {
218
230
  head,
219
231
  ...(baseBranch ? { baseBranch, baseHead } : {}),
220
232
  ...(workBranch && workBranch.name !== branch ? { workBranch } : {}),
233
+ ...(!ready && !otherThanStart && (branchMissing || pre.startProblem) ? { startChoiceCanFix: true } : {}),
221
234
  ...(settings && inputs.length === 0 && !first.vcsBaseCommit && !blocking
222
235
  ? { carry: `the input files (${settings.patterns.join(', ')}) are not in the working tree: when the first task starts they are committed on top of its start as ${prefix}input/${first.id}, from the latest earlier capture of them` }
223
236
  : {}),
@@ -257,6 +270,8 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
257
270
  if (x.vcs) {
258
271
  x.vcs.startFrom = 'branch';
259
272
  x.vcs.baseBranch = group.branch ?? undefined;
273
+ // The branch the repository is on is a local one.
274
+ delete x.vcs.baseRemote;
260
275
  }
261
276
  x.vcsBaseCommit = undefined;
262
277
  x.vcsStart = undefined;
@@ -289,8 +304,10 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
289
304
  * branch does: the snapshot is the commit every session here starts from, and it skipped the update
290
305
  * without a word, so a run cut its work from a main the team had already moved past (live run 2026-10-03).
291
306
  */
292
- const update = first.vcs?.updateFromRemote !== false ? await updateFromRemote(top, group.baseBranch) : undefined;
293
- const baseHead = (await git(top, ['rev-parse', '--verify', '--quiet', `refs/heads/${group.baseBranch}^{commit}`])).stdout || group.baseHead;
307
+ // Not a remote branch chosen in the start window: that was fetched there, and has no local branch to move.
308
+ const baseRef = baseRefOf(first.vcs);
309
+ const update = first.vcs?.updateFromRemote !== false && !baseRef.remote ? await updateFromRemote(top, group.baseBranch) : undefined;
310
+ const baseHead = (await git(top, ['rev-parse', '--verify', '--quiet', `${baseRef.ref}^{commit}`])).stdout || group.baseHead;
294
311
  const files = [];
295
312
  for (const path of paths) {
296
313
  const blob = await git(top, ['hash-object', '-w', `--path=${path}`, '--', join(top, path)]);
@@ -119,7 +119,9 @@ async function startProblem(session, dir, branch, head, allSessions) {
119
119
  const commit = resolved.start?.commit;
120
120
  if (!commit || commit === head)
121
121
  return undefined;
122
- const wanted = resolved.start?.kind === 'previous-session' ? `the end of "${resolved.start.fromSession?.name ?? ''}"'s branch ${resolved.start.branch}` : `the local branch ${resolved.start?.branch ?? ''}`;
122
+ const wanted = resolved.start?.kind === 'previous-session'
123
+ ? `the end of "${resolved.start.fromSession?.name ?? ''}"'s branch ${resolved.start.branch}`
124
+ : `the ${resolved.start?.remote ? 'remote' : 'local'} branch ${resolved.start?.branch ?? ''}`;
123
125
  return (`the changes are on ${where}, and this session is set to start from ${wanted} (${commit.slice(0, 8)}). ` +
124
126
  'A snapshot is taken where the changes are, so the session would not start where it was told to. ' +
125
127
  `Put the repository on ${resolved.start?.branch ?? 'that branch'} first, or choose "From wherever the repository is (as until now)".`);
@@ -0,0 +1,295 @@
1
+ import { git, isAncestor, isValidBranchName, lastFetchedAt } from './git.js';
2
+ import { fetchAsOperator, fetchCommandFor } from './remoteAuth.js';
3
+ import { describeStart, remoteTip, repoDirOf } from './taskVcs.js';
4
+ const lines = (s) => s.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
5
+ const norm = (dir) => dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
6
+ /** A task of the session has run: a start recorded since then is the one its work stands on. */
7
+ export function startIsFixed(session) {
8
+ const ran = session.tasks.some((t) => !!t.startedAt || (t.attempts ?? []).some((a) => !!a.startedAt));
9
+ return !!session.vcsBaseCommit && ran;
10
+ }
11
+ /** The branches of the repository at `dir`, read without touching the network. */
12
+ export async function repoBranches(dir) {
13
+ const empty = (problem) => ({ repoDir: dir, problem, current: null, head: null, local: [], remotes: [] });
14
+ const inside = await git(dir, ['rev-parse', '--is-inside-work-tree']);
15
+ if (!inside.ok || inside.stdout !== 'true')
16
+ return empty(`${dir} is not a git repository.`);
17
+ const current = await git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
18
+ const head = await git(dir, ['rev-parse', '--verify', '--quiet', 'HEAD']);
19
+ const refs = await git(dir, ['for-each-ref', '--format=%(objectname) %(refname)', 'refs/heads', 'refs/remotes']);
20
+ const remoteNames = lines((await git(dir, ['remote'])).stdout);
21
+ const local = [];
22
+ const byRemote = new Map(remoteNames.map((r) => [r, []]));
23
+ for (const line of refs.ok ? lines(refs.stdout) : []) {
24
+ const [commit, ref] = [line.slice(0, line.indexOf(' ')), line.slice(line.indexOf(' ') + 1)];
25
+ if (ref.startsWith('refs/heads/'))
26
+ local.push({ name: ref.slice('refs/heads/'.length), commit });
27
+ else if (ref.startsWith('refs/remotes/')) {
28
+ const rest = ref.slice('refs/remotes/'.length);
29
+ const remote = remoteNames.find((r) => rest.startsWith(`${r}/`));
30
+ const name = remote ? rest.slice(remote.length + 1) : '';
31
+ // `origin/HEAD` points at a branch; it is not one.
32
+ if (!remote || !name || name === 'HEAD')
33
+ continue;
34
+ byRemote.get(remote)?.push({ name, commit });
35
+ }
36
+ }
37
+ const gitDir = (await git(dir, ['rev-parse', '--absolute-git-dir'])).stdout;
38
+ const fetched = gitDir ? lastFetchedAt(gitDir) : undefined;
39
+ return {
40
+ repoDir: dir,
41
+ current: current.ok && current.stdout ? current.stdout : null,
42
+ head: head.ok && head.stdout ? head.stdout : null,
43
+ local,
44
+ remotes: [...byRemote.entries()].map(([name, branches]) => ({ name, branches })),
45
+ ...(fetched ? { lastFetched: fetched } : {}),
46
+ };
47
+ }
48
+ /** The remote a branch is most likely wanted from: `origin`, else the first one. */
49
+ export function defaultRemote(repo) {
50
+ return repo.remotes.find((r) => r.name === 'origin')?.name ?? repo.remotes[0]?.name;
51
+ }
52
+ /**
53
+ * What the start window offers first for a session: its own setting, as one of the choices. A local
54
+ * branch that is not here is offered from the remote when the remote has it, else the branch the
55
+ * repository is on — a start from wherever it happens to be (`head`) is offered as that branch, which
56
+ * is what it would have been.
57
+ */
58
+ export function defaultChoice(session, repo) {
59
+ const vcs = session.vcs;
60
+ const how = vcs?.startFrom ?? 'head';
61
+ if (how === 'previous-session')
62
+ return { from: 'previous-session' };
63
+ const onBranch = how === 'existing-branch';
64
+ // A branch the session names, as against one assumed (`main`, or wherever the repository is).
65
+ const named = (how === 'head' ? '' : (onBranch ? vcs?.existingBranch : vcs?.baseBranch)?.trim()) ?? '';
66
+ const wanted = named || (how === 'head' ? repo.current ?? '' : 'main');
67
+ const remote = vcs?.baseRemote?.trim();
68
+ const hasRemote = (r, b) => !!repo.remotes.find((x) => x.name === r)?.branches.some((x) => x.name === b);
69
+ if (remote && hasRemote(remote, wanted))
70
+ return { from: 'remote', remote, branch: wanted, ...(onBranch ? { onBranch } : {}) };
71
+ if (wanted && repo.local.some((b) => b.name === wanted))
72
+ return { from: 'local', branch: wanted, ...(onBranch ? { onBranch } : {}) };
73
+ const other = remote && repo.remotes.some((x) => x.name === remote) ? remote : defaultRemote(repo);
74
+ if (other && wanted && hasRemote(other, wanted))
75
+ return { from: 'remote', remote: other, branch: wanted, ...(onBranch ? { onBranch } : {}) };
76
+ /*
77
+ * Named, and neither here nor fetched yet — a branch a colleague pushed after the last fetch: offered from
78
+ * the remote all the same, which the window fetches by name. Offered as the branch the repository is on,
79
+ * it was started from that instead with one press, and the session's own branch was written over.
80
+ */
81
+ if (named && other)
82
+ return { from: 'remote', remote: other, branch: named, ...(onBranch ? { onBranch } : {}) };
83
+ return { from: 'local', branch: repo.current ?? repo.local[0]?.name ?? wanted };
84
+ }
85
+ /**
86
+ * The sessions of a run as the start window shows them, in run order, with the branches of each
87
+ * repository they work in.
88
+ */
89
+ export async function startOptions(sessions) {
90
+ const repos = new Map();
91
+ const out = [];
92
+ const seenRepos = new Set();
93
+ for (const s of sessions) {
94
+ const dir = s.vcs?.enabled ? repoDirOf(s) : '';
95
+ if (!dir) {
96
+ out.push({ id: s.id, name: s.name, vcs: false, previousOffered: false });
97
+ continue;
98
+ }
99
+ const key = norm(dir);
100
+ if (!repos.has(key))
101
+ repos.set(key, await repoBranches(dir));
102
+ const repo = repos.get(key);
103
+ const earlierHere = seenRepos.has(key);
104
+ seenRepos.add(key);
105
+ const entry = {
106
+ id: s.id,
107
+ name: s.name,
108
+ vcs: true,
109
+ repoDir: repo.repoDir,
110
+ previousOffered: s.vcs?.startFrom === 'previous-session' || earlierHere,
111
+ };
112
+ if (startIsFixed(s) && s.vcsStart)
113
+ entry.fixed = describeStart(s.vcsStart);
114
+ else if (startIsFixed(s))
115
+ entry.fixed = `it started from ${s.vcsBaseCommit?.slice(0, 8)}`;
116
+ else {
117
+ if (s.vcsStart)
118
+ entry.prepared = describeStart(s.vcsStart);
119
+ if (!repo.problem)
120
+ entry.choice = defaultChoice(s, repo);
121
+ }
122
+ out.push(entry);
123
+ }
124
+ return { repos: [...repos.values()], sessions: out };
125
+ }
126
+ /**
127
+ * Checks a choice against the repository and works out what it sets. Nothing is written to the session
128
+ * here; for `remote` with `onBranch` the local branch is made, or moved forward, since that is the
129
+ * choice — and only then, after everything else about it has been checked, and never with `dry`, which
130
+ * only says whether it could be done (every session of a run is checked so before any branch moves).
131
+ */
132
+ export async function applyStartChoice(session, choice, opts = {}) {
133
+ // Nothing to choose without version control, or once the start is fixed: see `startIsFixed`.
134
+ if (!session.vcs?.enabled || startIsFixed(session))
135
+ return { ok: true };
136
+ const dir = repoDirOf(session);
137
+ const before = pointOf(session.vcs);
138
+ if (choice.from === 'previous-session') {
139
+ const vcs = { startFrom: 'previous-session' };
140
+ return { ok: true, applied: { vcs, clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit, said: `"${session.name}" carries on the session before it in this repository` } };
141
+ }
142
+ const name = choice.branch.trim();
143
+ if (!name || !(await isValidBranchName(dir, name)))
144
+ return { ok: false, problem: `"${choice.branch}" is not a branch name git accepts.` };
145
+ if (choice.from === 'local') {
146
+ const tip = (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}^{commit}`])).stdout;
147
+ if (!tip)
148
+ return { ok: false, problem: `${dir} has no local branch "${name}". Choose one it has, or the remote branch of that name.` };
149
+ const vcs = choice.onBranch
150
+ ? { startFrom: 'existing-branch', existingBranch: name, baseRemote: undefined, updateFromRemote: false }
151
+ : { startFrom: 'branch', baseBranch: name, baseRemote: undefined, existingBranch: undefined, updateFromRemote: false };
152
+ return {
153
+ ok: true,
154
+ applied: {
155
+ vcs,
156
+ clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit,
157
+ said: choice.onBranch
158
+ ? `"${session.name}" works on the local branch ${name} itself (${tip.slice(0, 8)}), as it is here`
159
+ : `"${session.name}" starts from the local branch ${name} (${tip.slice(0, 8)}), as it is here`,
160
+ },
161
+ };
162
+ }
163
+ const remote = choice.remote.trim();
164
+ const remotes = lines((await git(dir, ['remote'])).stdout);
165
+ if (!remotes.includes(remote))
166
+ return { ok: false, problem: `${dir} has no remote called "${remote}"${remotes.length > 0 ? ` (it has: ${remotes.join(', ')})` : ''}.` };
167
+ const theirs = await remoteTip(dir, remote, name);
168
+ if (!theirs)
169
+ return { ok: false, problem: `${remote}/${name} is not known here: it was not fetched. Start again, and it is fetched first.` };
170
+ if (!choice.onBranch) {
171
+ const vcs = { startFrom: 'branch', baseBranch: name, baseRemote: remote, existingBranch: undefined, updateFromRemote: false };
172
+ return {
173
+ ok: true,
174
+ applied: {
175
+ vcs,
176
+ clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit,
177
+ said: `"${session.name}" starts from the remote branch ${remote}/${name} (${theirs.slice(0, 8)}), as fetched now; the local branch ${name} is not touched`,
178
+ },
179
+ };
180
+ }
181
+ // Work on the branch itself: the local branch made from the remote one, or brought up to it.
182
+ const ours = (await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${name}^{commit}`])).stdout;
183
+ let note;
184
+ if (!ours) {
185
+ // By its full name: `origin/x` alone is looked up among tags and local branches first.
186
+ const made = opts.dry ? { ok: true, stdout: '', stderr: '' } : await git(dir, ['branch', '--track', name, `refs/remotes/${remote}/${name}`]);
187
+ if (!made.ok)
188
+ return { ok: false, problem: `the local branch ${name} could not be made from ${remote}/${name}: ${(made.stderr || made.stdout).split(/\r?\n/)[0]}` };
189
+ note = `made the local branch ${name} from ${remote}/${name} (${theirs.slice(0, 8)})`;
190
+ }
191
+ else if (ours === theirs) {
192
+ note = `the local branch ${name} is already where ${remote}/${name} is (${theirs.slice(0, 8)})`;
193
+ }
194
+ else if (await isAncestor(dir, ours, theirs)) {
195
+ const current = (await git(dir, ['symbolic-ref', '--quiet', '--short', 'HEAD'])).stdout;
196
+ if (current === name) {
197
+ const dirty = (await git(dir, ['status', '--porcelain', '--untracked-files=no'])).stdout.trim();
198
+ if (dirty)
199
+ return { ok: false, problem: `${name} is checked out with uncommitted changes, so it cannot be brought up to ${remote}/${name}. Commit or stash them first; nothing was changed.` };
200
+ }
201
+ const moved = opts.dry
202
+ ? { ok: true, stdout: '', stderr: '' }
203
+ : current === name
204
+ ? await git(dir, ['merge', '--ff-only', '--quiet', theirs])
205
+ : await git(dir, ['update-ref', '-m', `copilot-operator: brought up to ${remote}/${name} in the start window`, `refs/heads/${name}`, theirs, ours]);
206
+ if (!moved.ok)
207
+ return { ok: false, problem: `${name} could not be brought up to ${remote}/${name}: ${(moved.stderr || moved.stdout).split(/\r?\n/)[0]}. Nothing else was changed.` };
208
+ note = `brought the local branch ${name} up to ${remote}/${name}: ${ours.slice(0, 8)} → ${theirs.slice(0, 8)}, fast-forward`;
209
+ }
210
+ else if (await isAncestor(dir, theirs, ours)) {
211
+ note = `the local branch ${name} has commits ${remote}/${name} does not; it was left as it is (${ours.slice(0, 8)}) and the work goes on top of them`;
212
+ }
213
+ else {
214
+ return {
215
+ ok: false,
216
+ problem: `the local branch ${name} (${ours.slice(0, 8)}) and ${remote}/${name} (${theirs.slice(0, 8)}) have each gone their own way, so working on it would mean choosing one of them. ` +
217
+ `Nothing was changed. Start from ${remote}/${name} on a new branch instead, or bring the two together yourself first.`,
218
+ };
219
+ }
220
+ const vcs = { startFrom: 'existing-branch', existingBranch: name, baseRemote: remote, updateFromRemote: false };
221
+ return {
222
+ ok: true,
223
+ applied: {
224
+ vcs,
225
+ clearStart: changed(before, { ...session.vcs, ...vcs }) && !!session.vcsBaseCommit,
226
+ note,
227
+ said: `"${session.name}" works on the branch ${name} itself, from ${remote}/${name} as fetched now`,
228
+ },
229
+ };
230
+ }
231
+ /** Where a session's settings say it starts: the fields that decide the commit, nothing else. */
232
+ function pointOf(vcs) {
233
+ return JSON.stringify([vcs?.startFrom ?? 'head', vcs?.baseBranch?.trim() || 'main', vcs?.baseRemote?.trim() ?? '', vcs?.existingBranch?.trim() ?? '']);
234
+ }
235
+ function changed(before, after) {
236
+ return before !== pointOf(after);
237
+ }
238
+ /** The fields of an `AppliedChoice` written onto a session's version control: `undefined` removes one. */
239
+ export function withChoice(vcs, patch) {
240
+ const out = { ...vcs };
241
+ for (const [k, v] of Object.entries(patch)) {
242
+ if (v === undefined)
243
+ delete out[k];
244
+ else
245
+ out[k] = v;
246
+ }
247
+ return out;
248
+ }
249
+ /**
250
+ * The refspecs that fetch exactly these branches of `remote` into their remote-tracking branches, or
251
+ * why one of the names cannot be fetched.
252
+ */
253
+ export async function refspecsFor(dir, remote, branches) {
254
+ const refspecs = [];
255
+ for (const b of [...new Set(branches.map((x) => x.trim()).filter(Boolean))]) {
256
+ if (!(await isValidBranchName(dir, b)))
257
+ return { problem: `"${b}" is not a branch name git accepts.` };
258
+ refspecs.push(`+refs/heads/${b}:refs/remotes/${remote}/${b}`);
259
+ }
260
+ return { refspecs };
261
+ }
262
+ /** The branch names a failed fetch said the remote does not have. */
263
+ export function missingOnRemote(detail) {
264
+ return [...detail.matchAll(/couldn't find remote ref (?:refs\/heads\/)?(\S+)/gi)].map((m) => m[1].replace(/[.,;:]+$/, ''));
265
+ }
266
+ /** Where git keeps the repository's records, for `lastFetchedAt`. */
267
+ export async function gitDirOf(dir) {
268
+ const r = await git(dir, ['rev-parse', '--absolute-git-dir']);
269
+ return r.ok && r.stdout ? r.stdout : null;
270
+ }
271
+ /**
272
+ * Fetches `branches` of `remote` — or the whole remote, pruned, when none are named — for the start
273
+ * window, where the operator is looking: through Git's password window when there is one (`askpass`).
274
+ * Only remote-tracking branches move; no local branch, no checkout.
275
+ */
276
+ export async function fetchForStart(dir, remote, branches, askpass) {
277
+ const remotes = lines((await git(dir, ['remote'])).stdout);
278
+ if (!remotes.includes(remote))
279
+ return { ok: false, problem: `${dir} has no remote called "${remote}"${remotes.length > 0 ? ` (it has: ${remotes.join(', ')})` : ''}.`, missing: [] };
280
+ const specs = branches && branches.length > 0 ? await refspecsFor(dir, remote, branches) : { refspecs: [] };
281
+ if ('problem' in specs)
282
+ return { ok: false, problem: specs.problem, missing: [] };
283
+ const gitDir = await gitDirOf(dir);
284
+ // Read before fetching: a fetch that fails empties FETCH_HEAD, and with it the time of the last one that worked.
285
+ const lastBefore = gitDir ? lastFetchedAt(gitDir) : undefined;
286
+ const fetched = await fetchAsOperator(dir, remote, { askpass, refspecs: specs.refspecs });
287
+ const missing = fetched.ok ? [] : missingOnRemote(fetched.detail ?? '');
288
+ return {
289
+ ok: fetched.ok,
290
+ fetched,
291
+ missing,
292
+ ...(fetched.ok ? {} : { fetchCommand: fetchCommandFor(dir, remote), ...(lastBefore ? { lastFetched: lastBefore } : {}) }),
293
+ repo: await repoBranches(dir),
294
+ };
295
+ }
@@ -375,9 +375,25 @@ export async function sessionStart(session, dir, head, allSessions,
375
375
  opts = {}) {
376
376
  const how = session.vcs?.startFrom ?? 'head';
377
377
  const baseBranch = session.vcs?.baseBranch?.trim() || 'main';
378
+ const baseRemote = session.vcs?.baseRemote?.trim() || '';
378
379
  if (how === 'head')
379
380
  return head ? { start: { kind: 'head', commit: head } } : {};
380
381
  const fromBranch = async (note) => {
382
+ /*
383
+ * A remote branch chosen in the start window: the remote-tracking branch as that window fetched it,
384
+ * with the operator there to type a key's passphrase. Not fetched again here — a run's fetch may not
385
+ * ask anybody anything — and the local branch of that name is neither read nor moved.
386
+ */
387
+ if (baseRemote) {
388
+ const tip = await remoteTip(dir, baseRemote, baseBranch);
389
+ if (!tip) {
390
+ return {
391
+ problem: `this session is set to start from the remote branch "${baseRemote}/${baseBranch}", and ${dir} has no copy of it. ` +
392
+ 'Start the run again and choose it in the start window, which fetches it.',
393
+ };
394
+ }
395
+ return { start: { kind: 'branch', commit: tip, branch: `${baseRemote}/${baseBranch}`, remote: baseRemote, ...(note ? { note } : {}) } };
396
+ }
381
397
  // The code as it is on the server, not as this checkout last pulled it: see `updateFromRemote`.
382
398
  const update = !opts.dry && session.vcs?.updateFromRemote !== false && (await branchTip(dir, baseBranch)) ? await updateFromRemote(dir, baseBranch) : undefined;
383
399
  const tip = await branchTip(dir, baseBranch);
@@ -520,6 +536,22 @@ async function branchTip(dir, branch) {
520
536
  const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}^{commit}`]);
521
537
  return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
522
538
  }
539
+ /** Where `<remote>/<branch>` was when it was last fetched here, or null when it never was. */
540
+ export async function remoteTip(dir, remote, branch) {
541
+ if (!/^[\w.-]+$/.test(remote) || !(await isValidBranchName(dir, branch)))
542
+ return null;
543
+ const r = await git(dir, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${branch}^{commit}`]);
544
+ return r.ok && r.stdout.trim() ? r.stdout.trim() : null;
545
+ }
546
+ /**
547
+ * The ref a session's `startFrom: branch` reads, and the name it is shown by: `refs/remotes/origin/x`
548
+ * as `origin/x` for a remote branch chosen in the start window, else `refs/heads/<baseBranch>`.
549
+ */
550
+ export function baseRefOf(vcs) {
551
+ const branch = vcs?.baseBranch?.trim() || 'main';
552
+ const remote = vcs?.baseRemote?.trim();
553
+ return remote ? { ref: `refs/remotes/${remote}/${branch}`, name: `${remote}/${branch}`, remote, branch } : { ref: `refs/heads/${branch}`, name: branch, branch };
554
+ }
523
555
  function normalise(dir) {
524
556
  return dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
525
557
  }
@@ -535,7 +567,9 @@ export function describeStart(start) {
535
567
  : start.kind === 'existing-branch'
536
568
  ? `this session carries on the existing branch ${start.branch}, from its tip ${at}`
537
569
  : start.kind === 'branch'
538
- ? `this session starts from the local branch ${start.branch} (${at})`
570
+ ? start.remote
571
+ ? `this session starts from the remote branch ${start.branch} (${at}), as fetched when the run was started`
572
+ : `this session starts from the local branch ${start.branch} (${at})`
539
573
  : `this session starts from where the repository was (${at})`;
540
574
  const updated = start.update ? ` (${describeUpdate(start.update)})` : '';
541
575
  const plus = carried ? `, plus the operator's input files carried from ${carried.from} as ${start.commit.slice(0, 8)} on ${carried.branch}` : '';
@@ -552,11 +586,11 @@ export async function whereLeft(session) {
552
586
  const dir = repoDirOf(session);
553
587
  if (!session.vcs?.enabled || files.length === 0 || !dir)
554
588
  return null;
555
- const base = session.vcs.baseBranch?.trim() || 'main';
589
+ const { ref, name: base } = baseRefOf(session.vcs);
556
590
  const state = await repoState(dir).catch(() => null);
557
591
  if (!state?.isRepo || !state.branch || state.branch === base)
558
592
  return null;
559
- const onBase = await git(dir, ['cat-file', '-e', `refs/heads/${base}:${files[0].path}`]);
593
+ const onBase = await git(dir, ['cat-file', '-e', `${ref}:${files[0].path}`]);
560
594
  if (onBase.ok)
561
595
  return null;
562
596
  const paths = files.map((f) => f.path);
@@ -598,6 +632,27 @@ export function sessionBranches(session) {
598
632
  function firstAttemptBase(task) {
599
633
  return earliestBase(task);
600
634
  }
635
+ /**
636
+ * The branch a per-task session's task will be put on when its next attempt starts, worked out before the
637
+ * run by the rules `prepareForTask` applies at the start: a continuation, or a new prompt on finished work,
638
+ * goes on the previous attempt's branch; an attempt after one that committed nothing goes on that empty
639
+ * branch again; otherwise the planned or derived name, "-aN" from the second attempt on. A task that has
640
+ * run is judged as the attempt it would be queued as, by a plain re-run (no continuation). The one thing
641
+ * not asked here is whether that empty branch still sits where it was cut, which only the start can see.
642
+ */
643
+ export function nextAttemptBranch(session, task) {
644
+ const prefix = session.vcs?.branchPrefix || 'cop/';
645
+ const ran = task.status !== 'queued';
646
+ const attempt = (task.attempt ?? 1) + (ran ? 1 : 0);
647
+ const prev = ran ? { vcs: task.vcs } : task.attempts?.at(-1);
648
+ const carried = !ran && (task.continuing || task.buildsOn) ? task.attempts?.at(-1)?.vcs?.branch : undefined;
649
+ if (carried)
650
+ return carried;
651
+ if (prev?.vcs?.branch && prev.vcs.baseCommit && !prev.vcs.commit && prev.vcs.baseCommit === firstAttemptBase(task))
652
+ return prev.vcs.branch;
653
+ const planned = task.vcsPlan?.branch?.trim();
654
+ return planned ? plannedBranchName(planned, prefix, attempt) : branchNameFrom([session.name, task.title, attempt > 1 ? `a${attempt}` : undefined], prefix);
655
+ }
601
656
  async function switchTo(session, task, dir, wantedName, from, bus,
602
657
  /** `mustExist`: carry on this branch or nothing; never create it. */
603
658
  opts) {
@@ -718,7 +773,7 @@ export function noteFor(repoDir, vcs, ctx = { mode: 'per-task', earlier: [] }) {
718
773
  : ctx.start?.kind === 'existing-branch'
719
774
  ? `This session carries on the existing branch \`${ctx.start.branch}\`: the work already on it is in your working tree. Build on it; do not redo it.`
720
775
  : ctx.start?.kind === 'branch'
721
- ? `This session started from the local branch \`${ctx.start.branch}\`, not from any earlier session's work${ctx.start.note ? ` (${ctx.start.note})` : ''}.`
776
+ ? `This session started from the ${ctx.start.remote ? 'remote branch' : 'local branch'} \`${ctx.start.branch}\`${ctx.start.remote ? ' (the code on the server, fetched when the run was started)' : ''}, not from any earlier session's work${ctx.start.note ? ` (${ctx.start.note})` : ''}.`
722
777
  : '';
723
778
  return [
724
779
  '## Version control',
@@ -1143,8 +1198,9 @@ export async function vcsPreflight(session, allSessions = async () => []) {
1143
1198
  */
1144
1199
  if (!session.vcsBaseCommit && settings.startFrom === 'previous-session') {
1145
1200
  const planned = await sessionStart(session, repoDir, state.head, allSessions, { dry: true });
1201
+ // About where it starts only: another choice in the start window gets past it (see `RunVcsGroup.startChoiceCanFix`).
1146
1202
  if ('problem' in planned)
1147
- return { ok: false, repoDir, branch: state.branch ?? undefined, problem: planned.problem };
1203
+ return { ok: false, repoDir, branch: state.branch ?? undefined, problem: planned.problem, startProblem: true };
1148
1204
  }
1149
1205
  return { ok: true, repoDir, branch: state.branch ?? undefined };
1150
1206
  }