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.
Files changed (56) hide show
  1. package/dist/src/api/operator.service.js +33 -2
  2. package/dist/src/cli.js +11 -29
  3. package/dist/src/config/version.js +30 -0
  4. package/dist/src/orchestrator/derivedChecks.js +19 -1
  5. package/dist/src/orchestrator/review.js +2 -2
  6. package/dist/src/orchestrator/taskRunner.js +130 -10
  7. package/dist/src/plan/brief.js +38 -4
  8. package/dist/src/plan/importPlan.js +42 -1
  9. package/dist/src/plan/schema.js +53 -0
  10. package/dist/src/plan/systemGuide.js +50 -2
  11. package/dist/src/protocol/replySchema.js +1 -1
  12. package/dist/src/protocol/reviewSchema.js +10 -5
  13. package/dist/src/session/exports.js +53 -13
  14. package/dist/src/session/model.js +20 -0
  15. package/dist/src/session/store.js +80 -0
  16. package/dist/src/vcs/artifacts.js +185 -0
  17. package/dist/src/vcs/inputs.js +449 -0
  18. package/dist/src/vcs/snapshot.js +174 -22
  19. package/dist/src/vcs/taskVcs.js +272 -37
  20. package/dist/web/404.html +2 -2
  21. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
  22. package/dist/web/_next/static/chunks/867-d9c54f7a2b285b3f.js +1 -0
  23. package/dist/web/_next/static/chunks/app/appearance/page-a18019cdc395de3d.js +1 -0
  24. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
  25. package/dist/web/_next/static/chunks/app/layout-3efbb282292ccc38.js +1 -0
  26. package/dist/web/_next/static/chunks/app/sessions/view/page-99f9153fd75078d0.js +1 -0
  27. package/dist/web/_next/static/chunks/app/system/page-5ae1f8054208e6eb.js +1 -0
  28. package/dist/web/appearance.html +2 -2
  29. package/dist/web/appearance.txt +25 -26
  30. package/dist/web/defaults.html +2 -2
  31. package/dist/web/defaults.txt +25 -26
  32. package/dist/web/history.html +2 -2
  33. package/dist/web/history.txt +25 -26
  34. package/dist/web/import.html +2 -2
  35. package/dist/web/import.txt +25 -26
  36. package/dist/web/index.html +2 -2
  37. package/dist/web/index.txt +25 -26
  38. package/dist/web/level1.html +2 -2
  39. package/dist/web/level1.txt +25 -26
  40. package/dist/web/presets.html +2 -2
  41. package/dist/web/presets.txt +25 -26
  42. package/dist/web/sessions/view.html +2 -2
  43. package/dist/web/sessions/view.txt +25 -26
  44. package/dist/web/system.html +2 -2
  45. package/dist/web/system.txt +25 -26
  46. package/package.json +8 -3
  47. package/prompts/level1.md +547 -546
  48. package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +0 -1
  49. package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +0 -1
  50. package/dist/web/_next/static/chunks/app/appearance/page-9b13583bfee63b32.js +0 -1
  51. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
  52. package/dist/web/_next/static/chunks/app/layout-560bd6d173302e5a.js +0 -1
  53. package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +0 -1
  54. package/dist/web/_next/static/chunks/app/system/page-3aa2a0fef5454e69.js +0 -1
  55. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_buildManifest.js +0 -0
  56. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → ofnRLEgWx9th5w_qtuFnB}/_ssgManifest.js +0 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Evidence the work produces — reports, ZIP archives, test results — kept with the run, not in git.
