copilot-operator 0.1.23 → 0.1.25

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 (66) hide show
  1. package/dist/src/api/operator.controller.js +1 -1
  2. package/dist/src/api/operator.service.js +326 -33
  3. package/dist/src/exec/confinement.js +85 -7
  4. package/dist/src/exec/programs.js +12 -3
  5. package/dist/src/exec/reportFile.js +5 -3
  6. package/dist/src/exec/shells.js +13 -2
  7. package/dist/src/log/runLog.js +6 -1
  8. package/dist/src/orchestrator/contract.js +29 -7
  9. package/dist/src/orchestrator/review.js +16 -3
  10. package/dist/src/orchestrator/taskRunner.js +391 -54
  11. package/dist/src/plan/schema.js +11 -1
  12. package/dist/src/plan/systemGuide.js +12 -4
  13. package/dist/src/protocol/parser.js +33 -5
  14. package/dist/src/protocol/reporter.js +2 -0
  15. package/dist/src/session/compose.js +18 -9
  16. package/dist/src/session/exportRecord.js +12 -3
  17. package/dist/src/session/exports.js +46 -9
  18. package/dist/src/session/handoff.js +5 -2
  19. package/dist/src/session/interruption.js +3 -1
  20. package/dist/src/session/runLog.js +42 -1
  21. package/dist/src/session/runName.js +3 -1
  22. package/dist/src/session/store.js +7 -2
  23. package/dist/src/transport/copilotTransport.js +89 -62
  24. package/dist/src/transport/modelMatch.js +37 -0
  25. package/dist/src/vcs/artifacts.js +20 -7
  26. package/dist/src/vcs/commitHygiene.js +7 -2
  27. package/dist/src/vcs/contentIntegrity.js +65 -6
  28. package/dist/src/vcs/git.js +42 -7
  29. package/dist/src/vcs/prepareFromRemote.js +21 -14
  30. package/dist/src/vcs/remoteAuth.js +31 -0
  31. package/dist/src/vcs/runPreflight.js +100 -31
  32. package/dist/src/vcs/scope.js +18 -2
  33. package/dist/src/vcs/snapshot.js +61 -16
  34. package/dist/src/vcs/syncCommand.js +3 -11
  35. package/dist/src/vcs/taskVcs.js +173 -24
  36. package/dist/web/404.html +2 -2
  37. package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +1 -0
  38. package/dist/web/_next/static/chunks/867-52e371cb1ec49089.js +1 -0
  39. package/dist/web/_next/static/chunks/app/defaults/{page-0e778dafc69afbd3.js → page-62c8fbe84baa436a.js} +1 -1
  40. package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +1 -0
  41. package/dist/web/_next/static/chunks/app/sessions/view/{page-9b66b729748595bb.js → page-2d3bbc68b27fed2c.js} +1 -1
  42. package/dist/web/appearance.html +2 -2
  43. package/dist/web/appearance.txt +10 -10
  44. package/dist/web/defaults.html +2 -2
  45. package/dist/web/defaults.txt +10 -10
  46. package/dist/web/history.html +2 -2
  47. package/dist/web/history.txt +10 -10
  48. package/dist/web/import.html +2 -2
  49. package/dist/web/import.txt +10 -10
  50. package/dist/web/index.html +2 -2
  51. package/dist/web/index.txt +10 -10
  52. package/dist/web/level1.html +2 -2
  53. package/dist/web/level1.txt +10 -10
  54. package/dist/web/presets.html +2 -2
  55. package/dist/web/presets.txt +10 -10
  56. package/dist/web/sessions/view.html +2 -2
  57. package/dist/web/sessions/view.txt +10 -10
  58. package/dist/web/system.html +2 -2
  59. package/dist/web/system.txt +10 -10
  60. package/package.json +3 -2
  61. package/prompts/level1.md +10 -3
  62. package/dist/web/_next/static/chunks/549-df7a4876917615da.js +0 -1
  63. package/dist/web/_next/static/chunks/867-a67ea8ba3f9b24aa.js +0 -1
  64. package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
  65. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_buildManifest.js +0 -0
  66. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → beP1HgHJZvBVRTsoU1sZv}/_ssgManifest.js +0 -0
@@ -24,15 +24,18 @@
24
24
  * The run is refused before the browser opens while any group is not ready (see the service).
25
25
  */
26
26
  import { join, resolve } from 'node:path';
