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,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turns one Copilot reply into a validated set of steps.
|
|
3
|
+
*
|
|
4
|
+
* The input is the **raw markdown** of the reply, obtained by clicking "Copy Response" and
|
|
5
|
+
* reading the clipboard. It is never the rendered DOM text: code blocks in the Copilot UI are
|
|
6
|
+
* virtualized and interleave line numbers with the code, and a reply has already been
|
|
7
|
+
* observed coming back from `innerText` with a chunk of valid JSON simply missing.
|
|
8
|
+
*/
|
|
9
|
+
import { ReplySchema } from './replySchema.js';
|
|
10
|
+
import { ReviewSchema } from './reviewSchema.js';
|
|
11
|
+
/** Every fenced block in a markdown string, with its info string. */
|
|
12
|
+
export function extractFencedBlocks(markdown) {
|
|
13
|
+
const blocks = [];
|
|
14
|
+
// Fences of three or more backticks or tildes; the closing fence must be at least as long.
|
|
15
|
+
const re = /^([ \t]*)(`{3,}|~{3,})[ \t]*([^\n`]*)\n([\s\S]*?)^[ \t]*\2[ \t]*$/gm;
|
|
16
|
+
let m;
|
|
17
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
18
|
+
blocks.push({ lang: (m[3] ?? '').trim().toLowerCase(), body: m[4] ?? '' });
|
|
19
|
+
}
|
|
20
|
+
return blocks;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Strips the line numbers the chat's code widget injects, as a last-resort repair when the
|
|
24
|
+
* text came from the DOM rather than the clipboard. A line that is nothing but digits, in a
|
|
25
|
+
* block whose other lines are not, is a gutter number.
|
|
26
|
+
*/
|
|
27
|
+
export function stripLineNumbers(body) {
|
|
28
|
+
const lines = body.split(/\r?\n/);
|
|
29
|
+
const numbered = lines.filter((l) => /^\s*\d+\s*$/.test(l)).length;
|
|
30
|
+
if (numbered < 2 || numbered * 2 < lines.length - numbered)
|
|
31
|
+
return body;
|
|
32
|
+
return lines.filter((l) => !/^\s*\d+\s*$/.test(l)).join('\n');
|
|
33
|
+
}
|
|
34
|
+
function findJsonCandidates(markdown) {
|
|
35
|
+
const blocks = extractFencedBlocks(markdown);
|
|
36
|
+
const out = [];
|
|
37
|
+
for (const b of blocks) {
|
|
38
|
+
if (b.lang === 'json')
|
|
39
|
+
out.push(b.body);
|
|
40
|
+
}
|
|
41
|
+
// Copilot sometimes tags the block `JSON` in the widget but emits no info string.
|
|
42
|
+
if (out.length === 0) {
|
|
43
|
+
for (const b of blocks) {
|
|
44
|
+
if (b.lang === '' && b.body.trimStart().startsWith('{'))
|
|
45
|
+
out.push(b.body);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Nothing fenced at all: fall back to the outermost braces in the reply.
|
|
49
|
+
if (out.length === 0) {
|
|
50
|
+
const first = markdown.indexOf('{');
|
|
51
|
+
const last = markdown.lastIndexOf('}');
|
|
52
|
+
if (first >= 0 && last > first)
|
|
53
|
+
out.push(markdown.slice(first, last + 1));
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
/** Removes Copilot's 【n-hash】 citation markers and the whitespace they leave behind. */
|
|
58
|
+
export function stripCitations(text) {
|
|
59
|
+
if (text === undefined)
|
|
60
|
+
return undefined;
|
|
61
|
+
return text.replace(/\s*【[^】]*】/g, '').replace(/[ ]+$/gm, '').trim();
|
|
62
|
+
}
|
|
63
|
+
export function parseReply(markdown, opts) {
|
|
64
|
+
const candidates = findJsonCandidates(markdown);
|
|
65
|
+
if (candidates.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
reason: 'no-json-block',
|
|
69
|
+
detail: 'The reply contained no fenced ```json block and no JSON object at all.',
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const errors = [];
|
|
73
|
+
for (const raw of candidates) {
|
|
74
|
+
for (const text of [raw, stripLineNumbers(raw)]) {
|
|
75
|
+
let value;
|
|
76
|
+
try {
|
|
77
|
+
value = JSON.parse(text);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
errors.push(`JSON.parse failed: ${e.message}`);
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const result = ReplySchema.safeParse(value);
|
|
84
|
+
if (!result.success) {
|
|
85
|
+
errors.push(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '));
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const reply = result.data;
|
|
89
|
+
// Copilot appends citation markers such as 【1-8313d0】 to prose it grounded in a file.
|
|
90
|
+
// They mean nothing outside the chat and would otherwise end up in the UI and the log.
|
|
91
|
+
reply.notes = stripCitations(reply.notes);
|
|
92
|
+
reply.summary = stripCitations(reply.summary);
|
|
93
|
+
const steps = reply.steps.map((s) => ({ ...s, shell: s.shell ?? opts.defaultShell }));
|
|
94
|
+
const markerHit = markdown.includes(opts.stopMarker);
|
|
95
|
+
const hasSummary = (reply.summary ?? '').trim().length > 0;
|
|
96
|
+
// The stop word alone is not enough to end a task any more: the summary is the
|
|
97
|
+
// deliverable, and a marker without one would let a task close with nothing to show.
|
|
98
|
+
// status "done" already guarantees a summary through the schema.
|
|
99
|
+
// A reply that gives up says so in `status`, and the stop word cannot override it: a
|
|
100
|
+
// model that writes "Край" under an explanation of why it cannot finish has ended the
|
|
101
|
+
// task, not completed it, and reading that as `done` would file a failure as a success.
|
|
102
|
+
const blocked = reply.status === 'blocked';
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
reply: { ...reply, steps },
|
|
106
|
+
done: reply.status === 'done' || (markerHit && hasSummary && !blocked),
|
|
107
|
+
blocked,
|
|
108
|
+
json: text.trim(),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
ok: false,
|
|
114
|
+
reason: 'invalid-json',
|
|
115
|
+
detail: errors.slice(0, 4).join(' | '),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The same job for a reviewing conversation: find the json, validate it, hand it back.
|
|
120
|
+
*
|
|
121
|
+
* A sibling of `parseReply` rather than a generalisation of it. The two contracts differ in
|
|
122
|
+
* what they are allowed to say and in what ends them, and folding them into one parameterised
|
|
123
|
+
* function would mean every future change to either has to be reasoned about twice. The parts
|
|
124
|
+
* genuinely in common — finding the block, repairing line numbers, stripping citations — are
|
|
125
|
+
* shared as they are.
|
|
126
|
+
*
|
|
127
|
+
* There is no stop word here. A review ends through its verdict and nothing else, because a
|
|
128
|
+
* reviewer writing the implementer's stop word is a reviewer quoting the work, not finishing.
|
|
129
|
+
*/
|
|
130
|
+
export function parseReview(markdown, opts) {
|
|
131
|
+
const candidates = findJsonCandidates(markdown);
|
|
132
|
+
if (candidates.length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: 'no-json-block',
|
|
136
|
+
detail: 'The reply contained no fenced ```json block and no JSON object at all.',
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const errors = [];
|
|
140
|
+
for (const raw of candidates) {
|
|
141
|
+
for (const text of [raw, stripLineNumbers(raw)]) {
|
|
142
|
+
let value;
|
|
143
|
+
try {
|
|
144
|
+
value = JSON.parse(text);
|
|
145
|
+
}
|
|
146
|
+
catch (e) {
|
|
147
|
+
errors.push(`JSON.parse failed: ${e.message}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const result = ReviewSchema.safeParse(value);
|
|
151
|
+
if (!result.success) {
|
|
152
|
+
errors.push(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '));
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const review = result.data;
|
|
156
|
+
review.notes = stripCitations(review.notes);
|
|
157
|
+
review.summary = stripCitations(review.summary);
|
|
158
|
+
const steps = review.steps.map((x) => ({ ...x, shell: x.shell ?? opts.defaultShell }));
|
|
159
|
+
return { ok: true, review: { ...review, steps }, verdict: review.status, json: text.trim() };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return { ok: false, reason: 'invalid-json', detail: errors.slice(0, 4).join(' | ') };
|
|
163
|
+
}
|
|
164
|
+
/** The message sent back when a reply did not validate. Names the problem, nothing else. */
|
|
165
|
+
export function formatErrorMessage(fail, attempt, maxAttempts) {
|
|
166
|
+
return [
|
|
167
|
+
`Your last reply did not match the required format (attempt ${attempt} of ${maxAttempts}).`,
|
|
168
|
+
fail.reason === 'no-json-block'
|
|
169
|
+
? 'There was no fenced json code block in it.'
|
|
170
|
+
: `The json block did not validate: ${fail.detail}`,
|
|
171
|
+
'Resend the same answer as exactly one fenced code block tagged json, with the fields',
|
|
172
|
+
'status, steps and notes, and nothing else tagged json in the reply.',
|
|
173
|
+
].join(' ');
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Spots a command that arrived damaged rather than badly written.
|
|
177
|
+
*
|
|
178
|
+
* Copilot's own output pipeline eats a `[label]:` sequence, apparently reading it the way
|
|
179
|
+
* markdown reads a link-reference definition. PowerShell writes static calls as
|
|
180
|
+
* `[math]::Round(...)`, which contains exactly that sequence, so what reaches the runner is
|
|
181
|
+
* `:Round(...)`. Observed live three iterations in a row, and confirmed from the chat itself:
|
|
182
|
+
* the mangled form was already on screen, and Copilot's own notes complained that ":Round"
|
|
183
|
+
* had been emitted instead of "[math]::".
|
|
184
|
+
*
|
|
185
|
+
* The damage is not repairable here, because the type name is gone and guessing it would run
|
|
186
|
+
* a command nobody wrote. So it is detected, refused, and explained back to Copilot.
|
|
187
|
+
*/
|
|
188
|
+
export function findLikelyDamage(command) {
|
|
189
|
+
// A method call whose type literal has been eaten: `{:Round(`, `= :Round(`, `+ ::Round(`.
|
|
190
|
+
// `]` and `$name` before the colons are the intact forms, `[math]::Round` and `$m::Round`,
|
|
191
|
+
// so they must not match.
|
|
192
|
+
const eaten = /(^|[^\w.$:\]]):{1,2}[A-Za-z_]\w*\s*\(/.exec(command);
|
|
193
|
+
if (eaten) {
|
|
194
|
+
return (`"${eaten[0].trim()}" looks like a .NET static call whose type was lost in transit: ` +
|
|
195
|
+
'`[math]::Round(...)` arrives as `:Round(...)` because the chat consumes `[label]:`');
|
|
196
|
+
}
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
/** What to tell Copilot when a command arrived damaged, with alternatives that survive. */
|
|
200
|
+
export function damageGuidance() {
|
|
201
|
+
/*
|
|
202
|
+
* The advice has to cover the call that was actually made, not one example of it.
|
|
203
|
+
*
|
|
204
|
+
* It named `[math]::Round` and offered number formatting, because rounding was the first
|
|
205
|
+
* casualty. A later run lost `[regex]::Escape`, `[regex]::Matches` and `[math]::Min` — and
|
|
206
|
+
* a model told only how to format a number has nothing to take from that. So the general
|
|
207
|
+
* rule leads (put the type in a variable, which works for every static call there is), and
|
|
208
|
+
* the native PowerShell way follows for the shapes that keep coming up.
|
|
209
|
+
*/
|
|
210
|
+
return [
|
|
211
|
+
'A command you sent arrived with its type literal missing: `[regex]::Escape(...)` reached',
|
|
212
|
+
'the runner as `:Escape(...)`. Anything of the form `[name]:` is consumed before it gets',
|
|
213
|
+
'here, so no .NET static call written that way can survive.',
|
|
214
|
+
'**The rule that always works: put the type in a variable first, then call through it.**',
|
|
215
|
+
'`$re = [regex]; $re::Escape($x)` · `$m = [math]; $m::Round($x, 2)` — the variable has no',
|
|
216
|
+
'bracket before the colons, so nothing eats it. A space before `::` is a syntax error, not',
|
|
217
|
+
'a workaround.',
|
|
218
|
+
'Where PowerShell has its own way of doing it, that is shorter and safer still:',
|
|
219
|
+
'to escape for a match, compare with `-like` or use `.Replace()` and drop the regex;',
|
|
220
|
+
'to find every match, `Select-String -Pattern ... -AllMatches` and read `.Matches.Groups`;',
|
|
221
|
+
'for the smallest or largest, `Measure-Object -Minimum -Maximum`;',
|
|
222
|
+
'to format a number, `"{0:N2}" -f $x` or `$x.ToString("N2")`.',
|
|
223
|
+
].join(' ');
|
|
224
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contract Copilot must answer in, as a schema.
|
|
3
|
+
*
|
|
4
|
+
* The prose version the user pastes into the chat lives in `prompts/level1.md`. This file is
|
|
5
|
+
* the enforcement: anything that does not validate here is rejected and Copilot is asked to
|
|
6
|
+
* resend. Being strict is deliberate. A half-understood reply that still parses is worse
|
|
7
|
+
* than a rejected one, because it runs commands nobody intended.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
export const ShellSchema = z.enum(['pwsh', 'powershell', 'cmd']);
|
|
11
|
+
const CommandStep = z.object({
|
|
12
|
+
id: z.number().int().positive(),
|
|
13
|
+
/*
|
|
14
|
+
* The only kind of step there is.
|
|
15
|
+
*
|
|
16
|
+
* There used to be a second, `download`: the chat attached a file and the runner fetched it and
|
|
17
|
+
* ran it. That is gone — not disabled, removed — because a process that fetches a file and
|
|
18
|
+
* executes it is a loader whatever its intentions, and it is what put a security team on the
|
|
19
|
+
* phone. The message below is what a chat still writing the old form is told, since the reply
|
|
20
|
+
* comes straight back to it and "invalid literal" would teach it nothing.
|
|
21
|
+
*/
|
|
22
|
+
type: z.literal('command', {
|
|
23
|
+
error: 'this runner has no file steps: it never fetches or runs a file the chat provides. Every step is ' +
|
|
24
|
+
'a `command`. For something too long for one line, write the file with Set-Content in one command ' +
|
|
25
|
+
'step and run it with its interpreter in the next.',
|
|
26
|
+
}),
|
|
27
|
+
shell: ShellSchema.optional(),
|
|
28
|
+
cmd: z.string().min(1),
|
|
29
|
+
expect: z.enum(['fast', 'long']).optional(),
|
|
30
|
+
timeoutSec: z.number().int().positive().optional(),
|
|
31
|
+
idleTimeoutSec: z.number().int().positive().optional(),
|
|
32
|
+
});
|
|
33
|
+
export const StepSchema = CommandStep;
|
|
34
|
+
/**
|
|
35
|
+
* The shortest `summary` that counts as an explanation. Below this it is a label, not the
|
|
36
|
+
* deliverable the user asked for, and the reply is sent back.
|
|
37
|
+
*/
|
|
38
|
+
export const MIN_SUMMARY_CHARS = 40;
|
|
39
|
+
/**
|
|
40
|
+
* How many different approaches must have been tried before a task may be given up on.
|
|
41
|
+
*
|
|
42
|
+
* Two is not a high bar, and that is the point: it is not meant to make giving up hard, it is
|
|
43
|
+
* meant to make it deliberate. A model that has tried one thing has not found a wall, it has
|
|
44
|
+
* found a first attempt, and the field forces that distinction to be written down where a
|
|
45
|
+
* person can read it and disagree.
|
|
46
|
+
*/
|
|
47
|
+
export const MIN_TRIED_APPROACHES = 2;
|
|
48
|
+
/**
|
|
49
|
+
* One instruction that was not followed as written, and what was done instead.
|
|
50
|
+
*
|
|
51
|
+
* The field exists because the alternative was prose, and prose is where two real decisions
|
|
52
|
+
* went to die in one run. A task required `moduleResolution node`; the TypeScript that
|
|
53
|
+
* `npm install` fetched had removed it, so the implementer pinned an older TypeScript in one
|
|
54
|
+
* package of a repository whose other package kept the new one, and said so in `notes`. A task
|
|
55
|
+
* required `jsx preserve`; `next build` rewrote the file after every run, so the implementer
|
|
56
|
+
* restored the value after each build, and said so in `notes`. Nobody reads `notes`: not the
|
|
57
|
+
* reviewer, which is denied the implementer's account on purpose; not the commit message; not
|
|
58
|
+
* the register. Both were product decisions taken by a model and recorded nowhere anyone
|
|
59
|
+
* would look.
|
|
60
|
+
*
|
|
61
|
+
* As data, a deviation goes three places: onto the task and into the commit, where a person
|
|
62
|
+
* finds it; and to the reviewer, as a claim to test rather than an account to trust. A true
|
|
63
|
+
* claim is a finding about the task; a false one is a finding about the work. Either way the
|
|
64
|
+
* `about: task` path finally has an entrance that does not depend on the reviewer noticing on
|
|
65
|
+
* its own.
|
|
66
|
+
*/
|
|
67
|
+
export const DeviationSchema = z.object({
|
|
68
|
+
/** The instruction, quoted or closely paraphrased, so it can be found in the task text. */
|
|
69
|
+
instruction: z.string().trim().min(1),
|
|
70
|
+
/** What was done instead. */
|
|
71
|
+
did: z.string().trim().min(1),
|
|
72
|
+
/** Why the instruction could not be followed as written: the error, the version, the fact. */
|
|
73
|
+
why: z.string().trim().min(1),
|
|
74
|
+
});
|
|
75
|
+
/**
|
|
76
|
+
* A review finding the implementer says is wrong, with what shows it.
|
|
77
|
+
*
|
|
78
|
+
* The findings message used to say "if you believe a finding is wrong, say so in your summary
|
|
79
|
+
* with the evidence" — and the next reviewer never sees the summary, on purpose. So a wrong
|
|
80
|
+
* finding had no way to be answered: in one run a reviewer searched the page for label text
|
|
81
|
+
* the task never specified, found none, and the implementer — having objected once, in prose,
|
|
82
|
+
* to nobody — renamed the labels to match. As data, a dispute goes to the next reviewer as a
|
|
83
|
+
* claim to test, and to the record. The finding is named by the id the runner gave it.
|
|
84
|
+
*/
|
|
85
|
+
export const DisputeSchema = z.object({
|
|
86
|
+
/** The finding's id as it was given: `r1f2`. */
|
|
87
|
+
finding: z.string().trim().min(1),
|
|
88
|
+
/** Why it is wrong. */
|
|
89
|
+
why: z.string().trim().min(1),
|
|
90
|
+
/** What shows it: the command run and what came back, quoted. */
|
|
91
|
+
evidence: z.string().trim().min(1),
|
|
92
|
+
});
|
|
93
|
+
export const ReplySchema = z
|
|
94
|
+
.object({
|
|
95
|
+
/**
|
|
96
|
+
* How this reply ends the exchange, or does not.
|
|
97
|
+
*
|
|
98
|
+
* `continue` — here are steps to run, send me the output.
|
|
99
|
+
* `done` — the work is finished and verified, and `summary` says so.
|
|
100
|
+
* `blocked` — the work cannot be finished, and here is what was tried and what is in the way.
|
|
101
|
+
*
|
|
102
|
+
* `blocked` exists because the alternative is worse. Without it a model that has run out of
|
|
103
|
+
* ideas either claims `done` on work it did not do, or keeps sending steps until a limit
|
|
104
|
+
* cuts it off — and the second one has happened for fifteen iterations at a stretch, each
|
|
105
|
+
* costing a message, a reply and a report, and ending in a timeout that says nothing about
|
|
106
|
+
* why. A task that stops with "I tried these three things and this is in the way" is a
|
|
107
|
+
* result. A task that runs out of iterations is not.
|
|
108
|
+
*/
|
|
109
|
+
status: z.enum(['continue', 'done', 'blocked']),
|
|
110
|
+
steps: z.array(StepSchema).default([]),
|
|
111
|
+
notes: z.string().optional(),
|
|
112
|
+
/**
|
|
113
|
+
* Required when `status` is `done` or `blocked`: what was done, in what order, what the
|
|
114
|
+
* result is, and what the user should know. Shown in the UI as the outcome of the task.
|
|
115
|
+
*/
|
|
116
|
+
summary: z.string().optional(),
|
|
117
|
+
/**
|
|
118
|
+
* Required when `status` is `blocked`: the distinct approaches that were tried, one per
|
|
119
|
+
* entry. "Ran it again" is not an approach.
|
|
120
|
+
*/
|
|
121
|
+
tried: z.array(z.string().trim().min(1)).default([]),
|
|
122
|
+
/** What would unblock it: a decision, a credential, a missing file, a corrected task. */
|
|
123
|
+
needed: z.string().optional(),
|
|
124
|
+
/**
|
|
125
|
+
* Instructions that could not be followed as written, with what was done instead and why.
|
|
126
|
+
* Accepted on any reply, because a deviation is made when it is made, not at the end; the
|
|
127
|
+
* runner keeps them for the task and merges repeats by instruction.
|
|
128
|
+
*/
|
|
129
|
+
deviations: z.array(DeviationSchema).default([]),
|
|
130
|
+
/**
|
|
131
|
+
* Review findings the implementer says are wrong, by id, with evidence. Accepted on any
|
|
132
|
+
* reply; the runner keeps them for the task and hands them to the next reviewer.
|
|
133
|
+
*/
|
|
134
|
+
disputed: z.array(DisputeSchema).default([]),
|
|
135
|
+
})
|
|
136
|
+
.refine((r) => r.status !== 'continue' || r.steps.length > 0, {
|
|
137
|
+
message: 'status "continue" with an empty steps array would stall the run. ' +
|
|
138
|
+
'Send at least one step, or set status to "done" or "blocked".',
|
|
139
|
+
})
|
|
140
|
+
.refine((r) => r.status === 'continue' || r.steps.length === 0, {
|
|
141
|
+
message: 'status "done" and status "blocked" both end the task, so they cannot carry steps. ' +
|
|
142
|
+
'Send the steps with status "continue" first, then end.',
|
|
143
|
+
})
|
|
144
|
+
.refine((r) => r.status !== 'done' || (r.summary ?? '').trim().length >= MIN_SUMMARY_CHARS, {
|
|
145
|
+
message: 'status "done" requires a real summary: several sentences explaining what was done, ' +
|
|
146
|
+
'what the result is, and what the user should know. Put it in the "summary" field.',
|
|
147
|
+
})
|
|
148
|
+
.refine((r) => r.status !== 'blocked' || (r.summary ?? '').trim().length >= MIN_SUMMARY_CHARS, {
|
|
149
|
+
message: 'status "blocked" requires a summary too: what state the work is in, what is done and ' +
|
|
150
|
+
'what is not, so somebody can pick it up from where you stopped.',
|
|
151
|
+
})
|
|
152
|
+
.refine((r) => r.status !== 'blocked' || r.tried.length >= MIN_TRIED_APPROACHES, {
|
|
153
|
+
message: `status "blocked" requires "tried": at least ${MIN_TRIED_APPROACHES} genuinely different ` +
|
|
154
|
+
'approaches you attempted, one per array entry. Running the same command again is not a ' +
|
|
155
|
+
'second approach. If you have only tried one thing, try another before giving up.',
|
|
156
|
+
});
|
|
157
|
+
/** What makes two deviations the same one: the instruction, ignoring case and spacing. */
|
|
158
|
+
function deviationKey(d) {
|
|
159
|
+
return d.instruction.toLowerCase().replace(/\s+/g, ' ').trim();
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The deviations a task has declared so far, with a new reply's merged in.
|
|
163
|
+
*
|
|
164
|
+
* A deviation reported in iteration 5 must survive a `done` in iteration 12 that does not
|
|
165
|
+
* repeat it, and a `done` that does repeat it must not list it twice. The instruction keeps
|
|
166
|
+
* the wording it was first declared with — that is the one written when it happened — while
|
|
167
|
+
* `did` and `why` take the latest, so a reason refined as the work goes on is not lost.
|
|
168
|
+
*/
|
|
169
|
+
export function mergeDeviations(existing, incoming) {
|
|
170
|
+
const merged = new Map();
|
|
171
|
+
for (const d of [...existing, ...incoming]) {
|
|
172
|
+
const key = deviationKey(d);
|
|
173
|
+
merged.set(key, { instruction: merged.get(key)?.instruction ?? d.instruction, did: d.did, why: d.why });
|
|
174
|
+
}
|
|
175
|
+
return [...merged.values()];
|
|
176
|
+
}
|
|
177
|
+
/** The deviations written out, for the commit message and the record. */
|
|
178
|
+
export function describeDeviations(deviations) {
|
|
179
|
+
return deviations
|
|
180
|
+
.map((d, i) => `${i + 1}. Instruction: ${d.instruction}\n Did instead: ${d.did}\n Because: ${d.why}`)
|
|
181
|
+
.join('\n\n');
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The deviations a task carries after a reply.
|
|
185
|
+
*
|
|
186
|
+
* A `continue` adds to the list. A `done` or `blocked` that carries deviations replaces it,
|
|
187
|
+
* because the closing reply is the final account: a deviation declared mid-way and undone
|
|
188
|
+
* since — `moduleResolution` set to Node16, then back to `node` once TypeScript was pinned —
|
|
189
|
+
* stood in the commit as a fact. A closing reply that names none keeps what was declared,
|
|
190
|
+
* because forgetting to repeat is more common than meaning to retract.
|
|
191
|
+
*/
|
|
192
|
+
export function resolveDeviations(existing, status, incoming) {
|
|
193
|
+
if (status !== 'continue' && incoming.length > 0)
|
|
194
|
+
return mergeDeviations([], incoming);
|
|
195
|
+
return mergeDeviations(existing, incoming);
|
|
196
|
+
}
|
|
197
|
+
/** Disputes so far with a reply's merged in: one per finding id, latest wording wins. */
|
|
198
|
+
export function mergeDisputes(existing, incoming) {
|
|
199
|
+
const merged = new Map();
|
|
200
|
+
for (const d of [...existing, ...incoming])
|
|
201
|
+
merged.set(d.finding.toLowerCase().trim(), d);
|
|
202
|
+
return [...merged.values()];
|
|
203
|
+
}
|
|
204
|
+
/** The disputes written out, for the record and the next reviewer. */
|
|
205
|
+
export function describeDisputes(disputes) {
|
|
206
|
+
return disputes.map((d, i) => `${i + 1}. Finding ${d.finding}: ${d.why}\n Evidence: ${d.evidence}`).join('\n\n');
|
|
207
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Never send an attachment without text. This is the fallback if everything else is empty. */
|
|
2
|
+
export const MINIMUM_COVERING_TEXT = 'Ето отговора от терминала. Файлът е прикачен.';
|
|
3
|
+
/**
|
|
4
|
+
* A non-zero exit with nothing printed at all.
|
|
5
|
+
*
|
|
6
|
+
* In PowerShell that is what a cmdlet that found nothing looks like — `Get-NetTCPConnection`
|
|
7
|
+
* on a free port, `Get-Process` with no match — and the free port is the good outcome. Twice
|
|
8
|
+
* in one run a step ended this way at the exact moment the task had succeeded, and the model
|
|
9
|
+
* spent iterations proving with netstat that nothing was wrong. The runner cannot change the
|
|
10
|
+
* exit code; it can say what it sees.
|
|
11
|
+
*/
|
|
12
|
+
export function silentFailure(r) {
|
|
13
|
+
return r.outcome === 'completed' && r.exitCode !== 0 && r.stdout.trim() === '' && r.stderr.trim() === '';
|
|
14
|
+
}
|
|
15
|
+
function outcomeSummary(r) {
|
|
16
|
+
switch (r.outcome) {
|
|
17
|
+
case 'completed':
|
|
18
|
+
return silentFailure(r) ? `step ${r.id} exit ${r.exitCode} with no output at all` : `step ${r.id} exit ${r.exitCode}`;
|
|
19
|
+
case 'hard-timeout':
|
|
20
|
+
return `step ${r.id} hit its time limit`;
|
|
21
|
+
case 'idle-timeout':
|
|
22
|
+
return `step ${r.id} produced no output and was stopped`;
|
|
23
|
+
case 'aborted':
|
|
24
|
+
// Exit -4 is `refusedResult`: the runner declined it — a deny pattern, a repeat, damaged
|
|
25
|
+
// text, a shell that is not here — and it never reached a process. Calling all of those
|
|
26
|
+
// "aborted by the operator" told the model a person had stopped it, which is a reason to
|
|
27
|
+
// wait rather than to write the step again, and the reason it could act on was in the
|
|
28
|
+
// stderr underneath a sentence contradicting it.
|
|
29
|
+
return r.exitCode === -4 ? `step ${r.id} was not run; see why under it` : `step ${r.id} aborted by the operator`;
|
|
30
|
+
case 'spawn-error':
|
|
31
|
+
return `step ${r.id} could not be started`;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The covering message. Deliberately short: it can never approach the roughly 120 000
|
|
36
|
+
* character limit of the composer, because the output it describes is in the attachment.
|
|
37
|
+
*/
|
|
38
|
+
export function buildCoveringMessage(input) {
|
|
39
|
+
const { task, iteration, results, attachments, parts } = input;
|
|
40
|
+
const what = task?.trim() ? `"${task.trim()}", iteration ${iteration}` : `iteration ${iteration}`;
|
|
41
|
+
if (attachments.length === 0) {
|
|
42
|
+
// Should not happen, but an empty message is unsendable, so never return one.
|
|
43
|
+
return `${MINIMUM_COVERING_TEXT} (${what}, no file was produced)`;
|
|
44
|
+
}
|
|
45
|
+
const lines = [];
|
|
46
|
+
lines.push(`Terminal output for ${what}: ${results.length} step(s), ` +
|
|
47
|
+
`${results.map(outcomeSummary).join('; ')}.`);
|
|
48
|
+
if (parts && parts > 1) {
|
|
49
|
+
lines.push(`The output is split across ${parts} attached files: ${attachments.join(', ')}. ` +
|
|
50
|
+
`Read all of them, in order, before deciding the next steps.`);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
lines.push(`The full output is in the attached file ${attachments[0]}. ` +
|
|
54
|
+
`Read the whole file before deciding the next steps.`);
|
|
55
|
+
}
|
|
56
|
+
const stopped = results.filter((r) => r.outcome === 'idle-timeout' || r.outcome === 'hard-timeout');
|
|
57
|
+
if (stopped.length > 0) {
|
|
58
|
+
lines.push(`Note: ${stopped.map((r) => `step ${r.id}`).join(', ')} was stopped by the runner, not by the command itself.`);
|
|
59
|
+
}
|
|
60
|
+
const silent = results.filter(silentFailure);
|
|
61
|
+
if (silent.length > 0) {
|
|
62
|
+
lines.push(`Note: ${silent.map((r) => `step ${r.id}`).join(', ')} exited non-zero without printing anything. In PowerShell ` +
|
|
63
|
+
'that is usually a cmdlet that found nothing — a free port, no matching process — not a command that failed. ' +
|
|
64
|
+
'Decide from what the step was asking, not from the code alone.');
|
|
65
|
+
}
|
|
66
|
+
for (const note of input.notes ?? [])
|
|
67
|
+
lines.push(note);
|
|
68
|
+
return lines.join(' ');
|
|
69
|
+
}
|
|
70
|
+
/** Guard used right before clicking Send. Throws rather than sending an unsendable message. */
|
|
71
|
+
export function assertSendable(text, attachments) {
|
|
72
|
+
if (text.trim().length === 0) {
|
|
73
|
+
throw new Error(`Refusing to send: the composer requires text, and ${attachments.length} attachment(s) alone cannot be sent.`);
|
|
74
|
+
}
|
|
75
|
+
}
|