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,590 @@
1
+ /**
2
+ * The second opinion: a fresh conversation that checks the work by running it.
3
+ *
4
+ * A task that has finished and passed its checks has cleared two bars, and both of them were
5
+ * set by somebody who already knew what they were looking for. The checks test the claims the
6
+ * operator thought to write down; the implementer's own verification tests the claims the
7
+ * implementer thought to make. Neither catches the defect nobody anticipated — and the shape
8
+ * of that defect is always the same: a gap between what was made and what it promises.
9
+ *
10
+ * So the work goes to a conversation that had no part in it. It is given the task, the project
11
+ * instructions and the list of files that changed, and nothing else. In particular it is not
12
+ * given the implementer's summary, because the summary is an account of what somebody believes
13
+ * they did, and a reviewer reading it starts by trusting the very thing under review.
14
+ *
15
+ * It is not a second brain. The same model has the same blind spots in either conversation,
16
+ * which is why the reviewer can be put on a different model, and why the contract is built
17
+ * around forcing execution rather than inviting an opinion: a review that ran nothing cannot
18
+ * pass, whatever it writes. What independence buys is the absence of attachment — the reviewer
19
+ * has no idea what the work was meant to be, only what it is.
20
+ */
21
+ import { readFile, writeFile, mkdir } from 'node:fs/promises';
22
+ import { join } from 'node:path';
23
+ import { installLayout } from '../config/layout.js';
24
+ import { parseReview, formatErrorMessage, findLikelyDamage, damageGuidance } from '../protocol/parser.js';
25
+ import { describeFindings, findingId, isGrounded } from '../protocol/reviewSchema.js';
26
+ import { runStep } from '../exec/runner.js';
27
+ import { effectiveShell, preferredShell, refusalForChat, resolveShell, shellNote } from '../exec/shells.js';
28
+ import { describeStep, checkCommandRefusal } from '../exec/policy.js';
29
+ import { validateDerivedChecks } from './derivedChecks.js';
30
+ import { writeReport } from '../exec/reportFile.js';
31
+ import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
32
+ /** The sentence both sides get when a review left something listening. */
33
+ function leftoverNote(leftovers) {
34
+ const listening = leftovers.filter((l) => l.ports.length > 0);
35
+ if (listening.length === 0)
36
+ return '';
37
+ return (`After this review the runner stopped ${listening.length} process(es) the reviewer's own steps had left running: ` +
38
+ listening.map((l) => `${l.name} listening on ${l.ports.join(', ')}`).join('; ') +
39
+ '. A finding about one of those ports is the reviewer\'s own doing, not the work\'s.');
40
+ }
41
+ /** True when the closing account is the product, so the reviewer must be given it. */
42
+ export function deliverableFor(task, summary, tracked, changedFiles) {
43
+ const text = (summary ?? '').trim();
44
+ if (!text)
45
+ return undefined;
46
+ if (task.readOnly)
47
+ return { summary: text, why: 'read-only' };
48
+ if (tracked && changedFiles.length === 0)
49
+ return { summary: text, why: 'no-files-changed' };
50
+ return undefined;
51
+ }
52
+ export function reviewBrief(session, task, changedFiles, cwd, deviations = [], previous, disputes = [], derivedFailing = [], earlier = [], deliverable) {
53
+ const repo = session.vcs?.enabled ? session.vcs.repoDir?.trim() : '';
54
+ const files = changedFiles.length > 0 ? changedFiles.map((f) => `- ${f}`).join('\n') : '(version control recorded no file changes for this task)';
55
+ return [
56
+ '## The task that was given',
57
+ '',
58
+ task.prompt.trim(),
59
+ '',
60
+ '## The project instructions that applied',
61
+ '',
62
+ task.level2.trim() || '(none)',
63
+ ...(earlier.length > 0
64
+ ? [
65
+ '',
66
+ '## Earlier tasks of this session, for context',
67
+ '',
68
+ 'This task builds on the ones below, in the same working tree. They were reviewed already and',
69
+ 'are not yours to judge again; they are here because what they defined is what this task rests',
70
+ 'on — a label, a port, a route named there is the expectation here. A finding may quote them.',
71
+ '',
72
+ ...earlier.map((e) => `### Earlier task: ${e.title}\n\n${e.prompt.trim()}`),
73
+ ]
74
+ : []),
75
+ '',
76
+ '## Where to look',
77
+ '',
78
+ `Working directory for your commands: ${cwd}`,
79
+ repo ? `Repository: ${repo}` : 'There is no repository for this work.',
80
+ '',
81
+ 'Files this task changed:',
82
+ files,
83
+ ...(deliverable
84
+ ? [
85
+ '',
86
+ '## What the implementer delivered',
87
+ '',
88
+ deliverable.why === 'read-only'
89
+ ? 'This task was declared read-only: its product is not a change to files but the account below,'
90
+ : 'This task changed no files (version control recorded none), so its product is the account below,',
91
+ ...(deliverable.why === 'read-only'
92
+ ? [
93
+ '',
94
+ '**Do not give a `check` with a finding about this task.** A check is a command run against the',
95
+ 'repository, and this task may not change the repository: whatever the command reports is settled',
96
+ 'before the task starts, so no correction it is allowed to make can alter it. A check that instead',
97
+ 'freezes a copy of the account below inside itself tests a string you pasted, not the work. Your',
98
+ 'findings and your verdict are the whole of your leverage here, and they are enough: the work goes',
99
+ 'back with them, and the next review rules on what came back.',
100
+ ]
101
+ : []),
102
+ 'written by the conversation that did the work when it closed. You are given it because it is the',
103
+ 'thing under review, not because it is to be believed: every statement in it is a claim, and you',
104
+ 'test a claim by running what it says and comparing. What the task asked for and the account does',
105
+ 'not deliver is a finding; what the account states and the machine contradicts is a finding.',
106
+ '',
107
+ deliverable.summary,
108
+ ]
109
+ : []),
110
+ ...(deviations.length > 0
111
+ ? [
112
+ '',
113
+ '## What the implementer says could not be done as written',
114
+ '',
115
+ 'The implementer declared that these instructions could not be followed literally, and says',
116
+ 'what it did instead. These are claims, not facts, and you are given them for one reason: to',
117
+ 'test them. For each, establish whether the instruction really cannot be satisfied on this',
118
+ 'machine, with these tools, inside the project instructions. If it cannot, the task asks for',
119
+ 'something impossible and that is a finding with `"about": "task"`. If it can, the work',
120
+ 'deviated for no good reason and that is a finding with `"about": "work"`. Do not take the',
121
+ "implementer's word either way; the work is still judged against the task as written.",
122
+ '',
123
+ deviations
124
+ .map((d, i) => `${i + 1}. Instruction: ${d.instruction}\n Did instead: ${d.did}\n Claimed reason: ${d.why}`)
125
+ .join('\n\n'),
126
+ ]
127
+ : []),
128
+ ...(previous
129
+ ? [
130
+ '',
131
+ `## What review round ${previous.round} found`,
132
+ '',
133
+ 'A previous reviewer, in another conversation, failed this work with the findings below. They',
134
+ 'were sent to the implementer, which reports having fixed them, and the checks passed again.',
135
+ 'You are not bound by that verdict: verify each one afresh, by running things. If one is still',
136
+ 'there, do not simply raise it again. Decide, and say in `about`, whether fixing the work can',
137
+ 'resolve it at all — a finding that survives a reported fix is often one the task itself made',
138
+ 'unsatisfiable: an instruction a tool overwrites, a setting a version removed.',
139
+ '',
140
+ previous.findings
141
+ .map((f, i) => `${i + 1}. ${f.id ? `[${f.id}] ` : ''}${f.what}${f.where ? ` (${f.where})` : ''}${f.repeated ? ' — raised in more than one round already' : ''}`)
142
+ .join('\n'),
143
+ ...(previous.leftovers && leftoverNote(previous.leftovers)
144
+ ? ['', leftoverNote(previous.leftovers).replace('After this review', `After round ${previous.round}`), 'Stop what you start, in the same step, and check the port only after that.']
145
+ : []),
146
+ ]
147
+ : []),
148
+ ...(disputes.length > 0
149
+ ? [
150
+ '',
151
+ '## Findings the implementer disputes',
152
+ '',
153
+ 'The implementer says these findings from an earlier round are wrong, and gives its evidence. They',
154
+ 'are claims; test them. If a dispute holds, the earlier reviewer was wrong — do not raise that',
155
+ 'finding again, and say in your summary what you ran that settles it. If it does not hold, raise',
156
+ "the finding with `\"about\": \"work\"` and say why the implementer's evidence does not show what it",
157
+ 'claims. Either way the work is judged against the task as written.',
158
+ '',
159
+ disputes.map((d, i) => `${i + 1}. Finding ${d.finding}: ${d.why}\n Implementer's evidence: ${d.evidence}`).join('\n\n'),
160
+ ]
161
+ : []),
162
+ ...(derivedFailing.length > 0
163
+ ? [
164
+ '',
165
+ '## Checks from earlier reviews that still fail',
166
+ '',
167
+ 'Earlier reviewers gave these checks with their findings, and the implementer has had its rounds:',
168
+ 'they still fail. You decide, not a counter. If the defect is real, fail the work with that',
169
+ 'finding. If the check is wrong — it tests something the task never asked for, or tests it',
170
+ 'badly — judge the work on what you find and say so in your summary; a check you do not',
171
+ 'confirm is dropped.',
172
+ '',
173
+ derivedFailing.map((d, i) => `${i + 1}. ${d.name}\n Last result: ${d.detail}`).join('\n\n'),
174
+ ]
175
+ : []),
176
+ '',
177
+ '## Your job',
178
+ '',
179
+ 'Decide whether the work does what the task asked, by running it. Start now: your next',
180
+ 'reply should be steps, not a verdict. Remember that a verdict of "pass" is refused if you',
181
+ 'have run nothing, and that anything the work tells a human to run is a claim you must test',
182
+ 'by running exactly that.',
183
+ ].join('\n');
184
+ }
185
+ /**
186
+ * Runs one review to a verdict, in the conversation the transport is currently in.
187
+ *
188
+ * The caller owns the conversation: it opens a fresh one before calling this and returns to
189
+ * the implementer's afterwards. That split is deliberate — moving between conversations is the
190
+ * part that can fail in ways only the caller can recover from, and it should not be buried
191
+ * inside the loop that reads verdicts.
192
+ */
193
+ export async function runReview(transport, session, task, deps) {
194
+ const { cfg, authorizer, signal, pacer, dir, round } = deps;
195
+ await mkdir(dir, { recursive: true });
196
+ // From the program's own prompts, wherever it is installed — not from the folder it was started in.
197
+ const contract = await readFile(join(installLayout().promptsDir, 'review1.md'), 'utf8').catch(() => '');
198
+ if (!contract.trim()) {
199
+ return { verdict: 'error', findings: [], stepsRun: 0, iterations: 0, problem: 'the reviewer contract (prompts/review1.md) could not be read' };
200
+ }
201
+ let stepsRun = 0;
202
+ let iterations = 0;
203
+ let formatRetries = 0;
204
+ /**
205
+ * The shell a review step gets when it names none.
206
+ *
207
+ * The same answer the implementer's steps get, from the same place: a reviewer that ran the
208
+ * build in a different interpreter from the one that built it is not reviewing the same thing.
209
+ */
210
+ const defaultShell = preferredShell(cfg.execution.defaultShell);
211
+ /** A check that passes on the defective state is sent back once; after that it is dropped. */
212
+ let derivedRetried = false;
213
+ /** A finding whose basis is not in the task is sent back once; after that it is dropped. */
214
+ let groundingRetried = false;
215
+ /** What a finding may quote: this task, its instructions, and the earlier tasks it was shown. */
216
+ const groundingSources = [task.prompt, task.level2, ...(deps.earlier ?? []).map((e) => e.prompt)];
217
+ const saveReply = async (label, markdown) => {
218
+ await writeFile(join(dir, `${label}.md`), markdown, 'utf8').catch(() => undefined);
219
+ };
220
+ try {
221
+ // The contract first, on its own, exactly as a session does it: the handshake reply is not
222
+ // parsed, it only proves the conversation is alive and listening.
223
+ await pacer.throttleSend();
224
+ // The machine's shells, when they are not what the contract's example assumes — the same note
225
+ // the implementer's conversation opens with, for a conversation working to the same example.
226
+ const machineNote = shellNote(undefined, defaultShell);
227
+ let before = await transport.sendAndConfirm(machineNote ? `${contract}
228
+
229
+ ${machineNote}` : contract);
230
+ await transport.waitForReply(before);
231
+ await pacer.throttleSend();
232
+ // The brief is saved as sent. Until it was, whether a later round had been told what the
233
+ // earlier one found could not be checked from the run folder at all.
234
+ const brief = reviewBrief(session, task, deps.changedFiles, deps.cwd, deps.deviations, deps.previous, deps.disputes, deps.derivedFailing, deps.earlier, deps.deliverable);
235
+ await saveReply('00-brief', brief);
236
+ await deps.record(`REVIEW ${round} BRIEF`, brief);
237
+ before = await transport.sendAndConfirm(brief);
238
+ let markdown = (await transport.waitForReply(before)).markdown;
239
+ await saveReply('00-opening', markdown);
240
+ for (;;) {
241
+ if (signal?.aborted)
242
+ return { verdict: 'error', findings: [], stepsRun, iterations, problem: 'the run was stopped' };
243
+ if (iterations >= cfg.limits.maxReviewIterations) {
244
+ return {
245
+ verdict: 'error',
246
+ findings: [],
247
+ stepsRun,
248
+ iterations,
249
+ problem: `the review used its ${cfg.limits.maxReviewIterations} iterations without reaching a verdict`,
250
+ };
251
+ }
252
+ const parsed = parseReview(markdown, { defaultShell });
253
+ if (!parsed.ok) {
254
+ formatRetries += 1;
255
+ deps.event('review-format-error', { round, reason: parsed.reason, detail: parsed.detail }, `the reviewer's reply did not match its contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
256
+ if (formatRetries > cfg.limits.maxFormatRetries) {
257
+ return { verdict: 'error', findings: [], stepsRun, iterations, problem: `the reviewer would not keep its output contract: ${parsed.detail}` };
258
+ }
259
+ await pacer.throttleSend();
260
+ const b = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
261
+ markdown = (await transport.waitForReply(b)).markdown;
262
+ continue;
263
+ }
264
+ formatRetries = 0;
265
+ iterations += 1;
266
+ const { review, verdict } = parsed;
267
+ deps.event('review-parsed', { round, iteration: iterations, verdict, steps: review.steps.length, notes: review.notes }, `review ${round}, iteration ${iterations}: ${verdict}${review.steps.length > 0 ? `, ${review.steps.length} step(s)` : ''}${review.notes ? ` — ${review.notes}` : ''}`);
268
+ if (verdict !== 'continue') {
269
+ /*
270
+ * The rule a schema cannot express.
271
+ *
272
+ * "Pass" from a reviewer that never ran anything is the exact failure this whole
273
+ * mechanism exists to prevent, and it is mechanically detectable: the runner knows how
274
+ * many commands it executed in this conversation. So it is sent back once, in as many
275
+ * words. A "fail" is allowed to stand without steps — refusing to review something is
276
+ * itself a finding, and the contract requires evidence for it either way.
277
+ */
278
+ if (verdict === 'pass' && stepsRun === 0) {
279
+ deps.event('review-passed-nothing-run', { round }, 'the reviewer passed the work without running anything; sending it back', 'warn');
280
+ await pacer.throttleSend();
281
+ const b = await transport.sendAndConfirm('You reached a verdict of "pass" without running a single command in this review. ' +
282
+ 'That is refused: reading tells you what a file contains, not whether the work does what the task ' +
283
+ 'asked. Run it — build it, start it, call it, and run anything the work tells a human to run — ' +
284
+ 'then give a verdict based on what came back.');
285
+ markdown = (await transport.waitForReply(b)).markdown;
286
+ continue;
287
+ }
288
+ /*
289
+ * A finding's check is only worth keeping if it fails on the work as it stands.
290
+ *
291
+ * The reviewer says "this is the test that would have caught it"; the runner runs it
292
+ * now, on the defective state. One that passes captures something else — or nothing —
293
+ * and is sent back once with the result, then dropped. What survives is kept with the
294
+ * task for every attempt after this one.
295
+ */
296
+ // Whatever the review's own steps left running goes first, so that a check given
297
+ // with a finding is judged against the work and not against the reviewer's leftovers.
298
+ await deps.beforeVerdict?.();
299
+ /*
300
+ * A finding has to rest on a sentence somebody wrote.
301
+ *
302
+ * Two reviewers in a row failed a page for label text no task had specified. The basis
303
+ * is checked against what the reviewer was given: not there, and the finding is sent
304
+ * back once for the quote; still not there, and it is dropped as an invented
305
+ * requirement. A review left with nothing grounded has not reviewed anything.
306
+ */
307
+ const ungrounded = review.findings.filter((f) => !isGrounded(f.basis, groundingSources));
308
+ if (ungrounded.length > 0 && !groundingRetried) {
309
+ groundingRetried = true;
310
+ deps.event('review-finding-ungrounded', { round, count: ungrounded.length }, `${ungrounded.length} finding(s) rest on a sentence that is not in the task; sent back once for the quote`, 'warn');
311
+ await pacer.throttleSend();
312
+ const b = await transport.sendAndConfirm(ungroundedMessage(ungrounded));
313
+ markdown = (await transport.waitForReply(b)).markdown;
314
+ continue;
315
+ }
316
+ const grounded = review.findings.filter((f) => isGrounded(f.basis, groundingSources));
317
+ for (const f of ungrounded) {
318
+ deps.event('review-finding-dropped', { round, what: f.what.slice(0, 120) }, `dropped as an invented requirement — its basis is not in the task: ${f.what.slice(0, 120)}`, 'warn');
319
+ }
320
+ if (verdict === 'fail' && grounded.length === 0) {
321
+ return {
322
+ verdict: 'error',
323
+ findings: [],
324
+ stepsRun,
325
+ iterations,
326
+ problem: `every finding of round ${round} rested on something the task never asked for, twice; the review is inconclusive`,
327
+ };
328
+ }
329
+ const named = grounded.map((f, i) => ({ ...f, id: findingId(round, i) }));
330
+ /*
331
+ * A read-only task's findings never become gate conditions.
332
+ *
333
+ * A derived check is a command run against the repository, and a read-only task is
334
+ * forbidden to change the repository: whatever such a check reports is fixed before
335
+ * the task starts, so it cannot be satisfied by anything the task is allowed to do.
336
+ * Observed: a reviewer of a smoke test found the delivered testid inventory
337
+ * incomplete — it was — and gave a check that froze the delivered list inside a
338
+ * PowerShell array and compared it with the file. The implementer then delivered the
339
+ * complete inventory, which is exactly what was asked, and the check went on failing,
340
+ * because the list it compares against is a copy of an old summary. Three attempts,
341
+ * two of them in fresh conversations, ended blocked on a condition no action could
342
+ * meet, and the implementer said so with the exact evidence.
343
+ *
344
+ * The reviewer's judgement is not weakened by this: the finding still travels, the
345
+ * verdict still fails the work, and the next reviewer still rules on it. What it may
346
+ * not do is put a condition into a gate that the task has no lever to move.
347
+ */
348
+ if (task.readOnly && named.some((f) => f.check)) {
349
+ deps.event('review-check-not-kept-readonly', { round, findings: named.filter((f) => f.check).map((f) => f.id) }, 'this task may not change files, so a check given with a finding could never be satisfied by it; the findings stand, the checks are not kept', 'warn');
350
+ }
351
+ if (task.readOnly) {
352
+ await deps.record(`REVIEW ${round}: ${verdict.toUpperCase()}`, [review.summary ?? '', named.length > 0 ? describeFindings(named) : ''].filter(Boolean).join('\n\n'));
353
+ return {
354
+ verdict,
355
+ summary: review.summary,
356
+ findings: named,
357
+ stepsRun,
358
+ iterations,
359
+ chatUrl: (await transport.currentChatId().catch(() => null)) ?? undefined,
360
+ };
361
+ }
362
+ const validation = await validateDerivedChecks(named, {
363
+ cwd: deps.cwd,
364
+ logDir: dir,
365
+ repoDir: deps.repoDir,
366
+ /*
367
+ * A check the reviewer writes is kept with the task and run at every later gate with no
368
+ * one asked, so a fetch in one is refused rather than held: there is no approval screen
369
+ * at a gate. The finding stands without it, as for any check the runner will not run.
370
+ * See `network.ts`.
371
+ */
372
+ deny: (command, shell, cwd) => checkCommandRefusal(command, shell, cfg.execution, { roots: deps.roots, cwd }),
373
+ roots: deps.roots,
374
+ signal,
375
+ defaultShell,
376
+ tracker: deps.tracker,
377
+ passEnv: cfg.execution.passEnv,
378
+ });
379
+ if (validation.refused.length > 0 && !derivedRetried) {
380
+ derivedRetried = true;
381
+ deps.event('review-check-refused', { round, refused: validation.refused.map((r) => r.finding.id) }, `${validation.refused.length} check(s) given with findings pass on the work as it is, so they do not capture the defect; sent back once`, 'warn');
382
+ await pacer.throttleSend();
383
+ const b = await transport.sendAndConfirm(refusedChecksMessage(validation.refused.map((r) => ({ id: r.finding.id, detail: r.outcome.detail }))));
384
+ markdown = (await transport.waitForReply(b)).markdown;
385
+ continue;
386
+ }
387
+ for (const b of validation.blocked) {
388
+ deps.event('review-check-blocked', { round, finding: b.finding.id, detail: b.outcome.detail }, `the check given with ${b.finding.id} was refused by the runner and never ran; dropped, the finding stands without it — ${b.outcome.detail}`, 'warn');
389
+ }
390
+ for (const r of validation.refused) {
391
+ deps.event('review-check-dropped', { round, finding: r.finding.id }, `the check given with ${r.finding.id} still passes on the defective state; dropped`, 'warn');
392
+ }
393
+ for (const k of validation.kept) {
394
+ deps.event('review-check-kept', { round, finding: k.finding.id, name: k.check.name }, `kept with the task: "${k.check.name}" (from ${k.finding.id})`);
395
+ }
396
+ await deps.record(`REVIEW ${round}: ${verdict.toUpperCase()}`, [review.summary ?? '', named.length > 0 ? describeFindings(named) : ''].filter(Boolean).join('\n\n'));
397
+ return {
398
+ verdict,
399
+ summary: review.summary,
400
+ findings: named,
401
+ stepsRun,
402
+ iterations,
403
+ chatUrl: (await transport.currentChatId().catch(() => null)) ?? undefined,
404
+ derivedChecks: validation.kept.map((k) => ({ findingId: k.finding.id, check: k.check, what: k.finding.what, where: k.finding.where })),
405
+ };
406
+ }
407
+ // --- run what the reviewer asked for ------------------------------------------------
408
+ const results = [];
409
+ for (const step of review.steps) {
410
+ if (signal?.aborted)
411
+ break;
412
+ if (step.type !== 'command') {
413
+ results.push(refused(step.id, 'a review runs commands only; it does not download or execute files', '(not run)', step.shell));
414
+ continue;
415
+ }
416
+ const damage = findLikelyDamage(step.cmd);
417
+ if (damage) {
418
+ results.push(refused(step.id, `${damage}. ${damageGuidance()}`, step.cmd, step.shell));
419
+ continue;
420
+ }
421
+ deps.event('review-step-proposed', { round, id: step.id, description: describeStep(step) }, `review step ${step.id}: ${describeStep(step)}`);
422
+ const decision = await authorizer.authorize(step, {
423
+ sessionId: session.id,
424
+ taskId: task.id,
425
+ iteration: iterations,
426
+ confinement: { roots: deps.roots, cwd: deps.cwd },
427
+ });
428
+ if (decision.action !== 'run') {
429
+ results.push(refused(step.id, decision.reason, step.cmd, step.shell));
430
+ if (decision.action === 'abort')
431
+ break;
432
+ continue;
433
+ }
434
+ const long = step.expect === 'long';
435
+ const cap = cfg.execution.maxStepTimeoutSec;
436
+ const hard = Math.min(step.timeoutSec ?? (long ? cfg.execution.longCommandTimeoutSec : cfg.execution.commandTimeoutSec), cap);
437
+ const idle = Math.min(step.idleTimeoutSec ?? (long ? cfg.execution.longIdleTimeoutSec : cfg.execution.idleTimeoutSec), cap);
438
+ /*
439
+ * The same refusal the implementer's steps get, for the same reason.
440
+ *
441
+ * A reviewer works to `prompts/review1.md`, whose worked example also names `pwsh`, so on
442
+ * a machine without PowerShell 7 it writes `pwsh` and its step cannot run. Left to
443
+ * `runStep` it came back carrying the operator's sentence — install PowerShell 7, set
444
+ * `execution.defaultShell` — uploaded to a conversation that can do neither, and the
445
+ * review spent its rounds on it.
446
+ */
447
+ const named = resolveShell(step.shell);
448
+ if (!named.ok) {
449
+ deps.event('review-step-shell-missing', { round, id: step.id, requested: named.problem.requested, available: named.problem.available }, `review step ${step.id} asked for a shell this machine has not got: ${named.problem.message}`, 'warn');
450
+ results.push(refused(step.id, refusalForChat(named.problem, defaultShell), step.cmd, step.shell));
451
+ continue;
452
+ }
453
+ const result = await runStep({
454
+ id: step.id,
455
+ shell: step.shell ?? defaultShell,
456
+ command: step.cmd,
457
+ cwd: deps.cwd,
458
+ hardTimeoutMs: hard * 1000,
459
+ idleTimeoutMs: idle * 1000,
460
+ logPath: join(dir, 'steps', `${iterations}-${step.id}.log`),
461
+ passEnv: cfg.execution.passEnv,
462
+ }, { signal, tracker: deps.tracker });
463
+ stepsRun += 1;
464
+ results.push(result);
465
+ deps.event('review-step-finished', { round, id: step.id, outcome: result.outcome, exitCode: result.exitCode, shell: result.shell, requestedShell: result.requestedShell ?? null, shellPath: result.shellPath ?? '' }, `review step ${step.id}: ${result.outcome}, exit ${result.exitCode}, ${(result.durationMs / 1000).toFixed(1)}s`);
466
+ await pacer.settle();
467
+ }
468
+ const report = await writeReport(results, {
469
+ runId: `review-${round}`,
470
+ task: `review of "${task.title}"`,
471
+ iteration: iterations,
472
+ dir,
473
+ fileNameTemplate: cfg.report.fileName,
474
+ maxReportBytes: cfg.report.maxReportBytes,
475
+ maxOutputChars: cfg.report.maxOutputChars,
476
+ redactPatterns: cfg.report.redactPatterns,
477
+ });
478
+ await deps.record(`REVIEW ${round}, ITERATION ${iterations}`, await readFile(report.paths[0], 'utf8'));
479
+ if (report.redactions.length > 0) {
480
+ deps.event('report-redacted', { round, iteration: iterations, redactions: report.redactions }, `redacted before upload: ${report.redactions.map((r) => `${r.count}× ${r.name}`).join(', ')}`, 'warn');
481
+ }
482
+ const covering = buildCoveringMessage({
483
+ task: `review of "${task.title}"`,
484
+ iteration: iterations,
485
+ results,
486
+ attachments: report.names,
487
+ parts: report.parts,
488
+ });
489
+ assertSendable(covering, report.names);
490
+ await pacer.throttleSend();
491
+ const b = await transport.sendAndConfirm(covering, report.paths);
492
+ markdown = (await transport.waitForReply(b)).markdown;
493
+ await saveReply(`${String(iterations).padStart(2, '0')}-review`, markdown);
494
+ await pacer.settle();
495
+ }
496
+ }
497
+ catch (e) {
498
+ return { verdict: 'error', findings: [], stepsRun, iterations, problem: e.message };
499
+ }
500
+ }
501
+ /** Sent once when a finding's basis is not a quote from what the reviewer was given. */
502
+ export function ungroundedMessage(findings) {
503
+ return [
504
+ `${findings.length === 1 ? 'One of your findings rests' : `${findings.length} of your findings rest`} on a sentence that is not in the task, its project`,
505
+ 'instructions, or the earlier tasks you were shown:',
506
+ '',
507
+ findings.map((f) => `- "${f.basis}" — for: ${f.what}`).join('\n'),
508
+ '',
509
+ 'A finding must quote the sentence that asks for the thing it says is missing. Quote it exactly, from the',
510
+ 'text you were given; if there is no such sentence, the task never asked for it and it is not a finding.',
511
+ 'Reply with the same verdict and findings, corrected. This is asked once; a finding still without a basis',
512
+ 'in the task is dropped.',
513
+ ].join('\n');
514
+ }
515
+ /** Sent once when a finding's check passes on the work it is supposed to fail on. */
516
+ export function refusedChecksMessage(refused) {
517
+ return [
518
+ `The check you gave with ${refused.length === 1 ? 'finding' : 'findings'} ${refused.map((r) => `[${r.id}]`).join(', ')} was run on the work exactly as it is now, and it passed:`,
519
+ '',
520
+ refused.map((r) => `- [${r.id}]: ${r.detail}`).join('\n'),
521
+ '',
522
+ 'A check that passes on the defective state does not capture the defect you describe. Give one that',
523
+ 'fails now and would pass once the finding is fixed, or leave `check` out of that finding. Reply with the',
524
+ 'same verdict and the same findings, corrected. This is asked once; a check that still passes is dropped.',
525
+ ].join('\n');
526
+ }
527
+ /** A step the review did not run, in the shape the reporter expects. */
528
+ function refused(id, reason, command = '(not run)', requested) {
529
+ return {
530
+ id,
531
+ // The shell it would have run in, rather than the one this file used to name: a report line
532
+ // that says `pwsh` on a machine without it sends the reader after the wrong problem. The
533
+ // step's own choice comes first, because a step that named `cmd` and was refused was never
534
+ // going to run in anything else, and labelling it with the machine's fallback would be the
535
+ // same lie the other way round.
536
+ shell: effectiveShell(requested),
537
+ requestedShell: requested ?? null,
538
+ command,
539
+ exitCode: -4,
540
+ outcome: 'aborted',
541
+ durationMs: 0,
542
+ stdout: '',
543
+ stderr: `[policy] step not executed: ${reason}\n`,
544
+ truncated: false,
545
+ logPath: '',
546
+ lastOutputAgoMs: 0,
547
+ };
548
+ }
549
+ /**
550
+ * The message that carries a review's findings back to the conversation that did the work.
551
+ *
552
+ * `repeated` are the findings an earlier round already raised. They get a paragraph of their
553
+ * own, because fixing the same thing the same way a second time is the loop this exists to
554
+ * break: the implementer is asked to decide whether the fix did not hold or the instruction
555
+ * cannot be kept, and to declare the second case in `deviations`, where it reaches the reviewer
556
+ * and the person who wrote the task.
557
+ */
558
+ export function findingsMessage(outcome, round, maxRounds, repeated = [], leftovers = []) {
559
+ const note = leftoverNote(leftovers);
560
+ return [
561
+ `An independent review of your work found ${outcome.findings.length} problem(s). The reviewer is a`,
562
+ 'separate conversation that was given the task and the files you changed, ran the work itself, and',
563
+ 'did not see your summary.',
564
+ '',
565
+ outcome.summary ? `What it checked: ${outcome.summary}` : '',
566
+ '',
567
+ describeFindings(outcome.findings, (f) => repeated.includes(f)),
568
+ '',
569
+ ...(note ? [`${note} If that is what a finding is about, dispute it with the evidence from your own step.`, ''] : []),
570
+ ...(repeated.length > 0
571
+ ? [
572
+ `${repeated.length} of these ${repeated.length === 1 ? 'was' : 'were'} raised in the previous round as well: you reported a fix,`,
573
+ 'and a fresh reviewer found the same thing at the same place. Before fixing it the same way again,',
574
+ 'decide which of two things is true. Either the fix did not hold — then fix it differently, and show',
575
+ 'in the summary what you ran that proves it holds after the exact verification the task requires.',
576
+ 'Or the instruction cannot be satisfied as written — a tool rewrites the file, a version removed the',
577
+ 'option — then say so in `deviations`: the instruction, what you did instead, and why. That is the',
578
+ 'honest answer, and it is the only one that reaches the person who wrote the task.',
579
+ '',
580
+ ]
581
+ : []),
582
+ `Fix these, then verify as usual and close the task again. This is review round ${round} of ${maxRounds};`,
583
+ 'after that the task is closed as blocked with whatever is still outstanding.',
584
+ '',
585
+ 'If you believe a finding is wrong, say so in `disputed`: its id (the `[r1f2]` in front of it), why it',
586
+ 'is wrong, and the evidence that shows it — the command you ran and what came back. A dispute goes to',
587
+ 'the next reviewer as a claim to test; a sentence in your summary goes nowhere. Do not change the',
588
+ 'thing the reviewer looked at in order to make the objection go away.',
589
+ ].join('\n');
590
+ }