copilot-operator 0.1.9 → 0.1.10
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/dist/src/api/operator.service.js +4 -12
- package/dist/src/orchestrator/contract.js +58 -0
- package/dist/src/orchestrator/taskRunner.js +60 -4
- package/dist/src/plan/schema.js +21 -1
- package/dist/src/plan/systemGuide.js +16 -0
- package/dist/src/protocol/parser.js +98 -5
- package/dist/src/session/exports.js +1 -0
- package/dist/src/session/handoff.js +1 -1
- package/dist/src/session/store.js +44 -10
- package/dist/src/transport/copilotTransport.js +18 -0
- package/dist/src/vcs/scope.js +10 -7
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
- package/dist/web/_next/static/chunks/867-5ae3adf431bd10a2.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/{page-78be7820772f8882.js → page-0f88c46dcd9dd52c.js} +1 -1
- package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
- package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
- package/dist/web/_next/static/chunks/app/level1/{page-be366b35f4cc97ce.js → page-dd3ebf37d29dce4f.js} +1 -1
- package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
- package/dist/web/_next/static/chunks/app/presets/{page-cabc3876a8111edd.js → page-93ab518db67e52a2.js} +1 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-fb634b39ce9ba4a3.js +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +11 -11
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +11 -11
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +11 -11
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +11 -11
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +11 -11
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +11 -11
- package/dist/web/presets.html +3 -3
- package/dist/web/presets.txt +11 -11
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +11 -11
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +11 -11
- package/package.json +5 -2
- package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +0 -1
- package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +0 -1
- package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +0 -1
- package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +0 -1
- /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → d6YL07r-SeksvVLrCSZ3o}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → d6YL07r-SeksvVLrCSZ3o}/_ssgManifest.js +0 -0
|
@@ -17,9 +17,9 @@ import { readFile, readdir } from 'node:fs/promises';
|
|
|
17
17
|
import { existsSync } from 'node:fs';
|
|
18
18
|
import { join } from 'node:path';
|
|
19
19
|
import { installLayout } from '../config/layout.js';
|
|
20
|
-
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW } from '../session/store.js';
|
|
20
|
+
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyTaskPatch } from '../session/store.js';
|
|
21
21
|
import { EventBus } from '../session/events.js';
|
|
22
|
-
import { newId
|
|
22
|
+
import { newId } from '../session/model.js';
|
|
23
23
|
import { runSession, openBrowser, queuedToRun } from '../orchestrator/taskRunner.js';
|
|
24
24
|
import { buildExport } from '../session/exportRecord.js';
|
|
25
25
|
import { buildDebugExport } from '../session/debugExport.js';
|
|
@@ -303,16 +303,7 @@ let OperatorService = class OperatorService {
|
|
|
303
303
|
return await this.store.updateTask(sessionId, taskId, (t) => {
|
|
304
304
|
if (t.status !== 'queued')
|
|
305
305
|
throw new Error('Only a queued task can be edited.');
|
|
306
|
-
|
|
307
|
-
t.title = patch.title;
|
|
308
|
-
if (patch.level2 !== undefined)
|
|
309
|
-
t.level2 = patch.level2;
|
|
310
|
-
if (patch.prompt !== undefined)
|
|
311
|
-
t.prompt = patch.prompt;
|
|
312
|
-
if (patch.vcsPlan !== undefined)
|
|
313
|
-
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
314
|
-
if (patch.checks !== undefined)
|
|
315
|
-
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
306
|
+
applyTaskPatch(t, patch);
|
|
316
307
|
});
|
|
317
308
|
}
|
|
318
309
|
async deleteTask(sessionId, taskId) {
|
|
@@ -2035,6 +2026,7 @@ let OperatorService = class OperatorService {
|
|
|
2035
2026
|
disputes: t.disputes?.length || undefined,
|
|
2036
2027
|
readOnly: t.readOnly || undefined,
|
|
2037
2028
|
scope: t.scope?.length ? t.scope : undefined,
|
|
2029
|
+
stopCode: t.stopCode,
|
|
2038
2030
|
autoRetries: t.autoRetries || undefined,
|
|
2039
2031
|
attempt: t.attempt,
|
|
2040
2032
|
changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a task can be satisfied at all, decided before anything is sent to the chat.
|
|
3
|
+
*
|
|
4
|
+
* A task is a contract: a prompt, the checks that say when it is done, and what it may change
|
|
5
|
+
* (`readOnly`, `scope`). The three are written at different times — a plan, an edit, a new prompt on
|
|
6
|
+
* a finished task — and they can end up contradicting each other: an audit made read-only that
|
|
7
|
+
* still carries the old "the file now says X" check; a task scoped to its tests that must change
|
|
8
|
+
* the config to pass. Run as it is, such a task spends its rounds and ends blocked on a check the
|
|
9
|
+
* chat could never have met. This names the contradiction instead, before the first message.
|
|
10
|
+
*
|
|
11
|
+
* Only what can be known without running anything is judged: the flags against each other, and
|
|
12
|
+
* the file checks against the tree as it is now. A file check that already passes needs no change;
|
|
13
|
+
* one that fails needs its file changed, which a read-only task, or a scope that leaves the file
|
|
14
|
+
* out, forbids. Command checks are not judged: whether a command will pass depends on work the
|
|
15
|
+
* task is allowed to do elsewhere.
|
|
16
|
+
*/
|
|
17
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
18
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
19
|
+
import { inScope } from '../vcs/scope.js';
|
|
20
|
+
async function fileCheckPassesNow(check, cwd) {
|
|
21
|
+
const path = resolve(cwd, (check.cwd ?? '').trim() || '.', (check.file ?? '').trim());
|
|
22
|
+
const info = await stat(path).catch(() => null);
|
|
23
|
+
const exists = !!info?.isFile();
|
|
24
|
+
if (check.expect === 'file-exists')
|
|
25
|
+
return { passes: exists, path };
|
|
26
|
+
if (check.expect === 'file-missing')
|
|
27
|
+
return { passes: !exists, path };
|
|
28
|
+
const text = exists ? await readFile(path, 'utf8').catch(() => '') : '';
|
|
29
|
+
return { passes: exists && text.includes(check.value ?? ''), path };
|
|
30
|
+
}
|
|
31
|
+
/** Each contradiction in the task, as a sentence; empty when it can be satisfied. */
|
|
32
|
+
export async function contractConflicts(task, cwd, repoDir) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const scope = task.scope ?? [];
|
|
35
|
+
if (task.readOnly && scope.length > 0) {
|
|
36
|
+
out.push(`It is read-only, which allows no change, and also scoped to ${scope.join(', ')}, which allows changes there.`);
|
|
37
|
+
}
|
|
38
|
+
if (!task.readOnly && scope.length === 0)
|
|
39
|
+
return out;
|
|
40
|
+
const checks = [
|
|
41
|
+
...(task.checks ?? []),
|
|
42
|
+
...(task.reviewChecks ?? []).filter((rc) => rc.state === 'active').map((rc) => rc.check),
|
|
43
|
+
].filter((c) => c.expect === 'file-exists' || c.expect === 'file-missing' || c.expect === 'file-contains');
|
|
44
|
+
for (const check of checks) {
|
|
45
|
+
const { passes, path } = await fileCheckPassesNow(check, cwd);
|
|
46
|
+
if (passes)
|
|
47
|
+
continue;
|
|
48
|
+
const rel = relative(resolve(repoDir), path).replace(/\\/g, '/');
|
|
49
|
+
const inside = rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
50
|
+
if (task.readOnly) {
|
|
51
|
+
out.push(`The check "${check.name}" fails now and can only pass if ${rel || path} changes, but the task is read-only.`);
|
|
52
|
+
}
|
|
53
|
+
else if (inside && !inScope(rel, scope)) {
|
|
54
|
+
out.push(`The check "${check.name}" fails now and can only pass if ${rel} changes, but the task's scope (${scope.join(', ')}) leaves that file out.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
@@ -41,6 +41,7 @@ import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
|
|
|
41
41
|
import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
|
|
42
42
|
import { composeHandoff } from '../session/handoff.js';
|
|
43
43
|
import { ProgressWatch } from './progress.js';
|
|
44
|
+
import { contractConflicts } from './contract.js';
|
|
44
45
|
import { treeFingerprint } from '../vcs/git.js';
|
|
45
46
|
import { mirrorProject, describeMirror } from '../context/projectMirror.js';
|
|
46
47
|
import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
|
|
@@ -366,6 +367,12 @@ export async function runTask(transport, session, task, deps) {
|
|
|
366
367
|
const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
|
|
367
368
|
/** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
|
|
368
369
|
let noProgressReason = null;
|
|
370
|
+
/**
|
|
371
|
+
* Why the attempt stopped, when the status alone does not say: a format the chat could not keep,
|
|
372
|
+
* a task contract that contradicts itself, a loop, a check that could never run, a machine that
|
|
373
|
+
* lacks a shell. Written by `finish`. See `Task.stopCode`.
|
|
374
|
+
*/
|
|
375
|
+
let stopCode;
|
|
369
376
|
/**
|
|
370
377
|
* Checks earlier reviews gave with their findings — carried over from every attempt before
|
|
371
378
|
* this one, and grown by this one. See `derivedChecks.ts` for the three rules.
|
|
@@ -467,6 +474,8 @@ export async function runTask(transport, session, task, deps) {
|
|
|
467
474
|
t.reviewChecks = reviewChecks;
|
|
468
475
|
t.logFile = 'task-log.txt';
|
|
469
476
|
t.stats = stats;
|
|
477
|
+
if (stopCode)
|
|
478
|
+
t.stopCode = stopCode;
|
|
470
479
|
});
|
|
471
480
|
// The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
|
|
472
481
|
await setTask((t) => {
|
|
@@ -606,9 +615,31 @@ export async function runTask(transport, session, task, deps) {
|
|
|
606
615
|
* is the task's, and the starting commit is what a change goes back to.
|
|
607
616
|
*/
|
|
608
617
|
const scope = task.scope ?? [];
|
|
609
|
-
const
|
|
618
|
+
const onOwnBranch = !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
|
|
619
|
+
/*
|
|
620
|
+
* A read-only task is a scope of nothing, and is enforced the same way: whatever a round changed
|
|
621
|
+
* is put back before the chat reads the results. It used to be judged only at the end, so an
|
|
622
|
+
* audit that edited a file "to check something" kept the edit through every later round and its
|
|
623
|
+
* findings described a tree that was not the one it had been asked to audit.
|
|
624
|
+
*/
|
|
625
|
+
const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
|
|
610
626
|
/** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
|
|
611
627
|
const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
|
|
628
|
+
/*
|
|
629
|
+
* The task's contract, checked before a single message is sent.
|
|
630
|
+
*
|
|
631
|
+
* A task can be written so that no answer satisfies it: read-only and still given paths to
|
|
632
|
+
* change, or a check that needs a file to change while the task may not change it. Sent as it
|
|
633
|
+
* is, the chat works for rounds, the check fails at the end, and the task goes blocked on a
|
|
634
|
+
* finding the chat could never have fixed. The contradiction is named instead, now, and nothing
|
|
635
|
+
* is run. See `orchestrator/contract.ts`.
|
|
636
|
+
*/
|
|
637
|
+
const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd);
|
|
638
|
+
if (conflicts.length > 0) {
|
|
639
|
+
stopCode = 'contract-conflict';
|
|
640
|
+
sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
|
|
641
|
+
return await finish('blocked', `the task contradicts itself, so it was not started: ${conflicts.join(' ')} Change the prompt, the checks, the scope or read-only, and queue it again.`);
|
|
642
|
+
}
|
|
612
643
|
// --- project mirror, attached to the first message of the task ---------------------
|
|
613
644
|
let mirrorFiles = [];
|
|
614
645
|
if (session.mirror.enabled && session.mirror.rootDir) {
|
|
@@ -819,6 +850,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
819
850
|
if (stuck) {
|
|
820
851
|
noProgressReason = stuck;
|
|
821
852
|
stats.stoppedFor = 'no-progress';
|
|
853
|
+
stopCode = 'no-progress';
|
|
822
854
|
sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
|
|
823
855
|
return 'give-up';
|
|
824
856
|
}
|
|
@@ -1155,9 +1187,20 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1155
1187
|
formatRetries += 1;
|
|
1156
1188
|
stats.formatErrors += 1;
|
|
1157
1189
|
sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
|
|
1190
|
+
/*
|
|
1191
|
+
* Out of format retries: stopped at a limit, not failed.
|
|
1192
|
+
*
|
|
1193
|
+
* Nothing about the work went wrong here — the steps that ran are recorded, their output is
|
|
1194
|
+
* in the run folder, and the rejected replies ran nothing. So the task ends `limit-reached`,
|
|
1195
|
+
* marked `format-repair-exhausted`, which is what lets "Continue in the same chat" carry it
|
|
1196
|
+
* on from exactly here instead of the operator having to run it again from the start. It
|
|
1197
|
+
* used to end `failed`, which read as a verdict on the work and offered only a fresh start.
|
|
1198
|
+
*/
|
|
1158
1199
|
if (formatRetries > cfg.limits.maxFormatRetries) {
|
|
1159
1200
|
await transport.dumpFailure(log.path('failures'), 'format-error');
|
|
1160
|
-
|
|
1201
|
+
stopCode = 'format-repair-exhausted';
|
|
1202
|
+
return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
|
|
1203
|
+
`the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
|
|
1161
1204
|
}
|
|
1162
1205
|
await pacer.throttleSend();
|
|
1163
1206
|
const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
|
|
@@ -1167,6 +1210,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1167
1210
|
continue;
|
|
1168
1211
|
}
|
|
1169
1212
|
formatRetries = 0;
|
|
1213
|
+
if (parsed.coerced) {
|
|
1214
|
+
sink.event('format-coerced', { fields: parsed.coerced }, `read as text: ${parsed.coerced.join(', ')} arrived in another shape; nothing in them is run, so the reply was taken as it was`);
|
|
1215
|
+
}
|
|
1170
1216
|
iterations += 1;
|
|
1171
1217
|
await setTask((t) => {
|
|
1172
1218
|
t.iterations = iterations;
|
|
@@ -1238,11 +1284,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1238
1284
|
if (done && reply.steps.length === 0) {
|
|
1239
1285
|
const verdict = await gateOnChecks();
|
|
1240
1286
|
if (verdict === 'environment') {
|
|
1287
|
+
stopCode = 'environment';
|
|
1241
1288
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1242
1289
|
}
|
|
1243
1290
|
if (verdict === 'give-up') {
|
|
1244
1291
|
if (noProgressReason)
|
|
1245
1292
|
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1293
|
+
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1294
|
+
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1295
|
+
stopCode = 'invalid-check';
|
|
1246
1296
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1247
1297
|
}
|
|
1248
1298
|
if (verdict === 'accept') {
|
|
@@ -1382,6 +1432,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1382
1432
|
// Ended before the report is written and sent: there is nobody to send it to who could
|
|
1383
1433
|
// do anything with it, and the reason on the task says what to install or set instead.
|
|
1384
1434
|
if (environmentProblem) {
|
|
1435
|
+
stopCode = 'environment';
|
|
1385
1436
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1386
1437
|
}
|
|
1387
1438
|
/*
|
|
@@ -1390,7 +1441,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1390
1441
|
* and the chat is told in the same message.
|
|
1391
1442
|
*/
|
|
1392
1443
|
if (scopeEnforced) {
|
|
1393
|
-
const check = await enforceScope(repoDirOf(session), scope).catch((e) => ({
|
|
1444
|
+
const check = await enforceScope(repoDirOf(session), scope, !!task.readOnly).catch((e) => ({
|
|
1394
1445
|
outside: [],
|
|
1395
1446
|
reverted: [],
|
|
1396
1447
|
failed: [{ path: '(the working tree)', why: e.message }],
|
|
@@ -1398,7 +1449,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1398
1449
|
if (check.outside.length > 0 || check.failed.length > 0) {
|
|
1399
1450
|
sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
|
|
1400
1451
|
(check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
|
|
1401
|
-
runnerNotes.push(scopeMessage(check, scope));
|
|
1452
|
+
runnerNotes.push(scopeMessage(check, scope, !!task.readOnly));
|
|
1402
1453
|
stats.scopeReverts += check.reverted.length;
|
|
1403
1454
|
await setTask((t) => {
|
|
1404
1455
|
t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
|
|
@@ -1429,6 +1480,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1429
1480
|
const stuck = progress.afterRound(await treeNow(), results);
|
|
1430
1481
|
if (stuck) {
|
|
1431
1482
|
stats.stoppedFor = 'no-progress';
|
|
1483
|
+
stopCode = 'no-progress';
|
|
1432
1484
|
sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
|
|
1433
1485
|
return await finish('blocked', stuck, reply.summary, lastMarkdown);
|
|
1434
1486
|
}
|
|
@@ -1529,11 +1581,15 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1529
1581
|
if (done) {
|
|
1530
1582
|
const verdict = await gateOnChecks();
|
|
1531
1583
|
if (verdict === 'environment') {
|
|
1584
|
+
stopCode = 'environment';
|
|
1532
1585
|
return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
|
|
1533
1586
|
}
|
|
1534
1587
|
if (verdict === 'give-up') {
|
|
1535
1588
|
if (noProgressReason)
|
|
1536
1589
|
return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
|
|
1590
|
+
// Every failing check was refused before it ran: a verdict on the checks, not on the work.
|
|
1591
|
+
if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
|
|
1592
|
+
stopCode = 'invalid-check';
|
|
1537
1593
|
return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
|
|
1538
1594
|
}
|
|
1539
1595
|
if (verdict === 'accept') {
|
package/dist/src/plan/schema.js
CHANGED
|
@@ -432,7 +432,7 @@ export function checkPlan(text) {
|
|
|
432
432
|
if (!parsed.success) {
|
|
433
433
|
return { ok: false, warnings, issues: parsed.error.issues.map((i) => ({ path: pathOf(i.path), message: i.message })) };
|
|
434
434
|
}
|
|
435
|
-
const weak = weakCheckIssues(parsed.data);
|
|
435
|
+
const weak = [...weakCheckIssues(parsed.data), ...contradictionIssues(parsed.data)];
|
|
436
436
|
if (weak.length)
|
|
437
437
|
return { ok: false, warnings, issues: weak };
|
|
438
438
|
return {
|
|
@@ -503,6 +503,26 @@ function weakCheckIssues(plan) {
|
|
|
503
503
|
});
|
|
504
504
|
return out;
|
|
505
505
|
}
|
|
506
|
+
/**
|
|
507
|
+
* A task whose own flags contradict each other: read-only (change nothing) and scoped (change
|
|
508
|
+
* these). Refused at import, where the chat that wrote it can still be asked which it meant; the
|
|
509
|
+
* runner refuses it again before starting (orchestrator/contract.ts) for a task edited since.
|
|
510
|
+
*/
|
|
511
|
+
function contradictionIssues(plan) {
|
|
512
|
+
const out = [];
|
|
513
|
+
plan.sessions.forEach((s, i) => {
|
|
514
|
+
s.tasks.forEach((t, j) => {
|
|
515
|
+
if (!t.readOnly || t.scope.length === 0)
|
|
516
|
+
return;
|
|
517
|
+
out.push({
|
|
518
|
+
path: `sessions[${i}].tasks[${j}]`,
|
|
519
|
+
message: `"${t.title}" is "readOnly": true, which allows no change, and has a "scope" (${t.scope.join(', ')}), which allows changes there. ` +
|
|
520
|
+
'An audit that writes a report is not read-only: drop "readOnly" and scope it to the report. One that writes nothing: drop "scope".',
|
|
521
|
+
});
|
|
522
|
+
});
|
|
523
|
+
});
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
506
526
|
/** The issues as a block the user can hand back to the chat model without editing it. */
|
|
507
527
|
export function describeIssues(issues) {
|
|
508
528
|
return issues.map((i) => (i.path ? `- ${i.path}: ${i.message}` : `- ${i.message}`)).join('\n');
|
|
@@ -1437,6 +1437,22 @@ export const SYSTEM_GUIDE = [
|
|
|
1437
1437
|
doesEn: 'On a task that has already run: stores the edit and queues it again; the earlier attempt is kept. On a done task the new attempt builds on its work. It does not start it.',
|
|
1438
1438
|
doesBg: 'При вече изпълнена задача: записва промяната и я връща в опашката; предишният опит се пази. При готова задача новият опит стъпва върху свършеното. Не я пуска.',
|
|
1439
1439
|
},
|
|
1440
|
+
{
|
|
1441
|
+
key: 'task.readOnlyEdit',
|
|
1442
|
+
kind: 'checkbox',
|
|
1443
|
+
en: 'Read-only: this task must not change any file',
|
|
1444
|
+
bg: 'Само за четене: задачата не бива да променя нито един файл',
|
|
1445
|
+
doesEn: 'Also in the new-prompt dialog of the register. For an audit: every change a round makes is put back.',
|
|
1446
|
+
doesBg: 'Има го и в диалога за нов prompt в регистъра. За одит: всяка промяна от кръг се връща.',
|
|
1447
|
+
},
|
|
1448
|
+
{
|
|
1449
|
+
key: 'task.scopeEdit',
|
|
1450
|
+
kind: 'field',
|
|
1451
|
+
en: 'Files this task may change',
|
|
1452
|
+
bg: 'Файлове, които задачата може да променя',
|
|
1453
|
+
doesEn: 'One path or pattern per line; changes elsewhere are put back. Also in the new-prompt dialog, which lists the old checks to keep or drop.',
|
|
1454
|
+
doesBg: 'По един път или шаблон на ред; промени другаде се връщат. Има го и в диалога за нов prompt, който изброява старите проверки за запазване или махане.',
|
|
1455
|
+
},
|
|
1440
1456
|
{
|
|
1441
1457
|
key: 'task.branch',
|
|
1442
1458
|
kind: 'field',
|
|
@@ -60,8 +60,83 @@ export function stripCitations(text) {
|
|
|
60
60
|
return undefined;
|
|
61
61
|
return text.replace(/\s*【[^】]*】/g, '').replace(/[ ]+$/gm, '').trim();
|
|
62
62
|
}
|
|
63
|
+
/*
|
|
64
|
+
* Prose that arrived in another shape.
|
|
65
|
+
*
|
|
66
|
+
* `tried`, `needed`, `notes` and `summary` are sentences for a person to read; nothing in them is
|
|
67
|
+
* run. A chat that writes `tried` as a list of objects — `{"approach": "…", "result": "…"}` — has
|
|
68
|
+
* said exactly what was asked, in a shape the schema did not expect, and refusing the whole reply
|
|
69
|
+
* for it cost a format round each time and, three in a row, the task: it ended "failed" with the
|
|
70
|
+
* work done and its results already recorded. So these fields are read as text whatever shape they
|
|
71
|
+
* come in, and the reading is reported. The fields that are acted on — `status`, `steps` and
|
|
72
|
+
* everything in them — are never coerced: a step the runner would have to guess at is not run.
|
|
73
|
+
*/
|
|
74
|
+
const PROSE_KEYS = ['approach', 'what', 'tried', 'attempt', 'action', 'description', 'text', 'summary', 'detail', 'result', 'outcome', 'why', 'reason', 'needed'];
|
|
75
|
+
function proseOf(value) {
|
|
76
|
+
if (typeof value === 'string')
|
|
77
|
+
return value;
|
|
78
|
+
if (value === null || value === undefined)
|
|
79
|
+
return '';
|
|
80
|
+
if (typeof value === 'number' || typeof value === 'boolean')
|
|
81
|
+
return String(value);
|
|
82
|
+
if (Array.isArray(value))
|
|
83
|
+
return value.map(proseOf).filter((x) => x.trim()).join('\n');
|
|
84
|
+
if (typeof value === 'object') {
|
|
85
|
+
const record = value;
|
|
86
|
+
const named = PROSE_KEYS.filter((k) => typeof record[k] === 'string' && record[k].trim()).map((k) => record[k]);
|
|
87
|
+
if (named.length > 0)
|
|
88
|
+
return named.join(' — ');
|
|
89
|
+
const strings = Object.values(record).filter((v) => typeof v === 'string' && v.trim() !== '');
|
|
90
|
+
return strings.length > 0 ? strings.join(' — ') : JSON.stringify(value);
|
|
91
|
+
}
|
|
92
|
+
return String(value);
|
|
93
|
+
}
|
|
94
|
+
export function normaliseProse(value) {
|
|
95
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
96
|
+
return { value, coerced: [] };
|
|
97
|
+
const out = { ...value };
|
|
98
|
+
const coerced = [];
|
|
99
|
+
for (const key of ['notes', 'summary', 'needed']) {
|
|
100
|
+
if (key in out && out[key] !== undefined && typeof out[key] !== 'string') {
|
|
101
|
+
out[key] = proseOf(out[key]);
|
|
102
|
+
coerced.push(key);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if ('tried' in out && out.tried !== undefined) {
|
|
106
|
+
const tried = out.tried;
|
|
107
|
+
if (typeof tried === 'string') {
|
|
108
|
+
out.tried = tried.trim() ? [tried] : [];
|
|
109
|
+
coerced.push('tried');
|
|
110
|
+
}
|
|
111
|
+
else if (Array.isArray(tried) && tried.some((t) => typeof t !== 'string')) {
|
|
112
|
+
out.tried = tried.map(proseOf).filter((t) => t.trim() !== '');
|
|
113
|
+
tried.forEach((t, i) => {
|
|
114
|
+
if (typeof t !== 'string')
|
|
115
|
+
coerced.push(`tried.${i}`);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { value: out, coerced };
|
|
120
|
+
}
|
|
121
|
+
/** What each field must look like, said in the message that asks for a reformatted reply. */
|
|
122
|
+
const FIELD_SHAPES = [
|
|
123
|
+
[/^status$/, '"status" is one of "continue", "done", "blocked"'],
|
|
124
|
+
[/^steps(\.|$)/, '"steps" is a list of {"id": 1, "type": "command", "cmd": "the command"} ("shell", "expect", "timeoutSec" optional)'],
|
|
125
|
+
[/^tried(\.|$)/, '"tried" is a list of plain strings, one sentence per approach'],
|
|
126
|
+
[/^deviations(\.|$)/, '"deviations" is a list of {"instruction": "…", "did": "…", "why": "…"} with plain strings'],
|
|
127
|
+
[/^disputed(\.|$)/, '"disputed" is a list of {"finding": "…", "why": "…", "evidence": "…"} with plain strings'],
|
|
128
|
+
[/^(summary|notes|needed)$/, '"summary", "notes" and "needed" are plain strings'],
|
|
129
|
+
];
|
|
130
|
+
const FORMAT_EXAMPLE = [
|
|
131
|
+
'```json',
|
|
132
|
+
'{"status": "continue", "notes": "what this round does", "steps": [{"id": 1, "type": "command", "cmd": "npm test"}]}',
|
|
133
|
+
'```',
|
|
134
|
+
'or, to end: {"status": "done", "summary": "several sentences on what was done and what it shows"}',
|
|
135
|
+
'or {"status": "blocked", "summary": "…", "tried": ["first approach", "second approach"], "needed": "what would unblock it"}',
|
|
136
|
+
].join('\n');
|
|
63
137
|
export function parseReply(markdown, opts) {
|
|
64
138
|
const candidates = findJsonCandidates(markdown);
|
|
139
|
+
const paths = new Set();
|
|
65
140
|
if (candidates.length === 0) {
|
|
66
141
|
return {
|
|
67
142
|
ok: false,
|
|
@@ -80,9 +155,12 @@ export function parseReply(markdown, opts) {
|
|
|
80
155
|
errors.push(`JSON.parse failed: ${e.message}`);
|
|
81
156
|
continue;
|
|
82
157
|
}
|
|
83
|
-
const
|
|
158
|
+
const prose = normaliseProse(value);
|
|
159
|
+
const result = ReplySchema.safeParse(prose.value);
|
|
84
160
|
if (!result.success) {
|
|
85
161
|
errors.push(result.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join('; '));
|
|
162
|
+
for (const i of result.error.issues)
|
|
163
|
+
paths.add(i.path.join('.') || '(root)');
|
|
86
164
|
continue;
|
|
87
165
|
}
|
|
88
166
|
const reply = result.data;
|
|
@@ -106,6 +184,7 @@ export function parseReply(markdown, opts) {
|
|
|
106
184
|
done: reply.status === 'done' || (markerHit && hasSummary && !blocked),
|
|
107
185
|
blocked,
|
|
108
186
|
json: text.trim(),
|
|
187
|
+
...(prose.coerced.length > 0 ? { coerced: prose.coerced } : {}),
|
|
109
188
|
};
|
|
110
189
|
}
|
|
111
190
|
}
|
|
@@ -113,6 +192,7 @@ export function parseReply(markdown, opts) {
|
|
|
113
192
|
ok: false,
|
|
114
193
|
reason: 'invalid-json',
|
|
115
194
|
detail: errors.slice(0, 4).join(' | '),
|
|
195
|
+
...(paths.size > 0 ? { paths: [...paths] } : {}),
|
|
116
196
|
};
|
|
117
197
|
}
|
|
118
198
|
/**
|
|
@@ -162,15 +242,28 @@ export function parseReview(markdown, opts) {
|
|
|
162
242
|
return { ok: false, reason: 'invalid-json', detail: errors.slice(0, 4).join(' | ') };
|
|
163
243
|
}
|
|
164
244
|
/** The message sent back when a reply did not validate. Names the problem, nothing else. */
|
|
245
|
+
/**
|
|
246
|
+
* The message that asks for the same answer again, in the format.
|
|
247
|
+
*
|
|
248
|
+
* It names each rejected field with the shape it must have, gives a valid example, and says what
|
|
249
|
+
* did not happen: nothing from the rejected reply was run, so the results of the steps before it
|
|
250
|
+
* stand and the answer only needs reformatting. Without that last part a chat told "invalid" tends
|
|
251
|
+
* to start the round again, or to keep the same shape and change the wording, and the rounds run out.
|
|
252
|
+
*/
|
|
165
253
|
export function formatErrorMessage(fail, attempt, maxAttempts) {
|
|
254
|
+
const shapes = [...new Set((fail.paths ?? []).flatMap((p) => FIELD_SHAPES.filter(([re]) => re.test(p)).map(([, s]) => s)))];
|
|
166
255
|
return [
|
|
167
|
-
`Your last reply did not match the required format (
|
|
256
|
+
`Your last reply did not match the required format (format retry ${attempt} of ${maxAttempts}).`,
|
|
168
257
|
fail.reason === 'no-json-block'
|
|
169
258
|
? 'There was no fenced json code block in it.'
|
|
170
259
|
: `The json block did not validate: ${fail.detail}`,
|
|
171
|
-
|
|
172
|
-
'
|
|
173
|
-
|
|
260
|
+
shapes.length > 0 ? `What those fields must be: ${shapes.join('; ')}.` : '',
|
|
261
|
+
'Only the format is wrong. Nothing from that reply was run, and the results of the steps before it stand.',
|
|
262
|
+
'Send the same answer again, reformatted, as exactly one fenced code block tagged json — do not start over and do not change what it asks for. For example:',
|
|
263
|
+
FORMAT_EXAMPLE,
|
|
264
|
+
]
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
.join('\n');
|
|
174
267
|
}
|
|
175
268
|
/**
|
|
176
269
|
* Spots a command that arrived damaged rather than badly written.
|
|
@@ -399,6 +399,7 @@ async function domainTask(session, task, runsDir) {
|
|
|
399
399
|
scope: task.scope ?? [],
|
|
400
400
|
scopeReverted: task.scopeReverted ?? [],
|
|
401
401
|
handoff: task.handoff ?? null,
|
|
402
|
+
stopCode: task.stopCode ?? null,
|
|
402
403
|
prompt: p.prompt,
|
|
403
404
|
expected: p.expected,
|
|
404
405
|
level2: task.level2,
|
|
@@ -29,7 +29,7 @@ export function composeHandoff(task, notRun = []) {
|
|
|
29
29
|
if ((vcs.suspicious?.length ?? 0) > 0 || (vcs.foreignCommits?.length ?? 0) > 0)
|
|
30
30
|
manual.push('look at the committed files and commits listed above');
|
|
31
31
|
return {
|
|
32
|
-
outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}) },
|
|
32
|
+
outcome: { status: task.status, ...(task.reason ? { reason: task.reason } : {}), ...(task.stopCode ? { stopCode: task.stopCode } : {}) },
|
|
33
33
|
changedFiles: (vcs.files ?? []).map((f) => ({ path: f.path, added: f.added, removed: f.removed })),
|
|
34
34
|
validation: (task.checkResults ?? []).map((c) => ({ name: c.name, passed: c.passed })),
|
|
35
35
|
...(task.review ? { review: { verdict: task.review.verdict, open: openFindings.length } } : {}),
|
|
@@ -42,6 +42,47 @@ export const DEFAULT_MIRROR = {
|
|
|
42
42
|
respectGitignore: true,
|
|
43
43
|
includeEnvFiles: false,
|
|
44
44
|
};
|
|
45
|
+
/**
|
|
46
|
+
* Applies an edit to a task: the one place the rule about a new intent lives.
|
|
47
|
+
*
|
|
48
|
+
* A task given a new prompt is a new question. The checks the reviews of its earlier attempts
|
|
49
|
+
* added were findings about the old question, and inheriting them gated the new one on work it
|
|
50
|
+
* no longer asks for: a maintenance task rewritten as an audit still had to pass "the build uses
|
|
51
|
+
* the new config", and went blocked on an artificial finding. So a changed prompt drops them, with
|
|
52
|
+
* the reason kept beside each, and they stay on the record. The plan's own checks are not guessed
|
|
53
|
+
* at: whoever writes the new prompt says which still apply (the edit form and the new-prompt
|
|
54
|
+
* dialog both show them), and `checks` in the patch is that answer. A re-run with the same prompt
|
|
55
|
+
* keeps everything, because it is the same question asked again.
|
|
56
|
+
*/
|
|
57
|
+
export function applyTaskPatch(t, patch) {
|
|
58
|
+
const newPrompt = patch.prompt !== undefined && patch.prompt.trim() !== '' && patch.prompt.trim() !== t.prompt.trim();
|
|
59
|
+
if (patch.title !== undefined)
|
|
60
|
+
t.title = patch.title.trim() || t.title;
|
|
61
|
+
if (patch.prompt !== undefined && patch.prompt.trim())
|
|
62
|
+
t.prompt = patch.prompt;
|
|
63
|
+
if (patch.level2 !== undefined)
|
|
64
|
+
t.level2 = patch.level2;
|
|
65
|
+
if (patch.vcsPlan !== undefined)
|
|
66
|
+
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
67
|
+
if (patch.checks !== undefined)
|
|
68
|
+
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
69
|
+
if (patch.readOnly !== undefined) {
|
|
70
|
+
if (patch.readOnly)
|
|
71
|
+
t.readOnly = true;
|
|
72
|
+
else
|
|
73
|
+
delete t.readOnly;
|
|
74
|
+
}
|
|
75
|
+
if (patch.scope !== undefined) {
|
|
76
|
+
const scope = patch.scope.map((p) => p.trim()).filter(Boolean);
|
|
77
|
+
if (scope.length > 0)
|
|
78
|
+
t.scope = scope;
|
|
79
|
+
else
|
|
80
|
+
delete t.scope;
|
|
81
|
+
}
|
|
82
|
+
if (newPrompt && t.reviewChecks?.length) {
|
|
83
|
+
t.reviewChecks = t.reviewChecks.map((rc) => rc.state === 'dropped' ? rc : { ...rc, state: 'dropped', droppedBecause: 'the task was given a new prompt; this was a finding about the old one' });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
45
86
|
export class SessionStore {
|
|
46
87
|
defaultLevel1Path;
|
|
47
88
|
dir;
|
|
@@ -358,22 +399,14 @@ export class SessionStore {
|
|
|
358
399
|
continuing: t.continuing,
|
|
359
400
|
buildsOn: t.buildsOn,
|
|
360
401
|
freshRetry: t.freshRetry,
|
|
402
|
+
stopCode: t.stopCode,
|
|
361
403
|
scope: t.scope,
|
|
362
404
|
scopeReverted: t.scopeReverted,
|
|
363
405
|
vcs: t.vcs,
|
|
364
406
|
},
|
|
365
407
|
];
|
|
366
408
|
// An edit is applied only after the old attempt has been put beyond its reach.
|
|
367
|
-
|
|
368
|
-
t.title = patch.title.trim() || t.title;
|
|
369
|
-
if (patch.prompt !== undefined && patch.prompt.trim())
|
|
370
|
-
t.prompt = patch.prompt;
|
|
371
|
-
if (patch.level2 !== undefined)
|
|
372
|
-
t.level2 = patch.level2;
|
|
373
|
-
if (patch.vcsPlan !== undefined)
|
|
374
|
-
t.vcsPlan = tidyVcsPlan(patch.vcsPlan);
|
|
375
|
-
if (patch.checks !== undefined)
|
|
376
|
-
t.checks = patch.checks.filter((c) => c.name.trim() !== '');
|
|
409
|
+
applyTaskPatch(t, patch);
|
|
377
410
|
t.attempt = (t.attempt ?? 1) + 1;
|
|
378
411
|
t.status = 'queued';
|
|
379
412
|
t.iterations = 0;
|
|
@@ -404,6 +437,7 @@ export class SessionStore {
|
|
|
404
437
|
t.handoff = undefined;
|
|
405
438
|
t.scopeReverted = undefined;
|
|
406
439
|
t.freshRetry = undefined;
|
|
440
|
+
t.stopCode = undefined;
|
|
407
441
|
// The branch of the finished attempt stays in the repository and stays on the record
|
|
408
442
|
// above; the next attempt gets its own, cut from the same commit this one started at.
|
|
409
443
|
t.vcs = undefined;
|
|
@@ -750,12 +750,30 @@ Current URL: ${url}`);
|
|
|
750
750
|
await this.closeMenu();
|
|
751
751
|
return { ok: false, current: before, reason: `The picker does not offer "${name}" any more.` };
|
|
752
752
|
}
|
|
753
|
+
/*
|
|
754
|
+
* Already the one in force: close the menu and say so, without clicking.
|
|
755
|
+
*
|
|
756
|
+
* The button cannot answer this on its own, because it shortens names — "GPT 5.6 Think deeper"
|
|
757
|
+
* reads "GPT 5.6 Think" — so the exact comparison above lets a chat that is already on the
|
|
758
|
+
* model through to here. The row's own mark (`aria-checked` and its kin) is the answer. The
|
|
759
|
+
* click it used to get did nothing in Copilot: the row already in force ignores it, the menu
|
|
760
|
+
* stayed open over the composer, and the run went on with the model list hanging on screen.
|
|
761
|
+
*/
|
|
762
|
+
if (target.selected) {
|
|
763
|
+
await this.closeMenu();
|
|
764
|
+
this.emit('model-already-selected', { model: target.name, buttonShows: before });
|
|
765
|
+
return { ok: true, current: target.name };
|
|
766
|
+
}
|
|
753
767
|
if (target.disabled) {
|
|
754
768
|
await this.closeMenu();
|
|
755
769
|
return { ok: false, current: before, reason: `"${name}" is shown but not available right now.` };
|
|
756
770
|
}
|
|
757
771
|
await this.clickMenuRow(target.name, inSubmenu);
|
|
758
772
|
await this.p.waitForTimeout(1_000);
|
|
773
|
+
// Whatever the click did, the menu is not left open over the chat: a build that keeps it open
|
|
774
|
+
// after a choice, or a click that did not land, would otherwise leave it for the next message.
|
|
775
|
+
if ((await this.readMenuRows()).length > 0)
|
|
776
|
+
await this.closeMenu();
|
|
759
777
|
const after = await this.currentModel();
|
|
760
778
|
// The button shows the choice, so it is the check. It also **shortens** it: picking
|
|
761
779
|
// "GPT 5.6 Quick response" leaves the button reading "GPT 5.6 Quick". So a shown value
|
package/dist/src/vcs/scope.js
CHANGED
|
@@ -65,12 +65,13 @@ export function inScope(path, scope) {
|
|
|
65
65
|
* Puts back every change outside `scope`. Only for a repository whose tree was clean when the task
|
|
66
66
|
* started, which is why the caller passes the repository only when version control is active.
|
|
67
67
|
*/
|
|
68
|
-
export async function enforceScope(dir, scope) {
|
|
68
|
+
export async function enforceScope(dir, scope, nothing = false) {
|
|
69
69
|
const out = { outside: [], reverted: [], failed: [] };
|
|
70
|
-
|
|
70
|
+
// An empty scope allows everything; `nothing` is the read-only task, which allows no change at all.
|
|
71
|
+
if (scope.length === 0 && !nothing)
|
|
71
72
|
return out;
|
|
72
73
|
const changed = await workingTreePaths(dir);
|
|
73
|
-
out.outside = changed.filter((p) => !inScope(p, scope));
|
|
74
|
+
out.outside = nothing ? changed : changed.filter((p) => !inScope(p, scope));
|
|
74
75
|
const root = resolve(dir);
|
|
75
76
|
for (const rel of out.outside) {
|
|
76
77
|
const abs = resolve(join(dir, rel));
|
|
@@ -107,13 +108,15 @@ export async function enforceScope(dir, scope) {
|
|
|
107
108
|
return out;
|
|
108
109
|
}
|
|
109
110
|
/** What the chat is told, in the message that carries the step results. */
|
|
110
|
-
export function scopeMessage(check, scope) {
|
|
111
|
+
export function scopeMessage(check, scope, readOnly = false) {
|
|
111
112
|
const list = (paths) => paths.slice(0, 15).join(', ') + (paths.length > 15 ? `, and ${paths.length - 15} more` : '');
|
|
112
113
|
const parts = [
|
|
113
|
-
|
|
114
|
+
`${readOnly ? 'Changed by a read-only task' : "Outside this task's scope"}, so put back by the runner: ${list(check.reverted)}.`,
|
|
114
115
|
check.failed.length > 0 ? `Could not be put back: ${check.failed.map((f) => `${f.path} (${f.why})`).join('; ')}.` : '',
|
|
115
|
-
|
|
116
|
-
'
|
|
116
|
+
readOnly
|
|
117
|
+
? 'This task is read-only: it may change no file at all. Read, run and report; put what you found in your summary.'
|
|
118
|
+
: `This task may change only: ${scope.join(', ')}. If the work truly needs a file outside that, do not work around it: ` +
|
|
119
|
+
'end with status "blocked", name the file in "needed" and say why.',
|
|
117
120
|
];
|
|
118
121
|
return parts.filter(Boolean).join(' ');
|
|
119
122
|
}
|