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
@@ -22,12 +22,15 @@
22
22
  * Like the rest of `vcs/`: nothing is reset, stashed, deleted or rewritten. The snapshot adds a
23
23
  * branch and a commit.
24
24
  */
25
- import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
25
+ import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
26
26
  import { tmpdir } from 'node:os';
27
27
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
28
28
  import { looksGenerated } from './commitHygiene.js';
29
29
  import { branchNameFrom, freeBranchName, git, gitBytes, isValidBranchName, RUNNER_EMAIL } from './git.js';
30
- import { repoDirOf, sessionStart } from './taskVcs.js';
30
+ import { repoDirOf, sessionBranchName, sessionStart } from './taskVcs.js';
31
+ import { inputChanges, inputFilesInTree, inputSettings, inputSumsAt, missingInputPatterns, recaptureInputs } from './inputs.js';
32
+ import { artifactPatterns } from './artifacts.js';
33
+ import { inScope } from './scope.js';
31
34
  /** The session's dirty-tree policy, absent fields filled in: reject, and asking first. */
32
35
  export function dirtyPolicy(vcs) {
33
36
  const policy = vcs?.dirtyWorktree?.policy;
@@ -128,13 +131,18 @@ async function startProblem(session, dir, branch, head, allSessions) {
128
131
  */
129
132
  export async function planSnapshot(session, allSessions = async () => []) {
130
133
  const { policy, requireApproval } = dirtyPolicy(session.vcs);
134
+ const inputs = inputSettings(session.vcs);
131
135
  const repoDir = repoDirOf(session);
132
136
  const plan = { needed: false, ok: false, policy, requireApproval, repoDir, branch: null, head: null, entries: [] };
133
- if (!session.vcs?.enabled || policy === 'reject' || session.vcsBaseCommit)
137
+ if (!session.vcs?.enabled || (policy === 'reject' && !inputs))
138
+ return plan;
139
+ if (session.vcsBaseCommit && (!inputs || session.vcs.startFrom === 'existing-branch'))
134
140
  return plan;
135
141
  const top = repoDir ? await topOf(repoDir) : null;
136
142
  if (!top)
137
143
  return { ...plan, problem: `${repoDir || 'The repository folder'} is not a git repository.` };
144
+ if (session.vcsBaseCommit)
145
+ return await planRecapture(session, top, plan);
138
146
  const status = await statusEntries(top);
139
147
  if (!status)
140
148
  return { ...plan, problem: 'git status could not be read.' };
@@ -142,13 +150,43 @@ export async function planSnapshot(session, allSessions = async () => []) {
142
150
  const headR = await git(top, ['rev-parse', '--verify', '--quiet', 'HEAD']);
143
151
  plan.branch = branchR.ok && branchR.stdout !== 'HEAD' ? branchR.stdout : null;
144
152
  plan.head = headR.ok && headR.stdout ? headR.stdout : null;
145
- if (status.length === 0)
146
- return plan;
147
- plan.needed = true;
148
153
  // The project folder, when it is a folder inside the repository: a change outside it is not this session's.
149
154
  const project = session.projectDir?.trim() ? relative(top, resolve(session.projectDir.trim())).replace(/\\/g, '/') : '';
150
155
  const inProject = (p) => !project || project.startsWith('..') || isAbsolute(project) || p.toLowerCase() === project.toLowerCase() || p.toLowerCase().startsWith(`${project.toLowerCase()}/`);
151
- for (const s of status) {
156
+ const size = async (p) => (await stat(join(top, p)).catch(() => null))?.size;
157
+ /*
158
+ * The operator's input files that are not committed as they are: new, changed, or ignored. They
159
+ * are taken whatever the dirty-tree policy, or the snapshot is not; the rest of the tree is the
160
+ * policy's. A secrets file or one outside the project is never an input.
161
+ */
162
+ const inputPaths = new Set();
163
+ if (inputs) {
164
+ const dirtyNow = new Set(status.map((s) => s.path));
165
+ for (const f of await inputFilesInTree(top, inputs.patterns)) {
166
+ if (f.kind === 'tracked' && !dirtyNow.has(f.path))
167
+ continue;
168
+ inputPaths.add(f.path);
169
+ const blocked = looksGenerated(f.path)?.reason === 'a secrets file' ? 'a secrets file is never taken as an input' : !inProject(f.path) ? `outside the project folder ${project}` : undefined;
170
+ plan.entries.push({ path: f.path, kind: f.kind, input: true, size: await size(f.path), allowed: blocked ? [] : ['include'], choice: blocked ? null : 'include', reason: blocked ?? 'an input file: committed with the starting snapshot, then read-only' });
171
+ }
172
+ }
173
+ const others = status.filter((s) => !inputPaths.has(s.path));
174
+ if (policy === 'reject') {
175
+ if (plan.entries.length === 0)
176
+ return plan;
177
+ plan.needed = true;
178
+ if (others.length > 0) {
179
+ plan.problem =
180
+ `besides the input files there are other uncommitted changes (${someOf(others.map((o) => o.path))}). ` +
181
+ 'Commit them yourself, or choose "Take them as a starting snapshot" under "Uncommitted changes".';
182
+ return plan;
183
+ }
184
+ }
185
+ if (plan.entries.length === 0 && others.length === 0)
186
+ return plan;
187
+ plan.needed = true;
188
+ plan.requireApproval = (others.length > 0 && requireApproval) || (plan.entries.some((e) => e.input) && !!inputs?.requireApproval);
189
+ for (const s of policy === 'reject' ? [] : others) {
152
190
  const generated = looksGenerated(s.path);
153
191
  const outside = !inProject(s.path);
154
192
  if (!s.untracked) {
@@ -157,21 +195,31 @@ export async function planSnapshot(session, allSessions = async () => []) {
157
195
  : outside
158
196
  ? `outside the project folder ${project}; it is tracked, so it cannot be left out. Commit or undo that change yourself`
159
197
  : undefined;
160
- plan.entries.push({ path: s.path, ...(s.from ? { from: s.from } : {}), kind: 'tracked', allowed: blocked ? [] : ['include'], choice: blocked ? null : 'include', ...(blocked ? { reason: blocked } : {}) });
198
+ plan.entries.push({ path: s.path, ...(s.from ? { from: s.from } : {}), kind: 'tracked', size: await size(s.path), allowed: blocked ? [] : ['include'], choice: blocked ? null : 'include', ...(blocked ? { reason: blocked } : {}) });
161
199
  continue;
162
200
  }
163
- const why = generated ? generated.reason : outside ? `outside the project folder ${project}` : policy === 'tracked-only-snapshot' ? 'a new file, and this session takes tracked changes only' : undefined;
164
- plan.entries.push({ path: s.path, kind: 'untracked', allowed: why ? ['leave-out'] : ['include', 'leave-out'], choice: why ? 'leave-out' : 'include', ...(why ? { reason: why } : {}) });
201
+ const artifact = artifactPatterns(session.vcs).length > 0 && inScope(s.path, artifactPatterns(session.vcs));
202
+ const why = artifact
203
+ ? 'an artifact: kept with the run, never committed'
204
+ : generated ? generated.reason : outside ? `outside the project folder ${project}` : policy === 'tracked-only-snapshot' ? 'a new file, and this session takes tracked changes only' : undefined;
205
+ plan.entries.push({ path: s.path, kind: 'untracked', size: await size(s.path), allowed: why ? ['leave-out'] : ['include', 'leave-out'], choice: why ? 'leave-out' : 'include', ...(why ? { reason: why } : {}) });
165
206
  }
166
207
  if (policy === 'snapshot') {
167
208
  for (const path of await scopedIgnored(top, scopedPatterns(session))) {
168
- if (looksGenerated(path) || !inProject(path))
209
+ if (looksGenerated(path) || !inProject(path) || inputPaths.has(path))
169
210
  continue;
170
211
  plan.entries.push({ path, kind: 'ignored', allowed: ['include', 'leave-out'], choice: 'leave-out', reason: "ignored by git, and a task's scope names it: taken only when you tick it" });
171
212
  }
172
213
  }
173
214
  const blocked = plan.entries.filter((e) => e.allowed.length === 0);
174
- if (!plan.head)
215
+ // A pattern that matches nothing anywhere is said now, before anything is committed for approval.
216
+ const missing = inputs ? await missingInputPatterns(session, repoDir, top, allSessions) : [];
217
+ if (missing.length > 0) {
218
+ plan.problem =
219
+ `the input file pattern(s) ${missing.map((m) => `"${m}"`).join(', ')} match no file in the project, and nothing earlier captured them. ` +
220
+ 'Correct "Input files" (a "*" stays inside one folder; "**" crosses folders), or put the files in the project.';
221
+ }
222
+ else if (!plan.head)
175
223
  plan.problem = 'the repository has no commit yet, so there is nothing for a snapshot to sit on. Make the first commit yourself.';
176
224
  else if (blocked.length > 0)
177
225
  plan.problem = `${blocked.length} change(s) can be neither taken nor left out: ${blocked.map((e) => `${e.path} (${e.reason})`).join('; ')}.`;
@@ -187,6 +235,62 @@ export async function planSnapshot(session, allSessions = async () => []) {
187
235
  plan.ok = !plan.problem;
188
236
  return plan;
189
237
  }
238
+ /**
239
+ * After the session's start: the input files changed or added since it was recorded, to be taken
240
+ * onto its line of work (see `recaptureInputs`). Anything else uncommitted is in the way, as it is
241
+ * before a task, and refuses.
242
+ */
243
+ async function planRecapture(session, top, plan) {
244
+ const inputs = inputSettings(session.vcs);
245
+ if (!inputs || !session.vcsBaseCommit)
246
+ return plan;
247
+ const branchR = await git(top, ['rev-parse', '--abbrev-ref', 'HEAD']);
248
+ const headR = await git(top, ['rev-parse', '--verify', '--quiet', 'HEAD']);
249
+ plan.branch = branchR.ok && branchR.stdout !== 'HEAD' ? branchR.stdout : null;
250
+ plan.head = headR.ok && headR.stdout ? headR.stdout : null;
251
+ const changes = await inputChanges(top, inputs.patterns, session.vcsStart?.inputs?.files);
252
+ if (changes.length === 0)
253
+ return plan;
254
+ plan.needed = true;
255
+ plan.requireApproval = inputs.requireApproval;
256
+ const project = session.projectDir?.trim() ? relative(top, resolve(session.projectDir.trim())).replace(/\\/g, '/') : '';
257
+ const inProject = (p) => !project || project.startsWith('..') || isAbsolute(project) || p.toLowerCase() === project.toLowerCase() || p.toLowerCase().startsWith(`${project.toLowerCase()}/`);
258
+ for (const c of changes) {
259
+ const blocked = looksGenerated(c.path)?.reason === 'a secrets file' ? 'a secrets file is never taken as an input' : !inProject(c.path) ? `outside the project folder ${project}` : undefined;
260
+ plan.entries.push({
261
+ path: c.path,
262
+ kind: c.kind,
263
+ input: true,
264
+ size: (await stat(join(top, c.path)).catch(() => null))?.size,
265
+ allowed: blocked ? [] : ['include'],
266
+ choice: blocked ? null : 'include',
267
+ reason: blocked ?? 'an input file changed or added after the session started: committed on its line of work, then read-only',
268
+ });
269
+ }
270
+ const changed = new Set(changes.map((c) => c.path));
271
+ const others = ((await statusEntries(top)) ?? []).filter((s) => !changed.has(s.path)).map((s) => s.path);
272
+ const blocked = plan.entries.filter((e) => e.allowed.length === 0);
273
+ if (others.length > 0) {
274
+ plan.problem = `besides the input files there are other uncommitted changes (${someOf(others)}). Commit or stash them yourself first.`;
275
+ }
276
+ else if (blocked.length > 0) {
277
+ plan.problem = `${blocked.length} file(s) cannot be taken as inputs: ${blocked.map((e) => `${e.path} (${e.reason})`).join('; ')}.`;
278
+ }
279
+ // Where they go: on the session's one branch when it has it, else on the commit tasks are cut from.
280
+ const sessionBranch = sessionBranchName(session);
281
+ const tip = session.vcs?.branchMode === 'per-session' ? (await git(top, ['rev-parse', '--verify', '--quiet', `refs/heads/${sessionBranch}^{commit}`])).stdout : '';
282
+ if (tip) {
283
+ plan.recapture = { parent: tip, ffBranch: sessionBranch };
284
+ plan.baselineBranch = sessionBranch;
285
+ }
286
+ else {
287
+ const newBranch = await freeBranchName(top, `${branchNameFrom(['input'], session.vcs?.branchPrefix || 'cop/')}/${session.id}`);
288
+ plan.recapture = { parent: session.vcsBaseCommit, newBranch };
289
+ plan.baselineBranch = newBranch;
290
+ }
291
+ plan.ok = !plan.problem;
292
+ return plan;
293
+ }
190
294
  /** A path as a pattern for `.git/info/exclude` that matches that one file and nothing else. */
191
295
  function excludePattern(path) {
192
296
  return `/${path.replace(/[\\*?[]/g, (c) => `\\${c}`).replace(/ $/, '\\ ')}`;
@@ -215,16 +319,20 @@ async function leaveOut(top, session, paths) {
215
319
  return undefined;
216
320
  }
217
321
  /** The commit message: what was taken, what was left out, from where, and on whose word. */
218
- function snapshotMessage(session, plan, included, leftOut, approved) {
322
+ function snapshotMessage(session, plan, taken, leftOut, approved) {
219
323
  const list = (paths) => [...paths.slice(0, 200).map((p) => `- ${p}`), ...(paths.length > 200 ? [`- and ${paths.length - 200} more`] : [])].join('\n');
324
+ const inputs = taken.filter((e) => e.input);
325
+ const rest = taken.filter((e) => !e.input).map((e) => e.path);
326
+ const onlyInputs = rest.length === 0;
220
327
  return [
221
- 'Capture operator baseline before run',
328
+ onlyInputs ? 'Capture user-provided inputs' : 'Capture operator baseline before run',
222
329
  '',
223
- `The uncommitted changes in the repository when session "${session.name}" was first run, committed so that`,
330
+ `The ${onlyInputs ? "operator's input files" : 'uncommitted changes in the repository'} when session "${session.name}" was first run, committed so that`,
224
331
  "the session starts from them and every task's commit shows only that task's work.",
225
- '',
226
- `Taken (${included.length}):`,
227
- list(included),
332
+ ...(inputs.length > 0
333
+ ? ['', `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})` : ''}`))]
334
+ : []),
335
+ ...(rest.length > 0 ? ['', `Taken (${rest.length}):`, list(rest)] : []),
228
336
  ...(leftOut.length > 0 ? ['', `Left out, in .git/info/exclude (${leftOut.length}):`, list(leftOut)] : []),
229
337
  '',
230
338
  `Taken from ${plan.branch ?? 'a detached HEAD'} at ${(plan.head ?? '').slice(0, 8)}, ${approved ? "on the operator's approval of this list" : 'automatically (requireApproval: false)'}.`,
@@ -240,11 +348,55 @@ function snapshotMessage(session, plan, included, leftOut, approved) {
240
348
  * `approved`, every file takes its default and anything to be left out refuses (see `DirtyWorktree`).
241
349
  */
242
350
  export async function takeSnapshot(session, opts, bus, saveSession, allSessions = async () => []) {
243
- if (dirtyPolicy(session.vcs).policy === 'reject') {
351
+ if (dirtyPolicy(session.vcs).policy === 'reject' && !inputSettings(session.vcs)) {
244
352
  return { ok: false, problem: 'this session does not take uncommitted changes as a snapshot. Choose that under Version control → "Uncommitted changes" first.' };
245
353
  }
246
- if (session.vcsBaseCommit)
247
- return { ok: false, problem: 'this session has already started; a snapshot is taken only before its first task.' };
354
+ if (session.vcsBaseCommit) {
355
+ // Started already: only input files changed or added since can be taken, onto its line of work.
356
+ const plan = await planSnapshot(session, allSessions);
357
+ if (!plan.needed || !plan.recapture) {
358
+ return { ok: false, problem: 'this session has already started, and no input file has changed since: a snapshot is taken only before its first task.' };
359
+ }
360
+ if (!plan.ok)
361
+ return { ok: false, problem: plan.problem ?? 'the input files cannot be taken.' };
362
+ const paths = plan.entries.map((e) => e.path);
363
+ if (opts.approved) {
364
+ const shown = Object.keys(opts.choices ?? {}).sort();
365
+ if (JSON.stringify(shown) !== JSON.stringify([...paths].sort()) || paths.some((p) => opts.choices?.[p] !== 'include')) {
366
+ return { ok: false, problem: 'the input files changed since the list was shown. Look at the list again; nothing was done.' };
367
+ }
368
+ }
369
+ const made = await recaptureInputs(session, plan.repoDir, paths, plan.recapture);
370
+ if ('problem' in made)
371
+ return { ok: false, problem: made.problem };
372
+ const settings = inputSettings(session.vcs);
373
+ const top = (await topOf(plan.repoDir));
374
+ const before = session.vcsStart;
375
+ const start = {
376
+ ...before,
377
+ inputs: {
378
+ patterns: settings?.patterns ?? [],
379
+ files: await inputSumsAt(top, made.commit, settings?.patterns ?? []),
380
+ readOnly: settings?.readOnly ?? true,
381
+ ...(before.inputs?.carried ? { carried: before.inputs.carried } : {}),
382
+ recaptured: [...(before.inputs?.recaptured ?? []), { commit: made.commit, branch: made.branch, paths: paths.slice(0, 200), approved: opts.approved }],
383
+ },
384
+ };
385
+ await saveSession((s) => {
386
+ s.vcsStart = start;
387
+ // A commit tasks are cut from moves to the inputs' commit; a session branch moved forward itself.
388
+ if (plan.recapture?.newBranch)
389
+ s.vcsBaseCommit = made.commit;
390
+ });
391
+ bus.publish({
392
+ sessionId: session.id,
393
+ type: 'vcs-inputs-recaptured',
394
+ level: 'info',
395
+ message: `${paths.length} input file(s) changed or added since the session started are now the commit ${made.commit.slice(0, 8)} on ${made.branch}; the next task starts from it`,
396
+ data: { commit: made.commit, branch: made.branch, paths: paths.slice(0, 50) },
397
+ });
398
+ return { ok: true, start };
399
+ }
248
400
  const plan = await planSnapshot(session, allSessions);
249
401
  if (!plan.needed)
250
402
  return { ok: false, problem: 'there is nothing uncommitted to take.' };
@@ -320,7 +472,7 @@ export async function takeSnapshot(session, opts, bus, saveSession, allSessions
320
472
  finally {
321
473
  await rm(list, { force: true });
322
474
  }
323
- const message = snapshotMessage(session, plan, take.map((e) => e.path), leftOutAll, opts.approved);
475
+ const message = snapshotMessage(session, plan, take, leftOutAll, opts.approved);
324
476
  const commit = await git(top, ['-c', 'user.name=copilot-operator', '-c', `user.email=${RUNNER_EMAIL}`, 'commit', '--no-verify', '-q', '-m', message]);
325
477
  if (!commit.ok)
326
478
  return await back(`the snapshot could not be committed: ${commit.stderr || commit.stdout}.`);