27
- import { branchNameFrom, freeBranchName, git } from './git.js';
27
+ import { branchNameFrom, describeUpdate, freeBranchName, git, updateFromRemote } from './git.js';
28
28
  import { commitOnto, dirtyBesidesInputs, inputSettings, inputSumsAt, missingInputPatterns } from './inputs.js';
29
- import { dirtyPolicy, takeSnapshot } from './snapshot.js';
30
- import { repoDirOf, vcsPreflight } from './taskVcs.js';
29
+ import { approvalMismatch, dirtyPolicy, takeSnapshot } from './snapshot.js';
30
+ import { repoDirOf, sessionBranchName, vcsPreflight } from './taskVcs.js';
31
31
  import { artifactPatterns } from './artifacts.js';
32
32
  import { inScope } from './scope.js';
33
+ import { findSuspicious } from './commitHygiene.js';
33
34
  const norm = (dir) => dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
34
35
  const short = (c) => (c ?? '').slice(0, 8);
35
36
  const names = (list) => list.map((s) => `"${s.name}"`).join(', ');
37
+ /** "starts" for one session, "start" for several (live run 2026-10-03: '"x" start from it', in a commit message too). */
38
+ const verb = (list, one, many) => (list.length === 1 ? one : many);
36
39
  /** The groups, one per repository, for the sessions of a run in run order. */
37
40
  export async function runVcsPreflight(sessions, allSessions) {
38
41
  const byRepo = new Map();
@@ -58,10 +61,18 @@ async function groupFor(list, allSessions) {
58
61
  const baseBranch = startFrom === 'branch' || startFrom === 'previous-session' ? first.vcs?.baseBranch?.trim() || 'main' : undefined;
59
62
  const baseHead = baseBranch ? (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${baseBranch}^{commit}`])).stdout || null : undefined;
60
63
  const settings = inputSettings(first.vcs);
64
+ const workName = first.vcs?.startFrom === 'existing-branch'
65
+ ? first.vcs.existingBranch?.trim()
66
+ : first.vcs?.branchMode === 'per-session' && first.vcsBaseCommit
67
+ ? sessionBranchName(first)
68
+ : undefined;
69
+ const workBranch = workName ? { name: workName, head: (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${workName}^{commit}`])).stdout || null } : undefined;
61
70
  const sessions = list.map((s, i) => {
62
71
  const sf = s.vcs?.startFrom ?? 'head';
63
72
  const role = i === 0 ? 'first' : sf === 'previous-session' ? 'inherits' : sf === 'branch' && (s.vcs?.baseBranch?.trim() || 'main') === baseBranch && !s.vcsBaseCommit ? 'shares' : 'own';
64
- return { id: s.id, name: s.name, startFrom: sf, started: !!s.vcsBaseCommit, role };
73
+ // Started means a task of it ran; a start recorded by the panel (a snapshot) is not that (live run 2026-10-03).
74
+ const started = s.tasks.some((t) => !!t.startedAt || (t.attempts ?? []).some((a) => !!a.startedAt));
75
+ return { id: s.id, name: s.name, startFrom: sf, started, hasStart: !!s.vcsBaseCommit, role };
65
76
  });
66
77
  const pre = await vcsPreflight(first, allSessions);
67
78
  const plan = pre.snapshot;
