copilot-operator 0.1.17 → 0.1.19
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 +33 -2
- package/dist/src/cli.js +11 -29
- package/dist/src/config/version.js +30 -0
- package/dist/src/orchestrator/derivedChecks.js +19 -1
- package/dist/src/orchestrator/review.js +2 -2
- package/dist/src/orchestrator/taskRunner.js +130 -10
- package/dist/src/plan/brief.js +38 -4
- package/dist/src/plan/importPlan.js +42 -1
- package/dist/src/plan/schema.js +53 -0
- package/dist/src/plan/systemGuide.js +50 -2
- package/dist/src/protocol/replySchema.js +1 -1
- package/dist/src/protocol/reviewSchema.js +10 -5
- package/dist/src/session/exports.js +53 -13
- package/dist/src/session/model.js +20 -0
- package/dist/src/session/store.js +80 -0
- package/dist/src/vcs/artifacts.js +185 -0
- package/dist/src/vcs/inputs.js +449 -0
- package/dist/src/vcs/snapshot.js +174 -22
- package/dist/src/vcs/taskVcs.js +272 -37
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
- package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +1 -0
- package/dist/web/_next/static/chunks/app/appearance/page-a18019cdc395de3d.js +1 -0
- package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
- package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +1 -0
- package/dist/web/_next/static/chunks/app/system/page-5ae1f8054208e6eb.js +1 -0
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +25 -26
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +25 -26
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +25 -26
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +25 -26
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +25 -26
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +25 -26
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +25 -26
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +25 -26
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +25 -26
- package/package.json +8 -3
- package/prompts/level1.md +547 -546
- package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +0 -1
- package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +0 -1
- package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +0 -1
- package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
- package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +0 -1
- package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +0 -1
- package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +0 -1
- /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_ssgManifest.js +0 -0
|
@@ -19,7 +19,7 @@ import { join, resolve } from 'node:path';
|
|
|
19
19
|
import { installLayout } from '../config/layout.js';
|
|
20
20
|
import { SessionStore, DEFAULT_VCS, DEFAULT_REVIEW, applyDefaultProject, applyTaskPatch } from '../session/store.js';
|
|
21
21
|
import { EventBus } from '../session/events.js';
|
|
22
|
-
import { isContinuable, newId } from '../session/model.js';
|
|
22
|
+
import { freshRetriesOfLatestRun, isContinuable, 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';
|
|
@@ -46,6 +46,9 @@ import { createTransport } from '../transport/chatTransport.js';
|
|
|
46
46
|
import { composeHandoff } from '../session/handoff.js';
|
|
47
47
|
import { planSync } from '../vcs/syncCommand.js';
|
|
48
48
|
import { dirtyPolicy, takeSnapshot } from '../vcs/snapshot.js';
|
|
49
|
+
import { normalisePatterns } from '../vcs/inputs.js';
|
|
50
|
+
import { coveredByArtifacts } from '../vcs/artifacts.js';
|
|
51
|
+
import { botVersion } from '../config/version.js';
|
|
49
52
|
import { computeMetrics } from '../session/metrics.js';
|
|
50
53
|
import { resolveDesktopDir, desktopIsSynced } from '../context/desktopDir.js';
|
|
51
54
|
import { saveAndReveal } from './saveToDesktop.js';
|
|
@@ -419,6 +422,32 @@ let OperatorService = class OperatorService {
|
|
|
419
422
|
delete merged.dirtyWorktree;
|
|
420
423
|
else
|
|
421
424
|
merged.dirtyWorktree = { policy: dirty.policy, ...(dirty.requireApproval ? {} : { requireApproval: false }) };
|
|
425
|
+
// Input files and artifacts: patterns as the matching reads them, absent when there are none.
|
|
426
|
+
const inputPaths = normalisePatterns(merged.userInputs?.paths ?? []);
|
|
427
|
+
if (inputPaths.length === 0)
|
|
428
|
+
delete merged.userInputs;
|
|
429
|
+
else {
|
|
430
|
+
merged.userInputs = {
|
|
431
|
+
paths: inputPaths,
|
|
432
|
+
...(merged.userInputs?.readOnly === false ? { readOnly: false } : {}),
|
|
433
|
+
...(merged.userInputs?.requireApproval === false ? { requireApproval: false } : {}),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
const artifactPaths = normalisePatterns(merged.artifacts?.paths ?? []);
|
|
437
|
+
if (artifactPaths.length === 0)
|
|
438
|
+
delete merged.artifacts;
|
|
439
|
+
else
|
|
440
|
+
merged.artifacts = { paths: artifactPaths };
|
|
441
|
+
// Artifacts are kept out of git; one that covers the input files would keep them out too.
|
|
442
|
+
const hidden = coveredByArtifacts(merged.userInputs?.paths ?? [], artifactPaths);
|
|
443
|
+
if (hidden.length > 0) {
|
|
444
|
+
throw new Error(`An artifacts pattern covers the input files ${hidden.map((h) => `"${h}"`).join(', ')}: artifacts are kept out of git, so the inputs would be too. ` +
|
|
445
|
+
'Name only the folders the evidence is written to, not a whole project folder.');
|
|
446
|
+
}
|
|
447
|
+
// New inputs are settled again at the next task: the recorded ones are no longer the whole list.
|
|
448
|
+
if (JSON.stringify(merged.userInputs ?? null) !== JSON.stringify(s.vcs?.userInputs ?? null) && s.vcsStart?.inputs) {
|
|
449
|
+
s.vcsStart = { ...s.vcsStart, inputs: undefined };
|
|
450
|
+
}
|
|
422
451
|
// Only the program marks a name as one of its own branches, and a name changed is not that one.
|
|
423
452
|
const stillOwn = !!s.vcs?.branchNameExact && (merged.branchName ?? '').trim() === (s.vcs.branchName ?? '').trim();
|
|
424
453
|
if (stillOwn)
|
|
@@ -2601,7 +2630,7 @@ let OperatorService = class OperatorService {
|
|
|
2601
2630
|
commit: t.vcs?.commit,
|
|
2602
2631
|
vcsProblem: t.vcs?.problem,
|
|
2603
2632
|
sessionInactive: s.active === false || undefined,
|
|
2604
|
-
autoRetries: t
|
|
2633
|
+
autoRetries: freshRetriesOfLatestRun(t) || undefined,
|
|
2605
2634
|
attempt: t.attempt,
|
|
2606
2635
|
changedFiles: t.vcs?.commit && t.vcs.baseCommit ? (t.vcs.files?.length ?? 0) : undefined,
|
|
2607
2636
|
interrupted: t.status === 'aborted' && !!t.interruption ? true : undefined,
|
|
@@ -2637,6 +2666,8 @@ let OperatorService = class OperatorService {
|
|
|
2637
2666
|
const edge = ['C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe', 'C:/Program Files/Microsoft/Edge/Application/msedge.exe'].find((p) => existsSync(p));
|
|
2638
2667
|
const holders = findEdgeUsingProfile(cfg.resolved.profileDir);
|
|
2639
2668
|
return {
|
|
2669
|
+
// Which copilot-operator this is: `cop --version` without a terminal.
|
|
2670
|
+
version: botVersion(),
|
|
2640
2671
|
node: process.versions.node,
|
|
2641
2672
|
edge: edge ?? null,
|
|
2642
2673
|
profileDir: cfg.resolved.profileDir,
|
package/dist/src/cli.js
CHANGED
|
@@ -24,32 +24,14 @@ import { PolicyLockUnusableError } from './config/lockedPolicy.js';
|
|
|
24
24
|
import { EventBus } from './session/events.js';
|
|
25
25
|
import { terminalAuthorizer, unattendedAuthorizer } from './exec/authorizer.js';
|
|
26
26
|
import { unattendedPrecondition } from './exec/policy.js';
|
|
27
|
-
import {
|
|
27
|
+
import { isOwnCheckout } from './exec/workDir.js';
|
|
28
28
|
import { desktopIsSynced, resolveDesktopDir } from './context/desktopDir.js';
|
|
29
29
|
import { Url } from './transport/locators.js';
|
|
30
|
+
import { botVersion } from './config/version.js';
|
|
30
31
|
import { findEdgeUsingProfile } from './transport/profileLock.js';
|
|
31
32
|
import { assessIsolation, readIsolationSignals } from './exec/isolation.js';
|
|
32
|
-
/**
|
|
33
|
-
|
|
34
|
-
*
|
|
35
|
-
* It was a literal, '0.1.0', and stayed that through thirteen releases, so a bug report's
|
|
36
|
-
* `cop --version` named a version that never had the bug. Read where the code is — `src/` under
|
|
37
|
-
* tsx, `dist/src/` in a clone, `node_modules/copilot-operator/dist/src/` once installed — so it
|
|
38
|
-
* cannot drift from what npm installed. botRootDir() falls back to the working directory when it
|
|
39
|
-
* finds no package.json of ours, and that one is the user's project's: its version is not ours to
|
|
40
|
-
* print, so the name is checked and anything else says "unknown".
|
|
41
|
-
*/
|
|
42
|
-
function ownVersion() {
|
|
43
|
-
try {
|
|
44
|
-
const pkg = JSON.parse(readFileSync(join(botRootDir(), 'package.json'), 'utf8'));
|
|
45
|
-
if (pkg.name === 'copilot-operator' && typeof pkg.version === 'string' && pkg.version)
|
|
46
|
-
return pkg.version;
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
// No readable package.json: say so rather than guess.
|
|
50
|
-
}
|
|
51
|
-
return 'unknown';
|
|
52
|
-
}
|
|
33
|
+
/** The running version; see `config/version.ts`. */
|
|
34
|
+
const ownVersion = botVersion;
|
|
53
35
|
const program = new Command();
|
|
54
36
|
program
|
|
55
37
|
.name('cop')
|
|
@@ -213,7 +195,7 @@ program
|
|
|
213
195
|
replyTimeoutMs: 60_000,
|
|
214
196
|
signInTimeoutMs: 15 * 60_000,
|
|
215
197
|
humanWaitMs: 15 * 60_000,
|
|
216
|
-
onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
|
|
198
|
+
onEvent: (e, d) => process.stdout.write(` ${e}${d && Object.keys(d).length ? ' ' + JSON.stringify(d) : ''}
|
|
217
199
|
`),
|
|
218
200
|
});
|
|
219
201
|
await transport.open();
|
|
@@ -506,14 +488,14 @@ program
|
|
|
506
488
|
console.log('');
|
|
507
489
|
console.log(`task "${done?.title}": ${done?.status} after ${done?.iterations ?? 0} iteration(s)`);
|
|
508
490
|
if (done?.summary)
|
|
509
|
-
console.log(`
|
|
510
|
-
Summary:
|
|
491
|
+
console.log(`
|
|
492
|
+
Summary:
|
|
511
493
|
${done.summary}`);
|
|
512
494
|
if (done?.reason)
|
|
513
|
-
console.log(`
|
|
495
|
+
console.log(`
|
|
514
496
|
Reason: ${done.reason}`);
|
|
515
497
|
if (final?.chat)
|
|
516
|
-
console.log(`
|
|
498
|
+
console.log(`
|
|
517
499
|
chat: ${final.chat.name} — ${final.chat.url}`);
|
|
518
500
|
if (done?.runId)
|
|
519
501
|
console.log(`log: ${join(cfg.resolved.runsDir, done.runId, 'task-log.txt')}`);
|
|
@@ -533,8 +515,8 @@ program
|
|
|
533
515
|
return;
|
|
534
516
|
}
|
|
535
517
|
const c = withChat.chat;
|
|
536
|
-
console.log(`${c.name}
|
|
537
|
-
${c.url}
|
|
518
|
+
console.log(`${c.name}
|
|
519
|
+
${c.url}
|
|
538
520
|
session ${withChat.id}, ${withChat.tasks.length} task(s), created ${c.createdAt}`);
|
|
539
521
|
});
|
|
540
522
|
program.parseAsync(process.argv).catch((e) => {
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The version of the copilot-operator that is running, from its own package.json.
|
|
3
|
+
*
|
|
4
|
+
* It was a literal, '0.1.0', and stayed that through thirteen releases, so a bug report's
|
|
5
|
+
* `cop --version` named a version that never had the bug. Read where the code is — `src/` under
|
|
6
|
+
* tsx, `dist/src/` in a clone, `node_modules/copilot-operator/dist/src/` once installed — so it
|
|
7
|
+
* cannot drift from what npm installed. botRootDir() falls back to the working directory when it
|
|
8
|
+
* finds no package.json of ours, and that one is the user's project's: its version is not ours to
|
|
9
|
+
* print, so the name is checked and anything else says "unknown".
|
|
10
|
+
*
|
|
11
|
+
* Written into every export and attempt record too: a report sent as a screenshot from another
|
|
12
|
+
* machine could not say which version made it, and the operator had to be asked.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { botRootDir } from '../exec/workDir.js';
|
|
17
|
+
let cached;
|
|
18
|
+
export function botVersion() {
|
|
19
|
+
if (cached)
|
|
20
|
+
return cached;
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(readFileSync(join(botRootDir(), 'package.json'), 'utf8'));
|
|
23
|
+
if (pkg.name === 'copilot-operator' && typeof pkg.version === 'string' && pkg.version)
|
|
24
|
+
return (cached = pkg.version);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// No readable package.json: say so rather than guess.
|
|
28
|
+
}
|
|
29
|
+
return 'unknown';
|
|
30
|
+
}
|
|
@@ -23,7 +23,25 @@ export function derivedCheckName(findingId, name) {
|
|
|
23
23
|
return `review ${findingId}: ${name}`;
|
|
24
24
|
}
|
|
25
25
|
export function isDerivedCheck(check) {
|
|
26
|
-
return /^review r\d+f\d+: /.test(check.name);
|
|
26
|
+
return /^review (?:a\d+)?r\d+f\d+: /.test(check.name);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Kept checks written before a finding's id carried its attempt, given the id it carries now.
|
|
30
|
+
*
|
|
31
|
+
* Those records hold `r1f1` for attempt 1's first finding and again for attempt 2's, under one check
|
|
32
|
+
* name, and a dispute, a deferral or a drop aimed at one acted on both. Renamed from the attempt each
|
|
33
|
+
* was made on (see `findingId`), check name included, so the gate and the implementer see the same
|
|
34
|
+
* name a dispute can then use. A check of the first attempt, and one already renamed, is left as it is.
|
|
35
|
+
*/
|
|
36
|
+
export function withAttemptIds(reviewChecks) {
|
|
37
|
+
return reviewChecks.map((rc) => {
|
|
38
|
+
if ((rc.attempt ?? 1) <= 1 || !/^r\d+f\d+$/.test(rc.findingId))
|
|
39
|
+
return rc;
|
|
40
|
+
const id = `a${rc.attempt}${rc.findingId}`;
|
|
41
|
+
const before = derivedCheckName(rc.findingId, '');
|
|
42
|
+
const name = rc.check.name.startsWith(before) ? derivedCheckName(id, rc.check.name.slice(before.length)) : rc.check.name;
|
|
43
|
+
return { ...rc, findingId: id, check: { ...rc.check, name } };
|
|
44
|
+
});
|
|
27
45
|
}
|
|
28
46
|
/**
|
|
29
47
|
* Runs each finding's check on the current state. Kept if it fails now, refused if it passes.
|
|
@@ -329,7 +329,7 @@ ${machineNote}` : contract);
|
|
|
329
329
|
problem: `every finding of round ${round} rested on something the task never asked for, twice; the review is inconclusive`,
|
|
330
330
|
};
|
|
331
331
|
}
|
|
332
|
-
const named = grounded.map((f, i) => ({ ...f, id: findingId(round, i) }));
|
|
332
|
+
const named = grounded.map((f, i) => ({ ...f, id: findingId(round, i, task.attempt ?? 1) }));
|
|
333
333
|
/*
|
|
334
334
|
* A read-only task's findings never become gate conditions.
|
|
335
335
|
*
|
|
@@ -605,7 +605,7 @@ export function findingsMessage(outcome, round, maxRounds, repeated = [], leftov
|
|
|
605
605
|
`Fix these, then verify as usual and close the task again. This is review round ${round} of ${maxRounds};`,
|
|
606
606
|
'after that the task is closed as blocked with whatever is still outstanding.',
|
|
607
607
|
'',
|
|
608
|
-
'If you believe a finding is wrong, say so in `disputed`: its id (the `[r1f2]` in front of it), why it',
|
|
608
|
+
'If you believe a finding is wrong, say so in `disputed`: its id as written (the `[r1f2]` in front of it), why it',
|
|
609
609
|
'is wrong, and the evidence that shows it — the command you ran and what came back. A dispute goes to',
|
|
610
610
|
'the next reviewer as a claim to test; a sentence in your summary goes nowhere. Do not change the',
|
|
611
611
|
'thing the reviewer looked at in order to make the objection go away.',
|
|
@@ -21,7 +21,7 @@ import { buildCoveringMessage, assertSendable } from '../protocol/reporter.js';
|
|
|
21
21
|
import { runStep } from '../exec/runner.js';
|
|
22
22
|
import { availableShells, detectShells, effectiveShell, preferredShell, refusalForChat, resolveShell, shellNote } from '../exec/shells.js';
|
|
23
23
|
import { runChecks, failureMessage, failureRedactions, failureReport, environmentProblemIn, COMMIT_CLEAN_CHECK, CONTENT_CLEAN_CHECK, RUNNER_CHECK_KINDS } from '../exec/checks.js';
|
|
24
|
-
import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing, isDerivedCheck } from './derivedChecks.js';
|
|
24
|
+
import { activeChecks, suspendDisputed, settleAfterReview, onlyDerivedFailing, isDerivedCheck, withAttemptIds } from './derivedChecks.js';
|
|
25
25
|
import { workingDirFor, isWorkingDirProblem, workingDirNote } from '../exec/workDir.js';
|
|
26
26
|
import { redactSecrets } from '../exec/redaction.js';
|
|
27
27
|
import { snapshotProcesses, reapLeftovers, describeLeftovers, ProcessTracker } from '../exec/processes.js';
|
|
@@ -45,7 +45,9 @@ import { composeHandoff } from '../session/handoff.js';
|
|
|
45
45
|
import { ProgressWatch } from './progress.js';
|
|
46
46
|
import { contractConflicts, readsTreeClean } from './contract.js';
|
|
47
47
|
import { treeFingerprint } from '../vcs/git.js';
|
|
48
|
-
import { prepareForTask, commitTaskResult, repoDirOf, trackedRepoOf } from '../vcs/taskVcs.js';
|
|
48
|
+
import { prepareForTask, commitTaskResult, commitShortfall, repoDirOf, trackedRepoOf } from '../vcs/taskVcs.js';
|
|
49
|
+
import { protectInputs, inputsMessage, inputsAtCommit, untrackedInputs } from '../vcs/inputs.js';
|
|
50
|
+
import { artifactPatterns, artifactState, keepArtifacts } from '../vcs/artifacts.js';
|
|
49
51
|
import { exportMachine, writeAttemptRecord } from '../session/exports.js';
|
|
50
52
|
/** Why a task that was reported as done is being closed as failed. */
|
|
51
53
|
function checksFailedReason(outcomes) {
|
|
@@ -538,9 +540,10 @@ export async function runTask(transport, session, task, deps) {
|
|
|
538
540
|
let runAfterCommit = null;
|
|
539
541
|
/**
|
|
540
542
|
* Checks earlier reviews gave with their findings — carried over from every attempt before
|
|
541
|
-
* this one, and grown by this one. See `derivedChecks.ts` for the three rules.
|
|
543
|
+
* this one, and grown by this one. See `derivedChecks.ts` for the three rules. Ones written before
|
|
544
|
+
* a finding's id carried its attempt are renamed here, before anything finds one by id or name.
|
|
542
545
|
*/
|
|
543
|
-
let reviewChecks = task.reviewChecks ?? [];
|
|
546
|
+
let reviewChecks = withAttemptIds(task.reviewChecks ?? []);
|
|
544
547
|
/** Derived checks that still failed when the rounds ran out; the reviewer is told. */
|
|
545
548
|
let derivedStillFailing = [];
|
|
546
549
|
/**
|
|
@@ -577,10 +580,68 @@ export async function runTask(transport, session, task, deps) {
|
|
|
577
580
|
await record(`LEFT RUNNING BY ${by.toUpperCase()}`, describeLeftovers([...result.killed, ...result.failed]));
|
|
578
581
|
return all.map((l) => ({ name: l.name, ports: l.ports }));
|
|
579
582
|
};
|
|
583
|
+
/** The session's read-only input files and the commit they are put back from, once the task is on its own branch. */
|
|
584
|
+
let inputsGuard = null;
|
|
585
|
+
/** The artifact files as they were when the task started; what is kept afterwards is what changed. */
|
|
586
|
+
let artifactsBefore;
|
|
587
|
+
/** Puts back whatever a round did to the input files, and tells the chat and the record. */
|
|
588
|
+
const guardInputs = async (iteration) => {
|
|
589
|
+
if (!inputsGuard)
|
|
590
|
+
return null;
|
|
591
|
+
const guard = inputsGuard;
|
|
592
|
+
const check = await protectInputs(repoDirOf(session), guard.base, guard.inputs, guard.keep).catch((e) => ({
|
|
593
|
+
changed: ['(the input files)'],
|
|
594
|
+
restored: [],
|
|
595
|
+
failed: [{ path: '(the input files)', why: e.message }],
|
|
596
|
+
}));
|
|
597
|
+
if (check.changed.length > 0) {
|
|
598
|
+
sink.event('inputs-restored', { iteration, restored: check.restored, failed: check.failed }, `the operator's input files are read-only; put back: ${check.restored.join(', ') || '(none)'}` +
|
|
599
|
+
(check.failed.length > 0 ? `; could not be put back: ${check.failed.map((f) => f.path).join(', ')}` : ''), 'warn');
|
|
600
|
+
runnerNotes.push(inputsMessage(check));
|
|
601
|
+
await setTask((t) => {
|
|
602
|
+
t.inputsRestored = [...new Set([...(t.inputsRestored ?? []), ...check.restored])];
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
return check;
|
|
606
|
+
};
|
|
580
607
|
const finish = async (status, reason, summary, finalReply) => {
|
|
581
608
|
await record(`TASK ${status.toUpperCase()}`, [summary ?? '', reason ? `Reason: ${reason}` : ''].filter(Boolean).join('\n\n') || '(no details)');
|
|
582
609
|
// The net under the plan's own checks: whatever the task left running is stopped and named.
|
|
583
610
|
await reap(processesBefore, 'the task');
|
|
611
|
+
/*
|
|
612
|
+
* The operator's input files, once more before the commit: put back what the last round changed,
|
|
613
|
+
* so it is not committed as the task's work. One that cannot be put back fails a done task. See
|
|
614
|
+
* `vcs/inputs.ts`.
|
|
615
|
+
*/
|
|
616
|
+
if (inputsGuard) {
|
|
617
|
+
const check = await guardInputs();
|
|
618
|
+
if (check && check.failed.length > 0 && status === 'done') {
|
|
619
|
+
status = 'failed';
|
|
620
|
+
reason = `the operator's input file(s) ${check.failed.map((f) => f.path).join(', ')} were changed and could not be put back as they were: ${check.failed.map((f) => f.why).join('; ')}.`;
|
|
621
|
+
}
|
|
622
|
+
// Said on the record either way: the inputs as the task leaves them, against their recorded sums.
|
|
623
|
+
if (check && check.failed.length === 0) {
|
|
624
|
+
sink.event('inputs-verified', { files: inputsGuard.inputs.files.length }, `${inputsGuard.inputs.files.length} input file(s) are as the session's start recorded them: same sums`);
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
/*
|
|
628
|
+
* The session's artifacts, whatever the outcome: copied into this attempt's record, so the
|
|
629
|
+
* evidence of each attempt is kept even after a later one overwrites it. See `vcs/artifacts.ts`.
|
|
630
|
+
*/
|
|
631
|
+
const patterns = artifactPatterns(session.vcs);
|
|
632
|
+
const projectRoot = repoDirOf(session) || session.projectDir?.trim() || '';
|
|
633
|
+
if ((patterns.length > 0 || (task.outputs?.length ?? 0) > 0) && projectRoot) {
|
|
634
|
+
// Only what this task made or changed, inside its scope, or declared as its outputs.
|
|
635
|
+
const kept = await keepArtifacts(projectRoot, patterns, join(artifactsDir, 'project'), { before: artifactsBefore, scope: task.scope, outputs: task.outputs })
|
|
636
|
+
.catch((e) => ({ kept: [], skipped: [`(all: ${e.message})`], unchanged: 0, outsideScope: [] }));
|
|
637
|
+
sink.event('artifacts-kept', { kept: kept.kept.length, skipped: kept.skipped, unchanged: kept.unchanged, outsideScope: kept.outsideScope.slice(0, 20) }, `artifacts kept with the run: ${kept.kept.length} file(s) this task made or changed` +
|
|
638
|
+
`${kept.unchanged > 0 ? `; ${kept.unchanged} already there and untouched, not kept` : ''}` +
|
|
639
|
+
`${kept.outsideScope.length > 0 ? `; ${kept.outsideScope.length} outside the task's scope, not kept` : ''}` +
|
|
640
|
+
`${kept.skipped.length > 0 ? `; not kept: ${kept.skipped.slice(0, 5).join('; ')}` : ''}`, kept.skipped.length > 0 ? 'warn' : 'info');
|
|
641
|
+
await setTask((t) => {
|
|
642
|
+
t.artifactsKept = kept.kept.length > 0 ? kept.kept : undefined;
|
|
643
|
+
});
|
|
644
|
+
}
|
|
584
645
|
/*
|
|
585
646
|
* A read-only task that changed files has failed, whatever it reported.
|
|
586
647
|
*
|
|
@@ -604,10 +665,26 @@ export async function runTask(transport, session, task, deps) {
|
|
|
604
665
|
// The task is re-read first, because the branch was recorded on it after this closure
|
|
605
666
|
// was created.
|
|
606
667
|
const fresh = (await store.getSession(session.id))?.tasks.find((x) => x.id === task.id);
|
|
668
|
+
let vcsError;
|
|
607
669
|
const vcsAfter = await commitTaskResult(session, { ...task, vcs: fresh?.vcs }, { status, summary, reason, deviations }, bus).catch((e) => {
|
|
670
|
+
vcsError = e.message;
|
|
608
671
|
sink.event('vcs-error', { error: String(e) }, `version control failed after the task: ${e.message}`, 'warn');
|
|
609
672
|
return undefined;
|
|
610
673
|
});
|
|
674
|
+
/*
|
|
675
|
+
* With version control on, a task is done only when its work is committed. The commit used to
|
|
676
|
+
* fail — on the branch it was moved off, on a git error, on uncommitted files left behind — and
|
|
677
|
+
* the task still ended done, its work loose in the tree and the next task refusing to start over
|
|
678
|
+
* it. Now it ends failed with why; the work stays in the working tree, nothing is lost.
|
|
679
|
+
*/
|
|
680
|
+
if (status === 'done') {
|
|
681
|
+
const uncommitted = await commitShortfall(session, fresh?.vcs ?? task.vcs, vcsAfter, vcsError);
|
|
682
|
+
if (uncommitted) {
|
|
683
|
+
status = 'failed';
|
|
684
|
+
reason = uncommitted;
|
|
685
|
+
sink.event('commit-failed', { problem: vcsAfter?.problem ?? vcsError, uncommitted: vcsAfter?.afterCommit?.changed?.slice(0, 20) }, uncommitted, 'error');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
611
688
|
/*
|
|
612
689
|
* The checks about a clean tree, now that the runner has committed. A done task whose tree is
|
|
613
690
|
* still not clean after the commit has failed; any other ending keeps its own reason, and the
|
|
@@ -737,6 +814,9 @@ export async function runTask(transport, session, task, deps) {
|
|
|
737
814
|
t.runId = runId;
|
|
738
815
|
t.runGroup = deps.runGroup;
|
|
739
816
|
t.startedAt = new Date().toISOString();
|
|
817
|
+
// The kept checks under the ids this run uses (`withAttemptIds`), so the record names them as the gate does.
|
|
818
|
+
if (reviewChecks.length > 0)
|
|
819
|
+
t.reviewChecks = reviewChecks;
|
|
740
820
|
});
|
|
741
821
|
sink.event('task-started', { runId, title: task.title }, `task "${task.title}" starting (run ${runId})`);
|
|
742
822
|
await writeFile(taskLogPath, `TASK: ${task.title}\nSESSION: ${session.name} (${session.id})\nRUN: ${runId}\nSTARTED: ${new Date().toISOString()}\n`, 'utf8');
|
|
@@ -856,6 +936,26 @@ export async function runTask(transport, session, task, deps) {
|
|
|
856
936
|
* findings described a tree that was not the one it had been asked to audit.
|
|
857
937
|
*/
|
|
858
938
|
const scopeEnforced = (scope.length > 0 || !!task.readOnly) && onOwnBranch;
|
|
939
|
+
// The operator's input files, read-only on a branch of the task's own; see `vcs/inputs.ts`.
|
|
940
|
+
const startNow = (await store.getSession(session.id))?.vcsStart;
|
|
941
|
+
if (onOwnBranch && startNow?.inputs?.readOnly && startNow.inputs.files.length > 0) {
|
|
942
|
+
/*
|
|
943
|
+
* Held to the inputs as this task's own starting commit has them, not the session's record: a
|
|
944
|
+
* re-run is cut from where the task first started, before any inputs changed since. And what is
|
|
945
|
+
* under the patterns untracked now is the operator's, never removed by the guard.
|
|
946
|
+
*/
|
|
947
|
+
const baseCommit = prepared.vcs.baseCommit;
|
|
948
|
+
inputsGuard = {
|
|
949
|
+
base: baseCommit,
|
|
950
|
+
inputs: await inputsAtCommit(repoDirOf(session), baseCommit, startNow.inputs),
|
|
951
|
+
keep: await untrackedInputs(repoDirOf(session), startNow.inputs.patterns),
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
// Before the first step: what is under the artifact patterns now is not this task's evidence.
|
|
955
|
+
const artifactRoot = repoDirOf(session) || session.projectDir?.trim() || '';
|
|
956
|
+
const artifactPatternsNow = [...artifactPatterns(session.vcs), ...(task.outputs ?? [])];
|
|
957
|
+
if (artifactRoot && artifactPatternsNow.length > 0)
|
|
958
|
+
artifactsBefore = await artifactState(artifactRoot, artifactPatternsNow).catch(() => undefined);
|
|
859
959
|
/** The working tree's fingerprint, when the task is on a branch of its own; null otherwise. */
|
|
860
960
|
const treeNow = async () => prepared.vcs.branch && !prepared.vcs.problem && prepared.vcs.baseCommit ? await treeFingerprint(repoDirOf(session)).catch(() => null) : null;
|
|
861
961
|
/*
|
|
@@ -946,7 +1046,12 @@ export async function runTask(transport, session, task, deps) {
|
|
|
946
1046
|
workDirNote: workingDirNote(work),
|
|
947
1047
|
vcsNote: prepared.note,
|
|
948
1048
|
readOnlyNote: task.readOnly ? READ_ONLY_NOTE : undefined,
|
|
949
|
-
scopeNote:
|
|
1049
|
+
scopeNote: [
|
|
1050
|
+
scopeNote(scope, scopeEnforced),
|
|
1051
|
+
(task.outputs?.length ?? 0) > 0
|
|
1052
|
+
? `## Outputs\n\nThis task is to produce: ${(task.outputs ?? []).map((o) => `\`${o}\``).join(', ')}. What it creates or changes there is kept with the run's record as its evidence.`
|
|
1053
|
+
: '',
|
|
1054
|
+
].filter(Boolean).join('\n\n'),
|
|
950
1055
|
approachesNote: minApproaches > MIN_TRIED_APPROACHES
|
|
951
1056
|
? `## Giving up\n\nThis task may end with status "blocked" only after at least ${minApproaches} genuinely different approaches, ` +
|
|
952
1057
|
`each listed in "tried". With fewer, the runner asks you for another approach instead of accepting it.`
|
|
@@ -1409,7 +1514,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1409
1514
|
// `repeated` still identifies them.
|
|
1410
1515
|
const named = outcome.findings.map((f, i) => ({
|
|
1411
1516
|
...f,
|
|
1412
|
-
id: f.id ?? findingId(reviewRounds, i),
|
|
1517
|
+
id: f.id ?? findingId(reviewRounds, i, attempt),
|
|
1413
1518
|
...(repeated.includes(f) ? { repeated: true } : {}),
|
|
1414
1519
|
}));
|
|
1415
1520
|
outcome = { ...outcome, findings: named };
|
|
@@ -1936,6 +2041,7 @@ export async function runTask(transport, session, task, deps) {
|
|
|
1936
2041
|
});
|
|
1937
2042
|
}
|
|
1938
2043
|
}
|
|
2044
|
+
await guardInputs(iterations);
|
|
1939
2045
|
// --- report back ---------------------------------------------------------------
|
|
1940
2046
|
const report = await writeReport(results, {
|
|
1941
2047
|
runId,
|
|
@@ -2160,9 +2266,23 @@ export async function runSession(sessionId, deps) {
|
|
|
2160
2266
|
break;
|
|
2161
2267
|
}
|
|
2162
2268
|
session = (await store.getSession(sessionId));
|
|
2163
|
-
|
|
2269
|
+
let task = session.tasks.find((t) => t.id === queuedTask.id);
|
|
2164
2270
|
if (!task || task.status !== 'queued')
|
|
2165
2271
|
continue;
|
|
2272
|
+
/*
|
|
2273
|
+
* A task taken from the queue was started by the operator, never by the retry loop below, which
|
|
2274
|
+
* runs its own. A fresh-retry mark on it is left from a retry that was queued and never ran —
|
|
2275
|
+
* the new chat failed to open, the program was closed — and counted as this run's, it added the
|
|
2276
|
+
* old run's retry to this one's (`freshRetriesOfLatestRun`).
|
|
2277
|
+
*/
|
|
2278
|
+
if (task.freshRetry) {
|
|
2279
|
+
session = await store.updateSession(sessionId, (s) => {
|
|
2280
|
+
const t = s.tasks.find((x) => x.id === queuedTask.id);
|
|
2281
|
+
if (t)
|
|
2282
|
+
t.freshRetry = undefined;
|
|
2283
|
+
});
|
|
2284
|
+
task = session.tasks.find((t) => t.id === queuedTask.id);
|
|
2285
|
+
}
|
|
2166
2286
|
let outcome = await runTask(chat, session, task, deps);
|
|
2167
2287
|
ran += 1;
|
|
2168
2288
|
/*
|
|
@@ -2194,11 +2314,11 @@ export async function runSession(sessionId, deps) {
|
|
|
2194
2314
|
session = await store.updateSession(sessionId, (s) => {
|
|
2195
2315
|
s.chat = undefined;
|
|
2196
2316
|
s.contractSent = false;
|
|
2317
|
+
// Marked, not counted: how many retries this run made is read back from the marks
|
|
2318
|
+
// (`freshRetriesOfLatestRun`), so a later run cannot add to an earlier one's count.
|
|
2197
2319
|
const again = s.tasks.find((x) => x.id === task.id);
|
|
2198
|
-
if (again)
|
|
2199
|
-
again.autoRetries = (again.autoRetries ?? 0) + 1;
|
|
2320
|
+
if (again)
|
|
2200
2321
|
again.freshRetry = true;
|
|
2201
|
-
}
|
|
2202
2322
|
});
|
|
2203
2323
|
await chat.newChat();
|
|
2204
2324
|
/*
|
package/dist/src/plan/brief.js
CHANGED
|
@@ -324,6 +324,7 @@ const FIELD_ROWS_EN = [
|
|
|
324
324
|
['review', 'task', 'no', 'Set to `false` to skip the review for one task. Absent means the session decides.'],
|
|
325
325
|
['readOnly', 'task', 'no', '`true` for a task that must not change files — an audit, a smoke test, a report. The runner fails it if the tree changed, and still commits the change on its branch so nothing is lost.'],
|
|
326
326
|
['scope', 'task', 'no', 'The paths the task may change: files, folders (`tests/e2e/`) or `*`/`**` patterns. With version control on, the runner puts back any change outside them. Give it when the task is about one suite or module.'],
|
|
327
|
+
['outputs', 'task', 'no', 'Evidence the task is to produce (a report, an archive): patterns. What it creates or changes there is kept with the run\'s record.'],
|
|
327
328
|
['tasks', 'session', 'yes', 'At least one, in the order they must run.'],
|
|
328
329
|
['title', 'task', 'yes', '3 to 120 characters. Short, latin, hyphenated.'],
|
|
329
330
|
['prompt', 'task', 'yes', 'The task itself, at least 30 characters. This is what Copilot reads.'],
|
|
@@ -394,6 +395,7 @@ const FIELD_ROWS_BG = [
|
|
|
394
395
|
['review', 'задача', 'не', 'Сложи `false`, за да се пропусне рецензията само за тази задача. Липсата значи каквото казва сесията.'],
|
|
395
396
|
['readOnly', 'задача', 'не', '`true` за задача, която не бива да променя файлове — одит, smoke тест, доклад. Runner-ът я проваля, ако дървото е променено, и пак комитва промяната на клона ѝ, за да не се губи нищо.'],
|
|
396
397
|
['scope', 'задача', 'не', 'Пътищата, които задачата може да променя: файлове, папки (`tests/e2e/`) или шаблони с `*`/`**`. При включен контрол на версиите runner-ът връща всяка промяна извън тях. Давай го, когато задачата е за един suite или модул.'],
|
|
398
|
+
['outputs', 'задача', 'не', 'Доказателства, които задачата трябва да създаде (отчет, архив): шаблони. Каквото създаде или промени там, се пази към записа на пускането.'],
|
|
397
399
|
['tasks', 'сесия', 'да', 'Поне една, в реда, в който трябва да се изпълнят.'],
|
|
398
400
|
['title', 'задача', 'да', 'От 3 до 120 знака. Кратко, латиница, с тирета.'],
|
|
399
401
|
['prompt', 'задача', 'да', 'Самата задача, поне 30 знака. Това чете Copilot.'],
|
|
@@ -461,8 +463,24 @@ its own, and the session starts from it; the user approves the exact list on the
|
|
|
461
463
|
\`"tracked-only-snapshot"\` takes tracked changes only. Secrets (\`.env\`), tool output and files outside
|
|
462
464
|
the project are never taken, and an ignored file only when a task's \`scope\` names it. The snapshot is
|
|
463
465
|
taken where the changes are, so use it with \`startFrom\` \`"branch"\` only when the repository is already on
|
|
464
|
-
that branch, and never with \`"existing-branch"\`. Leaving it out keeps \`"reject"\`:
|
|
465
|
-
|
|
466
|
+
that branch, and never with \`"existing-branch"\`. Leaving it out keeps \`"reject"\`: a tree with uncommitted
|
|
467
|
+
changes refuses the task, and nothing runs.
|
|
468
|
+
|
|
469
|
+
**userInputs** — files the user provides for the work (schemas, test data):
|
|
470
|
+
\`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. They go into the commit every session starts from —
|
|
471
|
+
new or ignored ones in the starting snapshot, approved by the user; a later session whose start lacks them
|
|
472
|
+
gets them committed on top of it — and they are read-only while the tasks run (\`"readOnly": false\` only if
|
|
473
|
+
the user wants the work to change them). Ask the user which files are inputs; never name a whole folder of
|
|
474
|
+
work, and never \`.env\`. **artifacts** — evidence the work writes (reports, ZIP archives, test results):
|
|
475
|
+
\`{ "paths": ["rules-engine/test-results/**"] }\`. Never committed, kept with the run; tell tasks to write
|
|
476
|
+
evidence there instead of changing \`.gitignore\`. Only what a task creates or changes there is kept, and an
|
|
477
|
+
artifacts pattern must never cover the input files or a task's work ("rules-engine/**" would hide both): name
|
|
478
|
+
the evidence folders only.
|
|
479
|
+
|
|
480
|
+
A session with \`"startFrom": "previous-session"\` continues the whole work of the one before it only when
|
|
481
|
+
that one is \`"branchMode": "per-session"\`: per task, its work is on several branches and the next session
|
|
482
|
+
refuses to start from part of it. It also refuses when the session before has not finished (a task failed or
|
|
483
|
+
blocked): finish it first.
|
|
466
484
|
|
|
467
485
|
Put the same \`startFrom\` on every session that works in one repository unless the user wants it
|
|
468
486
|
mixed. Leaving it out keeps the old behaviour — whatever branch the repository is on — which is
|
|
@@ -529,8 +547,24 @@ run“, на отделен клон, и сесията тръгва от нег
|
|
|
529
547
|
сесията. \`"tracked-only-snapshot"\` взема само tracked промени. Тайни (\`.env\`), изход от инструменти и
|
|
530
548
|
файлове извън проекта никога не се вземат, а ignored файл — само когато \`scope\` на задача го посочва.
|
|
531
549
|
Снимката се прави там, където са промените, затова с \`startFrom\` \`"branch"\` само ако хранилището вече е на
|
|
532
|
-
този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`:
|
|
533
|
-
|
|
550
|
+
този клон, и никога с \`"existing-branch"\`. Ако го пропуснеш, остава \`"reject"\`: дърво с некомитнати
|
|
551
|
+
промени отказва задачата и нищо не се изпълнява.
|
|
552
|
+
|
|
553
|
+
**userInputs** — файлове, които потребителят дава за работата (схеми, тестови данни):
|
|
554
|
+
\`{ "paths": ["rules-engine/test-data/schemas/*.yaml"] }\`. Влизат в комита, от който тръгва всяка сесия —
|
|
555
|
+
новите или ignored в началната снимка, с одобрение от потребителя; следваща сесия, чието начало ги няма, ги
|
|
556
|
+
получава комитнати върху него — и са само за четене, докато задачите работят (\`"readOnly": false\` само ако
|
|
557
|
+
потребителят иска работата да ги променя). Питай кои файлове са вход; никога цяла папка с работа и никога
|
|
558
|
+
\`.env\`. **artifacts** — доказателства, които работата пише (отчети, ZIP архиви, резултати от тестове):
|
|
559
|
+
\`{ "paths": ["rules-engine/test-results/**"] }\`. Никога не се комитват, пазят се към пускането; кажи на
|
|
560
|
+
задачите да пишат доказателствата там, вместо да променят \`.gitignore\`. Пази се само каквото задачата създаде
|
|
561
|
+
или промени там, и шаблон за artifacts никога не покрива входните файлове или работата на задача
|
|
562
|
+
("rules-engine/**" би скрил и двете): посочвай само папките с доказателства.
|
|
563
|
+
|
|
564
|
+
Сесия с \`"startFrom": "previous-session"\` продължава цялата работа на предната само ако тя е
|
|
565
|
+
\`"branchMode": "per-session"\`: при per-task работата ѝ е на няколко клона и следващата сесия отказва да
|
|
566
|
+
тръгне от част от нея. Отказва и когато предната сесия не е завършила (задача е провалена или блокирана):
|
|
567
|
+
първо я завърши.
|
|
534
568
|
|
|
535
569
|
Сложи един и същ \`startFrom\` на всички сесии в едно хранилище, освен ако потребителят не иска
|
|
536
570
|
различни. Ако го пропуснеш, остава старото поведение — от който клон е хранилището в момента —
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { DEFAULT_VCS, applyDefaultProject } from '../session/store.js';
|
|
2
|
+
import { normalisePatterns } from '../vcs/inputs.js';
|
|
3
|
+
import { coveredByArtifacts } from '../vcs/artifacts.js';
|
|
2
4
|
import { availableShells, detectShells } from '../exec/shells.js';
|
|
3
5
|
/** The heading under which a task's acceptance bar reaches Copilot. English, like level 1. */
|
|
4
6
|
const EXPECTED_HEADER = '### Expected result';
|
|
@@ -165,6 +167,35 @@ defaultProjectDir = '') {
|
|
|
165
167
|
const sharedGroup = plan.conversation === 'shared'
|
|
166
168
|
? (plan.plan.trim() || `plan-${new Date().toISOString().slice(0, 16).replace(/[:T-]/g, '')}`).slice(0, 60)
|
|
167
169
|
: '';
|
|
170
|
+
/*
|
|
171
|
+
* A session that continues the one before it in the same repository, when that one ran per task
|
|
172
|
+
* with several tasks: its work is on several branches, and the continuation is refused at its start
|
|
173
|
+
* (see `sessionStart`). Said now, when the plan can still be changed.
|
|
174
|
+
*/
|
|
175
|
+
const lastIn = new Map();
|
|
176
|
+
for (const planned of plan.sessions) {
|
|
177
|
+
const repo = (planned.vcs?.repoDir || planned.projectDir || '').trim().replace(/[\\/]+$/, '').toLowerCase();
|
|
178
|
+
if (!planned.vcs?.enabled || !repo)
|
|
179
|
+
continue;
|
|
180
|
+
const before = lastIn.get(repo);
|
|
181
|
+
if (planned.vcs.startFrom === 'previous-session' && before?.perTask && before.tasks > 1) {
|
|
182
|
+
warnings.push(`Session "${planned.name}" continues "${before.name}", which runs a branch per task: its ${before.tasks} tasks' work will be on ${before.tasks} branches, ` +
|
|
183
|
+
`and "${planned.name}" will refuse to start from part of it. Give "${before.name}" "branchMode": "per-session" if the next session builds on all of it.`);
|
|
184
|
+
}
|
|
185
|
+
lastIn.set(repo, { name: planned.name, perTask: (planned.vcs.branchMode ?? 'per-task') === 'per-task', tasks: planned.tasks.length });
|
|
186
|
+
}
|
|
187
|
+
// A task whose whole scope is under the artifact patterns would have its work kept out of git.
|
|
188
|
+
for (const planned of plan.sessions) {
|
|
189
|
+
const arts = planned.vcs?.artifacts?.paths ?? [];
|
|
190
|
+
if (!planned.vcs?.enabled || arts.length === 0)
|
|
191
|
+
continue;
|
|
192
|
+
for (const task of planned.tasks) {
|
|
193
|
+
if (task.scope.length > 0 && coveredByArtifacts(task.scope, arts).length === task.scope.length) {
|
|
194
|
+
warnings.push(`Task "${task.title}" in session "${planned.name}" may change only ${task.scope.join(', ')}, and the session's artifacts patterns cover all of it: ` +
|
|
195
|
+
'whatever it writes is kept with the run and never committed. If that work is meant to be committed, narrow "artifacts".');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
168
199
|
for (const planned of plan.sessions) {
|
|
169
200
|
// Attaching project files to the chat was removed; an old plan's root is still its project folder.
|
|
170
201
|
if (planned.mirror?.enabled) {
|
|
@@ -172,8 +203,17 @@ defaultProjectDir = '') {
|
|
|
172
203
|
'the chat reads the project by running commands. "mirror" was ignored.');
|
|
173
204
|
}
|
|
174
205
|
const created = await store.createSession(planned.name, planned.projectDir || planned.mirror?.rootDir || '');
|
|
175
|
-
const { dirtyWorktree: plannedDirty, ...plannedVcs } = planned.vcs ?? {};
|
|
206
|
+
const { dirtyWorktree: plannedDirty, userInputs: plannedInputs, artifacts: plannedArtifacts, ...plannedVcs } = planned.vcs ?? {};
|
|
176
207
|
const vcs = { ...DEFAULT_VCS, ...plannedVcs };
|
|
208
|
+
// Input files and artifacts in the one spelling the session has: see `UserInputs`.
|
|
209
|
+
const inputPaths = plannedInputs?.enabled === false ? [] : normalisePatterns(plannedInputs?.paths ?? []);
|
|
210
|
+
if (inputPaths.length > 0) {
|
|
211
|
+
const readOnly = plannedInputs?.readOnly ?? plannedInputs?.readOnlyAfterCapture;
|
|
212
|
+
vcs.userInputs = { paths: inputPaths, ...(readOnly === false ? { readOnly: false } : {}), ...(plannedInputs?.requireApproval === false ? { requireApproval: false } : {}) };
|
|
213
|
+
}
|
|
214
|
+
const artifactPaths = normalisePatterns(plannedArtifacts?.paths ?? []);
|
|
215
|
+
if (artifactPaths.length > 0)
|
|
216
|
+
vcs.artifacts = { paths: artifactPaths };
|
|
177
217
|
// Stored in the one spelling the session has: see `DirtyWorktree`.
|
|
178
218
|
const policy = plannedDirty?.policy === 'snapshot' && plannedDirty.includeUntracked === false ? 'tracked-only-snapshot' : plannedDirty?.policy;
|
|
179
219
|
if (policy && policy !== 'reject')
|
|
@@ -211,6 +251,7 @@ defaultProjectDir = '') {
|
|
|
211
251
|
reviewEnabled: task.review,
|
|
212
252
|
readOnly: task.readOnly || undefined,
|
|
213
253
|
scope: task.scope.length > 0 ? task.scope : undefined,
|
|
254
|
+
outputs: task.outputs.length > 0 ? task.outputs : undefined,
|
|
214
255
|
});
|
|
215
256
|
titles.push(added.title);
|
|
216
257
|
}
|