copilot-operator 0.1.17 → 0.1.18

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 (48) hide show
  1. package/dist/src/api/operator.service.js +23 -2
  2. package/dist/src/orchestrator/derivedChecks.js +19 -1
  3. package/dist/src/orchestrator/review.js +2 -2
  4. package/dist/src/orchestrator/taskRunner.js +81 -8
  5. package/dist/src/plan/brief.js +26 -0
  6. package/dist/src/plan/importPlan.js +28 -1
  7. package/dist/src/plan/schema.js +38 -0
  8. package/dist/src/plan/systemGuide.js +40 -0
  9. package/dist/src/protocol/replySchema.js +1 -1
  10. package/dist/src/protocol/reviewSchema.js +10 -5
  11. package/dist/src/session/exports.js +44 -13
  12. package/dist/src/session/model.js +20 -0
  13. package/dist/src/session/store.js +78 -0
  14. package/dist/src/vcs/artifacts.js +138 -0
  15. package/dist/src/vcs/inputs.js +332 -0
  16. package/dist/src/vcs/snapshot.js +59 -18
  17. package/dist/src/vcs/taskVcs.js +136 -25
  18. package/dist/web/404.html +2 -2
  19. package/dist/web/_next/static/chunks/549-5d7c845fb0a27dbe.js +1 -0
  20. package/dist/web/_next/static/chunks/867-c078b5b504bf4289.js +1 -0
  21. package/dist/web/_next/static/chunks/app/history/page-a0bd59986f7c8beb.js +1 -0
  22. package/dist/web/_next/static/chunks/app/sessions/view/page-27cb2d544b498d92.js +1 -0
  23. package/dist/web/appearance.html +2 -2
  24. package/dist/web/appearance.txt +11 -11
  25. package/dist/web/defaults.html +2 -2
  26. package/dist/web/defaults.txt +11 -11
  27. package/dist/web/history.html +2 -2
  28. package/dist/web/history.txt +11 -11
  29. package/dist/web/import.html +2 -2
  30. package/dist/web/import.txt +11 -11
  31. package/dist/web/index.html +2 -2
  32. package/dist/web/index.txt +11 -11
  33. package/dist/web/level1.html +2 -2
  34. package/dist/web/level1.txt +11 -11
  35. package/dist/web/presets.html +2 -2
  36. package/dist/web/presets.txt +11 -11
  37. package/dist/web/sessions/view.html +2 -2
  38. package/dist/web/sessions/view.txt +11 -11
  39. package/dist/web/system.html +2 -2
  40. package/dist/web/system.txt +11 -11
  41. package/package.json +7 -3
  42. package/prompts/level1.md +547 -546
  43. package/dist/web/_next/static/chunks/549-a13ae394c5e6a837.js +0 -1
  44. package/dist/web/_next/static/chunks/867-7f27ef819d251cc5.js +0 -1
  45. package/dist/web/_next/static/chunks/app/history/page-d7314ac22d47a3ab.js +0 -1
  46. package/dist/web/_next/static/chunks/app/sessions/view/page-d28825430ca280de.js +0 -1
  47. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → JPmlfAEvbNkbghoyvY9uC}/_buildManifest.js +0 -0
  48. /package/dist/web/_next/static/{kkxFcGzA9Uo1uRAgcoDti → JPmlfAEvbNkbghoyvY9uC}/_ssgManifest.js +0 -0
