copilot-operator 0.1.23 → 0.1.26

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 +337 -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 +100 -74
  24. package/dist/src/transport/modelMatch.js +60 -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-17dd586d1d6bd068.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-54ab3cb89b84679d.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 → jYvSWuPPywbxckIuz6cNs}/_buildManifest.js +0 -0
  66. /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → jYvSWuPPywbxckIuz6cNs}/_ssgManifest.js +0 -0
@@ -55,6 +55,19 @@ export function isWithin(path, roots) {
55
55
  return target === root || target.startsWith(`${root}\\`);
56
56
  });
57
57
  }
58
+ /**
59
+ * The folders a session's commands may reach: its own, and the projects registered in Settings when its
60
+ * own folder is one of them (or inside one) — a front end, a back end and their tests are one piece of
61
+ * work and a test suite starts the application next door. A session working anywhere else is confined to
62
+ * its own folder: found live on 2026-10-03, a fixture session elsewhere on the disk could write into every
63
+ * registered project, whose paths were also sent to the chat in each refusal, and nothing there would have
64
+ * been put back or committed.
65
+ */
66
+ export function sessionRoots(cwd, registered) {
67
+ const projects = projectRoots(registered);
68
+ const inProject = projects.some((p) => isWithin(cwd, [p]));
69
+ return projectRoots([cwd, ...(inProject ? projects : [])]);
70
+ }
58
71
  /** Roots as a set: resolved, de-duplicated, empty ones dropped. */
