copilot-operator 0.1.23 → 0.1.26
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.controller.js +1 -1
- package/dist/src/api/operator.service.js +337 -33
- package/dist/src/exec/confinement.js +85 -7
- package/dist/src/exec/programs.js +12 -3
- package/dist/src/exec/reportFile.js +5 -3
- package/dist/src/exec/shells.js +13 -2
- package/dist/src/log/runLog.js +6 -1
- package/dist/src/orchestrator/contract.js +29 -7
- package/dist/src/orchestrator/review.js +16 -3
- package/dist/src/orchestrator/taskRunner.js +391 -54
- package/dist/src/plan/schema.js +11 -1
- package/dist/src/plan/systemGuide.js +12 -4
- package/dist/src/protocol/parser.js +33 -5
- package/dist/src/protocol/reporter.js +2 -0
- package/dist/src/session/compose.js +18 -9
- package/dist/src/session/exportRecord.js +12 -3
- package/dist/src/session/exports.js +46 -9
- package/dist/src/session/handoff.js +5 -2
- package/dist/src/session/interruption.js +3 -1
- package/dist/src/session/runLog.js +42 -1
- package/dist/src/session/runName.js +3 -1
- package/dist/src/session/store.js +7 -2
- package/dist/src/transport/copilotTransport.js +100 -74
- package/dist/src/transport/modelMatch.js +60 -0
- package/dist/src/vcs/artifacts.js +20 -7
- package/dist/src/vcs/commitHygiene.js +7 -2
- package/dist/src/vcs/contentIntegrity.js +65 -6
- package/dist/src/vcs/git.js +42 -7
- package/dist/src/vcs/prepareFromRemote.js +21 -14
- package/dist/src/vcs/remoteAuth.js +31 -0
- package/dist/src/vcs/runPreflight.js +100 -31
- package/dist/src/vcs/scope.js +18 -2
- package/dist/src/vcs/snapshot.js +61 -16
- package/dist/src/vcs/syncCommand.js +3 -11
- package/dist/src/vcs/taskVcs.js +173 -24
- package/dist/web/404.html +2 -2
- package/dist/web/_next/static/chunks/549-7e35e4fdefadb05d.js +1 -0
- package/dist/web/_next/static/chunks/867-52e371cb1ec49089.js +1 -0
- package/dist/web/_next/static/chunks/app/defaults/{page-0e778dafc69afbd3.js → page-17dd586d1d6bd068.js} +1 -1
- package/dist/web/_next/static/chunks/app/page-4defcfd1eb482644.js +1 -0
- package/dist/web/_next/static/chunks/app/sessions/view/{page-9b66b729748595bb.js → page-54ab3cb89b84679d.js} +1 -1
- package/dist/web/appearance.html +2 -2
- package/dist/web/appearance.txt +10 -10
- package/dist/web/defaults.html +2 -2
- package/dist/web/defaults.txt +10 -10
- package/dist/web/history.html +2 -2
- package/dist/web/history.txt +10 -10
- package/dist/web/import.html +2 -2
- package/dist/web/import.txt +10 -10
- package/dist/web/index.html +2 -2
- package/dist/web/index.txt +10 -10
- package/dist/web/level1.html +2 -2
- package/dist/web/level1.txt +10 -10
- package/dist/web/presets.html +2 -2
- package/dist/web/presets.txt +10 -10
- package/dist/web/sessions/view.html +2 -2
- package/dist/web/sessions/view.txt +10 -10
- package/dist/web/system.html +2 -2
- package/dist/web/system.txt +10 -10
- package/package.json +3 -2
- package/prompts/level1.md +10 -3
- package/dist/web/_next/static/chunks/549-df7a4876917615da.js +0 -1
- package/dist/web/_next/static/chunks/867-a67ea8ba3f9b24aa.js +0 -1
- package/dist/web/_next/static/chunks/app/page-0a861b38da0a1c51.js +0 -1
- /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → jYvSWuPPywbxckIuz6cNs}/_buildManifest.js +0 -0
- /package/dist/web/_next/static/{-dZtu-yqxc-HXzYTmqYSc → jYvSWuPPywbxckIuz6cNs}/_ssgManifest.js +0 -0
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* Only for a fetch the operator just pressed a button for: a run's own fetches stay silent, since a
|
|
18
18
|
* window nobody answers would hold the run until it times out.
|
|
19
19
|
*/
|
|
20
|
+
import { execFile } from 'node:child_process';
|
|
20
21
|
import { existsSync } from 'node:fs';
|
|
21
22
|
import { resolve } from 'node:path';
|
|
22
23
|
import { git } from './git.js';
|
|
@@ -55,6 +56,36 @@ export function describeFetchFailure(stderr) {
|
|
|
55
56
|
const auth = /permission denied|publickey|passphrase|authentication failed|could not read (username|password)|host key verification failed|terminal prompts disabled/i.test(stderr);
|
|
56
57
|
return { detail: lines.slice(0, 3).join(' ') || 'git fetch failed', auth };
|
|
57
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* Closes the password windows of a fetch that was given up on: every process of exactly this program
|
|
61
|
+
* started since `since`.
|
|
62
|
+
*
|
|
63
|
+
* Needed because stopping git and its process tree does not reach them. Git for Windows starts ssh, and
|
|
64
|
+
* ssh the window, the way its POSIX layer does, and the Windows parent of the window is a process that
|
|
65
|
+
* is already gone — so `taskkill /T` stops git and leaves the window on screen (seen live on
|
|
66
|
+
* 2026-10-03, and in a process listing: the window's parent id named no running process). Once the
|
|
67
|
+
* window is closed the ssh waiting on it ends by itself: git, at the other end of its pipe, is gone.
|
|
68
|
+
* Matched by the program's full path and its start time, so nothing else is touched; a second
|
|
69
|
+
* password window of the operator's own, opened in the same minutes, would at worst be cancelled.
|
|
70
|
+
*/
|
|
71
|
+
export function closePasswordWindows(program, since) {
|
|
72
|
+
if (process.platform !== 'win32')
|
|
73
|
+
return Promise.resolve(0);
|
|
74
|
+
const script = [
|
|
75
|
+
`$path = '${program.replace(/'/g, "''")}'`,
|
|
76
|
+
`$since = [datetime]::Parse('${since.toISOString()}').ToUniversalTime()`,
|
|
77
|
+
'$n = 0',
|
|
78
|
+
"Get-CimInstance Win32_Process -Filter \"Name='git-askpass.exe' OR Name='$([IO.Path]::GetFileName($path))'\" |",
|
|
79
|
+
' Where-Object { $_.ExecutablePath -and $_.ExecutablePath -ieq $path -and $_.CreationDate.ToUniversalTime() -ge $since } |',
|
|
80
|
+
' ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue; $n++ }',
|
|
81
|
+
'$n',
|
|
82
|
+
].join('\n');
|
|
83
|
+
return new Promise((resolve) => {
|
|
84
|
+
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], { windowsHide: true, timeout: 30_000 }, (_e, stdout) => {
|
|
85
|
+
resolve(Number.parseInt(String(stdout).trim(), 10) || 0);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
}
|
|
58
89
|
/** The fetch for the operator to run in their own PowerShell, where ssh can ask them directly. */
|
|
59
90
|
export function fetchCommandFor(dir, remote) {
|
|
60
91
|
return `git -C ${psQuote(dir)} fetch --prune ${psQuote(remote)}`;
|
|
@@ -24,15 +24,18 @@
|
|
|
24
24
|
* The run is refused before the browser opens while any group is not ready (see the service).
|
|
25
25
|
*/
|
|
26
26
|
import { join, resolve } from 'node:path';
|
|
27
|
-
import { branchNameFrom, freeBranchName, git } from './git.js';
|
|
27
|
+
import { branchNameFrom, describeUpdate, freeBranchName, git, updateFromRemote } from './git.js';
|
|
28
28
|
import { commitOnto, dirtyBesidesInputs, inputSettings, inputSumsAt, missingInputPatterns } from './inputs.js';
|
|
29
|
-
import { dirtyPolicy, takeSnapshot } from './snapshot.js';
|
|
30
|
-
import { repoDirOf, vcsPreflight } from './taskVcs.js';
|
|
29
|
+
import { approvalMismatch, dirtyPolicy, takeSnapshot } from './snapshot.js';
|
|
30
|
+
import { repoDirOf, sessionBranchName, vcsPreflight } from './taskVcs.js';
|
|
31
31
|
import { artifactPatterns } from './artifacts.js';
|
|
32
32
|
import { inScope } from './scope.js';
|
|
33
|
+
import { findSuspicious } from './commitHygiene.js';
|
|
33
34
|
const norm = (dir) => dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
|
|
34
35
|
const short = (c) => (c ?? '').slice(0, 8);
|
|
35
36
|
const names = (list) => list.map((s) => `"${s.name}"`).join(', ');
|
|
37
|
+
/** "starts" for one session, "start" for several (live run 2026-10-03: '"x" start from it', in a commit message too). */
|
|
38
|
+
const verb = (list, one, many) => (list.length === 1 ? one : many);
|
|
36
39
|
/** The groups, one per repository, for the sessions of a run in run order. */
|
|
37
40
|
export async function runVcsPreflight(sessions, allSessions) {
|
|
38
41
|
const byRepo = new Map();
|
|
@@ -58,10 +61,18 @@ async function groupFor(list, allSessions) {
|
|
|
58
61
|
const baseBranch = startFrom === 'branch' || startFrom === 'previous-session' ? first.vcs?.baseBranch?.trim() || 'main' : undefined;
|
|
59
62
|
const baseHead = baseBranch ? (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${baseBranch}^{commit}`])).stdout || null : undefined;
|
|
60
63
|
const settings = inputSettings(first.vcs);
|
|
64
|
+
const workName = first.vcs?.startFrom === 'existing-branch'
|
|
65
|
+
? first.vcs.existingBranch?.trim()
|
|
66
|
+
: first.vcs?.branchMode === 'per-session' && first.vcsBaseCommit
|
|
67
|
+
? sessionBranchName(first)
|
|
68
|
+
: undefined;
|
|
69
|
+
const workBranch = workName ? { name: workName, head: (await git(repoDir, ['rev-parse', '--verify', '--quiet', `refs/heads/${workName}^{commit}`])).stdout || null } : undefined;
|
|
61
70
|
const sessions = list.map((s, i) => {
|
|
62
71
|
const sf = s.vcs?.startFrom ?? 'head';
|
|
63
72
|
const role = i === 0 ? 'first' : sf === 'previous-session' ? 'inherits' : sf === 'branch' && (s.vcs?.baseBranch?.trim() || 'main') === baseBranch && !s.vcsBaseCommit ? 'shares' : 'own';
|
|
64
|
-
|
|
73
|
+
// Started means a task of it ran; a start recorded by the panel (a snapshot) is not that (live run 2026-10-03).
|
|
74
|
+
const started = s.tasks.some((t) => !!t.startedAt || (t.attempts ?? []).some((a) => !!a.startedAt));
|
|
75
|
+
return { id: s.id, name: s.name, startFrom: sf, started, hasStart: !!s.vcsBaseCommit, role };
|
|
65
76
|
});
|
|
66
77
|
const pre = await vcsPreflight(first, allSessions);
|
|
67
78
|
const plan = pre.snapshot;
|
|
@@ -77,7 +88,11 @@ async function groupFor(list, allSessions) {
|
|
|
77
88
|
? []
|
|
78
89
|
: (await dirtyBesidesInputs(repoDir, first.vcsStart)).other
|
|
79
90
|
.filter((path) => !inputPaths.has(path) && !(artifacts.length > 0 && inScope(path, artifacts)))
|
|
80
|
-
.map((path) =>
|
|
91
|
+
.map((path) => {
|
|
92
|
+
// Said under every policy, not only once a snapshot is asked for (live run 2026-10-03: ".env" with no reason).
|
|
93
|
+
const reason = reasons.get(path) ?? findSuspicious([path])[0]?.reason;
|
|
94
|
+
return { path, ...(reason ? { reason } : {}) };
|
|
95
|
+
});
|
|
81
96
|
// Before anything is approved: a pattern that matches nothing is said here, not after the browser opens.
|
|
82
97
|
let problem = pre.ok ? undefined : pre.problem;
|
|
83
98
|
// Problems no approval or setting of version control's own can get past: they need the operator.
|
|
@@ -87,7 +102,9 @@ async function groupFor(list, allSessions) {
|
|
|
87
102
|
const missing = topR.ok ? await missingInputPatterns(first, repoDir, resolve(topR.stdout), allSessions) : [];
|
|
88
103
|
if (missing.length > 0) {
|
|
89
104
|
blocking = true;
|
|
90
|
-
|
|
105
|
+
// Added to what was already in the way, not instead of it: the dirty tree was hidden (live run 2026-10-03).
|
|
106
|
+
const absent = `the input file pattern(s) ${missing.map((m) => `"${m}"`).join(', ')} match no file in the project: put the files in yourself (nothing here can make them), or correct "Input files" on the session's page.`;
|
|
107
|
+
problem = problem ? `${absent} And besides that: ${problem.charAt(0).toLowerCase()}${problem.slice(1)}` : absent;
|
|
91
108
|
}
|
|
92
109
|
}
|
|
93
110
|
/*
|
|
@@ -121,7 +138,7 @@ async function groupFor(list, allSessions) {
|
|
|
121
138
|
const why = !baseHead
|
|
122
139
|
? `there is no local branch "${baseBranch}"`
|
|
123
140
|
: unrelated.length > 0
|
|
124
|
-
? `besides the input files there are other uncommitted changes (${unrelated.slice(0, 5).map((u) => u.path).join(', ')}${unrelated.length > 5 ? '…' : ''}): commit or stash them first, or use the current branch instead`
|
|
141
|
+
? `besides the input files there are other uncommitted changes (${unrelated.slice(0, 5).map((u) => u.path).join(', ')}${unrelated.length > 5 ? '…' : ''}): commit or stash them first${branch !== baseBranch ? ', or use the current branch instead' : ', or take them as a starting snapshot'}`
|
|
125
142
|
: blockedInputs.length > 0
|
|
126
143
|
? `${blockedInputs.map((b) => b.path).join(', ')} cannot be taken as input files`
|
|
127
144
|
: problem && /match no file/.test(problem)
|
|
@@ -130,14 +147,25 @@ async function groupFor(list, allSessions) {
|
|
|
130
147
|
actions.push({
|
|
131
148
|
id: 'snapshot-on-base',
|
|
132
149
|
available: !why,
|
|
133
|
-
recommended:
|
|
150
|
+
// The fix that leaves the checkout alone: recommended whenever it can be pressed (live run 2026-10-03: not marked on the base branch).
|
|
151
|
+
recommended: !why,
|
|
134
152
|
...(why ? { why } : {}),
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
153
|
+
// The update from the remote said first: it moves the base branch, and the checkout with it when that is the branch checked out (live run 2026-10-04).
|
|
154
|
+
result: (first.vcs?.updateFromRemote !== false
|
|
155
|
+
? `First brings "${baseBranch}" up to its remote, fast-forward only${branch === baseBranch ? ', which moves your checkout with it' : ''}; then commits`
|
|
156
|
+
: 'Commits') +
|
|
157
|
+
` the ${inputs.length} input file(s) on top of "${baseBranch}" (${short(baseHead)}${first.vcs?.updateFromRemote !== false ? ' now, or where the update takes it' : ''}) as "Capture user-provided inputs", on the new branch ${baselineName}, ` +
|
|
158
|
+
`without switching branches: your checkout${branch ? ` of "${branch}"` : ''} and the files stay as they are${first.vcs?.updateFromRemote !== false && branch === baseBranch ? ', apart from that update' : ''}. ` +
|
|
159
|
+
`${names(sharing)} ${verb(sharing, 'starts', 'start')} from it${inheriting.length > 0 ? `; ${names(inheriting)} ${verb(inheriting, 'carries', 'carry')} it on through the chain` : ''}.`,
|
|
138
160
|
});
|
|
139
161
|
}
|
|
140
|
-
|
|
162
|
+
/*
|
|
163
|
+
* On the base branch already, with only the inputs uncommitted, the snapshot "where the repository is"
|
|
164
|
+
* is the snapshot on the base branch under a second button with the same label (seen live on
|
|
165
|
+
* 2026-10-03: two "Create starting snapshot on main"). The one that leaves the checkout alone stays.
|
|
166
|
+
*/
|
|
167
|
+
const onBaseOffered = actions.some((a) => a.id === 'snapshot-on-base' && a.available);
|
|
168
|
+
if (plan?.needed && plan.ok && plan.requireApproval && !(onBaseOffered && branch === baseBranch)) {
|
|
141
169
|
actions.push({
|
|
142
170
|
id: 'snapshot-here',
|
|
143
171
|
available: true,
|
|
@@ -151,17 +179,37 @@ async function groupFor(list, allSessions) {
|
|
|
151
179
|
actions.push({
|
|
152
180
|
id: 'use-current-branch',
|
|
153
181
|
available: true,
|
|
154
|
-
result: `${names(sharing)} start from "${branch}" (${short(head)}) instead of "${baseBranch}": their
|
|
182
|
+
result: `${names(sharing)} ${verb(sharing, 'starts', 'start')} from the local branch "${branch}" (${short(head)} now) instead of "${baseBranch}": ${verb(sharing, 'its', 'their')} base branch becomes "${branch}". ` +
|
|
155
183
|
'Nothing is committed yet; a snapshot on that branch is offered next if one is needed.',
|
|
156
184
|
});
|
|
157
185
|
}
|
|
158
|
-
|
|
186
|
+
/*
|
|
187
|
+
* Offered whenever other uncommitted files are in the way under "refuse", inputs or not. It used to need
|
|
188
|
+
* `!plan.needed`, and input files make a snapshot needed — so exactly when inputs sat next to other
|
|
189
|
+
* changes it never appeared, "use the current branch" left only "review", and the panel could not become
|
|
190
|
+
* ready (live run 2026-10-03).
|
|
191
|
+
*/
|
|
192
|
+
if (unrelated.length > 0 && dirtyPolicy(first.vcs).policy === 'reject' && !first.vcsBaseCommit && !noCommits) {
|
|
159
193
|
actions.push({
|
|
160
194
|
id: 'allow-snapshot',
|
|
161
195
|
available: true,
|
|
162
196
|
result: `"Uncommitted changes" for "${first.name}" becomes "Take them as a starting snapshot": the files are listed here for you to approve. Nothing is committed yet.`,
|
|
163
197
|
});
|
|
164
198
|
}
|
|
199
|
+
/*
|
|
200
|
+
* While something only the operator can fix is in the way — an input that is not there, a branch that
|
|
201
|
+
* does not exist — no fix here can make the run ready: each says so instead of being offered as one
|
|
202
|
+
* (live run 2026-10-03: "fixes" offered that could never clear the refusal).
|
|
203
|
+
*/
|
|
204
|
+
if (blocking) {
|
|
205
|
+
for (const a of actions) {
|
|
206
|
+
if (a.id === 'review-inputs' || !a.available)
|
|
207
|
+
continue;
|
|
208
|
+
a.available = false;
|
|
209
|
+
a.recommended = false;
|
|
210
|
+
a.why = 'first the problem above has to be fixed outside the bot; this cannot make the run ready until then.';
|
|
211
|
+
}
|
|
212
|
+
}
|
|
165
213
|
return {
|
|
166
214
|
repoDir,
|
|
167
215
|
ready,
|
|
@@ -169,6 +217,10 @@ async function groupFor(list, allSessions) {
|
|
|
169
217
|
branch,
|
|
170
218
|
head,
|
|
171
219
|
...(baseBranch ? { baseBranch, baseHead } : {}),
|
|
220
|
+
...(workBranch && workBranch.name !== branch ? { workBranch } : {}),
|
|
221
|
+
...(settings && inputs.length === 0 && !first.vcsBaseCommit && !blocking
|
|
222
|
+
? { carry: `the input files (${settings.patterns.join(', ')}) are not in the working tree: when the first task starts they are committed on top of its start as ${prefix}input/${first.id}, from the latest earlier capture of them` }
|
|
223
|
+
: {}),
|
|
172
224
|
sessions,
|
|
173
225
|
inputPatterns: settings?.patterns ?? [],
|
|
174
226
|
inputs,
|
|
@@ -194,16 +246,23 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
|
|
|
194
246
|
if (action === 'review-inputs')
|
|
195
247
|
return { ok: true, result: offered.result };
|
|
196
248
|
if (action === 'use-current-branch') {
|
|
197
|
-
const moved = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.
|
|
249
|
+
const moved = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.hasStart);
|
|
198
250
|
for (const s of moved) {
|
|
199
251
|
await saveSession(s.id, (x) => {
|
|
200
|
-
|
|
201
|
-
|
|
252
|
+
/*
|
|
253
|
+
* The branch the operator was shown, pinned as the base branch. "From wherever the repository is"
|
|
254
|
+
* read HEAD when each session's first task began, and HEAD moves between sessions — the next one
|
|
255
|
+
* would have started from the branch of the one before (live run 2026-10-03).
|
|
256
|
+
*/
|
|
257
|
+
if (x.vcs) {
|
|
258
|
+
x.vcs.startFrom = 'branch';
|
|
259
|
+
x.vcs.baseBranch = group.branch ?? undefined;
|
|
260
|
+
}
|
|
202
261
|
x.vcsBaseCommit = undefined;
|
|
203
262
|
x.vcsStart = undefined;
|
|
204
263
|
});
|
|
205
264
|
}
|
|
206
|
-
return { ok: true, result: `${names(moved)} now start from "${group.branch}".` };
|
|
265
|
+
return { ok: true, result: `${names(moved)} now ${verb(moved, 'starts', 'start')} from "${group.branch}".` };
|
|
207
266
|
}
|
|
208
267
|
if (action === 'allow-snapshot') {
|
|
209
268
|
await saveSession(first.id, (x) => {
|
|
@@ -218,36 +277,42 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
|
|
|
218
277
|
}
|
|
219
278
|
// snapshot-on-base: the base branch's tree plus the approved inputs only, with a temporary index.
|
|
220
279
|
const paths = group.inputs.map((e) => e.path);
|
|
221
|
-
const
|
|
222
|
-
if (
|
|
223
|
-
return { ok: false, problem:
|
|
224
|
-
}
|
|
280
|
+
const wrong = approvalMismatch(paths, choices, { allMustBeIncluded: true });
|
|
281
|
+
if (wrong)
|
|
282
|
+
return { ok: false, problem: wrong };
|
|
225
283
|
const topR = await git(repoDir, ['rev-parse', '--show-toplevel']);
|
|
226
284
|
if (!topR.ok || !topR.stdout || !group.baseHead || !group.baseBranch)
|
|
227
285
|
return { ok: false, problem: 'the base branch could not be read.' };
|
|
228
286
|
const top = resolve(topR.stdout);
|
|
287
|
+
/*
|
|
288
|
+
* The base branch brought up to its remote first, when the session asks for that, as a start from the
|
|
289
|
+
* branch does: the snapshot is the commit every session here starts from, and it skipped the update
|
|
290
|
+
* without a word, so a run cut its work from a main the team had already moved past (live run 2026-10-03).
|
|
291
|
+
*/
|
|
292
|
+
const update = first.vcs?.updateFromRemote !== false ? await updateFromRemote(top, group.baseBranch) : undefined;
|
|
293
|
+
const baseHead = (await git(top, ['rev-parse', '--verify', '--quiet', `refs/heads/${group.baseBranch}^{commit}`])).stdout || group.baseHead;
|
|
229
294
|
const files = [];
|
|
230
295
|
for (const path of paths) {
|
|
231
296
|
const blob = await git(top, ['hash-object', '-w', `--path=${path}`, '--', join(top, path)]);
|
|
232
297
|
if (!blob.ok || !blob.stdout)
|
|
233
298
|
return { ok: false, problem: `${path} could not be read into git: ${blob.stderr}` };
|
|
234
|
-
const mode = (await git(top, ['ls-tree',
|
|
299
|
+
const mode = (await git(top, ['ls-tree', baseHead, '--', path])).stdout.split(' ')[0] || '100644';
|
|
235
300
|
files.push({ path, mode, blob: blob.stdout });
|
|
236
301
|
}
|
|
237
302
|
const branch = await freeBranchName(top, `${branchNameFrom(['baseline'], first.vcs?.branchPrefix || 'cop/')}/${first.id}`);
|
|
238
|
-
const sharing = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.
|
|
303
|
+
const sharing = group.sessions.filter((s) => (s.role === 'first' || s.role === 'shares') && !s.hasStart);
|
|
239
304
|
const message = [
|
|
240
305
|
'Capture user-provided inputs',
|
|
241
306
|
'',
|
|
242
|
-
`The operator's input files, approved on the run screen, on top of "${group.baseBranch}" (${short(
|
|
243
|
-
`Made without switching branches; the sessions ${names(sharing)} start from it. The work reads them and does not change them.`,
|
|
307
|
+
`The operator's input files, approved on the run screen, on top of "${group.baseBranch}" (${short(baseHead)}).`,
|
|
308
|
+
`Made without switching branches; ${verb(sharing, 'the session', 'the sessions')} ${names(sharing)} ${verb(sharing, 'starts', 'start')} from it. The work reads them and does not change them.`,
|
|
244
309
|
'',
|
|
245
310
|
...paths.slice(0, 200).map((p) => `- ${p}`),
|
|
246
311
|
'',
|
|
247
312
|
'Committed by copilot-operator. Not pushed.',
|
|
248
313
|
'',
|
|
249
314
|
].join('\n');
|
|
250
|
-
const made = await commitOnto(top,
|
|
315
|
+
const made = await commitOnto(top, baseHead, files, message, branch);
|
|
251
316
|
if ('problem' in made)
|
|
252
317
|
return { ok: false, problem: made.problem };
|
|
253
318
|
const settings = inputSettings(first.vcs);
|
|
@@ -258,9 +323,10 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
|
|
|
258
323
|
kind: 'snapshot',
|
|
259
324
|
commit: made.commit,
|
|
260
325
|
branch,
|
|
326
|
+
...(update ? { update } : {}),
|
|
261
327
|
snapshot: {
|
|
262
328
|
fromBranch: group.baseBranch,
|
|
263
|
-
fromCommit:
|
|
329
|
+
fromCommit: baseHead,
|
|
264
330
|
included: paths.slice(0, 500),
|
|
265
331
|
leftOut: [],
|
|
266
332
|
approved: true,
|
|
@@ -278,9 +344,12 @@ export async function runVcsPrepare(sessions, repoDir, action, choices, bus, sav
|
|
|
278
344
|
sessionId: s.id,
|
|
279
345
|
type: 'vcs-snapshot',
|
|
280
346
|
level: 'info',
|
|
281
|
-
message: `starting snapshot approved on the run screen: ${paths.length} input file(s) on top of ${group.baseBranch} (${short(
|
|
282
|
-
|
|
347
|
+
message: `starting snapshot approved on the run screen: ${paths.length} input file(s) on top of ${group.baseBranch} (${short(baseHead)}) as ${short(made.commit)} on ${branch}; ` +
|
|
348
|
+
(update?.outcome === 'updated' && group.branch === group.baseBranch
|
|
349
|
+
? `the checked-out ${group.baseBranch} was fast-forwarded to ${update.remote ?? 'its remote'} first, and nothing else in the checkout was touched`
|
|
350
|
+
: 'the checkout was not touched'),
|
|
351
|
+
data: { commit: made.commit, branch, base: baseHead, baseBranch: group.baseBranch, inputs: paths, approvedAt, sessions: sharing.map((x) => x.id) },
|
|
283
352
|
});
|
|
284
353
|
}
|
|
285
|
-
return { ok: true, result: `taken: ${short(made.commit)} on ${branch}, on top of "${group.baseBranch}"; ${names(sharing)} start from it.` };
|
|
354
|
+
return { ok: true, result: `${update ? `${describeUpdate(update)}. ` : ''}taken: ${short(made.commit)} on ${branch}, on top of "${group.baseBranch}" (${short(baseHead)}); ${names(sharing)} ${verb(sharing, 'starts', 'start')} from it.` };
|
|
286
355
|
}
|
package/dist/src/vcs/scope.js
CHANGED
|
@@ -144,12 +144,28 @@ export function scopeMessage(check, scope, readOnly = false) {
|
|
|
144
144
|
return parts.filter(Boolean).join(' ');
|
|
145
145
|
}
|
|
146
146
|
/** The line the opening message carries, so the chat knows the rule before its first step. */
|
|
147
|
-
export function scopeNote(scope, enforced
|
|
147
|
+
export function scopeNote(scope, enforced,
|
|
148
|
+
/** The session's artifact patterns and the task's declared outputs: not put back, and kept only as said here. */
|
|
149
|
+
evidence = {}) {
|
|
148
150
|
if (scope.length === 0)
|
|
149
151
|
return '';
|
|
152
|
+
const artifacts = evidence.artifacts ?? [];
|
|
153
|
+
const outputs = evidence.outputs ?? [];
|
|
154
|
+
/*
|
|
155
|
+
* One account of what is put back and what is kept. The message said "anything outside is put back",
|
|
156
|
+
* then "this task is to produce" a file outside, then "evidence goes under reports/**" — and a report
|
|
157
|
+
* there outside the scope was neither put back nor kept (live run 2026-10-03).
|
|
158
|
+
*/
|
|
159
|
+
const exempt = [
|
|
160
|
+
...(outputs.length > 0 ? [`the files this task is to produce (${outputs.join(', ')}) are not put back and are kept with the run`] : []),
|
|
161
|
+
...(artifacts.length > 0
|
|
162
|
+
? [`files under ${artifacts.join(', ')} are never committed and are not put back; of those, only the ones inside the paths above${outputs.length > 0 ? ' or among the files to produce' : ''} are kept with the run`]
|
|
163
|
+
: []),
|
|
164
|
+
];
|
|
150
165
|
return (`## Scope\n\nThis task may change only these paths (repository-relative): ${scope.join(', ')}. ` +
|
|
151
166
|
(enforced
|
|
152
167
|
? 'After every round of steps the runner puts back any change outside them and tells you which.'
|
|
153
|
-
: 'Version control is off for this session, so this is not enforced: keep to it
|
|
168
|
+
: `${evidence.why ?? 'Version control is off for this session'}, so this is not enforced: keep to it.`) +
|
|
169
|
+
(exempt.length > 0 ? ` Except: ${exempt.join('; ')}.` : '') +
|
|
154
170
|
' Reading any file in the project is fine.');
|
|
155
171
|
}
|
package/dist/src/vcs/snapshot.js
CHANGED
|
@@ -199,6 +199,15 @@ export async function planSnapshot(session, allSessions = async () => []) {
|
|
|
199
199
|
continue;
|
|
200
200
|
}
|
|
201
201
|
const artifact = artifactPatterns(session.vcs).length > 0 && inScope(s.path, artifactPatterns(session.vcs));
|
|
202
|
+
/*
|
|
203
|
+
* A name that looks temporary (scratch.txt, tmp-x, a .bak) is the operator's to decide: left out unless
|
|
204
|
+
* ticked, not forced out. The rule was written for the chat's own scratch copies, and it took the choice
|
|
205
|
+
* away from an operator's own note (live run 2026-10-04).
|
|
206
|
+
*/
|
|
207
|
+
if (!artifact && !outside && /^a (temporary copy|scratch folder)/.test(generated?.reason ?? '')) {
|
|
208
|
+
plan.entries.push({ path: s.path, kind: 'untracked', size: await size(s.path), allowed: ['include', 'leave-out'], choice: 'leave-out', reason: 'looks like a temporary or scratch file: left out unless you include it' });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
202
211
|
const why = artifact
|
|
203
212
|
? 'an artifact: kept with the run, never committed'
|
|
204
213
|
: generated ? generated.reason : outside ? `outside the project folder ${project}` : policy === 'tracked-only-snapshot' ? 'a new file, and this session takes tracked changes only' : undefined;
|
|
@@ -296,15 +305,46 @@ function excludePattern(path) {
|
|
|
296
305
|
return `/${path.replace(/[\\*?[]/g, (c) => `\\${c}`).replace(/ $/, '\\ ')}`;
|
|
297
306
|
}
|
|
298
307
|
/** Adds the files to the repository's own exclude list, under a line that says who and why. */
|
|
308
|
+
/**
|
|
309
|
+
* Why an approval does not match the list it answers, said as what is wrong, or null when it matches.
|
|
310
|
+
* Every mismatch used to read "the files changed since the list was shown", also when the approval only
|
|
311
|
+
* left a file without a choice and nothing had changed (live run 2026-10-03).
|
|
312
|
+
*/
|
|
313
|
+
export function approvalMismatch(listed, choices, opts = {}) {
|
|
314
|
+
const given = Object.keys(choices);
|
|
315
|
+
const missing = listed.filter((p) => !(p in choices));
|
|
316
|
+
const unknown = given.filter((p) => !listed.includes(p));
|
|
317
|
+
const leftOut = opts.allMustBeIncluded ? listed.filter((p) => p in choices && choices[p] !== 'include') : [];
|
|
318
|
+
const some = (paths) => paths.slice(0, 5).join(', ') + (paths.length > 5 ? ` and ${paths.length - 5} more` : '');
|
|
319
|
+
const parts = [
|
|
320
|
+
...(unknown.length > 0 ? [`${some(unknown)} ${unknown.length === 1 ? 'is' : 'are'} not on the list any more — the files changed since it was shown`] : []),
|
|
321
|
+
...(missing.length > 0 ? [`the approval has no choice for ${some(missing)}`] : []),
|
|
322
|
+
...(leftOut.length > 0 ? [`every input file is taken into this snapshot, and ${some(leftOut)} ${leftOut.length === 1 ? 'was' : 'were'} left out`] : []),
|
|
323
|
+
];
|
|
324
|
+
return parts.length === 0 ? null : `${parts.join('; ')}. Look at the list again; nothing was done.`;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Writes the left-out files into `.git/info/exclude`. `undo` puts the file back as it was, for every way
|
|
328
|
+
* the snapshot can still fail afterwards: the failure says the repository is back as it was, and a
|
|
329
|
+
* left-out file silently gone from `git status` would make that untrue.
|
|
330
|
+
*/
|
|
299
331
|
async function leaveOut(top, session, paths) {
|
|
332
|
+
const nothing = { undo: async () => undefined };
|
|
300
333
|
if (paths.length === 0)
|
|
301
|
-
return
|
|
334
|
+
return nothing;
|
|
302
335
|
const where = await git(top, ['rev-parse', '--git-path', 'info/exclude']);
|
|
303
336
|
if (!where.ok || !where.stdout)
|
|
304
|
-
return 'the repository\'s exclude file could not be found';
|
|
337
|
+
return { ...nothing, problem: 'the repository\'s exclude file could not be found' };
|
|
305
338
|
const file = resolve(top, where.stdout);
|
|
306
339
|
await mkdir(dirname(file), { recursive: true });
|
|
340
|
+
const existed = await readFile(file, 'utf8').then(() => true, () => false);
|
|
307
341
|
const before = await readFile(file, 'utf8').catch(() => '');
|
|
342
|
+
const undo = async () => {
|
|
343
|
+
if (existed)
|
|
344
|
+
await writeFile(file, before, 'utf8').catch(() => undefined);
|
|
345
|
+
else
|
|
346
|
+
await rm(file, { force: true }).catch(() => undefined);
|
|
347
|
+
};
|
|
308
348
|
const block = [
|
|
309
349
|
`# copilot-operator: left out of the starting snapshot of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}), ${new Date().toISOString()}`,
|
|
310
350
|
...paths.map(excludePattern),
|
|
@@ -314,9 +354,10 @@ async function leaveOut(top, session, paths) {
|
|
|
314
354
|
await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
|
|
315
355
|
}
|
|
316
356
|
catch (err) {
|
|
317
|
-
|
|
357
|
+
await undo();
|
|
358
|
+
return { ...nothing, problem: `the exclude file could not be written: ${err.message}` };
|
|
318
359
|
}
|
|
319
|
-
return
|
|
360
|
+
return { undo };
|
|
320
361
|
}
|
|
321
362
|
/** The commit message: what was taken, what was left out, from where, and on whose word. */
|
|
322
363
|
function snapshotMessage(session, plan, taken, leftOut, approved) {
|
|
@@ -327,7 +368,7 @@ function snapshotMessage(session, plan, taken, leftOut, approved) {
|
|
|
327
368
|
return [
|
|
328
369
|
onlyInputs ? 'Capture user-provided inputs' : 'Capture operator baseline before run',
|
|
329
370
|
'',
|
|
330
|
-
`The ${onlyInputs ? "operator's input files" : 'uncommitted changes in the repository'}
|
|
371
|
+
`The ${onlyInputs ? "operator's input files" : 'uncommitted changes in the repository'} of session "${session.name}", ${approved ? 'approved by the operator and ' : ''}committed before its first task so that`,
|
|
331
372
|
"the session starts from them and every task's commit shows only that task's work.",
|
|
332
373
|
...(inputs.length > 0
|
|
333
374
|
? ['', `Input files, read by the work and not changed by it (${inputs.length}):`, list(inputs.map((e) => `${e.path}${e.size !== undefined ? ` (${e.size} bytes, ${e.kind})` : ''}`))]
|
|
@@ -361,9 +402,9 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
|
|
|
361
402
|
return { ok: false, problem: plan.problem ?? 'the input files cannot be taken.' };
|
|
362
403
|
const paths = plan.entries.map((e) => e.path);
|
|
363
404
|
if (opts.approved) {
|
|
364
|
-
const
|
|
365
|
-
if (
|
|
366
|
-
return { ok: false, problem:
|
|
405
|
+
const wrong = approvalMismatch(paths, opts.choices ?? {}, { allMustBeIncluded: true });
|
|
406
|
+
if (wrong) {
|
|
407
|
+
return { ok: false, problem: wrong };
|
|
367
408
|
}
|
|
368
409
|
}
|
|
369
410
|
const made = await recaptureInputs(session, plan.repoDir, paths, plan.recapture);
|
|
@@ -405,11 +446,9 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
|
|
|
405
446
|
const chosen = new Map();
|
|
406
447
|
if (opts.approved) {
|
|
407
448
|
const choices = opts.choices ?? {};
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
return { ok: false, problem: 'the uncommitted files changed since the list was shown. Look at the list again; nothing was done.' };
|
|
412
|
-
}
|
|
449
|
+
const wrong = approvalMismatch(plan.entries.map((e) => e.path), choices);
|
|
450
|
+
if (wrong)
|
|
451
|
+
return { ok: false, problem: wrong };
|
|
413
452
|
for (const e of plan.entries) {
|
|
414
453
|
const c = choices[e.path];
|
|
415
454
|
if (!c || !e.allowed.includes(c))
|
|
@@ -436,22 +475,26 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
|
|
|
436
475
|
if (take.length === 0)
|
|
437
476
|
return { ok: false, problem: 'nothing would be taken: every file is left out. Leave them out yourself, or take at least one.' };
|
|
438
477
|
const excluded = await leaveOut(top, session, leaveOutPaths);
|
|
439
|
-
if (excluded)
|
|
440
|
-
return { ok: false, problem: excluded };
|
|
478
|
+
if (excluded.problem)
|
|
479
|
+
return { ok: false, problem: excluded.problem };
|
|
441
480
|
// With the left-out files gone from git's view, what is uncommitted must be exactly what is taken.
|
|
442
481
|
const after = await statusEntries(top);
|
|
443
482
|
const wanted = new Set(take.filter((e) => e.kind !== 'ignored').map((e) => e.path));
|
|
444
483
|
const stray = (after ?? []).filter((s) => !wanted.has(s.path)).map((s) => s.path);
|
|
445
484
|
if (!after || stray.length > 0) {
|
|
485
|
+
await excluded.undo();
|
|
446
486
|
return { ok: false, problem: `the repository changed while the snapshot was being taken (${someOf(stray)}). Nothing was committed; look at the list again.` };
|
|
447
487
|
}
|
|
448
488
|
const branch = plan.baselineBranch;
|
|
449
489
|
const cut = await git(top, ['checkout', '-b', branch]);
|
|
450
|
-
if (!cut.ok)
|
|
490
|
+
if (!cut.ok) {
|
|
491
|
+
await excluded.undo();
|
|
451
492
|
return { ok: false, problem: `the branch ${branch} could not be made: ${cut.stderr || cut.stdout}` };
|
|
493
|
+
}
|
|
452
494
|
const back = async (why) => {
|
|
453
495
|
if (plan.branch)
|
|
454
496
|
await git(top, ['checkout', plan.branch]);
|
|
497
|
+
await excluded.undo();
|
|
455
498
|
return { ok: false, problem: `${why} Nothing was committed; the repository is back on ${plan.branch ?? 'where it was'} with your changes as they were.` };
|
|
456
499
|
};
|
|
457
500
|
// Literal paths from a file: no glob in a name is read as a pattern, and no command line gets too long.
|
|
@@ -487,6 +530,8 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
|
|
|
487
530
|
included: take.map((e) => e.path).slice(0, 500),
|
|
488
531
|
leftOut: leftOutAll.slice(0, 500),
|
|
489
532
|
approved: opts.approved,
|
|
533
|
+
// When, so the record and the run log say it (live run 2026-10-03: never timestamped).
|
|
534
|
+
...(opts.approved ? { approvedAt: new Date().toISOString() } : {}),
|
|
490
535
|
},
|
|
491
536
|
};
|
|
492
537
|
await saveSession((s) => {
|
|
@@ -14,9 +14,7 @@
|
|
|
14
14
|
* Nothing here writes to the repository or the network. `git fetch` is in the command, not here:
|
|
15
15
|
* what "would be lost" is computed against the remote branch as last fetched, and the page says so.
|
|
16
16
|
*/
|
|
17
|
-
import {
|
|
18
|
-
import { join } from 'node:path';
|
|
19
|
-
import { git, porcelainPaths } from './git.js';
|
|
17
|
+
import { git, lastFetchedAt, porcelainPaths } from './git.js';
|
|
20
18
|
/** A string as a single-quoted PowerShell literal: nothing inside it is expanded. */
|
|
21
19
|
export function psQuote(text) {
|
|
22
20
|
return `'${text.replace(/'/g, "''")}'`;
|
|
@@ -77,14 +75,8 @@ export async function planSync(repoDir) {
|
|
|
77
75
|
const commits = await git(repoDir, ['log', '--format=%h %s', `${target}..HEAD`]);
|
|
78
76
|
const status = await git(repoDir, ['status', '--porcelain', '--untracked-files=no']);
|
|
79
77
|
const clean = await git(repoDir, ['clean', '-nd']);
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
|
|
83
|
-
lastFetched = statSync(join(gitDir, 'FETCH_HEAD')).mtime.toISOString();
|
|
84
|
-
}
|
|
85
|
-
catch {
|
|
86
|
-
/* never fetched: the page says so */
|
|
87
|
-
}
|
|
78
|
+
const gitDir = (await git(repoDir, ['rev-parse', '--absolute-git-dir'])).stdout;
|
|
79
|
+
const lastFetched = gitDir ? lastFetchedAt(gitDir) : undefined;
|
|
88
80
|
return {
|
|
89
81
|
ok: true,
|
|
90
82
|
repoDir,
|