copilot-operator 0.1.9 → 0.1.11

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 (57) hide show
  1. package/dist/src/api/operator.controller.js +11 -0
  2. package/dist/src/api/operator.service.js +32 -13
  3. package/dist/src/config/schema.js +6 -0
  4. package/dist/src/orchestrator/contract.js +58 -0
  5. package/dist/src/orchestrator/taskRunner.js +104 -6
  6. package/dist/src/plan/schema.js +21 -1
  7. package/dist/src/plan/systemGuide.js +48 -0
  8. package/dist/src/protocol/parser.js +98 -5
  9. package/dist/src/session/compose.js +1 -1
  10. package/dist/src/session/exports.js +1 -0
  11. package/dist/src/session/handoff.js +1 -1
  12. package/dist/src/session/store.js +44 -10
  13. package/dist/src/transport/copilotTransport.js +145 -65
  14. package/dist/src/vcs/scope.js +10 -7
  15. package/dist/src/vcs/syncCommand.js +105 -0
  16. package/dist/web/404.html +2 -2
  17. package/dist/web/_next/static/chunks/295-04151ded0741d18d.js +1 -0
  18. package/dist/web/_next/static/chunks/36-17a3bcb0c2553c7c.js +1 -0
  19. package/dist/web/_next/static/chunks/549-8f69ef8086802748.js +1 -0
  20. package/dist/web/_next/static/chunks/867-f82c74b7b2ed5c54.js +1 -0
  21. package/dist/web/_next/static/chunks/app/defaults/page-abfa73fb08822e36.js +1 -0
  22. package/dist/web/_next/static/chunks/app/history/page-a9f2060f86a4a10e.js +1 -0
  23. package/dist/web/_next/static/chunks/app/import/page-999d6cd958989723.js +1 -0
  24. package/dist/web/_next/static/chunks/app/level1/{page-be366b35f4cc97ce.js → page-dd3ebf37d29dce4f.js} +1 -1
  25. package/dist/web/_next/static/chunks/app/page-2ac6b67f4ddcb808.js +1 -0
  26. package/dist/web/_next/static/chunks/app/presets/{page-cabc3876a8111edd.js → page-93ab518db67e52a2.js} +1 -1
  27. package/dist/web/_next/static/chunks/app/sessions/view/page-d7e1f83d13c6356b.js +1 -0
  28. package/dist/web/_next/static/css/{20c6b36996df5e4c.css → 2ad2a74b7e07dcc9.css} +1 -1
  29. package/dist/web/appearance.html +2 -2
  30. package/dist/web/appearance.txt +12 -12
  31. package/dist/web/defaults.html +2 -2
  32. package/dist/web/defaults.txt +12 -12
  33. package/dist/web/history.html +2 -2
  34. package/dist/web/history.txt +12 -12
  35. package/dist/web/import.html +2 -2
  36. package/dist/web/import.txt +12 -12
  37. package/dist/web/index.html +2 -2
  38. package/dist/web/index.txt +12 -12
  39. package/dist/web/level1.html +2 -2
  40. package/dist/web/level1.txt +12 -12
  41. package/dist/web/presets.html +3 -3
  42. package/dist/web/presets.txt +12 -12
  43. package/dist/web/sessions/view.html +2 -2
  44. package/dist/web/sessions/view.txt +12 -12
  45. package/dist/web/system.html +2 -2
  46. package/dist/web/system.txt +12 -12
  47. package/package.json +6 -2
  48. package/dist/web/_next/static/chunks/373-56833488fcb5aa29.js +0 -1
  49. package/dist/web/_next/static/chunks/549-43d1a5681752fbd4.js +0 -1
  50. package/dist/web/_next/static/chunks/867-76cc83e4e54812dc.js +0 -1
  51. package/dist/web/_next/static/chunks/app/defaults/page-78be7820772f8882.js +0 -1
  52. package/dist/web/_next/static/chunks/app/history/page-90bc1c718d76b6b2.js +0 -1
  53. package/dist/web/_next/static/chunks/app/import/page-1060c7cb8e8d66eb.js +0 -1
  54. package/dist/web/_next/static/chunks/app/page-9e2736286f16422d.js +0 -1
  55. package/dist/web/_next/static/chunks/app/sessions/view/page-0e2b6829643e9b04.js +0 -1
  56. /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_buildManifest.js +0 -0
  57. /package/dist/web/_next/static/{auD7lTmj-JDMJIQ4TVivR → oyfXbuB_cq0sRZP7ukHlQ}/_ssgManifest.js +0 -0
