copilot-operator 0.1.16 → 0.1.18

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 (52) hide show
  1. package/dist/src/api/operator.controller.js +23 -0
  2. package/dist/src/api/operator.service.js +55 -3
  3. package/dist/src/orchestrator/derivedChecks.js +19 -1
  4. package/dist/src/orchestrator/review.js +2 -2
  5. package/dist/src/orchestrator/taskRunner.js +81 -8
  6. package/dist/src/plan/brief.js +60 -0
  7. package/dist/src/plan/importPlan.js +35 -1
  8. package/dist/src/plan/schema.js +62 -0
  9. package/dist/src/plan/systemGuide.js +72 -0
  10. package/dist/src/protocol/replySchema.js +1 -1
  11. package/dist/src/protocol/reviewSchema.js +10 -5
  12. package/dist/src/session/exports.js +46 -13
  13. package/dist/src/session/model.js +20 -0
  14. package/dist/src/session/store.js +146 -6
  15. package/dist/src/vcs/artifacts.js +138 -0
  16. package/dist/src/vcs/inputs.js +332 -0
  17. package/dist/src/vcs/snapshot.js +395 -0
  18. package/dist/src/vcs/taskVcs.js +183 -27
  19. package/dist/web/404.html +2 -2
  20. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
  21. package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
  23. package/dist/web/_next/static/chunks/app/page-409e37e208dc12e5.js +1 -0
  24. package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +1 -0
  25. package/dist/web/_next/static/css/{e2a2c9eb3c226313.css → c37978b44194c37b.css} +1 -1
  26. package/dist/web/appearance.html +2 -2
  27. package/dist/web/appearance.txt +12 -12
  28. package/dist/web/defaults.html +2 -2
  29. package/dist/web/defaults.txt +12 -12
  30. package/dist/web/history.html +2 -2
  31. package/dist/web/history.txt +12 -12
  32. package/dist/web/import.html +2 -2
  33. package/dist/web/import.txt +12 -12
  34. package/dist/web/index.html +2 -2
  35. package/dist/web/index.txt +12 -12
  36. package/dist/web/level1.html +2 -2
  37. package/dist/web/level1.txt +12 -12
  38. package/dist/web/presets.html +2 -2
  39. package/dist/web/presets.txt +12 -12
  40. package/dist/web/sessions/view.html +2 -2
  41. package/dist/web/sessions/view.txt +12 -12
  42. package/dist/web/system.html +2 -2
  43. package/dist/web/system.txt +12 -12
  44. package/package.json +9 -3
  45. package/prompts/level1.md +547 -546
  46. package/dist/web/_next/static/chunks/549-7fd4fe96682299f5.js +0 -1
  47. package/dist/web/_next/static/chunks/867-631dc4314840fed6.js +0 -1
  48. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
  49. package/dist/web/_next/static/chunks/app/page-13572a726de0b7db.js +0 -1
  50. package/dist/web/_next/static/chunks/app/sessions/view/page-886f5fc99e146408.js +0 -1
  51. /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → JPmlfAEvbNkbghoyvY9uC}/_buildManifest.js +0 -0
  52. /package/dist/web/_next/static/{GhdY7nVcYi-WV3j7saVPK → JPmlfAEvbNkbghoyvY9uC}/_ssgManifest.js +0 -0