59
72
  export function projectRoots(folders) {
60
73
  const seen = new Map();
@@ -92,11 +105,74 @@ const TILDE = /(?:^|[\s"'=(,])~(?=[\\/]|$|[\s"'`;|&)])/;
92
105
  const CD_TO_ROOT = /(?:^|[\s;|&({])(?:Set-Location|Push-Location|cd|chdir|sl|pushd)\s+["']?[\\/]["']?(?=$|[\s;|&)}])/i;
93
106
  /** A path segment that climbs: `..` on its own, or between separators. */
94
107
  const CLIMBS = /(?:^|[\\/])\.\.(?:$|[\\/])/;
108
+ /**
109
+ * A here-string, `@'…'@` or `@"…"@`: its lines are text, not part of the command line.
110
+ */
111
+ const HERE_STRING = /@(['"])[ \t]*\r?\n[\s\S]*?\r?\n[ \t]*\1@/g;
112
+ /**
113
+ * A quoted PowerShell string, single- or double-quoted, over several lines too: a script's text kept in a
114
+ * variable over many lines was still read as paths when only one-line strings were matched (live run 2026-10-04).
115
+ */
116
+ const STRING = String.raw `(?:'(?:[^']|'')*'|"(?:[^"` + '`' + String.raw `]|""|` + '`' + String.raw `[\s\S])*")`;
117
+ /** Text: a quoted string, or an array of them `@('a', "b")` or `('a', "b")`, which may be joined `-join "…"`. */
118
+ const TEXT_VALUE = String.raw `(?:${STRING}|@?\(\s*(?:${STRING}\s*,?\s*)*\)(?:\s*-join\s*${STRING})?)`;
119
+ /** Where what follows is the text written: `-Value`, `-InputObject`, `WriteAllText(path, …`, or Set-Content's second positional argument. */
120
+ const CONTENT_LEAD = String.raw `-(?:Value|InputObject)\s+|::(?:Write|Append)All(?:Text|Lines)\s*\([^,]*,\s*|\b(?:Set|Add)-Content\s+(?:-(?:Path|LiteralPath)\s+)?(?:${STRING}|[^\s;|&()"'$-][^\s;|&()"'$]*)\s+`;
121
+ /** The text after `-Value`/`-InputObject`, the content argument of `WriteAllText`/`AppendAllText`, or Set-Content's positional text. */
122
+ const CONTENT_ARGUMENT = new RegExp(`(${CONTENT_LEAD})${TEXT_VALUE}`, 'gi');
123
+ /** A command that runs text as code: then the text is the command, and is read as one. */
124
+ const RUNS_TEXT = /\b(?:Invoke-Expression|iex|Invoke-Command|ScriptBlock\]?::Create|Start-Job)\b|\|\s*&?\s*(?:pwsh|powershell|cmd|node|python3?|py|bash|sh|wsl|ruby|perl|php)(?:\.exe)?\b/i;
125
+ /**
126
+ * The command without the text it writes into a file.
127
+ *
128
+ * Found in a live run on 2026-10-03: a step writing `src/cart.mjs` with a here-string, as the contract
129
+ * recommends, was refused because the file's own `new URL('../config/limits.json', import.meta.url)`
130
+ * was read as a path, resolved against the working folder — where it climbs out — instead of against
131
+ * the file it belongs to, where it does not. The same text in double quotes passed, by an accident of
132
+ * splitting. What a step writes into a file is data; the path it writes to is still read, and so is
133
+ * the text of a command that runs it (`iex`, a pipe into an interpreter). A script written now and run
134
+ * later is read when it is run (see `scriptFiles.ts`).
135
+ */
136
+ function withoutWrittenText(command) {
137
+ if (RUNS_TEXT.test(command))
138
+ return command;
139
+ let line = command.replace(HERE_STRING, ' ').replace(CONTENT_ARGUMENT, (_m, lead) => `${lead}''`);
140
+ /*
141
+ * The same text put in a variable first and then written: `$t = "…'../config/x'…"; $f::WriteAllText('a.mjs', $t, $e)`.
142
+ * Taken out only when every use of the variable is as what is written — never when it is used as a path —
143
+ * so `$p = '..\..\x'; Set-Content -Path $p` is still read (live run 2026-10-04, a second form of 2026-10-03's).
144
+ */
145
+ // An array of lines `$c = @("…", "…")` too, and Set-Content's positional text `Set-Content a.js $c` (live run 2026-10-04).
146
+ // And lines joined into another variable first, `$t = ($lines -join "`n")`: content when that one is.
147
+ const joined = new Map();
148
+ for (const j of line.matchAll(new RegExp(String.raw `\$(\w+)\s*=\s*\(?\s*\$(\w+)\s+-join\s*${STRING}\s*\)?`, 'gi')))
149
+ joined.set(j[1].toLowerCase(), j[2].toLowerCase());
150
+ const contentOnly = (name, depth = 0) => {
151
+ if (depth > 3)
152
+ return false;
153
+ const uses = [...line.matchAll(new RegExp(`\\$${name}\\b`, 'gi'))].length - 1;
154
+ if (uses <= 0)
155
+ return false;
156
+ const direct = [
157
+ new RegExp(`(?:${CONTENT_LEAD})\\$${name}\\b`, 'gi'),
158
+ new RegExp(`\\$${name}\\s*\\|\\s*(?:Set-Content|Add-Content|Out-File)\\b`, 'gi'),
159
+ ].reduce((n, re) => n + [...line.matchAll(re)].length, 0);
160
+ const viaJoin = [...joined].filter(([into, from]) => from === name.toLowerCase() && contentOnly(into, depth + 1)).length;
161
+ return direct + viaJoin === uses;
162
+ };
163
+ for (const m of [...line.matchAll(new RegExp(String.raw `\$(\w+)\s*=\s*${TEXT_VALUE}`, 'g'))]) {
164
+ const name = m[1];
165
+ if (contentOnly(name))
166
+ line = line.replace(m[0], () => `$${name} = ''`);
167
+ }
168
+ return line;
169
+ }
95
170
  /** Every whitespace- or separator-delimited token, quoted content split the same way. */
96
171
  function tokens(command) {
97
172
  const out = [];
98
173
  const rest = command.replace(QUOTED, (_whole, _q, inner) => {
99
- out.push(...inner.split(/\s+/));
174
+ // Split like the rest of the line: `URL('../x'` inside a double-quoted string is the token `../x`.
175
+ out.push(...inner.split(/[\s;|&<>(){},'"]+/));
100
176
  return ' ';
101
177
  });
102
178
  out.push(...rest.split(/[\s;|&<>(){},]+/));
@@ -199,21 +275,23 @@ export function confinementRefusal(command, c) {
199
275
  const pip = pipRefusal(command);
200
276
  if (pip)
201
277
  return pip;
202
- const env = ENV_LOCATION.exec(command);
278
+ // Paths are read from the command line without the text it writes into a file: see `withoutWrittenText`.
279
+ const line = withoutWrittenText(command);
280
+ const env = ENV_LOCATION.exec(line);
203
281
  if (env)
204
282
  return outside(`\`${env[0]}\` is a location on the machine — the operator's profile or the system — not in the project`, c);
205
- if (HOME_VAR.test(command) || TILDE.test(command)) {
283
+ if (HOME_VAR.test(line) || TILDE.test(line)) {
206
284
  return outside('`~` and `$HOME` are the operator\'s profile, not the project', c);
207
285
  }
208
- if (UNC.test(command))
286
+ if (UNC.test(line))
209
287
  return outside('a network share is not the project', c);
210
- if (CD_TO_ROOT.test(command))
288
+ if (CD_TO_ROOT.test(line))
211
289
  return outside('this changes to the root of the drive', c);
212
- for (const p of drivePaths(command)) {
290
+ for (const p of drivePaths(line)) {
213
291
  if (!isWithin(p, c.roots))
214
292
  return outside(`\`${p}\` is outside the project`, c);
215
293
  }
216
- for (const t of tokens(command)) {
294
+ for (const t of tokens(line)) {
217
295
  if (!CLIMBS.test(t))
218
296
  continue;
219
297
  const resolved = win32.resolve(c.cwd, t);
@@ -126,7 +126,13 @@ export function externalProgram(head) {
126
126
  * anywhere on the line refused honest work, which is the error this gate can least afford.
127
127
  */
128
128
  const INLINE_CODE = [
129
- { what: 'node -e', pattern: /\bnode(\.exe)?\s+(-\S+\s+)*?-(e|-eval)\b/i },
129
+ { what: 'node -e', pattern: /\bnode(\.exe)?\s+(-\S+\s+)*?-(e|-eval|p|-print)\b/i },
130
+ /*
131
+ * Code piped into an interpreter that has no script to run: the same thing as `-e`, spelled through
132
+ * stdin. Seen live on 2026-10-03: refused `node -e`, the chat sent `'…' | node` in its next step and
133
+ * it ran. An interpreter given a script file after the pipe (`| node tools/format.js`) is not this.
134
+ */
135
+ { what: 'code piped into an interpreter', pattern: /\|\s*&?\s*(node|deno|bun|python3?|py|ruby|perl|php)(\.exe)?(\s+-)?\s*($|[;|)&])/im },
130
136
  { what: 'deno eval', pattern: /\bdeno(\.exe)?\s+eval\b/i },
131
137
  { what: 'bun -e', pattern: /\bbun(\.exe)?\s+(-\S+\s+)*?-e\b/i },
132
138
  { what: 'python -c', pattern: /\b(python3?|py)(\.exe)?\s+(-\S+\s+)*?-c\b/i },
@@ -214,8 +220,11 @@ export function programRefusal(command, allowedPrograms, shell = 'pwsh') {
214
220
  const scan = scanCommandHeads(command, shell);
215
221
  if (scan.uncertain) {
216
222
  return (`refused: the runner could not tell which programs this line starts (${scan.uncertain}). ` +
217
- 'Write it so every program is named literally — close each quote and bracket, and call a program by its ' +
218
- 'name or a literal path rather than through a variable or an expression.');
223
+ // The advice for the case at hand: a here-string with text on its header line got the general one, which does not say what to change (live run 2026-10-04).
224
+ (/here-string/.test(scan.uncertain)
225
+ ? "PowerShell itself rejects it too: nothing may follow @' or @\" on its line — the text starts on the next line, and '@ or \"@ closes it at the very start of a line of its own."
226
+ : 'Write it so every program is named literally — close each quote and bracket, and call a program by its ' +
227
+ 'name or a literal path rather than through a variable or an expression.'));
219
228
  }
220
229
  // What `Start-Process` is asked to start is a program this line starts, as much as a head is.
221
230
  for (const head of [...scan.heads, ...startProcessTargets(command)]) {
@@ -44,9 +44,11 @@ export function clip(text, maxChars) {
44
44
  function sectionFor(r, maxOutputChars) {
45
45
  // A refused step never ran, so it is not given an exit code or a duration that could be read as
46
46
  // a command that ran and failed; the header says what happened instead.
47
- const head = r.outcome === 'refused'
48
- ? `--- step ${r.id} (${r.shell}, REFUSED by the runner, never executed, no exit code)\n$ ${r.command}\n`
49
- : `--- step ${r.id} (${r.shell}, ${r.outcome}, exit ${r.exitCode}, ` + `${(r.durationMs / 1000).toFixed(1)}s)\n$ ${r.command}\n`;
47
+ const head = r.outcome === 'refused' && r.skippedAfter !== undefined
48
+ ? `--- step ${r.id} (${r.shell}, NOT RUN because step ${r.skippedAfter} was refused, no exit code)\n$ ${r.command}\n`
49
+ : r.outcome === 'refused'
50
+ ? `--- step ${r.id} (${r.shell}, REFUSED by the runner, never executed, no exit code)\n$ ${r.command}\n`
51
+ : `--- step ${r.id} (${r.shell}, ${r.outcome}, exit ${r.exitCode}, ` + `${(r.durationMs / 1000).toFixed(1)}s)\n$ ${r.command}\n`;
50
52
  const out = clip(r.stdout, maxOutputChars);
51
53
  const err = r.stderr.trim().length > 0 ? `[stderr]\n${clip(r.stderr, maxOutputChars)}\n` : '';
52
54
  const note = r.truncated ? `[note] output was truncated; the full stream is in ${r.logPath}\n` : '';
@@ -276,6 +276,16 @@ export function shellNote(inventory = detectShells(), fallback) {
276
276
  * `cmdArgument`, because nothing else quotes them any more. PowerShell is not touched: it reads its
277
277
  * arguments by the C runtime's rules, and Node's quoting is the right quoting for it.
278
278
  */
279
+ /**
280
+ * Put in front of every PowerShell command: the console in UTF-8 both ways, and `$OutputEncoding` with it.
281
+ *
282
+ * Without it a step ran with the console's OEM code page (866 on this machine): what a native program
283
+ * printed and the step captured in a variable was decoded as 866 and written back mangled — node's "✔"
284
+ * saved as "тЬФ" in a kept artifact — and Cyrillic printed by a cmdlet reached the runner, which reads
285
+ * UTF-8, as "�" (live run 2026-10-03). Through a variable, because the line is a command line and the
286
+ * chat's own `[Type]::` restriction is about what survives the chat, not about what the runner runs.
287
+ */
288
+ export const PWSH_UTF8 = 'try { $__u = New-Object System.Text.UTF8Encoding $false; [Console]::OutputEncoding = $__u; [Console]::InputEncoding = $__u; $OutputEncoding = $__u; Remove-Variable __u } catch { }; ';
279
289
  export function invocationFor(resolved, command, scriptArgs) {
280
290
  const isScript = Array.isArray(scriptArgs);
281
291
  switch (resolved.shell) {
@@ -284,10 +294,11 @@ export function invocationFor(resolved, command, scriptArgs) {
284
294
  const base = ['-NoProfile', '-NonInteractive'];
285
295
  return isScript
286
296
  ? { file: resolved.path, args: [...base, '-File', command, ...(scriptArgs ?? [])], windowsVerbatimArguments: false }
287
- : { file: resolved.path, args: [...base, '-Command', command], windowsVerbatimArguments: false };
297
+ : { file: resolved.path, args: [...base, '-Command', `${PWSH_UTF8}${command}`], windowsVerbatimArguments: false };
288
298
  }
289
299
  case 'cmd': {
290
- const line = isScript ? [`"${command}"`, ...(scriptArgs ?? []).map(cmdArgument)].join(' ') : command;
300
+ // The code page to UTF-8 first, for the same reason as PowerShell's (see `PWSH_UTF8`).
301
+ const line = `chcp 65001 >nul & ${isScript ? [`"${command}"`, ...(scriptArgs ?? []).map(cmdArgument)].join(' ') : command}`;
291
302
  return { file: resolved.path, args: ['/d', '/s', '/c', `"${line}"`], windowsVerbatimArguments: true };
292
303
  }
293
304
  }
@@ -23,7 +23,12 @@ export class RunLog {
23
23
  }
24
24
  /** Structured record for the transcript; nothing is printed unless `human` is given. */
25
25
  event(type, data = {}, human, level = 'info') {
26
- this.stream.write(JSON.stringify({ at: new Date().toISOString(), type, ...data }) + '\n');
26
+ /*
27
+ * The level and the sentence go into the transcript too. Without them the runner export's list of
28
+ * problems was always empty — it looks for warn and error — and an event read back said only its type
29
+ * (live run 2026-10-03).
30
+ */
31
+ this.stream.write(JSON.stringify({ at: new Date().toISOString(), type, ...data, ...(human && data.message === undefined ? { message: human } : {}), ...(level !== 'info' ? { level } : {}) }) + '\n');
27
32
  if (human && !this.quiet) {
28
33
  const prefix = level === 'error' ? ' !! ' : level === 'warn' ? ' ! ' : ' ';
29
34
  process.stdout.write(prefix + human + '\n');
@@ -9,7 +9,7 @@
9
9
  * chat could never have met. This names the contradiction instead, before the first message.
10
10
  *
11
11
  * Only what can be known without running anything is judged: the flags against each other, and
12
- * the file checks against the tree as it is now. A file check that already passes needs no change;
12
+ * the file checks against the tree the task starts from (see `ContractTree`). A file check that already passes needs no change;
13
13
  * one that fails needs its file changed, which a read-only task, or a scope that leaves the file
14
14
  * out, forbids. Command checks are not judged: whether a command will pass depends on work the
15
15
  * task is allowed to do elsewhere.
@@ -17,15 +17,27 @@
17
17
  import { readFile, stat } from 'node:fs/promises';
18
18
  import { isAbsolute, relative, resolve } from 'node:path';
19
19
  import { inScope } from '../vcs/scope.js';
20
- async function fileCheckPassesNow(check, cwd) {
20
+ import { gitBytes } from '../vcs/git.js';
21
+ async function fileCheckPassesNow(check, cwd, repoDir, at) {
21
22
  const path = resolve(cwd, (check.cwd ?? '').trim() || '.', (check.file ?? '').trim());
22
- const info = await stat(path).catch(() => null);
23
- const exists = !!info?.isFile();
23
+ const rel = relative(resolve(repoDir), path).replace(/\\/g, '/');
24
+ const inRepo = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
25
+ let exists;
26
+ let text = '';
27
+ if (at !== 'tree' && inRepo) {
28
+ const shown = await gitBytes(repoDir, ['show', `${at.ref}:${rel}`]).catch(() => null);
29
+ exists = !!shown?.ok;
30
+ text = exists ? shown.stdout.toString('utf8') : '';
31
+ }
32
+ else {
33
+ const info = await stat(path).catch(() => null);
34
+ exists = !!info?.isFile();
35
+ text = exists && check.expect === 'file-contains' ? await readFile(path, 'utf8').catch(() => '') : '';
36
+ }
24
37
  if (check.expect === 'file-exists')
25
38
  return { passes: exists, path };
26
39
  if (check.expect === 'file-missing')
27
40
  return { passes: !exists, path };
28
- const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
29
41
  return { passes: exists && text.includes(check.value ?? ''), path };
30
42
  }
31
43
  /** `git` with the global options that may come before its subcommand. */
@@ -68,7 +80,7 @@ function branchCheckPasses(check, branch) {
68
80
  }
69
81
  /** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
70
82
  export async function contractConflicts(task, cwd, repoDir,
71
- /** The branch version control put the task on, when it is on. */
83
+ /** The branch version control put the task on, when it is on; where its file checks are judged (see `ContractTree`). */
72
84
  vcs = {}) {
73
85
  const out = [];
74
86
  const scope = task.scope ?? [];
@@ -96,12 +108,22 @@ vcs = {}) {
96
108
  ...(task.checks ?? []),
97
109
  ...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check),
98
110
  ].filter((c) => c.expect === 'file-exists' || c.expect === 'file-missing' || c.expect === 'file-contains');
111
+ const at = vcs.files ?? 'tree';
112
+ // Judged when the task starts, on its own tree, once that is known.
113
+ if (at === 'unknown')
114
+ return out;
99
115
  for (const check of checks) {
100
- const { passes, path } = await fileCheckPassesNow(check, cwd);
116
+ const { passes, path } = await fileCheckPassesNow(check, cwd, repoDir, at);
101
117
  if (passes)
102
118
  continue;
103
119
  const rel = relative(resolve(repoDir), path).replace(/\\/g, '/');
104
120
  const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
121
+ /*
122
+ * An input file the runner carries onto the task's start from its last capture is not in the tree
123
+ * judged here, and needs no change to be there: judged at the task's start (live run 2026-10-04).
124
+ */
125
+ if (inside && (vcs.carried?.length ?? 0) > 0 && inScope(rel, vcs.carried))
126
+ continue;
105
127
  if (task.readOnly) {
106
128
  out.push(`The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`);
107
129
  }
@@ -31,6 +31,7 @@ import { writeReport } from '../exec/reportFile.js';
31
31
  import { mergeRedactions } from '../exec/redaction.js';
32
32
  import { trackedRepoOf } from '../vcs/taskVcs.js';
33
33
  import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
34
+ import { inScope } from '../vcs/scope.js';
34
35
  /** The sentence both sides get when a review left something listening. */
35
36
  function leftoverNote(leftovers) {
36
37
  const listening = leftovers.filter((l) => l.ports.length > 0);
@@ -54,7 +55,18 @@ export function deliverableFor(task, summary, tracked, changedFiles) {
54
55
  export function reviewBrief(session, task, changedFiles, cwd, deviations = [], previous, disputes = [], derivedFailing = [], earlier = [], deliverable) {
55
56
  // The same repository the runner branched and read the changed files from, however it is named.
56
57
  const repo = trackedRepoOf(session);
57
- const files = changedFiles.length > 0 ? changedFiles.map((f) => `- ${f}`).join('\n') : '(version control recorded no file changes for this task)';
58
+ // Each file marked against the task's scope, and the scope said: the reviewer was shown side effects as
59
+ // the task's changes, with no scope to judge them by (live run 2026-10-03).
60
+ const scope = task.scope ?? [];
61
+ const outside = (f) => !!task.readOnly || (scope.length > 0 && !inScope(f, scope));
62
+ const files = changedFiles.length > 0
63
+ ? changedFiles.map((f) => `- ${f}${outside(f) ? " (outside what this task may change — a violation if it is the task's)" : ''}`).join('\n')
64
+ : '(version control recorded no file changes for this task)';
65
+ const rule = task.readOnly
66
+ ? 'This task is read-only: it may change no file in the project.'
67
+ : scope.length > 0
68
+ ? `This task may change only: ${scope.join(', ')}. The runner puts back what it changes outside that.`
69
+ : '';
58
70
  return [
59
71
  '## The task that was given',
60
72
  '',
@@ -81,6 +93,7 @@ export function reviewBrief(session, task, changedFiles, cwd, deviations = [], p
81
93
  `Working directory for your commands: ${cwd}`,
82
94
  repo ? `Repository: ${repo}` : 'There is no repository for this work.',
83
95
  '',
96
+ ...(rule ? [rule, ''] : []),
84
97
  'Files this task changed:',
85
98
  files,
86
99
  ...(deliverable
@@ -227,8 +240,8 @@ export async function runReview(transport, session, task, deps) {
227
240
  // The machine's shells, when they are not what the contract's example assumes — the same note
228
241
  // the implementer's conversation opens with, for a conversation working to the same example.
229
242
  const machineNote = shellNote(undefined, defaultShell);
230
- let before = await transport.sendAndConfirm(machineNote ? `${contract}
231
-
243
+ let before = await transport.sendAndConfirm(machineNote ? `${contract}
244
+
232
245
  ${machineNote}` : contract);
233
246
  await transport.waitForReply(before);
234
247
  await pacer.throttleSend();