copilot-operator 0.1.0

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 (128) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +556 -0
  3. package/dist/src/api/app.module.js +18 -0
  4. package/dist/src/api/dataAcl.js +109 -0
  5. package/dist/src/api/folderPicker.js +110 -0
  6. package/dist/src/api/main.js +132 -0
  7. package/dist/src/api/operator.controller.js +975 -0
  8. package/dist/src/api/operator.service.js +1971 -0
  9. package/dist/src/api/saveToDesktop.js +157 -0
  10. package/dist/src/api/security.js +225 -0
  11. package/dist/src/api/settings.js +57 -0
  12. package/dist/src/cli.js +531 -0
  13. package/dist/src/config/layout.js +66 -0
  14. package/dist/src/config/lockedPolicy.js +176 -0
  15. package/dist/src/config/schema.js +487 -0
  16. package/dist/src/context/contextFiles.js +295 -0
  17. package/dist/src/context/desktopMirror.js +113 -0
  18. package/dist/src/context/projectMirror.js +364 -0
  19. package/dist/src/exec/authorizer.js +61 -0
  20. package/dist/src/exec/checks.js +285 -0
  21. package/dist/src/exec/confinement.js +224 -0
  22. package/dist/src/exec/dangerous.js +161 -0
  23. package/dist/src/exec/environment.js +80 -0
  24. package/dist/src/exec/isolation.js +166 -0
  25. package/dist/src/exec/network.js +242 -0
  26. package/dist/src/exec/policy.js +201 -0
  27. package/dist/src/exec/policyManifest.js +84 -0
  28. package/dist/src/exec/processes.js +329 -0
  29. package/dist/src/exec/programs.js +290 -0
  30. package/dist/src/exec/redaction.js +93 -0
  31. package/dist/src/exec/reportFile.js +154 -0
  32. package/dist/src/exec/runner.js +245 -0
  33. package/dist/src/exec/scriptFiles.js +81 -0
  34. package/dist/src/exec/shellExecuteTrap.js +221 -0
  35. package/dist/src/exec/shells.js +298 -0
  36. package/dist/src/exec/stepEnv.js +72 -0
  37. package/dist/src/exec/winps.js +17 -0
  38. package/dist/src/exec/workDir.js +94 -0
  39. package/dist/src/log/runLog.js +43 -0
  40. package/dist/src/orchestrator/derivedChecks.js +95 -0
  41. package/dist/src/orchestrator/review.js +590 -0
  42. package/dist/src/orchestrator/taskRunner.js +1582 -0
  43. package/dist/src/plan/brief.js +1785 -0
  44. package/dist/src/plan/importPlan.js +202 -0
  45. package/dist/src/plan/schema.js +483 -0
  46. package/dist/src/plan/script.js +1012 -0
  47. package/dist/src/plan/systemGuide.js +1851 -0
  48. package/dist/src/protocol/parser.js +224 -0
  49. package/dist/src/protocol/replySchema.js +207 -0
  50. package/dist/src/protocol/reporter.js +75 -0
  51. package/dist/src/protocol/reviewSchema.js +205 -0
  52. package/dist/src/session/compose.js +67 -0
  53. package/dist/src/session/debugExport.js +165 -0
  54. package/dist/src/session/events.js +34 -0
  55. package/dist/src/session/exportRecord.js +261 -0
  56. package/dist/src/session/exports.js +484 -0
  57. package/dist/src/session/model.js +22 -0
  58. package/dist/src/session/retention.js +34 -0
  59. package/dist/src/session/runName.js +36 -0
  60. package/dist/src/session/store.js +440 -0
  61. package/dist/src/session/story.js +198 -0
  62. package/dist/src/transport/acceptance.js +34 -0
  63. package/dist/src/transport/chatSession.js +73 -0
  64. package/dist/src/transport/copilotTransport.js +1208 -0
  65. package/dist/src/transport/edgeCrash.js +59 -0
  66. package/dist/src/transport/locators.js +261 -0
  67. package/dist/src/transport/profileLock.js +102 -0
  68. package/dist/src/util/pacing.js +89 -0
  69. package/dist/src/vcs/commitHygiene.js +102 -0
  70. package/dist/src/vcs/git.js +286 -0
  71. package/dist/src/vcs/taskVcs.js +343 -0
  72. package/dist/web/404.html +15 -0
  73. package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
  74. package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
  75. package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
  76. package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
  77. package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
  78. package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
  79. package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
  80. package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
  81. package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
  82. package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
  83. package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
  84. package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
  85. package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
  86. package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
  87. package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
  88. package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
  89. package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
  90. package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
  91. package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
  92. package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
  93. package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
  94. package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
  95. package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
  96. package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
  97. package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
  98. package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
  99. package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
  100. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
  101. package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
  102. package/dist/web/appearance.html +15 -0
  103. package/dist/web/appearance.txt +26 -0
  104. package/dist/web/defaults.html +15 -0
  105. package/dist/web/defaults.txt +26 -0
  106. package/dist/web/history.html +15 -0
  107. package/dist/web/history.txt +26 -0
  108. package/dist/web/import.html +15 -0
  109. package/dist/web/import.txt +26 -0
  110. package/dist/web/index.html +15 -0
  111. package/dist/web/index.txt +26 -0
  112. package/dist/web/level1.html +15 -0
  113. package/dist/web/level1.txt +26 -0
  114. package/dist/web/presets.html +18 -0
  115. package/dist/web/presets.txt +26 -0
  116. package/dist/web/sessions/view.html +15 -0
  117. package/dist/web/sessions/view.txt +26 -0
  118. package/dist/web/system.html +15 -0
  119. package/dist/web/system.txt +26 -0
  120. package/package.json +111 -0
  121. package/prompts/level1.md +535 -0
  122. package/prompts/organisation.bg.md +44 -0
  123. package/prompts/organisation.en.md +44 -0
  124. package/prompts/persona.bg.md +17 -0
  125. package/prompts/persona.en.md +17 -0
  126. package/prompts/review1.md +277 -0
  127. package/prompts/work.bg.md +19 -0
  128. package/prompts/work.en.md +19 -0
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Which Windows shell a command is handed to, decided in one place for everything that runs.
3
+ *
4
+ * This module exists because the runner had four opinions about shells and no way to reconcile
5
+ * them. A step carried whatever the parser had filled in, a check fell back to `pwsh` in
6
+ * `checks.ts`, the policy gate assumed `pwsh` while it screened a command for traps, and the
7
+ * runner itself turned all of that into a bare `pwsh.exe` for `spawn`. On the machine the bot
8
+ * was written on those four opinions happened to agree, so nothing ever disagreed loudly enough
9
+ * to be noticed. On a second Windows machine, which has Windows PowerShell and `cmd` but no
10
+ * PowerShell 7, every one of them resolved to an executable that is not there: the tasks and
11
+ * the post-task checks died of `spawn ENOENT` while the very same commands ran perfectly
12
+ * through `cmd`, and the task spent its retry rounds asking a language model to fix an
13
+ * interpreter it cannot install.
14
+ *
15
+ * So the knowledge of what a shell is called, where it lives and how it is invoked is gathered
16
+ * here, and two questions are kept deliberately apart. `resolveShell` answers the executable
17
+ * question — may this run, and with what — and it is allowed to refuse; a shell that was asked
18
+ * for by name and is not installed is a configuration fault, reported as one, and never quietly
19
+ * swapped for a different interpreter that would read the same command differently.
20
+ * `effectiveShell` answers the cheaper question of which shell a command will be read by, which
21
+ * is what a label in the log and the syntax-trap screen need, and it never refuses.
22
+ *
23
+ * Nothing is substituted behind anybody's back, but a machine that has a usable shell does get
24
+ * to use it: when nothing named a shell, the fallback order is PowerShell 7, then Windows
25
+ * PowerShell, then `cmd`, because that is the order of how much of what this bot sends will
26
+ * actually be understood.
27
+ *
28
+ * Detection is a set of file-system lookups rather than a set of child processes, so it costs
29
+ * nothing to do at the start of a run, and it is cached like the rest of the environment probes
30
+ * for the same reason they are: a shell does not appear halfway through a task.
31
+ */
32
+ import { existsSync } from 'node:fs';
33
+ import { delimiter, join } from 'node:path';
34
+ /**
35
+ * The order a command that named no shell is offered to them in.
36
+ *
37
+ * Most of what reaches this runner is PowerShell, written by a model that was asked for
38
+ * PowerShell, so the two PowerShells come first and the more capable one comes before the
39
+ * older one. `cmd` is last and is a genuine last resort: it is on every Windows machine ever
40
+ * made, which is the only reason it is in the list at all.
41
+ */
42
+ export const SHELL_ORDER = ['pwsh', 'powershell', 'cmd'];
43
+ /** What each shell is called on disk. */
44
+ const EXECUTABLE = {
45
+ pwsh: 'pwsh.exe',
46
+ powershell: 'powershell.exe',
47
+ cmd: 'cmd.exe',
48
+ };
49
+ /** What to tell somebody whose machine does not have one, as the second half of a sentence. */
50
+ const REMEDY = {
51
+ pwsh: 'install PowerShell 7 (winget install Microsoft.PowerShell)',
52
+ powershell: 'repair the Windows PowerShell that ships with Windows',
53
+ cmd: 'repair the Windows installation; cmd.exe is part of it',
54
+ };
55
+ let cached = null;
56
+ /** The usual places a shell sits when PATH has been trimmed or has not been inherited. */
57
+ function wellKnownPaths(shell, env) {
58
+ const programFiles = env.ProgramFiles ?? 'C:\\Program Files';
59
+ const systemRoot = env.SystemRoot ?? env.windir ?? 'C:\\Windows';
60
+ switch (shell) {
61
+ case 'pwsh':
62
+ // 7 first: a machine with both is a machine that installed 7 on purpose.
63
+ return [join(programFiles, 'PowerShell', '7', 'pwsh.exe'), join(programFiles, 'PowerShell', '6', 'pwsh.exe')];
64
+ case 'powershell':
65
+ return [join(systemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')];
66
+ case 'cmd':
67
+ return [join(systemRoot, 'System32', 'cmd.exe')];
68
+ }
69
+ }
70
+ /**
71
+ * Looks one shell up the way the operating system would, and then in the places it lives.
72
+ *
73
+ * PATH is walked by hand rather than asked of `where.exe`, because this runs at the start of
74
+ * every run and a file-system lookup costs nothing next to a child process — and because a
75
+ * machine whose PATH is broken is exactly the machine this is trying to help.
76
+ */
77
+ function findOnDisk(shell, env = process.env) {
78
+ const exe = EXECUTABLE[shell];
79
+ const path = env.PATH ?? env.Path ?? '';
80
+ for (const dir of path.split(delimiter)) {
81
+ // A PATH entry may be quoted, and a quoted directory joined as-is finds nothing.
82
+ const clean = dir.trim().replace(/^"|"$/g, '');
83
+ if (!clean)
84
+ continue;
85
+ const candidate = join(clean, exe);
86
+ if (existsSync(candidate))
87
+ return candidate;
88
+ }
89
+ for (const candidate of wellKnownPaths(shell, env)) {
90
+ if (existsSync(candidate))
91
+ return candidate;
92
+ }
93
+ return null;
94
+ }
95
+ /**
96
+ * What this machine has, collected once and reused.
97
+ *
98
+ * `fresh` re-probes, the way the environment manifest does. `probe` replaces the lookup
99
+ * altogether and also replaces what is cached, which is how a test describes a machine that is
100
+ * not this one — a machine without PowerShell 7 cannot otherwise be reasoned about from a
101
+ * machine that has it, and installing and uninstalling an interpreter to make a point is not a
102
+ * test, it is an afternoon.
103
+ */
104
+ export function detectShells(opts = {}) {
105
+ if (cached && !opts.fresh && !opts.probe)
106
+ return cached;
107
+ const probe = opts.probe ?? ((shell) => findOnDisk(shell));
108
+ cached = {
109
+ detectedAt: new Date().toISOString(),
110
+ found: {
111
+ pwsh: probe('pwsh'),
112
+ powershell: probe('powershell'),
113
+ cmd: probe('cmd'),
114
+ },
115
+ };
116
+ return cached;
117
+ }
118
+ /** An inventory of exactly the shells named, for a caller that already knows what is there. */
119
+ export function inventoryOf(found) {
120
+ return detectShells({ probe: (shell) => found[shell] ?? null });
121
+ }
122
+ /** The shells this machine has, in the order a command that named none is offered to them. */
123
+ export function availableShells(inventory = detectShells()) {
124
+ return SHELL_ORDER.filter((shell) => inventory.found[shell]);
125
+ }
126
+ function problemFor(requested, inventory) {
127
+ const available = availableShells(inventory);
128
+ const here = available.length > 0 ? `what this machine has is ${available.join(', ')}` : 'this machine appears to have no shell at all';
129
+ const message = requested === null
130
+ ? `no Windows shell was found on this machine: none of ${SHELL_ORDER.map((s) => EXECUTABLE[s]).join(', ')} is on PATH or in its usual place. ` +
131
+ 'Nothing can be run until one of them is.'
132
+ : `${EXECUTABLE[requested]} is not on this machine, and it was asked for by name, so nothing was substituted for it. ` +
133
+ `To run this, ${REMEDY[requested]}, or ask for one of the shells that are here instead — ${here}. ` +
134
+ 'A step or a check names its shell in "shell"; the standing default is execution.defaultShell in the config.';
135
+ return { requested, available, message };
136
+ }
137
+ /**
138
+ * Which shell something runs in, and whether it may run at all.
139
+ *
140
+ * A named shell is honoured or refused, never exchanged: a command written for PowerShell and
141
+ * quietly handed to `cmd` does not fail, it does something else, and finding that out from a
142
+ * task's output costs more than the refusal ever would. A command that named no shell takes
143
+ * the first shell this machine has, in `SHELL_ORDER`.
144
+ */
145
+ export function resolveShell(requested, inventory = detectShells()) {
146
+ if (requested) {
147
+ const path = inventory.found[requested];
148
+ return path
149
+ ? { ok: true, resolved: { requested, shell: requested, path } }
150
+ : { ok: false, problem: problemFor(requested, inventory) };
151
+ }
152
+ const shell = availableShells(inventory)[0];
153
+ return shell
154
+ ? { ok: true, resolved: { requested: null, shell, path: inventory.found[shell] } }
155
+ : { ok: false, problem: problemFor(null, inventory) };
156
+ }
157
+ /**
158
+ * The same refusal, addressed to the chat instead of to the operator.
159
+ *
160
+ * `problemFor` writes for a person: install PowerShell 7, or set `execution.defaultShell`. Both
161
+ * are true and neither is anything a language model can do, and this runner already knows what
162
+ * happens when a model is handed an instruction it cannot carry out — it tries something else,
163
+ * fails the same way, and spends the round. So a step refused for its shell gets the half of the
164
+ * fact it can act on: what is here, and what to write instead.
165
+ *
166
+ * `fallback` is the shell a step that names none actually gets, which is not always the first one
167
+ * the machine has: `execution.defaultShell` decides it whenever the machine has that shell. The
168
+ * two have to be named by the same value or the message offers a choice between two things it
169
+ * calls equivalent and are not.
170
+ */
171
+ export function refusalForChat(problem, fallback) {
172
+ if (problem.available.length === 0) {
173
+ return ('this machine has no Windows shell the runner can find, so nothing can be run on it at all. ' +
174
+ 'Do not send more steps: end the task with status "blocked" and say that the machine has no shell.');
175
+ }
176
+ const use = fallback && problem.available.includes(fallback) ? fallback : problem.available[0];
177
+ const named = problem.requested ? `\`${problem.requested}\` is not installed on this machine` : 'the shell this step asked for is not installed on this machine';
178
+ return (`${named}, and nothing was substituted for it, because a command written for one shell and read by ` +
179
+ `another does not fail — it does something else. This machine has ${problem.available.map((s) => `\`${s}\``).join(' and ')}. ` +
180
+ `Send this step again with \`"shell": "${use}"\`, or leave \`shell\` out and the runner uses \`${use}\`, and write the ` +
181
+ `command for that shell. Do not ask for anything to be installed: nothing in this conversation can install it.`);
182
+ }
183
+ /**
184
+ * Which shell a command will be read by, for a label or for a screen of its syntax.
185
+ *
186
+ * Never refuses, because neither of those callers is about to start a process: describing a
187
+ * step in the log and deciding whether a command contains a trap both need a name, and a name
188
+ * they can always be given. Whether the thing may actually run is `resolveShell`'s question.
189
+ *
190
+ * A shell that was named and is not installed keeps its name here rather than falling through
191
+ * the order. It is not going to run in anything, so the honest label is the one that says what
192
+ * was asked for — which is also what the refusal, when it comes, will be about.
193
+ */
194
+ export function effectiveShell(requested, inventory = detectShells()) {
195
+ const choice = resolveShell(requested, inventory);
196
+ return choice.ok ? choice.resolved.shell : (requested ?? SHELL_ORDER[0]);
197
+ }
198
+ /**
199
+ * The standing default, checked against what is here.
200
+ *
201
+ * `execution.defaultShell` is a preference and not an instruction: nothing wrote it for this
202
+ * task, it is what a step or a check gets when it says nothing at all, and a machine that has
203
+ * not got it should fall through the order rather than fail every command with a spawn error.
204
+ * A shell a reply or a plan named for itself goes through `resolveShell` and is never treated
205
+ * this way.
206
+ */
207
+ export function preferredShell(configured, inventory = detectShells()) {
208
+ return inventory.found[configured] ? configured : (availableShells(inventory)[0] ?? configured);
209
+ }
210
+ /**
211
+ * What the chat is told about this machine's shells, or nothing when there is nothing to say.
212
+ *
213
+ * The contract in `prompts/level1.md` shows `"shell": "pwsh"` in its one worked example, and a
214
+ * model writing to an example copies the example. On the machine the bot was written on that is
215
+ * harmless, because `pwsh` is there. On a machine with only Windows PowerShell it is the whole
216
+ * problem: every first reply names an interpreter that does not exist, every step is refused, and
217
+ * a round is spent discovering something the runner knew before the conversation opened.
218
+ *
219
+ * So it is said up front — but only when it is not already true. A machine with PowerShell 7 gets
220
+ * no note at all, which is deliberate: the contract is already right there, and a paragraph
221
+ * repeating it is a paragraph of context spent saying nothing.
222
+ */
223
+ export function shellNote(inventory = detectShells(), fallback) {
224
+ if (inventory.found.pwsh)
225
+ return undefined;
226
+ const available = availableShells(inventory);
227
+ const lines = available.length === 0
228
+ ? [
229
+ '## Shells on this machine',
230
+ '',
231
+ 'This machine has none of pwsh.exe, powershell.exe or cmd.exe where the runner can find them.',
232
+ 'No command can be run until that is fixed. End the task with status "blocked", and fill',
233
+ '"tried" — the format wants two entries there — with what you attempted and this refusal.',
234
+ ]
235
+ : (() => {
236
+ const chosen = fallback && inventory.found[fallback] ? fallback : available[0];
237
+ return [
238
+ '## Shells on this machine',
239
+ '',
240
+ `**PowerShell 7 (\`pwsh\`) is not installed here.** This machine has ${available.map((s) => `\`${s}\``).join(' and ')}.`,
241
+ `A step that names \`"shell": "pwsh"\` is refused and has to be written again, so do not name it:`,
242
+ `write \`"shell": "${chosen}"\`, or leave \`shell\` out, and the runner uses \`${chosen}\`.`,
243
+ '',
244
+ chosen === 'cmd'
245
+ ? 'Write the commands for `cmd` rather than for PowerShell: no cmdlets, no pipelines of objects, no `$PSVersionTable`.'
246
+ : 'Windows PowerShell 5.1 is not PowerShell 7: no `??`, no `?.`, no ternary operator, and `Invoke-RestMethod` behaves differently.',
247
+ ];
248
+ })();
249
+ return lines.join('\n');
250
+ }
251
+ /**
252
+ * How one shell is asked to run one command.
253
+ *
254
+ * The flags are not incidental: no profile so that a machine's own startup script cannot change
255
+ * what a step does, and non-interactive so that a prompt is an error rather than a hang.
256
+ *
257
+ * No `-ExecutionPolicy` at all, since 2026-09-27. It was `Bypass` for the file steps that no longer
258
+ * exist, then `RemoteSigned` for a day. The operator's decision: the machine's own policy applies,
259
+ * whatever it is. On a company laptop that policy is set by the administrators, and a process that
260
+ * overrides it on every command line — even to a value they would agree with — is exactly the
261
+ * process their tooling is told to look at; and a policy the runner cannot change is one more thing
262
+ * a security review can take at face value. The cost is on a machine set to `Restricted` or
263
+ * `AllSigned`: the `.ps1` shims that npm and its kin install are then refused, and the chat is told
264
+ * (in the contract) to call the `.cmd` form of the tool instead.
265
+ */
266
+ export function invocationFor(resolved, command, scriptArgs) {
267
+ const isScript = Array.isArray(scriptArgs);
268
+ switch (resolved.shell) {
269
+ case 'pwsh':
270
+ case 'powershell': {
271
+ const base = ['-NoProfile', '-NonInteractive'];
272
+ return isScript
273
+ ? { file: resolved.path, args: [...base, '-File', command, ...(scriptArgs ?? [])] }
274
+ : { file: resolved.path, args: [...base, '-Command', command] };
275
+ }
276
+ case 'cmd':
277
+ return isScript
278
+ ? { file: resolved.path, args: ['/d', '/c', command, ...(scriptArgs ?? [])] }
279
+ : { file: resolved.path, args: ['/d', '/s', '/c', command] };
280
+ }
281
+ }
282
+ /**
283
+ * A shell that was found at detection time and would not start when it was wanted.
284
+ *
285
+ * Rare and worth saying plainly: an interpreter that was uninstalled during the run, a PATH
286
+ * entry pointing at a folder that has gone, an antivirus holding the file. It is the same class
287
+ * of fault as a missing shell — the machine, not the work — so it is reported the same way.
288
+ */
289
+ export function missingShellProblem(resolved, inventory = detectShells()) {
290
+ const others = availableShells(inventory).filter((s) => s !== resolved.shell);
291
+ return {
292
+ requested: resolved.requested,
293
+ available: availableShells(inventory),
294
+ message: `${resolved.path} would not start, although ${EXECUTABLE[resolved.shell]} was found there when this run began. ` +
295
+ `Check that it is still installed and still on PATH${others.length > 0 ? `, or run this in ${others.join(' or ')} instead` : ''}. ` +
296
+ `If it has gone, ${REMEDY[resolved.shell]}.`,
297
+ };
298
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The environment a command step is started with — named, not inherited.
3
+ *
4
+ * Steps used to be given the bot's whole environment. Found on 2026-09-27: that included the API
5
+ * token (put there by `npm start` for the web build), so a model-written step could read it and
6
+ * call the API that approves its own held steps, switches a run to unattended and edits the deny
7
+ * list. It also carried whatever else the operator's shell happened to hold — cloud keys, CI tokens
8
+ * — to be printed into a results file that goes to the chat.
9
+ *
10
+ * So a step gets the variables Windows and the toolchains need to work, by name, and nothing else:
11
+ * the system folders and the user's profile folders (npm, dotnet and pip keep their caches there),
12
+ * `PATH`, the processor facts some builds read, the toolchain homes, and the proxy and certificate
13
+ * settings a company network needs for `npm install` to reach its registry. `PSModulePath` is left
14
+ * out on purpose — see `winps.ts` for what it breaks in Windows PowerShell. Anything a project truly
15
+ * needs beyond this is named by the operator in `execution.passEnv`.
16
+ *
17
+ * And three are set, not passed:
18
+ *
19
+ * npm_config_ignore_scripts=true `npm install` no longer runs the install scripts of what it
20
+ * YARN_ENABLE_SCRIPTS=0 downloads. A package's `postinstall` is code fetched from a
21
+ * registry and executed with nobody reading it — exactly the
22
+ * download-then-run the operator ruled out (2026-09-27). The
23
+ * honest cost: packages that build something when installed
24
+ * (esbuild, sharp, Playwright's browsers) may not work until the
25
+ * operator installs them by hand.
26
+ * NEXT_TELEMETRY_DISABLED=1 a Next.js project's build would otherwise report to a third
27
+ * party from this machine.
28
+ */
29
+ /** Passed through when present, compared without regard to case as Windows does. */
30
+ const PASSED = [
31
+ // Windows itself
32
+ 'SystemRoot', 'SystemDrive', 'windir', 'ComSpec', 'PATH', 'PATHEXT', 'OS',
33
+ 'PROCESSOR_ARCHITECTURE', 'PROCESSOR_IDENTIFIER', 'PROCESSOR_LEVEL', 'PROCESSOR_REVISION', 'NUMBER_OF_PROCESSORS',
34
+ 'ProgramData', 'ProgramFiles', 'ProgramFiles(x86)', 'ProgramW6432',
35
+ 'CommonProgramFiles', 'CommonProgramFiles(x86)', 'CommonProgramW6432', 'ALLUSERSPROFILE', 'PUBLIC',
36
+ // the user's folders, where toolchains keep caches and settings
37
+ 'USERPROFILE', 'USERNAME', 'USERDOMAIN', 'COMPUTERNAME', 'HOMEDRIVE', 'HOMEPATH', 'APPDATA', 'LOCALAPPDATA', 'TEMP', 'TMP',
38
+ // toolchain homes
39
+ 'JAVA_HOME', 'DOTNET_ROOT', 'GOROOT', 'GOPATH', 'CARGO_HOME', 'RUSTUP_HOME', 'NVM_HOME', 'NVM_SYMLINK', 'PNPM_HOME',
40
+ // a company network: the proxy and the company's own certificate authority
41
+ 'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'REQUESTS_CA_BUNDLE', 'PIP_INDEX_URL',
42
+ // language and time, so output reads the same as at the operator's own prompt
43
+ 'LANG', 'LC_ALL', 'TZ',
44
+ ];
45
+ /** Names that pass whatever they are called: a toolchain's telemetry switch is always the operator's to set. */
46
+ const PASSED_BY_SHAPE = /telemetry|_optout$|^do_not_track$|_nologo$/i;
47
+ const FORCED = {
48
+ NO_COLOR: '1',
49
+ TERM: 'dumb',
50
+ npm_config_ignore_scripts: 'true',
51
+ YARN_ENABLE_SCRIPTS: '0',
52
+ // No toolchain reports home from a step: a company's opt-outs, set for every build tool that
53
+ // honours them, so that the step's own traffic is the project's and nothing else.
54
+ NEXT_TELEMETRY_DISABLED: '1',
55
+ DOTNET_CLI_TELEMETRY_OPTOUT: '1',
56
+ DOTNET_NOLOGO: '1',
57
+ POWERSHELL_TELEMETRY_OPTOUT: '1',
58
+ DO_NOT_TRACK: '1',
59
+ };
60
+ /** Names a step must never be given, even if the operator lists them: the bot's own keys. */
61
+ const NEVER = /^(NEXT_PUBLIC_COP_|COP_)/i;
62
+ export function stepEnvironment(from = process.env, extra = []) {
63
+ const wanted = new Set([...PASSED, ...extra].map((n) => n.toLowerCase()));
64
+ const out = {};
65
+ for (const [k, v] of Object.entries(from)) {
66
+ if (v === undefined || NEVER.test(k))
67
+ continue;
68
+ if (wanted.has(k.toLowerCase()) || PASSED_BY_SHAPE.test(k))
69
+ out[k] = v;
70
+ }
71
+ return { ...out, ...FORCED };
72
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The environment the bot's own Windows PowerShell calls are started with.
3
+ *
4
+ * Found on 2026-09-27: when the bot is started from a PowerShell 7 terminal, its environment carries
5
+ * PowerShell 7's `PSModulePath`, and a Windows PowerShell 5.1 started from it looks for its own
6
+ * built-in modules in PowerShell 7's folders first — `Get-Acl` then fails with "the module could not
7
+ * be loaded", and so can any cmdlet that lives in a module. Without the variable, Windows PowerShell
8
+ * builds its default path, which is the one it was written for. Every call the bot makes to
9
+ * `powershell` goes through this, so the answer does not depend on which terminal started it.
10
+ */
11
+ export function winPsEnv(env = process.env) {
12
+ const out = {};
13
+ for (const [k, v] of Object.entries(env))
14
+ if (k.toLowerCase() !== 'psmodulepath')
15
+ out[k] = v;
16
+ return out;
17
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Where a session's commands run.
3
+ *
4
+ * Until now every step, every check and every review command ran in `execution.cwd`, whose
5
+ * default is `.` — the directory the runner was started from, which for the API is this
6
+ * project's own checkout. The first step of a nine-task plan ran with
7
+ * `cwd=C:\Projects\automate-365` while writing files into `C:\Projects\calculator-test`, and
8
+ * it went right only because the model used absolute paths, as the plan's instructions told it
9
+ * to. That is a plan author compensating for a runner default: one relative path in one step
10
+ * and the model is editing the bot. The reviewer was told the same directory in as many words,
11
+ * "Working directory for your commands: C:\Projects\automate-365", and the implementer was told
12
+ * nothing at all.
13
+ *
14
+ * A session already knows its project — `vcs.repoDir` says where the branches go and
15
+ * `mirror.rootDir` where the files are — so that is where its commands run. The configured
16
+ * `execution.cwd` is the fallback for a session with no project, and this project's own
17
+ * checkout is never a fallback: a session that would land here by default does not run, and
18
+ * says why. Landing here on purpose — the operator pointed the session at the bot itself — is
19
+ * allowed and said out loud, because that is a decision somebody made.
20
+ */
21
+ import { readFileSync } from 'node:fs';
22
+ import { dirname, resolve, sep } from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+ let cachedBotRoot;
25
+ /**
26
+ * This project's own root: the nearest ancestor of this file with a package.json named
27
+ * `copilot-operator`. Walks rather than counting `..`, because the file sits at one depth under
28
+ * `src/` and another under `dist/src/`.
29
+ */
30
+ export function botRootDir() {
31
+ if (cachedBotRoot)
32
+ return cachedBotRoot;
33
+ let dir = dirname(fileURLToPath(import.meta.url));
34
+ for (;;) {
35
+ try {
36
+ const pkg = JSON.parse(readFileSync(resolve(dir, 'package.json'), 'utf8'));
37
+ if (pkg.name === 'copilot-operator')
38
+ return (cachedBotRoot = dir);
39
+ }
40
+ catch {
41
+ // No package.json here, or not ours. Keep walking.
42
+ }
43
+ const parent = dirname(dir);
44
+ if (parent === dir)
45
+ return (cachedBotRoot = process.cwd());
46
+ dir = parent;
47
+ }
48
+ }
49
+ function isInside(dir, root) {
50
+ const a = resolve(dir).toLowerCase();
51
+ const b = resolve(root).toLowerCase();
52
+ return a === b || a.startsWith(b.endsWith(sep) ? b : b + sep);
53
+ }
54
+ /** Whether a directory is, or is inside, this runner's own checkout. */
55
+ export function isOwnCheckout(dir, own = botRootDir()) {
56
+ return isInside(dir, own);
57
+ }
58
+ /**
59
+ * Decides the directory a session's commands run in, or refuses.
60
+ *
61
+ * `configCwd` is `execution.cwd` already resolved; `own` is this runner's root, a parameter so
62
+ * the rule can be exercised against a made-up one.
63
+ */
64
+ export function workingDirFor(session, configCwd, own = botRootDir()) {
65
+ const repo = (session.vcs?.repoDir ?? '').trim();
66
+ const mirror = (session.mirror?.rootDir ?? '').trim();
67
+ if (repo)
68
+ return { cwd: resolve(repo), source: 'repository', ownCheckout: isInside(repo, own) };
69
+ if (mirror)
70
+ return { cwd: resolve(mirror), source: 'mirror', ownCheckout: isInside(mirror, own) };
71
+ if (isInside(configCwd, own)) {
72
+ return {
73
+ cwd: resolve(configCwd),
74
+ problem: "no working directory for this session's commands: it names no project folder, and the configured " +
75
+ `default (${resolve(configCwd)}) is this runner's own checkout, where nothing the model writes belongs. ` +
76
+ 'Give the session a project folder, or set execution.cwd to a folder of its own.',
77
+ };
78
+ }
79
+ return { cwd: resolve(configCwd), source: 'config', ownCheckout: false };
80
+ }
81
+ export function isWorkingDirProblem(w) {
82
+ return 'problem' in w;
83
+ }
84
+ /** What the model is told, so a relative path is a fact rather than a guess. */
85
+ export function workingDirNote(w) {
86
+ return [
87
+ '## Working directory',
88
+ '',
89
+ `Commands run in \`${w.cwd}\` unless a step changes directory. Relative paths resolve there.`,
90
+ ...(w.ownCheckout
91
+ ? ['', "That folder is this runner's own checkout. The operator pointed the session at it deliberately."]
92
+ : []),
93
+ ].join('\n');
94
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The run log: a JSONL transcript on disk plus a readable line on the console.
3
+ *
4
+ * Everything the bot does is appended to `runs/<runId>/transcript.jsonl`, including every
5
+ * prompt sent, every reply received, every command and its exit code, and every file hash.
6
+ * A run that goes wrong is then reconstructable without guessing, which matters when the
7
+ * thing being audited is a machine running model-written commands.
8
+ */
9
+ import { createWriteStream } from 'node:fs';
10
+ import { mkdirSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ export class RunLog {
13
+ runId;
14
+ quiet;
15
+ stream;
16
+ dir;
17
+ constructor(runId, runsDir, quiet = false) {
18
+ this.runId = runId;
19
+ this.quiet = quiet;
20
+ this.dir = join(runsDir, runId);
21
+ mkdirSync(this.dir, { recursive: true });
22
+ this.stream = createWriteStream(join(this.dir, 'transcript.jsonl'), { flags: 'a' });
23
+ }
24
+ /** Structured record for the transcript; nothing is printed unless `human` is given. */
25
+ event(type, data = {}, human, level = 'info') {
26
+ this.stream.write(JSON.stringify({ at: new Date().toISOString(), type, ...data }) + '\n');
27
+ if (human && !this.quiet) {
28
+ const prefix = level === 'error' ? ' !! ' : level === 'warn' ? ' ! ' : ' ';
29
+ process.stdout.write(prefix + human + '\n');
30
+ }
31
+ }
32
+ /** Console-only, for things too noisy or too transient for the transcript. */
33
+ say(text) {
34
+ if (!this.quiet)
35
+ process.stdout.write(text + '\n');
36
+ }
37
+ path(...parts) {
38
+ return join(this.dir, ...parts);
39
+ }
40
+ async close() {
41
+ await new Promise((resolve) => this.stream.end(resolve));
42
+ }
43
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Checks that come from review findings, and what happens to them over a task's life.
3
+ *
4
+ * A review is judgement, and judgement varies between runs: the same missing `@HttpCode(200)`
5
+ * was found by one reviewer and missed by the next. So a reviewer may give, with a finding,
6
+ * the mechanical test that would have caught it, in the plan's own check shape — and from
7
+ * then on the runner repeats it, on this attempt and on every later one. Three rules keep that
8
+ * from turning a wrong finding into a permanent wall:
9
+ *
10
+ * - A check is kept only if it fails on the work as it stands. One that passes on the
11
+ * defective state does not capture the defect; it is refused, and the reviewer is told.
12
+ * - A dispute suspends the finding's check until the next review rules: raised again, the
13
+ * check comes back; not raised, it is dropped. A counter never overrules a person.
14
+ * - A derived check sends the work back like any other, but never ends the task by itself.
15
+ * With the rounds spent and only derived checks failing, the work goes to the reviewer
16
+ * with those failures named. The next reviewer's judgement outranks the last one's check.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { runCheck } from '../exec/checks.js';
20
+ import { isRepeat } from '../protocol/reviewSchema.js';
21
+ /** How a derived check is named in the gate, so its result is recognisable and cannot clash. */
22
+ export function derivedCheckName(findingId, name) {
23
+ return `review ${findingId}: ${name}`;
24
+ }
25
+ export function isDerivedCheck(check) {
26
+ return /^review r\d+f\d+: /.test(check.name);
27
+ }
28
+ /**
29
+ * Runs each finding's check on the current state. Kept if it fails now, refused if it passes.
30
+ */
31
+ export async function validateDerivedChecks(findings, opts) {
32
+ const out = { kept: [], refused: [], blocked: [] };
33
+ for (const [i, finding] of findings.entries()) {
34
+ if (!finding.check)
35
+ continue;
36
+ const check = { ...finding.check, name: derivedCheckName(finding.id, finding.check.name) };
37
+ const outcome = await runCheck(check, 500 + i, {
38
+ cwd: opts.cwd,
39
+ logDir: join(opts.logDir, 'derived'),
40
+ repoDir: opts.repoDir,
41
+ deny: opts.deny,
42
+ roots: opts.roots,
43
+ signal: opts.signal,
44
+ defaultShell: opts.defaultShell,
45
+ tracker: opts.tracker,
46
+ passEnv: opts.passEnv,
47
+ });
48
+ if (outcome.refusedBeforeRunning)
49
+ out.blocked.push({ finding, check, outcome });
50
+ else
51
+ (outcome.passed ? out.refused : out.kept).push({ finding, check, outcome });
52
+ }
53
+ return out;
54
+ }
55
+ /** The derived checks that run in the gate. */
56
+ export function activeChecks(reviewChecks) {
57
+ return reviewChecks.filter((rc) => rc.state === 'active').map((rc) => rc.check);
58
+ }
59
+ /** A dispute of a finding suspends its check until the next review rules. */
60
+ export function suspendDisputed(reviewChecks, disputedIds) {
61
+ const ids = new Set(disputedIds.map((d) => d.trim().toLowerCase()));
62
+ const suspended = [];
63
+ const checks = reviewChecks.map((rc) => {
64
+ if (rc.state !== 'active' || !ids.has(rc.findingId.toLowerCase()))
65
+ return rc;
66
+ suspended.push(rc.findingId);
67
+ return { ...rc, state: 'suspended' };
68
+ });
69
+ return { checks, suspended };
70
+ }
71
+ /**
72
+ * What a review's verdict does to suspended checks: raised again, back to active; not raised,
73
+ * dropped — the dispute stood. On a pass every suspended one is dropped.
74
+ */
75
+ export function settleAfterReview(reviewChecks, verdict, newFindings) {
76
+ const reactivated = [];
77
+ const dropped = [];
78
+ const checks = reviewChecks.map((rc) => {
79
+ if (rc.state !== 'suspended')
80
+ return rc;
81
+ const raisedAgain = verdict === 'fail' && newFindings.some((f) => isRepeat([{ what: rc.what, evidence: '', basis: '', where: rc.where ?? '', about: 'work' }], f));
82
+ if (raisedAgain) {
83
+ reactivated.push(rc.findingId);
84
+ return { ...rc, state: 'active' };
85
+ }
86
+ dropped.push(rc.findingId);
87
+ return { ...rc, state: 'dropped' };
88
+ });
89
+ return { checks, reactivated, dropped };
90
+ }
91
+ /** Whether the failures left are all derived, which is when the work goes to the reviewer anyway. */
92
+ export function onlyDerivedFailing(outcomes) {
93
+ const failed = outcomes.filter((o) => !o.passed);
94
+ return failed.length > 0 && failed.every((o) => isDerivedCheck(o.check));
95
+ }