@@ -0,0 +1,395 @@
1
+ /**
2
+ * The operator's uncommitted changes, taken as the commit a session starts from.
3
+ *
4
+ * Version control used to have one answer to a dirty tree before a session's first task: not ours
5
+ * to decide, so no branch at all. That stopped real work for the wrong reason — the YAML files a
6
+ * migration needed as input were untracked, the runner kept the repository clean and changed
7
+ * nothing, and the session could not do its job until the operator committed prerequisites by hand.
8
+ *
9
+ * `dirtyWorktree.policy: "snapshot"` makes it a decision the operator takes once, with the list in
10
+ * front of them: the changes become one commit, "Capture operator baseline before run", on a branch
11
+ * of its own (`<prefix>baseline/<session id>`), and the session's branches are cut from it. Nothing
12
+ * is lost (the changes are a commit), the starting state can be gone back to, and every task's
13
+ * commit shows only the task's work.
14
+ *
15
+ * What never goes in, whatever is chosen: secrets and tool output (`commitHygiene.ts`), files
16
+ * outside the session's project folder, and ignored files that no task's scope names. A file that
17
+ * does not go in cannot stay loose in the tree either — the first task's commit takes everything
18
+ * (`commitAll`) and a scope removes a new file outside it — so it is "left out": written to the
19
+ * repository's own `.git/info/exclude`, which is local, never committed, and leaves `.gitignore`
20
+ * alone. A tracked change cannot be left out that way; one that may not go in refuses the snapshot.
21
+ *
22
+ * Like the rest of `vcs/`: nothing is reset, stashed, deleted or rewritten. The snapshot adds a
23
+ * branch and a commit.
24
+ */
25
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
26
+ import { tmpdir } from 'node:os';
27
+ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
28
+ import { looksGenerated } from './commitHygiene.js';
29
+ import { branchNameFrom, freeBranchName, git, gitBytes, isValidBranchName, RUNNER_EMAIL } from './git.js';
30
+ import { repoDirOf, sessionStart } from './taskVcs.js';
31
+ import { inputFilesInTree, inputSettings } from './inputs.js';
32
+ import { artifactPatterns } from './artifacts.js';
33
+ import { inScope } from './scope.js';
34
+ /** The session's dirty-tree policy, absent fields filled in: reject, and asking first. */
35
+ export function dirtyPolicy(vcs) {
36
+ const policy = vcs?.dirtyWorktree?.policy;
37
+ return {
38
+ policy: policy === 'snapshot' || policy === 'tracked-only-snapshot' ? policy : 'reject',
39
+ requireApproval: vcs?.dirtyWorktree?.requireApproval !== false,
40
+ };
41
+ }
42
+ /** A shortened list of paths for a message. */
43
+ export function someOf(paths, n = 5) {
44
+ return `${paths.slice(0, n).join(', ')}${paths.length > n ? `, and ${paths.length - n} more` : ''}`;
45
+ }
46
+ /** What `git status` says is uncommitted, file by file, read raw so no path loses a character. */
47
+ async function statusEntries(top) {
48
+ const r = await gitBytes(top, ['status', '--porcelain=v1', '-z', '--untracked-files=all']);
49
+ if (!r.ok)
50
+ return null;
51
+ const parts = r.stdout.toString('utf8').split('\0');
52
+ const out = [];
53
+ for (let i = 0; i < parts.length; i += 1) {
54
+ const rec = parts[i] ?? '';
55
+ if (rec.length < 4)
56
+ continue;
57
+ const xy = rec.slice(0, 2);
58
+ const path = rec.slice(3);
59
+ // A rename or a copy is followed by the path it came from, as a record of its own.
60
+ if (xy[0] === 'R' || xy[0] === 'C') {
61
+ out.push({ path, from: parts[i + 1] || undefined, untracked: false });
62
+ i += 1;
63
+ }
64
+ else
65
+ out.push({ path, untracked: xy === '??' });
66
+ }
67
+ return out;
68
+ }
69
+ /**
70
+ * The patterns of the session's task scopes that name files explicitly enough to let an ignored
71
+ * file in. "The whole repository" (`.`, `**`, `*`) names nothing in particular.
72
+ */
73
+ function scopedPatterns(session) {
74
+ const out = new Set();
75
+ for (const task of session.tasks) {
76
+ for (const raw of task.scope ?? []) {
77
+ let p = raw.trim().replace(/\\/g, '/').replace(/^\.?\/+/, '');
78
+ if (p.endsWith('/'))
79
+ p += '**';
80
+ if (!p || p === '.' || /^[*/]+$/.test(p))
81
+ continue;
82
+ out.add(p);
83
+ }
84
+ }
85
+ return [...out];
86
+ }
87
+ /** Ignored files a task's scope names, for the operator to tick. */
88
+ async function scopedIgnored(top, patterns) {
89
+ if (patterns.length === 0)
90
+ return [];
91
+ const r = await gitBytes(top, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', ...patterns.map((p) => `:(glob,icase)${p}`)], 60_000, 32 * 1024 * 1024);
92
+ if (!r.ok)
93
+ return [];
94
+ return r.stdout.toString('utf8').split('\0').filter(Boolean).slice(0, 2000);
95
+ }
96
+ /** The repository's top folder, which every path git prints is relative to. */
97
+ async function topOf(dir) {
98
+ const r = await git(dir, ['rev-parse', '--show-toplevel']);
99
+ return r.ok && r.stdout ? resolve(r.stdout) : null;
100
+ }
101
+ /**
102
+ * Whether the session's start allows a snapshot, and why not. A snapshot is taken where the changes
103
+ * are — on HEAD — so the session has to start from HEAD: `head`, or a branch or previous session
104
+ * whose tip HEAD is. Asked without updating from the remote: a fetch is not the question here.
105
+ */
106
+ async function startProblem(session, dir, branch, head, allSessions) {
107
+ const how = session.vcs?.startFrom ?? 'head';
108
+ if (how === 'head')
109
+ return undefined;
110
+ const where = `${branch ? `the branch ${branch}` : 'a detached HEAD'} at ${head.slice(0, 8)}`;
111
+ if (how === 'existing-branch') {
112
+ return (`this session carries on the existing branch "${session.vcs?.existingBranch ?? ''}", and a starting snapshot is a branch of its own, ` +
113
+ 'so the work would not be on the branch you named. Commit the changes on that branch yourself, or choose another "Start from".');
114
+ }
115
+ const offline = { ...session, vcs: { ...session.vcs, updateFromRemote: false } };
116
+ const resolved = await sessionStart(offline, dir, head, allSessions);
117
+ if ('problem' in resolved)
118
+ return resolved.problem;
119
+ const commit = resolved.start?.commit;
120
+ if (!commit || commit === head)
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 ?? ''}`;
123
+ return (`the changes are on ${where}, and this session is set to start from ${wanted} (${commit.slice(0, 8)}). ` +
124
+ 'A snapshot is taken where the changes are, so the session would not start where it was told to. ' +
125
+ `Put the repository on ${resolved.start?.branch ?? 'that branch'} first, or choose "From wherever the repository is (as until now)".`);
126
+ }
127
+ /**
128
+ * What a snapshot of the repository would take now, and what may be done with each file.
129
+ *
130
+ * Not needed when the session has no snapshot policy, already has its start, or the tree is clean.
131
+ */
132
+ export async function planSnapshot(session, allSessions = async () => []) {
133
+ const { policy, requireApproval } = dirtyPolicy(session.vcs);
134
+ const inputs = inputSettings(session.vcs);
135
+ const repoDir = repoDirOf(session);
136
+ const plan = { needed: false, ok: false, policy, requireApproval, repoDir, branch: null, head: null, entries: [] };
137
+ if (!session.vcs?.enabled || (policy === 'reject' && !inputs) || session.vcsBaseCommit)
138
+ return plan;
139
+ const top = repoDir ? await topOf(repoDir) : null;
140
+ if (!top)
141
+ return { ...plan, problem: `${repoDir || 'The repository folder'} is not a git repository.` };
142
+ const status = await statusEntries(top);
143
+ if (!status)
144
+ return { ...plan, problem: 'git status could not be read.' };
145
+ const branchR = await git(top, ['rev-parse', '--abbrev-ref', 'HEAD']);
146
+ const headR = await git(top, ['rev-parse', '--verify', '--quiet', 'HEAD']);
147
+ plan.branch = branchR.ok && branchR.stdout !== 'HEAD' ? branchR.stdout : null;
148
+ plan.head = headR.ok && headR.stdout ? headR.stdout : null;
149
+ // The project folder, when it is a folder inside the repository: a change outside it is not this session's.
150
+ const project = session.projectDir?.trim() ? relative(top, resolve(session.projectDir.trim())).replace(/\\/g, '/') : '';
151
+ const inProject = (p) => !project || project.startsWith('..') || isAbsolute(project) || p.toLowerCase() === project.toLowerCase() || p.toLowerCase().startsWith(`${project.toLowerCase()}/`);
152
+ const size = async (p) => (await stat(join(top, p)).catch(() => null))?.size;
153
+ /*
154
+ * The operator's input files that are not committed as they are: new, changed, or ignored. They
155
+ * are taken whatever the dirty-tree policy, or the snapshot is not; the rest of the tree is the
156
+ * policy's. A secrets file or one outside the project is never an input.
157
+ */
158
+ const inputPaths = new Set();
159
+ if (inputs) {
160
+ const dirtyNow = new Set(status.map((s) => s.path));
161
+ for (const f of await inputFilesInTree(top, inputs.patterns)) {
162
+ if (f.kind === 'tracked' && !dirtyNow.has(f.path))
163
+ continue;
164
+ inputPaths.add(f.path);
165
+ const blocked = looksGenerated(f.path)?.reason === 'a secrets file' ? 'a secrets file is never taken as an input' : !inProject(f.path) ? `outside the project folder ${project}` : undefined;
166
+ plan.entries.push({ path: f.path, kind: f.kind, input: true, size: await size(f.path), allowed: blocked ? [] : ['include'], choice: blocked ? null : 'include', reason: blocked ?? 'an input file: committed with the starting snapshot, then read-only' });
167
+ }
168
+ }
169
+ const others = status.filter((s) => !inputPaths.has(s.path));
170
+ if (policy === 'reject') {
171
+ if (plan.entries.length === 0)
172
+ return plan;
173
+ plan.needed = true;
174
+ if (others.length > 0) {
175
+ plan.problem =
176
+ `besides the input files there are other uncommitted changes (${someOf(others.map((o) => o.path))}). ` +
177
+ 'Commit them yourself, or choose "Take them as a starting snapshot" under "Uncommitted changes".';
178
+ return plan;
179
+ }
180
+ }
181
+ if (plan.entries.length === 0 && others.length === 0)
182
+ return plan;
183
+ plan.needed = true;
184
+ plan.requireApproval = (others.length > 0 && requireApproval) || (plan.entries.some((e) => e.input) && !!inputs?.requireApproval);
185
+ for (const s of policy === 'reject' ? [] : others) {
186
+ const generated = looksGenerated(s.path);
187
+ const outside = !inProject(s.path);
188
+ if (!s.untracked) {
189
+ const blocked = generated?.reason === 'a secrets file'
190
+ ? `a secrets file with uncommitted changes; it is tracked, so it cannot be left out. Commit or undo that change yourself`
191
+ : outside
192
+ ? `outside the project folder ${project}; it is tracked, so it cannot be left out. Commit or undo that change yourself`
193
+ : undefined;
194
+ plan.entries.push({ path: s.path, ...(s.from ? { from: s.from } : {}), kind: 'tracked', size: await size(s.path), allowed: blocked ? [] : ['include'], choice: blocked ? null : 'include', ...(blocked ? { reason: blocked } : {}) });
195
+ continue;
196
+ }
197
+ const artifact = artifactPatterns(session.vcs).length > 0 && inScope(s.path, artifactPatterns(session.vcs));
198
+ const why = artifact
199
+ ? 'an artifact: kept with the run, never committed'
200
+ : generated ? generated.reason : outside ? `outside the project folder ${project}` : policy === 'tracked-only-snapshot' ? 'a new file, and this session takes tracked changes only' : undefined;
201
+ plan.entries.push({ path: s.path, kind: 'untracked', size: await size(s.path), allowed: why ? ['leave-out'] : ['include', 'leave-out'], choice: why ? 'leave-out' : 'include', ...(why ? { reason: why } : {}) });
202
+ }
203
+ if (policy === 'snapshot') {
204
+ for (const path of await scopedIgnored(top, scopedPatterns(session))) {
205
+ if (looksGenerated(path) || !inProject(path) || inputPaths.has(path))
206
+ continue;
207
+ plan.entries.push({ path, kind: 'ignored', allowed: ['include', 'leave-out'], choice: 'leave-out', reason: "ignored by git, and a task's scope names it: taken only when you tick it" });
208
+ }
209
+ }
210
+ const blocked = plan.entries.filter((e) => e.allowed.length === 0);
211
+ if (!plan.head)
212
+ plan.problem = 'the repository has no commit yet, so there is nothing for a snapshot to sit on. Make the first commit yourself.';
213
+ else if (blocked.length > 0)
214
+ plan.problem = `${blocked.length} change(s) can be neither taken nor left out: ${blocked.map((e) => `${e.path} (${e.reason})`).join('; ')}.`;
215
+ // The session's own spelling of the folder, which is what other sessions are matched by.
216
+ else
217
+ plan.problem = await startProblem(session, repoDir, plan.branch, plan.head, allSessions);
218
+ if (!plan.problem) {
219
+ const prefix = session.vcs.branchPrefix || 'cop/';
220
+ plan.baselineBranch = await freeBranchName(top, branchNameFrom(['baseline'], prefix) + `/${session.id}`);
221
+ if (!(await isValidBranchName(top, plan.baselineBranch)))
222
+ plan.problem = `"${plan.baselineBranch}" is not a name git accepts.`;
223
+ }
224
+ plan.ok = !plan.problem;
225
+ return plan;
226
+ }
227
+ /** A path as a pattern for `.git/info/exclude` that matches that one file and nothing else. */
228
+ function excludePattern(path) {
229
+ return `/${path.replace(/[\\*?[]/g, (c) => `\\${c}`).replace(/ $/, '\\ ')}`;
230
+ }
231
+ /** Adds the files to the repository's own exclude list, under a line that says who and why. */
232
+ async function leaveOut(top, session, paths) {
233
+ if (paths.length === 0)
234
+ return undefined;
235
+ const where = await git(top, ['rev-parse', '--git-path', 'info/exclude']);
236
+ if (!where.ok || !where.stdout)
237
+ return 'the repository\'s exclude file could not be found';
238
+ const file = resolve(top, where.stdout);
239
+ await mkdir(dirname(file), { recursive: true });
240
+ const before = await readFile(file, 'utf8').catch(() => '');
241
+ const block = [
242
+ `# copilot-operator: left out of the starting snapshot of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}), ${new Date().toISOString()}`,
243
+ ...paths.map(excludePattern),
244
+ '',
245
+ ].join('\n');
246
+ try {
247
+ await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
248
+ }
249
+ catch (err) {
250
+ return `the exclude file could not be written: ${err.message}`;
251
+ }
252
+ return undefined;
253
+ }
254
+ /** The commit message: what was taken, what was left out, from where, and on whose word. */
255
+ function snapshotMessage(session, plan, taken, leftOut, approved) {
256
+ const list = (paths) => [...paths.slice(0, 200).map((p) => `- ${p}`), ...(paths.length > 200 ? [`- and ${paths.length - 200} more`] : [])].join('\n');
257
+ const inputs = taken.filter((e) => e.input);
258
+ const rest = taken.filter((e) => !e.input).map((e) => e.path);
259
+ const onlyInputs = rest.length === 0;
260
+ return [
261
+ onlyInputs ? 'Capture user-provided inputs' : 'Capture operator baseline before run',
262
+ '',
263
+ `The ${onlyInputs ? "operator's input files" : 'uncommitted changes in the repository'} when session "${session.name}" was first run, committed so that`,
264
+ "the session starts from them and every task's commit shows only that task's work.",
265
+ ...(inputs.length > 0
266
+ ? ['', `Input files, read by the work and not changed by it (${inputs.length}):`, list(inputs.map((e) => `${e.path}${e.size !== undefined ? ` (${e.size} bytes, ${e.kind})` : ''}`))]
267
+ : []),
268
+ ...(rest.length > 0 ? ['', `Taken (${rest.length}):`, list(rest)] : []),
269
+ ...(leftOut.length > 0 ? ['', `Left out, in .git/info/exclude (${leftOut.length}):`, list(leftOut)] : []),
270
+ '',
271
+ `Taken from ${plan.branch ?? 'a detached HEAD'} at ${(plan.head ?? '').slice(0, 8)}, ${approved ? "on the operator's approval of this list" : 'automatically (requireApproval: false)'}.`,
272
+ 'Committed by copilot-operator. Not pushed.',
273
+ '',
274
+ ].join('\n');
275
+ }
276
+ /**
277
+ * Takes the snapshot: leaves out what is to be left out, cuts the baseline branch, commits the rest.
278
+ *
279
+ * `choices` is the operator's answer, one per file of the list they were shown; a list that no
280
+ * longer matches the repository is refused, because what was approved is not what is there. Without
281
+ * `approved`, every file takes its default and anything to be left out refuses (see `DirtyWorktree`).
282
+ */
283
+ export async function takeSnapshot(session, opts, bus, saveSession, allSessions = async () => []) {
284
+ if (dirtyPolicy(session.vcs).policy === 'reject' && !inputSettings(session.vcs)) {
285
+ return { ok: false, problem: 'this session does not take uncommitted changes as a snapshot. Choose that under Version control → "Uncommitted changes" first.' };
286
+ }
287
+ if (session.vcsBaseCommit)
288
+ return { ok: false, problem: 'this session has already started; a snapshot is taken only before its first task.' };
289
+ const plan = await planSnapshot(session, allSessions);
290
+ if (!plan.needed)
291
+ return { ok: false, problem: 'there is nothing uncommitted to take.' };
292
+ if (!plan.ok || !plan.baselineBranch || !plan.head)
293
+ return { ok: false, problem: plan.problem ?? 'the snapshot cannot be taken.' };
294
+ const chosen = new Map();
295
+ if (opts.approved) {
296
+ const choices = opts.choices ?? {};
297
+ const shown = Object.keys(choices).sort();
298
+ const now = plan.entries.map((e) => e.path).sort();
299
+ if (JSON.stringify(shown) !== JSON.stringify(now)) {
300
+ return { ok: false, problem: 'the uncommitted files changed since the list was shown. Look at the list again; nothing was done.' };
301
+ }
302
+ for (const e of plan.entries) {
303
+ const c = choices[e.path];
304
+ if (!c || !e.allowed.includes(c))
305
+ return { ok: false, problem: `${e.path} cannot be "${c ?? 'nothing'}": ${e.reason ?? 'not allowed'}. Nothing was done.` };
306
+ chosen.set(e.path, c);
307
+ }
308
+ }
309
+ else {
310
+ for (const e of plan.entries)
311
+ chosen.set(e.path, e.choice);
312
+ const toExclude = plan.entries.filter((e) => e.kind === 'untracked' && chosen.get(e.path) === 'leave-out');
313
+ if (toExclude.length > 0) {
314
+ return {
315
+ ok: false,
316
+ problem: `${toExclude.length} new file(s) would have to be left out of the snapshot (${toExclude.slice(0, 5).map((e) => `${e.path}: ${e.reason}`).join('; ')}), ` +
317
+ "and that writes to the repository's .git/info/exclude, which needs your approval. Approve the list on the session's page, or deal with the files yourself.",
318
+ };
319
+ }
320
+ }
321
+ const top = (await topOf(plan.repoDir));
322
+ const take = plan.entries.filter((e) => chosen.get(e.path) === 'include');
323
+ const leaveOutPaths = plan.entries.filter((e) => e.kind === 'untracked' && chosen.get(e.path) === 'leave-out').map((e) => e.path);
324
+ const leftOutAll = plan.entries.filter((e) => chosen.get(e.path) === 'leave-out').map((e) => e.path);
325
+ if (take.length === 0)
326
+ return { ok: false, problem: 'nothing would be taken: every file is left out. Leave them out yourself, or take at least one.' };
327
+ const excluded = await leaveOut(top, session, leaveOutPaths);
328
+ if (excluded)
329
+ return { ok: false, problem: excluded };
330
+ // With the left-out files gone from git's view, what is uncommitted must be exactly what is taken.
331
+ const after = await statusEntries(top);
332
+ const wanted = new Set(take.filter((e) => e.kind !== 'ignored').map((e) => e.path));
333
+ const stray = (after ?? []).filter((s) => !wanted.has(s.path)).map((s) => s.path);
334
+ if (!after || stray.length > 0) {
335
+ return { ok: false, problem: `the repository changed while the snapshot was being taken (${someOf(stray)}). Nothing was committed; look at the list again.` };
336
+ }
337
+ const branch = plan.baselineBranch;
338
+ const cut = await git(top, ['checkout', '-b', branch]);
339
+ if (!cut.ok)
340
+ return { ok: false, problem: `the branch ${branch} could not be made: ${cut.stderr || cut.stdout}` };
341
+ const back = async (why) => {
342
+ if (plan.branch)
343
+ await git(top, ['checkout', plan.branch]);
344
+ return { ok: false, problem: `${why} Nothing was committed; the repository is back on ${plan.branch ?? 'where it was'} with your changes as they were.` };
345
+ };
346
+ // Literal paths from a file: no glob in a name is read as a pattern, and no command line gets too long.
347
+ const list = join(tmpdir(), `cop-snapshot-${process.pid}-${Date.now()}.txt`);
348
+ const add = async (paths, force) => {
349
+ if (paths.length === 0)
350
+ return undefined;
351
+ await writeFile(list, `${paths.join('\0')}\0`, 'utf8');
352
+ const r = await git(top, ['--literal-pathspecs', 'add', ...(force ? ['-f'] : ['-A']), `--pathspec-from-file=${list}`, '--pathspec-file-nul']);
353
+ return r.ok ? undefined : r.stderr || r.stdout || 'git add failed';
354
+ };
355
+ try {
356
+ const plain = take.filter((e) => e.kind !== 'ignored').flatMap((e) => (e.from ? [e.path, e.from] : [e.path]));
357
+ const failed = (await add(plain, false)) ?? (await add(take.filter((e) => e.kind === 'ignored').map((e) => e.path), true));
358
+ if (failed)
359
+ return await back(`the files could not be staged: ${failed}.`);
360
+ }
361
+ finally {
362
+ await rm(list, { force: true });
363
+ }
364
+ const message = snapshotMessage(session, plan, take, leftOutAll, opts.approved);
365
+ const commit = await git(top, ['-c', 'user.name=copilot-operator', '-c', `user.email=${RUNNER_EMAIL}`, 'commit', '--no-verify', '-q', '-m', message]);
366
+ if (!commit.ok)
367
+ return await back(`the snapshot could not be committed: ${commit.stderr || commit.stdout}.`);
368
+ const sha = (await git(top, ['rev-parse', 'HEAD'])).stdout;
369
+ const start = {
370
+ kind: 'snapshot',
371
+ commit: sha,
372
+ branch,
373
+ snapshot: {
374
+ ...(plan.branch ? { fromBranch: plan.branch } : {}),
375
+ fromCommit: plan.head,
376
+ included: take.map((e) => e.path).slice(0, 500),
377
+ leftOut: leftOutAll.slice(0, 500),
378
+ approved: opts.approved,
379
+ },
380
+ };
381
+ await saveSession((s) => {
382
+ s.vcsBaseCommit = sha;
383
+ s.vcsStart = start;
384
+ });
385
+ bus.publish({
386
+ sessionId: session.id,
387
+ type: 'vcs-snapshot',
388
+ level: 'info',
389
+ message: `your uncommitted changes are now the commit ${sha.slice(0, 8)} on ${branch} (${take.length} file(s)` +
390
+ `${leftOutAll.length > 0 ? `, ${leftOutAll.length} left out` : ''}); ${plan.branch ?? 'the branch you were on'} itself is unchanged. ` +
391
+ 'The session starts from that commit.',
392
+ data: { branch, commit: sha, included: take.length, leftOut: leftOutAll.length },
393
+ });
394
+ return { ok: true, start };
395
+ }