@@ -466,6 +466,10 @@ let OperatorController = class OperatorController {
466
466
  * Separate from the session's own preflight because that one only speaks when version
467
467
  * control is already on, and the question here is whether it may be turned on.
468
468
  */
469
+ /** The command that brings a project's branch back to its remote's main — shown, never run. */
470
+ repoSync(dir) {
471
+ return this.ops.syncPlan(dir ?? '').catch(fail);
472
+ }
469
473
  repo(dir) {
470
474
  const problem = this.ops.repoProblem(dir ?? '');
471
475
  return problem ? { ok: false, problem } : { ok: true };
@@ -994,6 +998,13 @@ __decorate([
994
998
  __metadata("design:paramtypes", [Object]),
995
999
  __metadata("design:returntype", Promise)
996
1000
  ], OperatorController.prototype, "setProject", null);
1001
+ __decorate([
1002
+ Get('repo/sync'),
1003
+ __param(0, Query('dir')),
1004
+ __metadata("design:type", Function),
1005
+ __metadata("design:paramtypes", [String]),
1006
+ __metadata("design:returntype", Promise)
1007
+ ], OperatorController.prototype, "repoSync", null);
997
1008
  __decorate([
998
1009
  Get('repo'),
999
1010
  __param(0, Query('dir')),
@@ -15,11 +15,11 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
15
15
  import { Injectable } from '@nestjs/common';
16
16
  import { readFile, readdir } from 'node:fs/promises';
17
17
  import { existsSync } from 'node:fs';
18
- import { join } from 'node:path';
18
+ import { join, resolve } 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, tidyVcsPlan } from '../session/model.js';
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';
@@ -47,10 +47,17 @@ import { assessIsolation, readIsolationSignals, unattendedIsolationRefusal } fro
47
47
  import { unattendedPrecondition } from '../exec/policy.js';
48
48
  import { createTransport } from '../transport/chatTransport.js';
49
49
  import { composeHandoff } from '../session/handoff.js';
50
+ import { planSync } from '../vcs/syncCommand.js';
50
51
  import { computeMetrics } from '../session/metrics.js';
51
52
  import { resolveDesktopDir, desktopIsSynced } from '../context/contextFiles.js';
52
53
  import { saveAndReveal } from './saveToDesktop.js';
53
54
  import { Settings } from './settings.js';
55
+ /** A folder as compared: absolute, forward slashes, no trailing slash, and case folded as Windows does. */
56
+ function normaliseDir(dir) {
57
+ if (!dir.trim())
58
+ return '';
59
+ return resolve(dir.trim()).replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
60
+ }
54
61
  let OperatorService = class OperatorService {
55
62
  /** Where this install keeps its prompts and its records. See `config/layout.ts`. */
56
63
  layout = installLayout();
@@ -303,16 +310,7 @@ let OperatorService = class OperatorService {
303
310
  return await this.store.updateTask(sessionId, taskId, (t) => {
304
311
  if (t.status !== 'queued')
305
312
  throw new Error('Only a queued task can be edited.');
306
- if (patch.title !== undefined)
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() !== '');
313
+ applyTaskPatch(t, patch);
316
314
  });
317
315
  }
318
316
  async deleteTask(sessionId, taskId) {
@@ -1783,6 +1781,26 @@ let OperatorService = class OperatorService {
1783
1781
  repoProblem(dir) {
1784
1782
  return repoUnusableReason(dir);
1785
1783
  }
1784
+ /**
1785
+ * What bringing a project back to its remote's main branch would lose, and the command that does
1786
+ * it — for the operator to run. See `vcs/syncCommand.ts`; nothing here writes.
1787
+ *
1788
+ * Only for a folder this bot already works in: a project in Settings or the repository of a
1789
+ * session. The answer lists file names and commit subjects, and a local page asking about any
1790
+ * folder on the disk is not a question this API should answer.
1791
+ */
1792
+ async syncPlan(dir) {
1793
+ await this.init();
1794
+ const wanted = normaliseDir(dir);
1795
+ const cfg = await this.settings.load();
1796
+ const known = new Set([cfg.project?.rootDir ?? '', ...(cfg.project?.others ?? []).map((o) => o.rootDir), ...(await this.store.listSessions()).flatMap((s) => [s.vcs?.repoDir ?? '', s.mirror?.rootDir ?? ''])]
1797
+ .filter((d) => d.trim())
1798
+ .map(normaliseDir));
1799
+ if (!wanted || !known.has(wanted)) {
1800
+ throw new Error('That folder is not one of the projects in Settings or the repository of a session, so no command is offered for it.');
1801
+ }
1802
+ return await planSync(resolve(dir.trim()));
1803
+ }
1786
1804
  // --- the project being worked on ---------------------------------------------------------
1787
1805
  /**
1788
1806
  * The folder new sessions start pointed at, and whether it can carry version control.
@@ -2035,6 +2053,7 @@ let OperatorService = class OperatorService {
2035
2053
  disputes: t.disputes?.length || undefined,
2036
2054
  readOnly: t.readOnly || undefined,
2037
2055
  scope: t.scope?.length ? t.scope : undefined,
2056
+ stopCode: t.stopCode,
2038
2057
  autoRetries: t.autoRetries || undefined,
2039
2058
  attempt: t.attempt,
2040
2059
  changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
@@ -399,6 +399,12 @@ export const RunConfigSchema = z.object({
399
399
  * its record. Zero turns it off.
400
400
  */
401
401
  retryBlockedInFreshChat: z.number().int().nonnegative().default(2),
402
+ /*
403
+ * How many genuinely different approaches the chat must have tried before it may end a task
404
+ * "blocked". The reply format's own floor is 2 (replySchema.ts); this raises it. A "blocked"
405
+ * with fewer is sent back asking for another approach (see taskRunner.ts). Settings → Execution.
406
+ */
407
+ minApproachesBeforeBlocked: z.number().int().min(2).max(50).default(2),
402
408
  })
403
409
  .prefault({}),
404
410
  runsDir: z.string().default('./runs'),
@@ -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
+ }
@@ -38,9 +38,11 @@ import { writeReport } from '../exec/reportFile.js';
38
38
  import { Pacer } from '../util/pacing.js';
39
39
  import { RunLog } from '../log/runLog.js';
40
40
  import { composeOpening, READ_ONLY_NOTE } from '../session/compose.js';
41
+ import { MIN_TRIED_APPROACHES } from '../protocol/replySchema.js';
41
42
  import { enforceScope, scopeMessage, scopeNote } from '../vcs/scope.js';
42
43
  import { composeHandoff } from '../session/handoff.js';
43
44
  import { ProgressWatch } from './progress.js';
45
+ import { contractConflicts } from './contract.js';
44
46
  import { treeFingerprint } from '../vcs/git.js';
45
47
  import { mirrorProject, describeMirror } from '../context/projectMirror.js';
46
48
  import { projectNameFor, projectTargetDir, removeLegacyFlatMirror } from '../context/desktopMirror.js';
@@ -184,11 +186,12 @@ export async function openBrowser(cfg, bus, transportDir, sessionId) {
184
186
  'verification-cleared': 'Verification cleared, continuing.',
185
187
  'error-banner': 'The chat reported a transient error; reloading the page.',
186
188
  'reply-files-ignored': 'The reply came with a file attached. It was not downloaded and will not be run: the runner takes only the text of a reply.',
189
+ 'model-menu-stuck': 'The model menu would not close — Escape, the menu button and a click outside all left it open. Close it in the Edge window; the run goes on.',
187
190
  };
188
191
  bus.publish({
189
192
  sessionId,
190
193
  type: `browser:${event}`,
191
- level: event === 'verification-required' || event === 'reply-files-ignored' ? 'warn' : 'info',
194
+ level: event === 'verification-required' || event === 'reply-files-ignored' || event === 'model-menu-stuck' ? 'warn' : 'info',
192
195
  message: spoken[event],
193
196
  data: detail,
194
197
  });
@@ -360,12 +363,27 @@ export async function runTask(transport, session, task, deps) {
360
363
  let runnerNotes = [];
361
364
  /** Steps the chat sent that were not run, for the handoff at the end. */
362
365
  const notRun = [];
366
+ /**
367
+ * How many different approaches "blocked" needs, from Settings, never below the format's own two.
368
+ * A "blocked" with fewer is sent back; after `MAX_EARLY_BLOCKS` of them in a row with nothing tried
369
+ * in between the verdict is accepted, so a chat that cannot think of anything else does not spend
370
+ * the rest of the iterations saying so.
371
+ */
372
+ const minApproaches = Math.max(MIN_TRIED_APPROACHES, cfg.limits.minApproachesBeforeBlocked ?? MIN_TRIED_APPROACHES);
373
+ const MAX_EARLY_BLOCKS = 3;
374
+ let earlyBlocks = 0;
363
375
  /** What went wrong on the way, counted for the register's figures. See `TaskStats`. */
364
376
  const stats = { formatErrors: 0, doneRejected: 0, repeatsRefused: 0, stepsRefused: 0, operatorStops: 0, reviewRejections: 0, scopeReverts: 0 };
365
377
  /** Loops the older guards do not see; see `progress.ts`. */
366
378
  const progress = new ProgressWatch({ noProgress: Math.max(2, cfg.limits.maxNoProgressRounds ?? 3), oscillations: 2 });
367
379
  /** Set when a check round shows no progress; the give-up then ends the task blocked with it. */
368
380
  let noProgressReason = null;
381
+ /**
382
+ * Why the attempt stopped, when the status alone does not say: a format the chat could not keep,
383
+ * a task contract that contradicts itself, a loop, a check that could never run, a machine that
384
+ * lacks a shell. Written by `finish`. See `Task.stopCode`.
385
+ */
386
+ let stopCode;
369
387
  /**
370
388
  * Checks earlier reviews gave with their findings — carried over from every attempt before
371
389
  * this one, and grown by this one. See `derivedChecks.ts` for the three rules.
@@ -467,6 +485,8 @@ export async function runTask(transport, session, task, deps) {
467
485
  t.reviewChecks = reviewChecks;
468
486
  t.logFile = 'task-log.txt';
469
487
  t.stats = stats;
488
+ if (stopCode)
489
+ t.stopCode = stopCode;
470
490
  });
471
491
  // The end in one fixed shape, read off the record just written. See `session/handoff.ts`.
472
492
  await setTask((t) => {
@@ -606,9 +626,31 @@ export async function runTask(transport, session, task, deps) {
606
626
  * is the task's, and the starting commit is what a change goes back to.
607
627
  */
608
628
  const scope = task.scope ?? [];
609
- const scopeEnforced = scope.length > 0 && !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
629
+ const onOwnBranch = !!prepared.vcs.branch && !prepared.vcs.problem && !!prepared.vcs.baseCommit;
630
+ /*
631
+ * A read-only task is a scope of nothing, and is enforced the same way: whatever a round changed
632
+ * is put back before the chat reads the results. It used to be judged only at the end, so an
633
+ * audit that edited a file "to check something" kept the edit through every later round and its
634
+ * findings described a tree that was not the one it had been asked to audit.
635
+ */
636
+ const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
610
637
  /** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
611
638
  const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
639
+ /*
640
+ * The task's contract, checked before a single message is sent.
641
+ *
642
+ * A task can be written so that no answer satisfies it: read-only and still given paths to
643
+ * change, or a check that needs a file to change while the task may not change it. Sent as it
644
+ * is, the chat works for rounds, the check fails at the end, and the task goes blocked on a
645
+ * finding the chat could never have fixed. The contradiction is named instead, now, and nothing
646
+ * is run. See `orchestrator/contract.ts`.
647
+ */
648
+ const conflicts = await contractConflicts(task, work.cwd, repoDirOf(session) || work.cwd);
649
+ if (conflicts.length > 0) {
650
+ stopCode = 'contract-conflict';
651
+ sink.event('contract-conflict', { conflicts }, `the task contradicts itself: ${conflicts.join(' | ')}`, 'error');
652
+ 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.`);
653
+ }
612
654
  // --- project mirror, attached to the first message of the task ---------------------
613
655
  let mirrorFiles = [];
614
656
  if (session.mirror.enabled && session.mirror.rootDir) {
@@ -664,6 +706,10 @@ export async function runTask(transport, session, task, deps) {
664
706
  vcsNote: prepared.note,
665
707
  readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
666
708
  scopeNote: scopeNote(scope, scopeEnforced),
709
+ approachesNote: minApproaches > MIN_TRIED_APPROACHES
710
+ ? `## Giving up\n\nThis task may end with status "blocked" only after at least ${minApproaches} genuinely different approaches, ` +
711
+ `each listed in "tried". With fewer, the runner asks you for another approach instead of accepting it.`
712
+ : undefined,
667
713
  shellNote: shellNote(shells, defaultShell),
668
714
  });
669
715
  await setTask((t) => {
@@ -819,6 +865,7 @@ export async function runTask(transport, session, task, deps) {
819
865
  if (stuck) {
820
866
  noProgressReason = stuck;
821
867
  stats.stoppedFor = 'no-progress';
868
+ stopCode = 'no-progress';
822
869
  sink.event('no-progress', { kind: 'checks' }, stuck, 'warn');
823
870
  return 'give-up';
824
871
  }
@@ -1155,9 +1202,20 @@ export async function runTask(transport, session, task, deps) {
1155
1202
  formatRetries += 1;
1156
1203
  stats.formatErrors += 1;
1157
1204
  sink.event('format-error', { reason: parsed.reason, detail: parsed.detail }, `reply did not match the contract (${parsed.reason}), retry ${formatRetries}/${cfg.limits.maxFormatRetries}`, 'warn');
1205
+ /*
1206
+ * Out of format retries: stopped at a limit, not failed.
1207
+ *
1208
+ * Nothing about the work went wrong here — the steps that ran are recorded, their output is
1209
+ * in the run folder, and the rejected replies ran nothing. So the task ends `limit-reached`,
1210
+ * marked `format-repair-exhausted`, which is what lets "Continue in the same chat" carry it
1211
+ * on from exactly here instead of the operator having to run it again from the start. It
1212
+ * used to end `failed`, which read as a verdict on the work and offered only a fresh start.
1213
+ */
1158
1214
  if (formatRetries > cfg.limits.maxFormatRetries) {
1159
1215
  await transport.dumpFailure(log.path('failures'), 'format-error');
1160
- return await finish('failed', `Copilot did not keep the output contract: ${parsed.detail}`, undefined, lastMarkdown);
1216
+ stopCode = 'format-repair-exhausted';
1217
+ return await finish('limit-reached', `format repair exhausted: ${formatRetries} replies in a row did not match the format (maxFormatRetries ${cfg.limits.maxFormatRetries}), ` +
1218
+ `the last because ${parsed.detail}. Nothing from those replies was run; the work so far is kept.`, undefined, lastMarkdown);
1161
1219
  }
1162
1220
  await pacer.throttleSend();
1163
1221
  const before = await transport.sendAndConfirm(formatErrorMessage(parsed, formatRetries, cfg.limits.maxFormatRetries));
@@ -1167,6 +1225,9 @@ export async function runTask(transport, session, task, deps) {
1167
1225
  continue;
1168
1226
  }
1169
1227
  formatRetries = 0;
1228
+ if (parsed.coerced) {
1229
+ 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`);
1230
+ }
1170
1231
  iterations += 1;
1171
1232
  await setTask((t) => {
1172
1233
  t.iterations = iterations;
@@ -1231,18 +1292,49 @@ export async function runTask(transport, session, task, deps) {
1231
1292
  * what the reply said, at greater length and one message later. The reason carries the
1232
1293
  * approaches that were tried, and that is what the register shows.
1233
1294
  */
1295
+ if (!blocked && reply.steps.length > 0)
1296
+ earlyBlocks = 0;
1297
+ if (blocked && reply.tried.length < minApproaches && earlyBlocks < MAX_EARLY_BLOCKS) {
1298
+ /*
1299
+ * Too early to give up, by the operator's own measure (Settings → Execution). Sent back as a
1300
+ * request for another approach rather than accepted: the format only knows that two is the
1301
+ * floor, the operator decided this work deserves more tries than that.
1302
+ */
1303
+ earlyBlocks += 1;
1304
+ stats.blockedTooEarly = (stats.blockedTooEarly ?? 0) + 1;
1305
+ sink.event('blocked-too-early', { tried: reply.tried.length, needed: minApproaches, time: earlyBlocks }, `"blocked" after ${reply.tried.length} approach(es); Settings ask for ${minApproaches}, so the chat is asked for another`, 'warn');
1306
+ const message = `Not yet. This task is given up only after at least ${minApproaches} genuinely different approaches, and your reply lists ` +
1307
+ `${reply.tried.length}: ${reply.tried.map((x, i) => `(${i + 1}) ${x}`).join(' ')}. Try a different approach now — not one of ` +
1308
+ 'these again, and not the same command reworded — and send its steps with status "continue". If it fails too, add it to ' +
1309
+ '"tried" and try another. Nothing you listed is lost: the runner keeps what has been tried.';
1310
+ await record('BLOCKED TOO EARLY — MESSAGE SENT', message);
1311
+ await pacer.throttleSend();
1312
+ const before = await transport.sendAndConfirm(message);
1313
+ const next = await transport.waitForReply(before);
1314
+ await saveReply(`blocked-too-early-${earlyBlocks}`, next);
1315
+ lastMarkdown = next.markdown;
1316
+ await pacer.settle();
1317
+ continue;
1318
+ }
1234
1319
  if (blocked) {
1235
1320
  sink.event('task-blocked', { tried: reply.tried, needed: reply.needed }, `the task was given up as blocked after ${reply.tried.length} approach(es)`, 'warn');
1236
- return await finish('blocked', blockedReason(reply.tried, reply.needed), reply.summary, lastMarkdown);
1321
+ const early = reply.tried.length < minApproaches
1322
+ ? ` It gave up after ${reply.tried.length} of the ${minApproaches} approaches Settings ask for, having been asked ${earlyBlocks} time(s) for another.`
1323
+ : '';
1324
+ return await finish('blocked', blockedReason(reply.tried, reply.needed) + early, reply.summary, lastMarkdown);
1237
1325
  }
1238
1326
  if (done && reply.steps.length === 0) {
1239
1327
  const verdict = await gateOnChecks();
1240
1328
  if (verdict === 'environment') {
1329
+ stopCode = 'environment';
1241
1330
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1242
1331
  }
1243
1332
  if (verdict === 'give-up') {
1244
1333
  if (noProgressReason)
1245
1334
  return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1335
+ // Every failing check was refused before it ran: a verdict on the checks, not on the work.
1336
+ if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
1337
+ stopCode = 'invalid-check';
1246
1338
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1247
1339
  }
1248
1340
  if (verdict === 'accept') {
@@ -1382,6 +1474,7 @@ export async function runTask(transport, session, task, deps) {
1382
1474
  // Ended before the report is written and sent: there is nobody to send it to who could
1383
1475
  // do anything with it, and the reason on the task says what to install or set instead.
1384
1476
  if (environmentProblem) {
1477
+ stopCode = 'environment';
1385
1478
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1386
1479
  }
1387
1480
  /*
@@ -1390,7 +1483,7 @@ export async function runTask(transport, session, task, deps) {
1390
1483
  * and the chat is told in the same message.
1391
1484
  */
1392
1485
  if (scopeEnforced) {
1393
- const check = await enforceScope(repoDirOf(session), scope).catch((e) => ({
1486
+ const check = await enforceScope(repoDirOf(session), scope, !!task.readOnly).catch((e) => ({
1394
1487
  outside: [],
1395
1488
  reverted: [],
1396
1489
  failed: [{ path: '(the working tree)', why: e.message }],
@@ -1398,7 +1491,7 @@ export async function runTask(transport, session, task, deps) {
1398
1491
  if (check.outside.length > 0 || check.failed.length > 0) {
1399
1492
  sink.event('scope-reverted', { iteration: iterations, reverted: check.reverted, failed: check.failed }, `outside the task's scope, put back: ${check.reverted.join(', ') || '(none)'}` +
1400
1493
  (check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
1401
- runnerNotes.push(scopeMessage(check, scope));
1494
+ runnerNotes.push(scopeMessage(check, scope, !!task.readOnly));
1402
1495
  stats.scopeReverts += check.reverted.length;
1403
1496
  await setTask((t) => {
1404
1497
  t.scopeReverted = [...new Set([...(t.scopeReverted ?? []), ...check.reverted])];
@@ -1429,6 +1522,7 @@ export async function runTask(transport, session, task, deps) {
1429
1522
  const stuck = progress.afterRound(await treeNow(), results);
1430
1523
  if (stuck) {
1431
1524
  stats.stoppedFor = 'no-progress';
1525
+ stopCode = 'no-progress';
1432
1526
  sink.event('no-progress', { kind: 'rounds', iteration: iterations }, stuck, 'warn');
1433
1527
  return await finish('blocked', stuck, reply.summary, lastMarkdown);
1434
1528
  }
@@ -1529,11 +1623,15 @@ export async function runTask(transport, session, task, deps) {
1529
1623
  if (done) {
1530
1624
  const verdict = await gateOnChecks();
1531
1625
  if (verdict === 'environment') {
1626
+ stopCode = 'environment';
1532
1627
  return await finish('failed', shellProblemReason(environmentProblem), reply.summary, lastMarkdown);
1533
1628
  }
1534
1629
  if (verdict === 'give-up') {
1535
1630
  if (noProgressReason)
1536
1631
  return await finish('blocked', noProgressReason, reply.summary, lastMarkdown);
1632
+ // Every failing check was refused before it ran: a verdict on the checks, not on the work.
1633
+ if (lastOutcomes.some((o) => !o.passed) && lastOutcomes.every((o) => o.passed || o.refusedBeforeRunning))
1634
+ stopCode = 'invalid-check';
1537
1635
  return await finish('failed', checksFailedReason(lastOutcomes), reply.summary, lastMarkdown);
1538
1636
  }
1539
1637
  if (verdict === 'accept') {
@@ -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',
@@ -2828,6 +2844,38 @@ export const SYSTEM_GUIDE = [
2828
2844
  doesEn: 'Seconds to wait for a chat reply (default 900); a reply not finished by then fails the task with "Copilot did not finish a reply within …".',
2829
2845
  doesBg: 'Секунди чакане на отговор от чата (по подразбиране 900); незавършен дотогава отговор проваля задачата с „Copilot did not finish a reply within …“.',
2830
2846
  },
2847
+ {
2848
+ key: 'sync.show',
2849
+ kind: 'button',
2850
+ en: 'Command to bring this project back to the remote main branch',
2851
+ bg: 'Команда за връщане на проекта към основния клон на отдалеченото хранилище',
2852
+ doesEn: 'Under each project in Settings and on a session\u2019s version control: lists what would be lost and gives a PowerShell command to paste; the bot never runs it.',
2853
+ doesBg: 'Под всеки проект в Настройки и в контрола на версиите на сесия: изброява какво ще се загуби и дава команда за PowerShell; ботът никога не я пуска.',
2854
+ },
2855
+ {
2856
+ key: 'sync.copyPreview',
2857
+ kind: 'button',
2858
+ en: 'Copy the preview (changes nothing)',
2859
+ bg: 'Копирай прегледа (не променя нищо)',
2860
+ doesEn: 'Fetches and lists the commits, changes and new files the command would remove.',
2861
+ doesBg: 'Изтегля и изброява commit-ите, промените и новите файлове, които командата би махнала.',
2862
+ },
2863
+ {
2864
+ key: 'sync.copyCommand',
2865
+ kind: 'button',
2866
+ en: 'Copy the command',
2867
+ bg: 'Копирай командата',
2868
+ doesEn: 'fetch, reset --hard to the remote main, clean -fd — each only if the step before worked.',
2869
+ doesBg: 'fetch, reset --hard към основния клон, clean -fd — всяка само ако предишната е успяла.',
2870
+ },
2871
+ {
2872
+ key: 'exec.minApproaches',
2873
+ kind: 'field',
2874
+ en: 'Approaches the chat must try before it may give up',
2875
+ bg: 'Подходи, които чатът трябва да опита, преди да се откаже',
2876
+ doesEn: 'How many different approaches a "blocked" must list (2–20); with fewer the chat is asked for another.',
2877
+ doesBg: 'Колко различни подхода трябва да изброи „blocked“ (2–20); с по-малко чатът е помолен за още един.',
2878
+ },
2831
2879
  {
2832
2880
  key: 'exec.retryBlocked',
2833
2881
  kind: 'field',