3
+ *
4
+ * Found in a real run: evidence a task wrote was either committed with the work or, to stop that,
5
+ * `.gitignore` had to be changed for it, and a later session that needed an earlier one's discovery
6
+ * report had nothing reliable to read. `vcs.artifacts.paths` names those files, and:
7
+ *
8
+ * - they are never committed: the patterns go into the repository's own `.git/info/exclude`
9
+ * (local, never committed, `.gitignore` untouched) when the session's first task starts, so the
10
+ * runner's commit and a task's scope both leave them alone. A file git already tracks is not
11
+ * affected by an exclude, and is said so;
12
+ * - when each task ends, whatever its outcome, the files are copied into the attempt's record,
13
+ * `artifacts/project/<path>`, with their sizes and SHA-256 sums on the task — so the evidence of
14
+ * each attempt is kept even after a later one overwrites it in the project.
15
+ *
16
+ * Works with version control off too: then nothing is excluded, only kept.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { copyFile, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
20
+ import { dirname, join, resolve, sep } from 'node:path';
21
+ import { looksGenerated } from './commitHygiene.js';
22
+ import { git } from './git.js';
23
+ import { normalisePatterns } from './inputs.js';
24
+ import { inScope } from './scope.js';
25
+ /**
26
+ * The patterns among `patterns` whose files an artifact pattern would cover, and so keep out of git.
27
+ * Judged by a path the pattern stands for (`**` and `*` filled in). Found in a run on 0.1.18: an
28
+ * artifact pattern of a whole project folder covered the input files and the task's own work, every
29
+ * file the task wrote was ignored, and "nothing to commit" ended it done.
30
+ */
31
+ export function coveredByArtifacts(patterns, artifacts) {
32
+ const arts = normalisePatterns(artifacts);
33
+ if (arts.length === 0)
34
+ return [];
35
+ return normalisePatterns(patterns).filter((p) => {
36
+ const sample = p.replace(/\/+$/, '/f').replace(/\*\*/g, 'a/b').replace(/[*?]/g, 'x');
37
+ return inScope(sample, arts);
38
+ });
39
+ }
40
+ /** The session's artifact patterns, without ones that name the whole project. */
41
+ export function artifactPatterns(vcs) {
42
+ return normalisePatterns(vcs?.artifacts?.paths ?? []);
43
+ }
44
+ /** A pattern for `.git/info/exclude`: anchored at the repository's top, as a scope pattern is. */
45
+ function excludeLine(pattern) {
46
+ return `/${pattern.replace(/^\/+/, '')}`;
47
+ }
48
+ /**
49
+ * Writes the session's artifact patterns into `.git/info/exclude`, once each. Returns the files
50
+ * matching them that git already tracks, which an exclude does not stop from being committed.
51
+ */
52
+ export async function excludeArtifacts(dir, session) {
53
+ const patterns = artifactPatterns(session.vcs);
54
+ if (patterns.length === 0)
55
+ return { tracked: [] };
56
+ const where = await git(dir, ['rev-parse', '--git-path', 'info/exclude']);
57
+ if (!where.ok || !where.stdout)
58
+ return { problem: "the repository's exclude file could not be found", tracked: [] };
59
+ const top = (await git(dir, ['rev-parse', '--show-toplevel'])).stdout || dir;
60
+ const file = resolve(dir, where.stdout);
61
+ const before = await readFile(file, 'utf8').catch(() => '');
62
+ const have = new Set(before.split(/\r?\n/).map((l) => l.trim()));
63
+ const add = patterns.map(excludeLine).filter((l) => !have.has(l));
64
+ if (add.length > 0) {
65
+ try {
66
+ await mkdir(dirname(file), { recursive: true });
67
+ const block = [`# copilot-operator: artifacts of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}) — kept with the run, never committed`, ...add, ''].join('\n');
68
+ await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
69
+ }
70
+ catch (e) {
71
+ return { problem: `the exclude file could not be written: ${e.message}`, tracked: [] };
72
+ }
73
+ }
74
+ const tracked = (await git(resolve(top), ['ls-files'])).stdout.split('\n').filter((p) => p && inScope(p, patterns));
75
+ return { tracked };
76
+ }
77
+ const LIMITS = { files: 1000, fileBytes: 100 * 1024 * 1024, totalBytes: 500 * 1024 * 1024, listed: 20_000 };
78
+ /**
79
+ * The artifact files as they are before a task, so that what is kept afterwards is what the task
80
+ * made or changed. Found in a real run: a broad pattern kept every file already under it, evidence
81
+ * of earlier sessions included, as if this task had produced it.
82
+ */
83
+ export async function artifactState(root, patterns) {
84
+ const state = new Map();
85
+ if (patterns.length === 0 || !root)
86
+ return state;
87
+ for (const rel of await matching(root, patterns)) {
88
+ const info = await stat(join(root, rel)).catch(() => null);
89
+ if (info)
90
+ state.set(rel, `${info.size}:${info.mtimeMs}`);
91
+ }
92
+ return state;
93
+ }
94
+ /** Files under `root` matching the patterns, found on disk: git's view does not matter for evidence. */
95
+ async function matching(root, patterns) {
96
+ const out = [];
97
+ const starts = new Set(patterns.map((p) => {
98
+ const fixed = [];
99
+ for (const part of p.replace(/\/+$/, '').split('/')) {
100
+ if (/[*?[]/.test(part))
101
+ break;
102
+ fixed.push(part);
103
+ }
104
+ return fixed.join('/');
105
+ }));
106
+ const walk = async (rel) => {
107
+ if (out.length > LIMITS.listed)
108
+ return;
109
+ const abs = join(root, rel);
110
+ const info = await stat(abs).catch(() => null);
111
+ if (!info)
112
+ return;
113
+ if (info.isFile()) {
114
+ if (inScope(rel, patterns))
115
+ out.push(rel);
116
+ return;
117
+ }
118
+ if (!info.isDirectory())
119
+ return;
120
+ const name = rel.split('/').pop() ?? '';
121
+ if (name === '.git' || name === 'node_modules' || name === '.copilot-operator')
122
+ return;
123
+ for (const child of await readdir(abs).catch(() => []))
124
+ await walk(rel ? `${rel}/${child}` : child);
125
+ };
126
+ for (const start of starts)
127
+ await walk(start);
128
+ return [...new Set(out)].sort();
129
+ }
130
+ /**
131
+ * Copies into the attempt's record, `<dest>/<path>`, the artifact files this task made or changed:
132
+ * under the session's artifact patterns or the task's own `outputs`, new or changed since `before`
133
+ * (`artifactState` at the task's start), and inside the task's scope when it has one (its outputs
134
+ * always count). A file already there and untouched is not this task's evidence and is not kept.
135
+ * Secrets are never copied; past the limits the rest is skipped and said.
136
+ */
137
+ export async function keepArtifacts(root, patterns, dest, opts = {}) {
138
+ const kept = [];
139
+ const skipped = [];
140
+ const outsideScope = [];
141
+ let unchanged = 0;
142
+ const outputs = normalisePatterns(opts.outputs ?? []);
143
+ const scope = opts.scope ?? [];
144
+ const all = [...new Set([...patterns, ...outputs])];
145
+ if (all.length === 0 || !root)
146
+ return { kept, skipped, unchanged, outsideScope };
147
+ let total = 0;
148
+ const base = resolve(root);
149
+ for (const rel of await matching(root, all)) {
150
+ const from = resolve(join(root, rel));
151
+ if (!from.startsWith(base + sep))
152
+ continue;
153
+ if (looksGenerated(rel)?.reason === 'a secrets file') {
154
+ skipped.push(`${rel} (a secrets file)`);
155
+ continue;
156
+ }
157
+ const info = await stat(from).catch(() => null);
158
+ if (!info)
159
+ continue;
160
+ if (opts.before && opts.before.get(rel) === `${info.size}:${info.mtimeMs}`) {
161
+ unchanged += 1;
162
+ continue;
163
+ }
164
+ if (scope.length > 0 && !inScope(rel, scope) && !(outputs.length > 0 && inScope(rel, outputs))) {
165
+ outsideScope.push(rel);
166
+ continue;
167
+ }
168
+ if (kept.length >= LIMITS.files || info.size > LIMITS.fileBytes || total + info.size > LIMITS.totalBytes) {
169
+ skipped.push(`${rel} (past the limit of ${LIMITS.files} files, ${LIMITS.fileBytes / 1048576} MB a file, ${LIMITS.totalBytes / 1048576} MB in all)`);
170
+ continue;
171
+ }
172
+ const to = join(dest, rel);
173
+ try {
174
+ await mkdir(dirname(to), { recursive: true });
175
+ await copyFile(from, to);
176
+ const bytes = await readFile(to);
177
+ kept.push({ path: rel, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') });
178
+ total += info.size;
179
+ }
180
+ catch (e) {
181
+ skipped.push(`${rel} (${e.message})`);
182
+ }
183
+ }
184
+ return { kept, skipped, unchanged, outsideScope };
185
+ }
@@ -0,0 +1,449 @@
1
+ /**
2
+ * The operator's input files: in every session's starting commit, and not the bot's to change.
3
+ *
4
+ * Found in a real run: YAML schemas the work was built from were put into the project by hand and
5
+ * left untracked. Branches did not carry them predictably, a task could not tell "input from the
6
+ * operator" from "change made by the bot", and a session started from `main` began without them
7
+ * although they were in other commits. `vcs.userInputs` names them, and three things follow:
8
+ *
9
+ * - Before a session's first task they are in the commit it starts from. New, changed or ignored
10
+ * ones go into the starting snapshot (`snapshot.ts`), with the operator's approval. A session
11
+ * whose start lacks them — `startFrom: "branch"` after a session that captured them — has them
12
+ * committed on top of that start, "Capture user-provided inputs" on `<prefix>input/<session id>`,
13
+ * from where they were last committed: the repository's HEAD, else the latest session in the same
14
+ * repository that recorded them. Every session of a run sees the same inputs.
15
+ * - Their SHA-256 sums are recorded on the session's start, for the record and the exports.
16
+ * - Read-only, unless switched off: after every round of steps a changed, removed or added input
17
+ * file is put back from the task's starting commit and the chat is told; a task that ends with
18
+ * one that could not be put back fails.
19
+ *
20
+ * The commit on top of a start is made without touching the working tree or the index (a temporary
21
+ * index, `commit-tree`), so the operator's checkout is never disturbed to make it.
22
+ */
23
+ import { createHash } from 'node:crypto';
24
+ import { rm, stat } from 'node:fs/promises';
25
+ import { tmpdir } from 'node:os';
26
+ import { join, resolve, sep } from 'node:path';
27
+ import { looksGenerated } from './commitHygiene.js';
28
+ import { branchNameFrom, freeBranchName, git, gitBytes, isValidBranchName, RUNNER_EMAIL } from './git.js';
29
+ import { inScope } from './scope.js';
30
+ /** The session's input settings with defaults filled in, or null when it names no inputs. */
31
+ export function inputSettings(vcs) {
32
+ const patterns = normalisePatterns(vcs?.userInputs?.paths ?? []);
33
+ if (patterns.length === 0)
34
+ return null;
35
+ return { paths: patterns, patterns, readOnly: vcs?.userInputs?.readOnly !== false, requireApproval: vcs?.userInputs?.requireApproval !== false };
36
+ }
37
+ /**
38
+ * Patterns as the matching expects them, without the ones that name everything: an input is a file
39
+ * the operator can point at, and "the whole repository" would make every change the bot makes a
40
+ * change to an input.
41
+ */
42
+ export function normalisePatterns(paths) {
43
+ const out = new Set();
44
+ for (const raw of paths) {
45
+ const p = raw.trim().replace(/\\/g, '/').replace(/^\.?\/+/, '');
46
+ if (!p || p === '.' || /^[*/]+$/.test(p))
47
+ continue;
48
+ out.add(p);
49
+ }
50
+ return [...out];
51
+ }
52
+ /** Whether a pattern names everything, which an input pattern may not. */
53
+ export function namesEverything(pattern) {
54
+ const p = pattern.trim().replace(/\\/g, '/').replace(/^\.?\/+/, '');
55
+ return !p || p === '.' || /^[*/]+$/.test(p);
56
+ }
57
+ /** The folder part of a pattern before its first wildcard, for narrowing what git lists. */
58
+ function literalPrefix(pattern) {
59
+ const parts = pattern.replace(/\/+$/, '').split('/');
60
+ const fixed = [];
61
+ for (const part of parts) {
62
+ if (/[*?[]/.test(part))
63
+ break;
64
+ fixed.push(part);
65
+ }
66
+ return fixed.join('/');
67
+ }
68
+ function pathspecs(patterns) {
69
+ const prefixes = [...new Set(patterns.map(literalPrefix))];
70
+ return prefixes.includes('') ? [] : prefixes.map((p) => `:(icase)${p}`);
71
+ }
72
+ const list = async (top, args) => {
73
+ const r = await gitBytes(top, args, 60_000, 64 * 1024 * 1024);
74
+ return r.ok ? r.stdout.toString('utf8').split('\0').filter(Boolean) : [];
75
+ };
76
+ /** The input files in the working tree, each with what git thinks of it. */
77
+ export async function inputFilesInTree(top, patterns) {
78
+ const specs = pathspecs(patterns);
79
+ const tail = specs.length > 0 ? ['--', ...specs] : [];
80
+ const seen = new Map();
81
+ for (const path of await list(top, ['ls-files', '-z', '--cached', ...tail]))
82
+ seen.set(path, 'tracked');
83
+ for (const path of await list(top, ['ls-files', '-z', '--others', '--exclude-standard', ...tail]))
84
+ if (!seen.has(path))
85
+ seen.set(path, 'untracked');
86
+ for (const path of await list(top, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', ...tail])) {
87
+ // Installed or built folders are never inputs, however wide the pattern.
88
+ if (!seen.has(path) && !looksGenerated(path)?.key.endsWith('/'))
89
+ seen.set(path, 'ignored');
90
+ }
91
+ const out = [];
92
+ for (const [path, kind] of seen) {
93
+ if (!inScope(path, patterns))
94
+ continue;
95
+ // A tracked file deleted from the tree is still listed by --cached; it is not there to read.
96
+ if (kind === 'tracked' && !(await stat(join(top, path)).catch(() => null))?.isFile())
97
+ continue;
98
+ out.push({ path, kind });
99
+ }
100
+ return out.sort((a, b) => a.path.localeCompare(b.path));
101
+ }
102
+ /** The input files in one commit: path, mode and blob. */
103
+ async function inputsAt(top, commit, patterns) {
104
+ const specs = pathspecs(patterns);
105
+ const out = [];
106
+ for (const rec of await list(top, ['ls-tree', '-r', '-z', '--full-tree', commit, ...(specs.length > 0 ? ['--', ...specs.map((s) => s.replace(':(icase)', ''))] : [])])) {
107
+ const tab = rec.indexOf('\t');
108
+ const [mode = '', type = '', blob = ''] = rec.slice(0, tab).split(' ');
109
+ const path = rec.slice(tab + 1);
110
+ if (type === 'blob' && inScope(path, patterns))
111
+ out.push({ path, mode, blob });
112
+ }
113
+ // `ls-tree` pathspecs are case-sensitive; a pattern written in another case is matched in full.
114
+ if (out.length === 0 && specs.length > 0) {
115
+ for (const rec of await list(top, ['ls-tree', '-r', '-z', '--full-tree', commit])) {
116
+ const tab = rec.indexOf('\t');
117
+ const [mode = '', type = '', blob = ''] = rec.slice(0, tab).split(' ');
118
+ const path = rec.slice(tab + 1);
119
+ if (type === 'blob' && inScope(path, patterns))
120
+ out.push({ path, mode, blob });
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+ const sha = (bytes) => createHash('sha256').update(bytes).digest('hex');
126
+ /** The sums of the input files as one commit has them. */
127
+ export async function inputSumsAt(top, commit, patterns) {
128
+ const out = [];
129
+ for (const f of await inputsAt(top, commit, patterns)) {
130
+ const r = await gitBytes(top, ['cat-file', 'blob', f.blob], 120_000, 512 * 1024 * 1024);
131
+ if (r.ok)
132
+ out.push({ path: f.path, sha256: sha(r.stdout), size: r.stdout.length, blob: f.blob });
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * The blob id the working-tree file would be committed as, or null when it is not there.
138
+ *
139
+ * Compared by git's own hashing, with its line-ending conversion, rather than by the bytes: with
140
+ * `core.autocrlf` — usual on Windows — a checked-out file has CRLF where the commit has LF, and a
141
+ * byte comparison called every untouched input changed, every round.
142
+ */
143
+ export async function blobOfFile(top, path) {
144
+ if (!(await stat(join(top, path)).catch(() => null))?.isFile())
145
+ return null;
146
+ const r = await git(top, ['hash-object', `--path=${path}`, '--', join(top, path)]);
147
+ return r.ok && r.stdout ? r.stdout : null;
148
+ }
149
+ const norm = (dir) => dir.trim().replace(/[\\/]+$/, '').replace(/\//g, '\\').toLowerCase();
150
+ /**
151
+ * Makes `parent` plus the given files one commit, on a new branch, without touching the working
152
+ * tree or the index: a temporary index is read from `parent`, the files are set in it, and the
153
+ * tree is committed with `commit-tree`.
154
+ */
155
+ async function commitOnto(top, parent, files, message, branch) {
156
+ const index = join(tmpdir(), `cop-inputs-index-${process.pid}-${Date.now()}`);
157
+ const env = { ...process.env, GIT_INDEX_FILE: index };
158
+ try {
159
+ const read = await git(top, ['read-tree', parent], 60_000, env);
160
+ if (!read.ok)
161
+ return { problem: read.stderr || 'git read-tree failed' };
162
+ for (const f of files) {
163
+ const set = await git(top, ['update-index', '--add', '--cacheinfo', `${f.mode},${f.blob},${f.path}`], 60_000, env);
164
+ if (!set.ok)
165
+ return { problem: `${f.path}: ${set.stderr || 'git update-index failed'}` };
166
+ }
167
+ const tree = await git(top, ['write-tree'], 60_000, env);
168
+ if (!tree.ok || !tree.stdout)
169
+ return { problem: tree.stderr || 'git write-tree failed' };
170
+ const commit = await git(top, ['-c', 'user.name=copilot-operator', '-c', `user.email=${RUNNER_EMAIL}`, 'commit-tree', tree.stdout, '-p', parent, '-m', message]);
171
+ if (!commit.ok || !commit.stdout)
172
+ return { problem: commit.stderr || 'git commit-tree failed' };
173
+ if (branch) {
174
+ const made = await git(top, ['branch', branch, commit.stdout]);
175
+ if (!made.ok)
176
+ return { problem: made.stderr || `the branch ${branch} could not be made` };
177
+ }
178
+ return { commit: commit.stdout };
179
+ }
180
+ finally {
181
+ await rm(index, { force: true });
182
+ }
183
+ }
184
+ /**
185
+ * Where each input pattern's files are committed: the repository's HEAD first, then `start` (a
186
+ * snapshot approved on the page has them, wherever HEAD has moved since), then the latest earlier
187
+ * session in this repository that recorded them. A pattern found nowhere is `missing`.
188
+ */
189
+ async function inputSources(session, dir, top, patterns, start, allSessions) {
190
+ const head = (await git(top, ['rev-parse', '--verify', '--quiet', 'HEAD'])).stdout;
191
+ const wanted = [];
192
+ const sources = new Set();
193
+ const missing = [];
194
+ const atHead = head ? await inputsAt(top, head, patterns) : [];
195
+ const earlier = (await allSessions())
196
+ .filter((s) => s.id !== session.id && s.vcsStart?.inputs && norm(s.vcs?.repoDir || s.projectDir || '') === norm(dir))
197
+ // The newest first: a later capture is the operator's later word on the inputs.
198
+ .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
199
+ .map((s) => s.vcsStart);
200
+ for (const pattern of patterns) {
201
+ let found = atHead.filter((f) => inScope(f.path, [pattern]));
202
+ let from = head;
203
+ if (found.length === 0 && start) {
204
+ found = (await inputsAt(top, start, [pattern])).filter((f) => inScope(f.path, [pattern]));
205
+ from = start;
206
+ }
207
+ for (const s of earlier) {
208
+ if (found.length > 0)
209
+ break;
210
+ found = (await inputsAt(top, s.commit, [pattern])).filter((f) => inScope(f.path, [pattern]));
211
+ from = s.commit;
212
+ }
213
+ if (found.length === 0)
214
+ missing.push(pattern);
215
+ else
216
+ sources.add(from);
217
+ for (const f of found)
218
+ if (!wanted.some((w) => w.path === f.path))
219
+ wanted.push(f);
220
+ }
221
+ return { wanted, sources, missing };
222
+ }
223
+ /**
224
+ * The input patterns that match no file anywhere — not in the working tree, not committed, not
225
+ * captured by an earlier session. Asked before a snapshot is approved: found only afterwards, the
226
+ * snapshot was already taken when the session was refused for it.
227
+ */
228
+ export async function missingInputPatterns(session, dir, top, allSessions) {
229
+ const settings = inputSettings(session.vcs);
230
+ if (!settings)
231
+ return [];
232
+ const inTree = await inputFilesInTree(top, settings.patterns);
233
+ const notInTree = settings.patterns.filter((p) => !inTree.some((f) => inScope(f.path, [p])));
234
+ if (notInTree.length === 0)
235
+ return [];
236
+ return (await inputSources(session, dir, top, notInTree, session.vcsStart?.commit, allSessions)).missing;
237
+ }
238
+ /**
239
+ * Makes sure the commit a session starts from has the operator's input files, and records their
240
+ * sums on the start. Returns the start to use — the same one, or one whose commit has the inputs
241
+ * committed on top — or why the session cannot start.
242
+ *
243
+ * Called once the start is known and the working tree holds nothing uncommitted of the inputs (new
244
+ * or changed ones went into the snapshot before this). Where the inputs are taken from: the
245
+ * repository's HEAD for every pattern it has files for, else the latest session in this same
246
+ * repository whose start recorded files for that pattern.
247
+ */
248
+ export async function settleInputs(session, dir, start, allSessions) {
249
+ const settings = inputSettings(session.vcs);
250
+ if (!settings)
251
+ return { start };
252
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
253
+ if (!topR.ok || !topR.stdout)
254
+ return { problem: `${dir} is not a git repository.` };
255
+ const top = resolve(topR.stdout);
256
+ const { wanted, sources, missing } = await inputSources(session, dir, top, settings.patterns, start.commit, allSessions);
257
+ if (missing.length > 0) {
258
+ return {
259
+ problem: `the input file pattern(s) ${missing.map((m) => `"${m}"`).join(', ')} match no file: not in the working tree, not in the repository's HEAD, ` +
260
+ 'and no earlier session in this repository captured them. Put the files in the project (they will be listed for your approval), or correct "Input files".',
261
+ };
262
+ }
263
+ // What the start has already, file by file; only what differs is committed on top.
264
+ const inStart = new Map((await inputsAt(top, start.commit, settings.patterns)).map((f) => [f.path, f]));
265
+ const differ = wanted.filter((w) => inStart.get(w.path)?.blob !== w.blob);
266
+ let commit = start.commit;
267
+ let carried;
268
+ if (differ.length > 0) {
269
+ const branch = await freeBranchName(top, `${branchNameFrom(['input'], session.vcs?.branchPrefix || 'cop/')}/${session.id}`);
270
+ if (!(await isValidBranchName(top, branch)))
271
+ return { problem: `"${branch}" is not a name git accepts.` };
272
+ const from = [...sources].map((s) => s.slice(0, 8)).join(', ');
273
+ const message = [
274
+ 'Capture user-provided inputs',
275
+ '',
276
+ `The operator's input files for session "${session.name}", which the commit it starts from (${start.commit.slice(0, 8)}) did not have,`,
277
+ `taken from ${from}, where they were last committed. They are inputs: the work reads them and does not change them.`,
278
+ '',
279
+ ...differ.slice(0, 200).map((f) => `- ${f.path}`),
280
+ '',
281
+ 'Committed by copilot-operator. Not pushed.',
282
+ '',
283
+ ].join('\n');
284
+ const made = await commitOnto(top, start.commit, differ, message, branch);
285
+ if ('problem' in made)
286
+ return { problem: `the input files could not be committed on top of the session's start: ${made.problem}` };
287
+ commit = made.commit;
288
+ carried = { onto: start.commit, from, branch };
289
+ }
290
+ const files = await inputSumsAt(top, commit, settings.patterns);
291
+ return { start: { ...start, commit, inputs: { patterns: settings.patterns, files, readOnly: settings.readOnly, ...(carried ? { carried } : {}) } } };
292
+ }
293
+ /**
294
+ * The input files in the working tree that the session's record does not have as they are: new
295
+ * since it was taken, changed since, or never recorded and not committed. Committed and unchanged
296
+ * ones are not changes.
297
+ */
298
+ export async function inputChanges(top, patterns, recorded) {
299
+ const known = new Map((recorded ?? []).map((f) => [f.path, f.blob]));
300
+ const out = [];
301
+ for (const f of await inputFilesInTree(top, patterns)) {
302
+ const now = await blobOfFile(top, f.path);
303
+ if (!now || known.get(f.path) === now)
304
+ continue;
305
+ if (!known.has(f.path) && f.kind === 'tracked' && (await git(top, ['rev-parse', '--verify', '--quiet', `HEAD:${f.path}`])).stdout === now)
306
+ continue;
307
+ out.push(f);
308
+ }
309
+ return out;
310
+ }
311
+ /**
312
+ * Commits input files that changed after the session started, on its line of work, and moves the
313
+ * working tree onto that commit without losing a byte: per session, on top of the session's branch;
314
+ * per task, on top of the commit every task branch is cut from, on a branch of its own that becomes
315
+ * that commit. The files' bytes go in as git would store them (`hash-object -w --path`), the commit
316
+ * is made with a temporary index, and the files are then staged as they are, so the switch onto the
317
+ * commit keeps them. Asked for by the operator's approval, or without it when `requireApproval` is
318
+ * false. Found in a run on 0.1.18: inputs edited or added once the session had started were "a dirty
319
+ * tree to commit or stash", version control went off, and the task ran without it.
320
+ */
321
+ export async function recaptureInputs(session, dir, paths, target) {
322
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
323
+ if (!topR.ok || !topR.stdout)
324
+ return { problem: `${dir} is not a git repository.` };
325
+ const top = resolve(topR.stdout);
326
+ const files = [];
327
+ for (const path of paths) {
328
+ const blob = await git(top, ['hash-object', '-w', `--path=${path}`, '--', join(top, path)]);
329
+ if (!blob.ok || !blob.stdout)
330
+ return { problem: `${path} could not be read into git: ${blob.stderr}` };
331
+ const mode = (await git(top, ['ls-tree', target.parent, '--', path])).stdout.split(' ')[0] || '100644';
332
+ files.push({ path, mode, blob: blob.stdout });
333
+ }
334
+ const message = [
335
+ 'Capture user-provided inputs',
336
+ '',
337
+ `The operator's input files for session "${session.name}" as they are now, changed or added after the session started.`,
338
+ 'They are inputs: the work reads them and does not change them.',
339
+ '',
340
+ ...paths.slice(0, 200).map((p) => `- ${p}`),
341
+ '',
342
+ 'Committed by copilot-operator. Not pushed.',
343
+ '',
344
+ ].join('\n');
345
+ const made = await commitOnto(top, target.parent, files, message, target.newBranch);
346
+ if ('problem' in made)
347
+ return { problem: `the input files could not be committed: ${made.problem}` };
348
+ const branch = target.ffBranch ?? target.newBranch;
349
+ if (target.ffBranch) {
350
+ // Forward only, from the tip it was read at: nothing on the branch is lost or rewritten.
351
+ const moved = await git(top, ['update-ref', `refs/heads/${target.ffBranch}`, made.commit, target.parent]);
352
+ if (!moved.ok)
353
+ return { problem: `${target.ffBranch} could not be moved onto the inputs' commit: ${moved.stderr}` };
354
+ }
355
+ const staged = await git(top, ['--literal-pathspecs', 'add', '-f', '--', ...paths]);
356
+ if (!staged.ok)
357
+ return { problem: `the input files could not be staged: ${staged.stderr}` };
358
+ const on = (await git(top, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout;
359
+ if (on !== branch) {
360
+ const moved = await git(top, ['checkout', '-q', branch]);
361
+ if (!moved.ok)
362
+ return { problem: `the repository could not be put on ${branch}: ${moved.stderr}` };
363
+ }
364
+ return { commit: made.commit, branch };
365
+ }
366
+ /** The input files as one commit has them, for the guard of a task cut from it. */
367
+ export async function inputsAtCommit(dir, commit, inputs) {
368
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
369
+ if (!topR.ok || !topR.stdout)
370
+ return inputs;
371
+ return { ...inputs, files: await inputSumsAt(resolve(topR.stdout), commit, inputs.patterns) };
372
+ }
373
+ /** The files under the input patterns that git does not track, when a task starts: not the task's to remove. */
374
+ export async function untrackedInputs(dir, patterns) {
375
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
376
+ if (!topR.ok || !topR.stdout)
377
+ return new Set();
378
+ return new Set((await inputFilesInTree(resolve(topR.stdout), patterns)).filter((f) => f.kind !== 'tracked').map((f) => f.path));
379
+ }
380
+ /**
381
+ * Puts the input files back as the task's starting commit has them: a changed or removed one from
382
+ * that commit, a new one matching an input pattern removed. Only on the task's own branch, whose
383
+ * starting commit has the inputs (see `settleInputs`).
384
+ */
385
+ export async function protectInputs(dir, base, inputs,
386
+ /**
387
+ * Untracked files under the patterns that were there when the task started: the operator's, not
388
+ * the task's, and never removed. Found in a run on 0.1.18: an input the operator added, ignored by
389
+ * .gitignore and so in no commit, was "put back" by deleting it.
390
+ */
391
+ keep = new Set()) {
392
+ const out = { changed: [], restored: [], failed: [] };
393
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
394
+ if (!topR.ok || !topR.stdout)
395
+ return out;
396
+ const top = resolve(topR.stdout);
397
+ const known = new Map(inputs.files.map((f) => [f.path, f.blob]));
398
+ for (const f of inputs.files) {
399
+ if ((await blobOfFile(top, f.path)) === f.blob)
400
+ continue;
401
+ out.changed.push(f.path);
402
+ const back = await git(top, ['checkout', base, '--', f.path]);
403
+ if (back.ok && (await blobOfFile(top, f.path)) === f.blob)
404
+ out.restored.push(f.path);
405
+ else
406
+ out.failed.push({ path: f.path, why: back.stderr || 'it is not as the starting commit has it' });
407
+ }
408
+ // A file the task added under an input pattern: inputs are the operator's, so it goes.
409
+ for (const { path, kind } of await inputFilesInTree(top, inputs.patterns)) {
410
+ if (known.has(path) || kind === 'tracked' || keep.has(path))
411
+ continue;
412
+ out.changed.push(path);
413
+ const abs = resolve(join(top, path));
414
+ if (!abs.startsWith(resolve(top) + sep)) {
415
+ out.failed.push({ path, why: 'not inside the repository' });
416
+ continue;
417
+ }
418
+ try {
419
+ await rm(abs, { force: true });
420
+ out.restored.push(path);
421
+ }
422
+ catch (e) {
423
+ out.failed.push({ path, why: e.message });
424
+ }
425
+ }
426
+ return out;
427
+ }
428
+ /** What the chat is told when input files were put back. */
429
+ export function inputsMessage(check) {
430
+ const names = (paths) => paths.slice(0, 15).join(', ') + (paths.length > 15 ? `, and ${paths.length - 15} more` : '');
431
+ return [
432
+ `The operator's input files are read-only, so the runner put back: ${names(check.restored)}.`,
433
+ check.failed.length > 0 ? `Could not be put back: ${check.failed.map((f) => `${f.path} (${f.why})`).join('; ')}.` : '',
434
+ 'Read them as they are; do not change, move, delete or add to them. If the work truly needs a different input, end with status "blocked" and say which and why.',
435
+ ]
436
+ .filter(Boolean)
437
+ .join(' ');
438
+ }
439
+ /** The lines the version-control note carries about the inputs. */
440
+ export function inputsNote(inputs) {
441
+ if (!inputs || inputs.files.length === 0)
442
+ return '';
443
+ const shown = inputs.files.slice(0, 20).map((f) => `\`${f.path}\``).join(', ');
444
+ return (`The operator's input files are in your working tree, committed in the starting commit: ${shown}` +
445
+ `${inputs.files.length > 20 ? `, and ${inputs.files.length - 20} more` : ''}. ` +
446
+ (inputs.readOnly
447
+ ? 'They are read-only: read them, never change, move or delete them, and add nothing under their paths. The runner puts them back after every round of steps.'
448
+ : 'They are the work\'s input; change them only if the task says so.'));
449
+ }