@@ -21,6 +21,26 @@ export function tidyVcsPlan(plan) {
21
21
  export function isContinuable(t) {
22
22
  return t.status === 'limit-reached' || t.status === 'aborted' || (!!t.limit && (t.status === 'failed' || t.status === 'blocked'));
23
23
  }
24
+ /**
25
+ * How many times the runner ran the task again in a fresh conversation, on its own, in the run that
26
+ * produced its latest attempt (`limits.retryBlockedInFreshChat`): the attempts at the end of its
27
+ * history that it marked `freshRetry`, counted back to the one the operator started. A row that says
28
+ * "retried in a fresh chat, then done" means the chat was the cause; "still blocked after 2" means
29
+ * it was not.
30
+ *
31
+ * Derived, never stored. It was a counter on the task that the runner added to and nothing reset,
32
+ * so every Run again or Continue of a task that blocked added the run's retries to the last run's:
33
+ * the register said 2, then 4, then 6, while each run had retried twice.
34
+ */
35
+ export function freshRetriesOfLatestRun(task) {
36
+ if (!task.freshRetry)
37
+ return 0;
38
+ let n = 1;
39
+ const before = task.attempts ?? [];
40
+ for (let i = before.length - 1; i >= 0 && before[i]?.freshRetry; i -= 1)
41
+ n += 1;
42
+ return n;
43
+ }
24
44
  export function newId(prefix = '') {
25
45
  const now = new Date();
26
46
  const p = (n) => String(n).padStart(2, '0');
@@ -50,6 +50,66 @@ export function bySessionList(a, b) {
50
50
  return byTime;
51
51
  return b.id.localeCompare(a.id);
52
52
  }
53
+ /**
54
+ * What `rerunTask` does with every field of a task when a new attempt begins — Run again, Continue,
55
+ * a new prompt, the runner's own fresh-chat retry.
56
+ *
57
+ * archived belongs to the attempt that ended: copied onto its `TaskAttempt`, cleared on the task
58
+ * cleared belongs to that attempt and is not kept on the record: cleared
59
+ * reset given the new attempt's starting value (status queued, iterations 0, attempt + 1, …)
60
+ * kept the task's own, across attempts: its definition, or something every attempt inherits
61
+ *
62
+ * A record over every key of `Task`, so a field added to the task without a decision here does not
63
+ * compile. Fields that were left out of `rerunTask` by default were counted on by the next attempt:
64
+ * the fresh-chat retry count said 2, then 4, then 6 while each run had retried twice, and what an
65
+ * attempt left running was listed under every attempt after it. `npm run check:attempts` holds
66
+ * `rerunTask` to this table.
67
+ */
68
+ export const TASK_FIELD_ON_RERUN = {
69
+ id: 'kept',
70
+ title: 'kept',
71
+ level2: 'kept',
72
+ prompt: 'kept',
73
+ createdAt: 'kept',
74
+ checks: 'kept',
75
+ vcsPlan: 'kept',
76
+ reviewEnabled: 'kept',
77
+ readOnly: 'kept',
78
+ scope: 'kept',
79
+ // What one review noticed is arithmetic for every attempt after it.
80
+ reviewChecks: 'kept',
81
+ attempts: 'kept',
82
+ status: 'reset',
83
+ iterations: 'reset',
84
+ attempt: 'reset',
85
+ continuing: 'reset',
86
+ buildsOn: 'reset',
87
+ runId: 'archived',
88
+ runGroup: 'archived',
89
+ startedAt: 'archived',
90
+ finishedAt: 'archived',
91
+ summary: 'archived',
92
+ reason: 'archived',
93
+ deviations: 'archived',
94
+ disputes: 'archived',
95
+ checkResults: 'archived',
96
+ review: 'archived',
97
+ stats: 'archived',
98
+ freshRetry: 'archived',
99
+ stopCode: 'archived',
100
+ limit: 'archived',
101
+ scopeReverted: 'archived',
102
+ inputsRestored: 'archived',
103
+ artifactsKept: 'archived',
104
+ vcs: 'archived',
105
+ leftovers: 'archived',
106
+ environment: 'archived',
107
+ handoff: 'archived',
108
+ interruption: 'cleared',
109
+ finalReply: 'cleared',
110
+ firstMessage: 'cleared',
111
+ logFile: 'cleared',
112
+ };
53
113
  export const DEFAULT_VCS = {
54
114
  enabled: true,
55
115
  repoDir: '',
@@ -476,6 +536,9 @@ export class SessionStore {
476
536
  ...(current.interruption ? { interruption: current.interruption } : {}),
477
537
  });
478
538
  }
539
+ /**
540
+ * What a new attempt does with each field of a task — see `TASK_FIELD_ON_RERUN`.
541
+ */
479
542
  async rerunTask(sessionId, taskId, patch = {},
480
543
  /** Present when the new attempt carries on from the last one (see `continueTask`). */
481
544
  continuing,
@@ -495,6 +558,8 @@ export class SessionStore {
495
558
  {
496
559
  runId: t.runId,
497
560
  runGroup: t.runGroup,
561
+ leftovers: t.leftovers,
562
+ environment: t.environment,
498
563
  status: t.status,
499
564
  startedAt: t.startedAt,
500
565
  finishedAt: t.finishedAt,
@@ -518,7 +583,11 @@ export class SessionStore {
518
583
  stopCode: t.stopCode,
519
584
  limit: t.limit,
520
585
  scope: t.scope,
586
+ readOnly: t.readOnly,
521
587
  scopeReverted: t.scopeReverted,
588
+ handoff: t.handoff,
589
+ inputsRestored: t.inputsRestored,
590
+ artifactsKept: t.artifactsKept,
522
591
  vcs: t.vcs,
523
592
  },
524
593
  ];
@@ -553,9 +622,15 @@ export class SessionStore {
553
622
  t.stats = undefined;
554
623
  t.handoff = undefined;
555
624
  t.scopeReverted = undefined;
625
+ t.inputsRestored = undefined;
626
+ t.artifactsKept = undefined;
556
627
  t.freshRetry = undefined;
557
628
  t.stopCode = undefined;
558
629
  t.limit = undefined;
630
+ // What this attempt left running and the machine it ran on are this attempt's, as the rest:
631
+ // left in place, the next attempt's list was added to this one's and read as its own.
632
+ t.leftovers = undefined;
633
+ t.environment = undefined;
559
634
  // The branch of the finished attempt stays in the repository and stays on the record
560
635
  // above; the next attempt gets its own, cut from the same commit this one started at.
561
636
  t.vcs = undefined;
@@ -618,6 +693,9 @@ export class SessionStore {
618
693
  // Sessions written before the queue could be told what to do on a failure behave the
619
694
  // way they always did: the chain stops.
620
695
  s.onFailure ??= 'stop';
696
+ // A counter that added up across runs, written before it was derived (`freshRetriesOfLatestRun`).
697
+ for (const t of s.tasks ?? [])
698
+ delete t.autoRetries;
621
699
  return s;
622
700
  }
623
701
  catch {
@@ -0,0 +1,138 @@
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
+ /** The session's artifact patterns, without ones that name the whole project. */
26
+ export function artifactPatterns(vcs) {
27
+ return normalisePatterns(vcs?.artifacts?.paths ?? []);
28
+ }
29
+ /** A pattern for `.git/info/exclude`: anchored at the repository's top, as a scope pattern is. */
30
+ function excludeLine(pattern) {
31
+ return `/${pattern.replace(/^\/+/, '')}`;
32
+ }
33
+ /**
34
+ * Writes the session's artifact patterns into `.git/info/exclude`, once each. Returns the files
35
+ * matching them that git already tracks, which an exclude does not stop from being committed.
36
+ */
37
+ export async function excludeArtifacts(dir, session) {
38
+ const patterns = artifactPatterns(session.vcs);
39
+ if (patterns.length === 0)
40
+ return { tracked: [] };
41
+ const where = await git(dir, ['rev-parse', '--git-path', 'info/exclude']);
42
+ if (!where.ok || !where.stdout)
43
+ return { problem: "the repository's exclude file could not be found", tracked: [] };
44
+ const top = (await git(dir, ['rev-parse', '--show-toplevel'])).stdout || dir;
45
+ const file = resolve(dir, where.stdout);
46
+ const before = await readFile(file, 'utf8').catch(() => '');
47
+ const have = new Set(before.split(/\r?\n/).map((l) => l.trim()));
48
+ const add = patterns.map(excludeLine).filter((l) => !have.has(l));
49
+ if (add.length > 0) {
50
+ try {
51
+ await mkdir(dirname(file), { recursive: true });
52
+ const block = [`# copilot-operator: artifacts of session "${session.name.replace(/[\r\n]+/g, ' ')}" (${session.id}) — kept with the run, never committed`, ...add, ''].join('\n');
53
+ await writeFile(file, `${before}${before && !before.endsWith('\n') ? '\n' : ''}${block}`, 'utf8');
54
+ }
55
+ catch (e) {
56
+ return { problem: `the exclude file could not be written: ${e.message}`, tracked: [] };
57
+ }
58
+ }
59
+ const tracked = (await git(resolve(top), ['ls-files'])).stdout.split('\n').filter((p) => p && inScope(p, patterns));
60
+ return { tracked };
61
+ }
62
+ const LIMITS = { files: 1000, fileBytes: 100 * 1024 * 1024, totalBytes: 500 * 1024 * 1024 };
63
+ /** Files under `root` matching the patterns, found on disk: git's view does not matter for evidence. */
64
+ async function matching(root, patterns) {
65
+ const out = [];
66
+ const starts = new Set(patterns.map((p) => {
67
+ const fixed = [];
68
+ for (const part of p.replace(/\/+$/, '').split('/')) {
69
+ if (/[*?[]/.test(part))
70
+ break;
71
+ fixed.push(part);
72
+ }
73
+ return fixed.join('/');
74
+ }));
75
+ const walk = async (rel) => {
76
+ if (out.length > LIMITS.files * 2)
77
+ return;
78
+ const abs = join(root, rel);
79
+ const info = await stat(abs).catch(() => null);
80
+ if (!info)
81
+ return;
82
+ if (info.isFile()) {
83
+ if (inScope(rel, patterns))
84
+ out.push(rel);
85
+ return;
86
+ }
87
+ if (!info.isDirectory())
88
+ return;
89
+ const name = rel.split('/').pop() ?? '';
90
+ if (name === '.git' || name === 'node_modules' || name === '.copilot-operator')
91
+ return;
92
+ for (const child of await readdir(abs).catch(() => []))
93
+ await walk(rel ? `${rel}/${child}` : child);
94
+ };
95
+ for (const start of starts)
96
+ await walk(start);
97
+ return [...new Set(out)].sort();
98
+ }
99
+ /**
100
+ * Copies the artifact files into the attempt's record, `<dest>/<path>`. Secrets are never copied;
101
+ * past the limits the rest is skipped and said.
102
+ */
103
+ export async function keepArtifacts(root, patterns, dest) {
104
+ const kept = [];
105
+ const skipped = [];
106
+ if (patterns.length === 0 || !root)
107
+ return { kept, skipped };
108
+ let total = 0;
109
+ const base = resolve(root);
110
+ for (const rel of await matching(root, patterns)) {
111
+ const from = resolve(join(root, rel));
112
+ if (!from.startsWith(base + sep))
113
+ continue;
114
+ if (looksGenerated(rel)?.reason === 'a secrets file') {
115
+ skipped.push(`${rel} (a secrets file)`);
116
+ continue;
117
+ }
118
+ const info = await stat(from).catch(() => null);
119
+ if (!info)
120
+ continue;
121
+ if (kept.length >= LIMITS.files || info.size > LIMITS.fileBytes || total + info.size > LIMITS.totalBytes) {
122
+ skipped.push(`${rel} (past the limit of ${LIMITS.files} files, ${LIMITS.fileBytes / 1048576} MB a file, ${LIMITS.totalBytes / 1048576} MB in all)`);
123
+ continue;
124
+ }
125
+ const to = join(dest, rel);
126
+ try {
127
+ await mkdir(dirname(to), { recursive: true });
128
+ await copyFile(from, to);
129
+ const bytes = await readFile(to);
130
+ kept.push({ path: rel, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') });
131
+ total += info.size;
132
+ }
133
+ catch (e) {
134
+ skipped.push(`${rel} (${e.message})`);
135
+ }
136
+ }
137
+ return { kept, skipped };
138
+ }
@@ -0,0 +1,332 @@
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
+ const made = await git(top, ['branch', branch, commit.stdout]);
174
+ if (!made.ok)
175
+ return { problem: made.stderr || `the branch ${branch} could not be made` };
176
+ return { commit: commit.stdout };
177
+ }
178
+ finally {
179
+ await rm(index, { force: true });
180
+ }
181
+ }
182
+ /**
183
+ * Makes sure the commit a session starts from has the operator's input files, and records their
184
+ * sums on the start. Returns the start to use — the same one, or one whose commit has the inputs
185
+ * committed on top — or why the session cannot start.
186
+ *
187
+ * Called once the start is known and the working tree holds nothing uncommitted of the inputs (new
188
+ * or changed ones went into the snapshot before this). Where the inputs are taken from: the
189
+ * repository's HEAD for every pattern it has files for, else the latest session in this same
190
+ * repository whose start recorded files for that pattern.
191
+ */
192
+ export async function settleInputs(session, dir, start, allSessions) {
193
+ const settings = inputSettings(session.vcs);
194
+ if (!settings)
195
+ return { start };
196
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
197
+ if (!topR.ok || !topR.stdout)
198
+ return { problem: `${dir} is not a git repository.` };
199
+ const top = resolve(topR.stdout);
200
+ const head = (await git(top, ['rev-parse', '--verify', '--quiet', 'HEAD'])).stdout;
201
+ // Where each pattern's files are: HEAD first, then the latest earlier capture in this repository.
202
+ const wanted = [];
203
+ const sources = new Set();
204
+ const missing = [];
205
+ const atHead = head ? await inputsAt(top, head, settings.patterns) : [];
206
+ const earlier = (await allSessions())
207
+ .filter((s) => s.id !== session.id && s.vcsStart?.inputs && norm(s.vcs?.repoDir || s.projectDir || '') === norm(dir))
208
+ // The newest first: a later capture is the operator's later word on the inputs.
209
+ .sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
210
+ .map((s) => s.vcsStart);
211
+ for (const pattern of settings.patterns) {
212
+ let found = atHead.filter((f) => inScope(f.path, [pattern]));
213
+ let from = head;
214
+ // The start itself next: a snapshot approved on the page has them, wherever HEAD has moved since.
215
+ if (found.length === 0) {
216
+ found = (await inputsAt(top, start.commit, [pattern])).filter((f) => inScope(f.path, [pattern]));
217
+ from = start.commit;
218
+ }
219
+ for (const s of earlier) {
220
+ if (found.length > 0)
221
+ break;
222
+ found = (await inputsAt(top, s.commit, [pattern])).filter((f) => inScope(f.path, [pattern]));
223
+ from = s.commit;
224
+ }
225
+ if (found.length === 0)
226
+ missing.push(pattern);
227
+ else
228
+ sources.add(from);
229
+ for (const f of found)
230
+ if (!wanted.some((w) => w.path === f.path))
231
+ wanted.push(f);
232
+ }
233
+ if (missing.length > 0) {
234
+ return {
235
+ 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, ` +
236
+ '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".',
237
+ };
238
+ }
239
+ // What the start has already, file by file; only what differs is committed on top.
240
+ const inStart = new Map((await inputsAt(top, start.commit, settings.patterns)).map((f) => [f.path, f]));
241
+ const differ = wanted.filter((w) => inStart.get(w.path)?.blob !== w.blob);
242
+ let commit = start.commit;
243
+ let carried;
244
+ if (differ.length > 0) {
245
+ const branch = await freeBranchName(top, `${branchNameFrom(['input'], session.vcs?.branchPrefix || 'cop/')}/${session.id}`);
246
+ if (!(await isValidBranchName(top, branch)))
247
+ return { problem: `"${branch}" is not a name git accepts.` };
248
+ const from = [...sources].map((s) => s.slice(0, 8)).join(', ');
249
+ const message = [
250
+ 'Capture user-provided inputs',
251
+ '',
252
+ `The operator's input files for session "${session.name}", which the commit it starts from (${start.commit.slice(0, 8)}) did not have,`,
253
+ `taken from ${from}, where they were last committed. They are inputs: the work reads them and does not change them.`,
254
+ '',
255
+ ...differ.slice(0, 200).map((f) => `- ${f.path}`),
256
+ '',
257
+ 'Committed by copilot-operator. Not pushed.',
258
+ '',
259
+ ].join('\n');
260
+ const made = await commitOnto(top, start.commit, differ, message, branch);
261
+ if ('problem' in made)
262
+ return { problem: `the input files could not be committed on top of the session's start: ${made.problem}` };
263
+ commit = made.commit;
264
+ carried = { onto: start.commit, from, branch };
265
+ }
266
+ const files = await inputSumsAt(top, commit, settings.patterns);
267
+ return { start: { ...start, commit, inputs: { patterns: settings.patterns, files, readOnly: settings.readOnly, ...(carried ? { carried } : {}) } } };
268
+ }
269
+ /**
270
+ * Puts the input files back as the task's starting commit has them: a changed or removed one from
271
+ * that commit, a new one matching an input pattern removed. Only on the task's own branch, whose
272
+ * starting commit has the inputs (see `settleInputs`).
273
+ */
274
+ export async function protectInputs(dir, base, inputs) {
275
+ const out = { changed: [], restored: [], failed: [] };
276
+ const topR = await git(dir, ['rev-parse', '--show-toplevel']);
277
+ if (!topR.ok || !topR.stdout)
278
+ return out;
279
+ const top = resolve(topR.stdout);
280
+ const known = new Map(inputs.files.map((f) => [f.path, f.blob]));
281
+ for (const f of inputs.files) {
282
+ if ((await blobOfFile(top, f.path)) === f.blob)
283
+ continue;
284
+ out.changed.push(f.path);
285
+ const back = await git(top, ['checkout', base, '--', f.path]);
286
+ if (back.ok && (await blobOfFile(top, f.path)) === f.blob)
287
+ out.restored.push(f.path);
288
+ else
289
+ out.failed.push({ path: f.path, why: back.stderr || 'it is not as the starting commit has it' });
290
+ }
291
+ // A file the task added under an input pattern: inputs are the operator's, so it goes.
292
+ for (const { path, kind } of await inputFilesInTree(top, inputs.patterns)) {
293
+ if (known.has(path) || kind === 'tracked')
294
+ continue;
295
+ out.changed.push(path);
296
+ const abs = resolve(join(top, path));
297
+ if (!abs.startsWith(resolve(top) + sep)) {
298
+ out.failed.push({ path, why: 'not inside the repository' });
299
+ continue;
300
+ }
301
+ try {
302
+ await rm(abs, { force: true });
303
+ out.restored.push(path);
304
+ }
305
+ catch (e) {
306
+ out.failed.push({ path, why: e.message });
307
+ }
308
+ }
309
+ return out;
310
+ }
311
+ /** What the chat is told when input files were put back. */
312
+ export function inputsMessage(check) {
313
+ const names = (paths) => paths.slice(0, 15).join(', ') + (paths.length > 15 ? `, and ${paths.length - 15} more` : '');
314
+ return [
315
+ `The operator's input files are read-only, so the runner put back: ${names(check.restored)}.`,
316
+ check.failed.length > 0 ? `Could not be put back: ${check.failed.map((f) => `${f.path} (${f.why})`).join('; ')}.` : '',
317
+ '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.',
318
+ ]
319
+ .filter(Boolean)
320
+ .join(' ');
321
+ }
322
+ /** The lines the version-control note carries about the inputs. */
323
+ export function inputsNote(inputs) {
324
+ if (!inputs || inputs.files.length === 0)
325
+ return '';
326
+ const shown = inputs.files.slice(0, 20).map((f) => `\`${f.path}\``).join(', ');
327
+ return (`The operator's input files are in your working tree, committed in the starting commit: ${shown}` +
328
+ `${inputs.files.length > 20 ? `, and ${inputs.files.length - 20} more` : ''}. ` +
329
+ (inputs.readOnly
330
+ ? '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.'
331
+ : 'They are the work\'s input; change them only if the task says so.'));
332
+ }