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.
- package/LICENSE +21 -0
- package/README.md +556 -0
- package/dist/src/api/app.module.js +18 -0
- package/dist/src/api/dataAcl.js +109 -0
- package/dist/src/api/folderPicker.js +110 -0
- package/dist/src/api/main.js +132 -0
- package/dist/src/api/operator.controller.js +975 -0
- package/dist/src/api/operator.service.js +1971 -0
- package/dist/src/api/saveToDesktop.js +157 -0
- package/dist/src/api/security.js +225 -0
- package/dist/src/api/settings.js +57 -0
- package/dist/src/cli.js +531 -0
- package/dist/src/config/layout.js +66 -0
- package/dist/src/config/lockedPolicy.js +176 -0
- package/dist/src/config/schema.js +487 -0
- package/dist/src/context/contextFiles.js +295 -0
- package/dist/src/context/desktopMirror.js +113 -0
- package/dist/src/context/projectMirror.js +364 -0
- package/dist/src/exec/authorizer.js +61 -0
- package/dist/src/exec/checks.js +285 -0
- package/dist/src/exec/confinement.js +224 -0
- package/dist/src/exec/dangerous.js +161 -0
- package/dist/src/exec/environment.js +80 -0
- package/dist/src/exec/isolation.js +166 -0
- package/dist/src/exec/network.js +242 -0
- package/dist/src/exec/policy.js +201 -0
- package/dist/src/exec/policyManifest.js +84 -0
- package/dist/src/exec/processes.js +329 -0
- package/dist/src/exec/programs.js +290 -0
- package/dist/src/exec/redaction.js +93 -0
- package/dist/src/exec/reportFile.js +154 -0
- package/dist/src/exec/runner.js +245 -0
- package/dist/src/exec/scriptFiles.js +81 -0
- package/dist/src/exec/shellExecuteTrap.js +221 -0
- package/dist/src/exec/shells.js +298 -0
- package/dist/src/exec/stepEnv.js +72 -0
- package/dist/src/exec/winps.js +17 -0
- package/dist/src/exec/workDir.js +94 -0
- package/dist/src/log/runLog.js +43 -0
- package/dist/src/orchestrator/derivedChecks.js +95 -0
- package/dist/src/orchestrator/review.js +590 -0
- package/dist/src/orchestrator/taskRunner.js +1582 -0
- package/dist/src/plan/brief.js +1785 -0
- package/dist/src/plan/importPlan.js +202 -0
- package/dist/src/plan/schema.js +483 -0
- package/dist/src/plan/script.js +1012 -0
- package/dist/src/plan/systemGuide.js +1851 -0
- package/dist/src/protocol/parser.js +224 -0
- package/dist/src/protocol/replySchema.js +207 -0
- package/dist/src/protocol/reporter.js +75 -0
- package/dist/src/protocol/reviewSchema.js +205 -0
- package/dist/src/session/compose.js +67 -0
- package/dist/src/session/debugExport.js +165 -0
- package/dist/src/session/events.js +34 -0
- package/dist/src/session/exportRecord.js +261 -0
- package/dist/src/session/exports.js +484 -0
- package/dist/src/session/model.js +22 -0
- package/dist/src/session/retention.js +34 -0
- package/dist/src/session/runName.js +36 -0
- package/dist/src/session/store.js +440 -0
- package/dist/src/session/story.js +198 -0
- package/dist/src/transport/acceptance.js +34 -0
- package/dist/src/transport/chatSession.js +73 -0
- package/dist/src/transport/copilotTransport.js +1208 -0
- package/dist/src/transport/edgeCrash.js +59 -0
- package/dist/src/transport/locators.js +261 -0
- package/dist/src/transport/profileLock.js +102 -0
- package/dist/src/util/pacing.js +89 -0
- package/dist/src/vcs/commitHygiene.js +102 -0
- package/dist/src/vcs/git.js +286 -0
- package/dist/src/vcs/taskVcs.js +343 -0
- package/dist/web/404.html +15 -0
- package/dist/web/_next/static/chunks/12.f1f4d35b52633e8d.js +1 -0
- package/dist/web/_next/static/chunks/131-bae98f790a541679.js +1 -0
- package/dist/web/_next/static/chunks/197-f7c33a0114fc3db3.js +1 -0
- package/dist/web/_next/static/chunks/489.158bbe303a595fba.js +1 -0
- package/dist/web/_next/static/chunks/643-34b9f66a6c317c4a.js +1 -0
- package/dist/web/_next/static/chunks/867-03b7cac8f79896c3.js +1 -0
- package/dist/web/_next/static/chunks/955-63de06e182d98949.js +1 -0
- package/dist/web/_next/static/chunks/app/_not-found/page-0d0d8e7e62efca12.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-501842e7aeac7f6b.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/page-ed858127cbdfb4ab.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-bad4f3e89b485719.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-db1f5f1ab2f5cad1.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-72251c52cc54f95e.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/page-cf3e50cf64fecf4f.js +1 -0
- package/dist/web/_next/static/chunks/app/page-6fd16e5c8cf3b20f.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/page-951e78b80e0e22e6.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-f2e63618097d7221.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-44e7954c0e48f4ba.js +1 -0
- package/dist/web/_next/static/chunks/c7879cf7-b5ab1053c1d9a2e7.js +1 -0
- package/dist/web/_next/static/chunks/framework-9ac6a346bd42cedd.js +1 -0
- package/dist/web/_next/static/chunks/main-6be4444e2d667e41.js +1 -0
- package/dist/web/_next/static/chunks/main-app-e3a5f713ba31557e.js +1 -0
- package/dist/web/_next/static/chunks/pages/_app-32d20d5a04949d29.js +1 -0
- package/dist/web/_next/static/chunks/pages/_error-abbab53f25ea29b0.js +1 -0
- package/dist/web/_next/static/chunks/polyfills-42372ed130431b0a.js +1 -0
- package/dist/web/_next/static/chunks/webpack-0f31b926d9a9c5a4.js +1 -0
- package/dist/web/_next/static/css/d77735cdc9020a86.css +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_buildManifest.js +1 -0
- package/dist/web/_next/static/u8rZNwh4d3n-HGJnfZ72Y/_ssgManifest.js +1 -0
- package/dist/web/appearance.html +15 -0
- package/dist/web/appearance.txt +26 -0
- package/dist/web/defaults.html +15 -0
- package/dist/web/defaults.txt +26 -0
- package/dist/web/history.html +15 -0
- package/dist/web/history.txt +26 -0
- package/dist/web/import.html +15 -0
- package/dist/web/import.txt +26 -0
- package/dist/web/index.html +15 -0
- package/dist/web/index.txt +26 -0
- package/dist/web/level1.html +15 -0
- package/dist/web/level1.txt +26 -0
- package/dist/web/presets.html +18 -0
- package/dist/web/presets.txt +26 -0
- package/dist/web/sessions/view.html +15 -0
- package/dist/web/sessions/view.txt +26 -0
- package/dist/web/system.html +15 -0
- package/dist/web/system.txt +26 -0
- package/package.json +111 -0
- package/prompts/level1.md +535 -0
- package/prompts/organisation.bg.md +44 -0
- package/prompts/organisation.en.md +44 -0
- package/prompts/persona.bg.md +17 -0
- package/prompts/persona.en.md +17 -0
- package/prompts/review1.md +277 -0
- package/prompts/work.bg.md +19 -0
- 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
|
+
}
|