@@ -77,7 +88,11 @@ async function groupFor(list, allSessions) {
77
88
  ? []
78
89
  : (await dirtyBesidesInputs(repoDir, first.vcsStart)).other
79
90
  .filter((path) => !inputPaths.has(path) && !(artifacts.length > 0 && inScope(path, artifacts)))
80
- .map((path) => ({ path, ...(reasons.has(path) ? { reason: reasons.get(path) } : {}) }));
91
+ .map((path) => {
92
+ // Said under every policy, not only once a snapshot is asked for (live run 2026-10-03: ".env" with no reason).
93
+ const reason = reasons.get(path) ?? findSuspicious([path])[0]?.reason;
94
+ return { path, ...(reason ? { reason } : {}) };
95
+ });
81
96
  // Before anything is approved: a pattern that matches nothing is said here, not after the browser opens.
82
97
  let problem = pre.ok ? undefined : pre.problem;
83
98
  // Problems no approval or setting of version control's own can get past: they need the operator.
@@ -87,7 +102,9 @@ async function groupFor(list, allSessions) {
87
102
  const missing = topR.ok ? await missingInputPatterns(first, repoDir, resolve(topR.stdout), allSessions) : [];
88
103
  if (missing.length > 0) {
89
104
  blocking = true;
90
- problem = `the input file pattern(s) ${missing.map((m) => `"${m}"`).join(', ')} match no file in the project: put the files in, or correct "Input files" on the session's page.`;
105
+ // Added to what was already in the way, not instead of it: the dirty tree was hidden (live run 2026-10-03).
106
+ const absent = `the input file pattern(s) ${missing.map((m) => `"${m}"`).join(', ')} match no file in the project: put the files in yourself (nothing here can make them), or correct "Input files" on the session's page.`;
107
+ problem = problem ? `${absent} And besides that: ${problem.charAt(0).toLowerCase()}${problem.slice(1)}` : absent;
91
108
  }
92
109
  }
93
110
  /*
@@ -121,7 +138,7 @@ async function groupFor(list, allSessions) {
121
138
  const why = !baseHead
122
139
  ? `there is no local branch "${baseBranch}"`
123
140
  : unrelated.length > 0
124
- ? `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, or use the current branch instead`
141
+ ? `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'}`
125
142
  : blockedInputs.length > 0
126
143
  ? `${blockedInputs.map((b) => b.path).join(', ')} cannot be taken as input files`
127
144
  : problem && /match no file/.test(problem)
@@ -130,14 +147,25 @@ async function groupFor(list, allSessions) {
130
147
  actions.push({
131
148
  id: 'snapshot-on-base',
132
149
  available: !why,
133
- recommended: !why && branch !== baseBranch,
150
+ // The fix that leaves the checkout alone: recommended whenever it can be pressed (live run 2026-10-03: not marked on the base branch).
151
+ recommended: !why,
134
152
  ...(why ? { why } : {}),
135
- result: `Commits the ${inputs.length} input file(s) on top of "${baseBranch}" (${short(baseHead)}) as "Capture user-provided inputs", on the new branch ${baselineName}, ` +
136
- `without switching branches: your checkout${branch ? ` of "${branch}"` : ''} and the files stay exactly as they are. ` +
137
- `${names(sharing)} start from it${inheriting.length > 0 ? `; ${names(inheriting)} carry it on through the chain` : ''}.`,
153
+ // 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
155
+ ? `First brings "${baseBranch}" up to its remote, fast-forward only${branch === baseBranch ? ', which moves your checkout with it' : ''}; then commits`
156
+ : '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' : ''}. ` +
159
+ `${names(sharing)} ${verb(sharing, 'starts', 'start')} from it${inheriting.length > 0 ? `; ${names(inheriting)} ${verb(inheriting, 'carries', 'carry')} it on through the chain` : ''}.`,
138
160
  });
139
161
  }
140
- if (plan?.needed && plan.ok && plan.requireApproval) {
162
+ /*
163
+ * On the base branch already, with only the inputs uncommitted, the snapshot "where the repository is"
164
+ * is the snapshot on the base branch under a second button with the same label (seen live on
165
+ * 2026-10-03: two "Create starting snapshot on main"). The one that leaves the checkout alone stays.
166
+ */
167
+ const onBaseOffered = actions.some((a) => a.id === 'snapshot-on-base' && a.available);
168
+ if (plan?.needed && plan.ok && plan.requireApproval && !(onBaseOffered && branch === baseBranch)) {
141
169
  actions.push({
142
170
  id: 'snapshot-here',
143
171
  available: true,
@@ -151,17 +179,37 @@ async function groupFor(list, allSessions) {
151
179
  actions.push({
152
180
  id: 'use-current-branch',
153
181
  available: true,
154
- result: `${names(sharing)} start from "${branch}" (${short(head)}) instead of "${baseBranch}": their "Start from" becomes "From wherever the repository is". ` +
182
+ result: `${names(sharing)} ${verb(sharing, 'starts', 'start')} from the local branch "${branch}" (${short(head)} now) instead of "${baseBranch}": ${verb(sharing, 'its', 'their')} base branch becomes "${branch}". ` +
155
183
  'Nothing is committed yet; a snapshot on that branch is offered next if one is needed.',
156
184
  });
157
185
  }
158
- if (!plan?.needed && unrelated.length > 0 && dirtyPolicy(first.vcs).policy === 'reject' && !first.vcsBaseCommit && !noCommits) {
186
+ /*
187
+ * Offered whenever other uncommitted files are in the way under "refuse", inputs or not. It used to need
188
+ * `!plan.needed`, and input files make a snapshot needed — so exactly when inputs sat next to other
189
+ * changes it never appeared, "use the current branch" left only "review", and the panel could not become
190
+ * ready (live run 2026-10-03).
191
+ */
192
+ if (unrelated.length > 0 && dirtyPolicy(first.vcs).policy === 'reject' && !first.vcsBaseCommit && !noCommits) {
159
193
  actions.push({
160
194
  id: 'allow-snapshot',
161
195
  available: true,
162
196
  result: `"Uncommitted changes" for "${first.name}" becomes "Take them as a starting snapshot": the files are listed here for you to approve. Nothing is committed yet.`,
163
197
  });
164
198
  }
199
+ /*
200
+ * While something only the operator can fix is in the way — an input that is not there, a branch that
201
+ * does not exist — no fix here can make the run ready: each says so instead of being offered as one
202
+ * (live run 2026-10-03: "fixes" offered that could never clear the refusal).
203
+ */
204
+ if (blocking) {
205
+ for (const a of actions) {
206
+ if (a.id === 'review-inputs' || !a.available)
207
+ continue;
208
+ a.available = false;
209
+ a.recommended = false;
210
+ a.why = 'first the problem above has to be fixed outside the bot; this cannot make the run ready until then.';
211
+ }
212
+ }
165
213
  return {
166
214
  repoDir,
167
215
  ready,
@@ -169,6 +217,10 @@ async function groupFor(list, allSessions) {
169
217
  branch,
170
218
  head,
171
219
  ...(baseBranch ? { baseBranch, baseHead } : {}),
220
+ ...(workBranch && workBranch.name !== branch ? { workBranch } : {}),
221
+ ...(settings && inputs.length === 0 && !first.vcsBaseCommit && !blocking
222
+ ? { 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
+ : {}),
172
224
  sessions,
173
225
  inputPatterns: settings?.patterns ?? [],
174
226
  inputs,
@@ -194,16 +246,23 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
194
246
  if (action === 'review-inputs')
195
247
  return { ok: true, result: offered.result };
196
248
  if (action === 'use-current-branch') {
197
- const moved = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.started);
249
+ const moved = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.hasStart);
198
250
  for (const s of moved) {
199
251
  await saveSession(s.id, (x) => {
200
- if (x.vcs)
201
- x.vcs.startFrom = 'head';
252
+ /*
253
+ * The branch the operator was shown, pinned as the base branch. "From wherever the repository is"
254
+ * read HEAD when each session's first task began, and HEAD moves between sessions — the next one
255
+ * would have started from the branch of the one before (live run 2026-10-03).
256
+ */
257
+ if (x.vcs) {
258
+ x.vcs.startFrom = 'branch';
259
+ x.vcs.baseBranch = group.branch ?? undefined;
260
+ }
202
261
  x.vcsBaseCommit = undefined;
203
262
  x.vcsStart = undefined;
204
263
  });
205
264
  }
206
- return { ok: true, result: `${names(moved)} now start from "${group.branch}".` };
265
+ return { ok: true, result: `${names(moved)} now ${verb(moved, 'starts', 'start')} from "${group.branch}".` };
207
266
  }
208
267
  if (action === 'allow-snapshot') {
209
268
  await saveSession(first.id, (x) => {
@@ -218,36 +277,42 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
218
277
  }
219
278
  // snapshot-on-base: the base branch's tree plus the approved inputs only, with a temporary index.
220
279
  const paths = group.inputs.map((e) => e.path);
221
- const shown = Object.keys(choices).sort();
222
- if (JSON.stringify(shown) !== JSON.stringify([...paths].sort()) || paths.some((p) => choices[p] !== 'include')) {
223
- return { ok: false, problem: 'the input files changed since the list was shown. Look at the list again; nothing was done.' };
224
- }
280
+ const wrong = approvalMismatch(paths, choices, { allMustBeIncluded: true });
281
+ if (wrong)
282
+ return { ok: false, problem: wrong };
225
283
  const topR = await git(repoDir, ['rev-parse', '--show-toplevel']);
226
284
  if (!topR.ok || !topR.stdout || !group.baseHead || !group.baseBranch)
227
285
  return { ok: false, problem: 'the base branch could not be read.' };
228
286
  const top = resolve(topR.stdout);
287
+ /*
288
+ * The base branch brought up to its remote first, when the session asks for that, as a start from the
289
+ * branch does: the snapshot is the commit every session here starts from, and it skipped the update
290
+ * without a word, so a run cut its work from a main the team had already moved past (live run 2026-10-03).
291
+ */
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;
229
294
  const files = [];
230
295
  for (const path of paths) {
231
296
  const blob = await git(top, ['hash-object', '-w', `--path=${path}`, '--', join(top, path)]);
232
297
  if (!blob.ok || !blob.stdout)
233
298
  return { ok: false, problem: `${path} could not be read into git: ${blob.stderr}` };
234
- const mode = (await git(top, ['ls-tree', group.baseHead, '--', path])).stdout.split(' ')[0] || '100644';
299
+ const mode = (await git(top, ['ls-tree', baseHead, '--', path])).stdout.split(' ')[0] || '100644';
235
300
  files.push({ path, mode, blob: blob.stdout });
236
301
  }
237
302
  const branch = await freeBranchName(top, `${branchNameFrom(['baseline'], first.vcs?.branchPrefix || 'cop/')}/${first.id}`);
238
- const sharing = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.started);
303
+ const sharing = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.hasStart);
239
304
  const message = [
240
305
  'Capture user-provided inputs',
241
306
  '',
242
- `The operator's input files, approved on the run screen, on top of "${group.baseBranch}" (${short(group.baseHead)}).`,
243
- `Made without switching branches; the sessions ${names(sharing)} start from it. The work reads them and does not change them.`,
307
+ `The operator's input files, approved on the run screen, on top of "${group.baseBranch}" (${short(baseHead)}).`,
308
+ `Made without switching branches; ${verb(sharing, 'the session', 'the sessions')} ${names(sharing)} ${verb(sharing, 'starts', 'start')} from it. The work reads them and does not change them.`,
244
309
  '',
245
310
  ...paths.slice(0, 200).map((p) => `- ${p}`),
246
311
  '',
247
312
  'Committed by copilot-operator. Not pushed.',
248
313
  '',
249
314
  ].join('\n');
250
- const made = await commitOnto(top, group.baseHead, files, message, branch);
315
+ const made = await commitOnto(top, baseHead, files, message, branch);
251
316
  if ('problem' in made)
252
317
  return { ok: false, problem: made.problem };
253
318
  const settings = inputSettings(first.vcs);
@@ -258,9 +323,10 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
258
323
  kind: 'snapshot',
259
324
  commit: made.commit,
260
325
  branch,
326
+ ...(update ? { update } : {}),
261
327
  snapshot: {
262
328
  fromBranch: group.baseBranch,
263
- fromCommit: group.baseHead,
329
+ fromCommit: baseHead,
264
330
  included: paths.slice(0, 500),
265
331
  leftOut: [],
266
332
  approved: true,
@@ -278,9 +344,12 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
278
344
  sessionId: s.id,
279
345
  type: 'vcs-snapshot',
280
346
  level: 'info',
281
- message: `starting snapshot approved on the run screen: ${paths.length} input file(s) on top of ${group.baseBranch} (${short(group.baseHead)}) as ${short(made.commit)} on ${branch}; the checkout was not touched`,
282
- data: { commit: made.commit, branch, base: group.baseHead, baseBranch: group.baseBranch, inputs: paths, approvedAt, sessions: sharing.map((x) => x.id) },
347
+ message: `starting snapshot approved on the run screen: ${paths.length} input file(s) on top of ${group.baseBranch} (${short(baseHead)}) as ${short(made.commit)} on ${branch}; ` +
348
+ (update?.outcome === 'updated' && group.branch === group.baseBranch
349
+ ? `the checked-out ${group.baseBranch} was fast-forwarded to ${update.remote ?? 'its remote'} first, and nothing else in the checkout was touched`
350
+ : 'the checkout was not touched'),
351
+ data: { commit: made.commit, branch, base: baseHead, baseBranch: group.baseBranch, inputs: paths, approvedAt, sessions: sharing.map((x) => x.id) },
283
352
  });
284
353
  }
285
- return { ok: true, result: `taken: ${short(made.commit)} on ${branch}, on top of "${group.baseBranch}"; ${names(sharing)} start from it.` };
354
+ return { ok: true, result: `${update ? `${describeUpdate(update)}. ` : ''}taken: ${short(made.commit)} on ${branch}, on top of "${group.baseBranch}" (${short(baseHead)}); ${names(sharing)} ${verb(sharing, 'starts', 'start')} from it.` };
286
355
  }
@@ -144,12 +144,28 @@ export function scopeMessage(check, scope, readOnly = false) {
144
144
  return parts.filter(Boolean).join(' ');
145
145
  }
146
146
  /** The line the opening message carries, so the chat knows the rule before its first step. */
147
- export function scopeNote(scope, enforced) {
147
+ export function scopeNote(scope, enforced,
148
+ /** The session's artifact patterns and the task's declared outputs: not put back, and kept only as said here. */
149
+ evidence = {}) {
148
150
  if (scope.length === 0)
149
151
  return '';
152
+ const artifacts = evidence.artifacts ?? [];
153
+ const outputs = evidence.outputs ?? [];
154
+ /*
155
+ * One account of what is put back and what is kept. The message said "anything outside is put back",
156
+ * then "this task is to produce" a file outside, then "evidence goes under reports/**" — and a report
157
+ * there outside the scope was neither put back nor kept (live run 2026-10-03).
158
+ */
159
+ const exempt = [
160
+ ...(outputs.length > 0 ? [`the files this task is to produce (${outputs.join(', ')}) are not put back and are kept with the run`] : []),
161
+ ...(artifacts.length > 0
162
+ ? [`files under ${artifacts.join(', ')} are never committed and are not put back; of those, only the ones inside the paths above${outputs.length > 0 ? ' or among the files to produce' : ''} are kept with the run`]
163
+ : []),
164
+ ];
150
165
  return (`## Scope\n\nThis task may change only these paths (repository-relative): ${scope.join(', ')}. ` +
151
166
  (enforced
152
167
  ? 'After every round of steps the runner puts back any change outside them and tells you which.'
153
- : 'Version control is off for this session, so this is not enforced: keep to it.') +
168
+ : `${evidence.why ?? 'Version control is off for this session'}, so this is not enforced: keep to it.`) +
169
+ (exempt.length > 0 ? ` Except: ${exempt.join('; ')}.` : '') +
154
170
  ' Reading any file in the project is fine.');
155
171
  }
@@ -199,6 +199,15 @@ export async function planSnapshot(session, allSessions = async () => []) {
199
199
  continue;
200
200
  }
201
201
  const artifact = artifactPatterns(session.vcs).length > 0 && inScope(s.path, artifactPatterns(session.vcs));
202
+ /*
203
+ * A name that looks temporary (scratch.txt, tmp-x, a .bak) is the operator's to decide: left out unless
204
+ * ticked, not forced out. The rule was written for the chat's own scratch copies, and it took the choice
205
+ * away from an operator's own note (live run 2026-10-04).
206
+ */
207
+ if (!artifact && !outside && /^a (temporary copy|scratch folder)/.test(generated?.reason ?? '')) {
208
+ plan.entries.push({ path: s.path, kind: 'untracked', size: await size(s.path), allowed: ['include', 'leave-out'], choice: 'leave-out', reason: 'looks like a temporary or scratch file: left out unless you include it' });
209
+ continue;
210
+ }
202
211
  const why = artifact
203
212
  ? 'an artifact: kept with the run, never committed'
204
213
  : generated ? generated.reason : outside ? `outside the project folder ${project}` : policy === 'tracked-only-snapshot' ? 'a new file, and this session takes tracked changes only' : undefined;
@@ -296,15 +305,46 @@ function excludePattern(path) {
296
305
  return `/${path.replace(/[\\*?[]/g, (c) => `\\${c}`).replace(/ $/, '\\ ')}`;
297
306
  }
298
307
  /** Adds the files to the repository's own exclude list, under a line that says who and why. */
308
+ /**
309
+ * Why an approval does not match the list it answers, said as what is wrong, or null when it matches.
310
+ * Every mismatch used to read "the files changed since the list was shown", also when the approval only
311
+ * left a file without a choice and nothing had changed (live run 2026-10-03).
312
+ */
313
+ export function approvalMismatch(listed, choices, opts = {}) {
314
+ const given = Object.keys(choices);
315
+ const missing = listed.filter((p) => !(p in choices));
316
+ const unknown = given.filter((p) => !listed.includes(p));
317
+ const leftOut = opts.allMustBeIncluded ? listed.filter((p) => p in choices && choices[p] !== 'include') : [];
318
+ const some = (paths) => paths.slice(0, 5).join(', ') + (paths.length > 5 ? ` and ${paths.length - 5} more` : '');
319
+ const parts = [
320
+ ...(unknown.length > 0 ? [`${some(unknown)} ${unknown.length === 1 ? 'is' : 'are'} not on the list any more — the files changed since it was shown`] : []),
321
+ ...(missing.length > 0 ? [`the approval has no choice for ${some(missing)}`] : []),
322
+ ...(leftOut.length > 0 ? [`every input file is taken into this snapshot, and ${some(leftOut)} ${leftOut.length === 1 ? 'was' : 'were'} left out`] : []),
323
+ ];
324
+ return parts.length === 0 ? null : `${parts.join('; ')}. Look at the list again; nothing was done.`;
325
+ }
326
+ /**
327
+ * Writes the left-out files into `.git/info/exclude`. `undo` puts the file back as it was, for every way
328
+ * the snapshot can still fail afterwards: the failure says the repository is back as it was, and a
329
+ * left-out file silently gone from `git status` would make that untrue.
330
+ */
299
331
  async function leaveOut(top, session, paths) {
332
+ const nothing = { undo: async () => undefined };
300
333
  if (paths.length === 0)
301
- return undefined;
334
+ return nothing;
302
335
  const where = await git(top, ['rev-parse', '--git-path', 'info/exclude']);
303
336
  if (!where.ok || !where.stdout)
304
- return 'the repository\'s exclude file could not be found';
337
+ return { ...nothing, problem: 'the repository\'s exclude file could not be found' };
305
338
  const file = resolve(top, where.stdout);
306
339
  await mkdir(dirname(file), { recursive: true });
340
+ const existed = await readFile(file, 'utf8').then(() => true, () => false);
307
341
  const before = await readFile(file, 'utf8').catch(() => '');
342
+ const undo = async () => {
343
+ if (existed)
344
+ await writeFile(file, before, 'utf8').catch(() => undefined);
345
+ else
346
+ await rm(file, { force: true }).catch(() => undefined);
347
+ };
308
348
  const block = [
309
349
  `# copilot-operator: left out of the starting snapshot of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}), ${new Date().toISOString()}`,
310
350
  ...paths.map(excludePattern),
@@ -314,9 +354,10 @@ async function leaveOut(top, session, paths) {
314
354
  await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
315
355
  }
316
356
  catch (err) {
317
- return `the exclude file could not be written: ${err.message}`;
357
+ await undo();
358
+ return { ...nothing, problem: `the exclude file could not be written: ${err.message}` };
318
359
  }
319
- return undefined;
360
+ return { undo };
320
361
  }
321
362
  /** The commit message: what was taken, what was left out, from where, and on whose word. */
322
363
  function snapshotMessage(session, plan, taken, leftOut, approved) {
@@ -327,7 +368,7 @@ function snapshotMessage(session, plan, taken, leftOut, approved) {
327
368
  return [
328
369
  onlyInputs ? 'Capture user-provided inputs' : 'Capture operator baseline before run',
329
370
  '',
330
- `The ${onlyInputs ? "operator's input files" : 'uncommitted changes in the repository'} when session "${session.name}" was first run, committed so that`,
371
+ `The ${onlyInputs ? "operator's input files" : 'uncommitted changes in the repository'} of session "${session.name}", ${approved ? 'approved by the operator and ' : ''}committed before its first task so that`,
331
372
  "the session starts from them and every task's commit shows only that task's work.",
332
373
  ...(inputs.length > 0
333
374
  ? ['', `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})` : ''}`))]
@@ -361,9 +402,9 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
361
402
  return { ok: false, problem: plan.problem ?? 'the input files cannot be taken.' };
362
403
  const paths = plan.entries.map((e) => e.path);
363
404
  if (opts.approved) {
364
- const shown = Object.keys(opts.choices ?? {}).sort();
365
- if (JSON.stringify(shown) !== JSON.stringify([...paths].sort()) || paths.some((p) => opts.choices?.[p] !== 'include')) {
366
- return { ok: false, problem: 'the input files changed since the list was shown. Look at the list again; nothing was done.' };
405
+ const wrong = approvalMismatch(paths, opts.choices ?? {}, { allMustBeIncluded: true });
406
+ if (wrong) {
407
+ return { ok: false, problem: wrong };
367
408
  }
368
409
  }
369
410
  const made = await recaptureInputs(session, plan.repoDir, paths, plan.recapture);
@@ -405,11 +446,9 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
405
446
  const chosen = new Map();
406
447
  if (opts.approved) {
407
448
  const choices = opts.choices ?? {};
408
- const shown = Object.keys(choices).sort();
409
- const now = plan.entries.map((e) => e.path).sort();
410
- if (JSON.stringify(shown) !== JSON.stringify(now)) {
411
- return { ok: false, problem: 'the uncommitted files changed since the list was shown. Look at the list again; nothing was done.' };
412
- }
449
+ const wrong = approvalMismatch(plan.entries.map((e) => e.path), choices);
450
+ if (wrong)
451
+ return { ok: false, problem: wrong };
413
452
  for (const e of plan.entries) {
414
453
  const c = choices[e.path];
415
454
  if (!c || !e.allowed.includes(c))
@@ -436,22 +475,26 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
436
475
  if (take.length === 0)
437
476
  return { ok: false, problem: 'nothing would be taken: every file is left out. Leave them out yourself, or take at least one.' };
438
477
  const excluded = await leaveOut(top, session, leaveOutPaths);
439
- if (excluded)
440
- return { ok: false, problem: excluded };
478
+ if (excluded.problem)
479
+ return { ok: false, problem: excluded.problem };
441
480
  // With the left-out files gone from git's view, what is uncommitted must be exactly what is taken.
442
481
  const after = await statusEntries(top);
443
482
  const wanted = new Set(take.filter((e) => e.kind !== 'ignored').map((e) => e.path));
444
483
  const stray = (after ?? []).filter((s) => !wanted.has(s.path)).map((s) => s.path);
445
484
  if (!after || stray.length > 0) {
485
+ await excluded.undo();
446
486
  return { ok: false, problem: `the repository changed while the snapshot was being taken (${someOf(stray)}). Nothing was committed; look at the list again.` };
447
487
  }
448
488
  const branch = plan.baselineBranch;
449
489
  const cut = await git(top, ['checkout', '-b', branch]);
450
- if (!cut.ok)
490
+ if (!cut.ok) {
491
+ await excluded.undo();
451
492
  return { ok: false, problem: `the branch ${branch} could not be made: ${cut.stderr || cut.stdout}` };
493
+ }
452
494
  const back = async (why) => {
453
495
  if (plan.branch)
454
496
  await git(top, ['checkout', plan.branch]);
497
+ await excluded.undo();
455
498
  return { ok: false, problem: `${why} Nothing was committed; the repository is back on ${plan.branch ?? 'where it was'} with your changes as they were.` };
456
499
  };
457
500
  // Literal paths from a file: no glob in a name is read as a pattern, and no command line gets too long.
@@ -487,6 +530,8 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
487
530
  included: take.map((e) => e.path).slice(0, 500),
488
531
  leftOut: leftOutAll.slice(0, 500),
489
532
  approved: opts.approved,
533
+ // When, so the record and the run log say it (live run 2026-10-03: never timestamped).
534
+ ...(opts.approved ? { approvedAt: new Date().toISOString() } : {}),
490
535
  },
491
536
  };
492
537
  await saveSession((s) => {
@@ -14,9 +14,7 @@
14
14
  * Nothing here writes to the repository or the network. `git fetch` is in the command, not here:
15
15
  * what "would be lost" is computed against the remote branch as last fetched, and the page says so.
16
16
  */
17
- import { statSync } from 'node:fs';
18
- import { join } from 'node:path';
19
- import { git, porcelainPaths } from './git.js';
17
+ import { git, lastFetchedAt, porcelainPaths } from './git.js';
20
18
  /** A string as a single-quoted PowerShell literal: nothing inside it is expanded. */
21
19
  export function psQuote(text) {
22
20
  return `'${text.replace(/'/g, "''")}'`;
@@ -77,14 +75,8 @@ export async function planSync(repoDir) {
77
75
  const commits = await git(repoDir, ['log', '--format=%h %s', `${target}..HEAD`]);
78
76
  const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
79
77
  const clean = await git(repoDir, ['clean', '-nd']);
80
- let lastFetched;
81
- try {
82
- const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
83
- lastFetched = statSync(join(gitDir, 'FETCH_HEAD')).mtime.toISOString();
84
- }
85
- catch {
86
- /* never fetched: the page says so */
87
- }
78
+ const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
79
+ const lastFetched = gitDir ? lastFetchedAt(gitDir) : undefined;
88
80
  return {
89
81
  ok: true,
90
82
  repoDir,