papilot 0.2.0 → 0.3.0

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/index.html CHANGED
@@ -8,6 +8,6 @@
8
8
  document.documentElement.dataset.theme = dark ? 'dark' : 'light';
9
9
  document.querySelector('meta[name="theme-color"]').content = dark ? '#26272b' : '#ffffff';
10
10
  })();
11
- </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-Czf1eCno.js"></script>
12
- <link rel="stylesheet" crossorigin href="/assets/index-CqhlgnOu.css">
11
+ </script><style>html{--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;background:#fff}html[data-theme=dark]{--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark;background:#26272b}</style> <script type="module" crossorigin src="/assets/index-BkowXmKr.js"></script>
12
+ <link rel="stylesheet" crossorigin href="/assets/index-Dq3s-vhH.css">
13
13
  </head><body><div id="root"></div></body></html>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "papilot",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A Cursor-like workspace for your papers: annotate and review PDFs directly without ever touching LaTeX.",
5
5
  "keywords": [
6
6
  "latex",
@@ -36,6 +36,7 @@
36
36
  "src/shared.ts",
37
37
  "src/notes.ts",
38
38
  "src/conversation.ts",
39
+ "src/commit.ts",
39
40
  "skills",
40
41
  "dist",
41
42
  "scripts/demo.ts",
package/src/commit.ts ADDED
@@ -0,0 +1,23 @@
1
+ // What the Git panel leaves out of a commit unless chosen, and offers to add
2
+ // to .gitignore: LaTeX's build output, minted's cache and macOS folder files.
3
+ // Each file maps to the pattern that ignores every file of its kind.
4
+ const BUILD = /\.(aux|log|out|toc|lof|lot|lol|fls|fdb_latexmk|synctex(?:\.gz)?|bbl|blg|bcf|run\.xml|nav|snm|vrb|xdv|dvi|idx|ilg|ind|glo|gls|glg|ist|thm|loa)$/;
5
+ export function ignorePattern(file: string): string | null {
6
+ const name = file.slice(file.lastIndexOf('/') + 1);
7
+ if (name === '.DS_Store') return '.DS_Store';
8
+ if (/(?:^|\/)_minted[^/]*\//.test(file)) return '_minted*/';
9
+ const build = BUILD.exec(name);
10
+ return build ? `*.${build[1]}` : null;
11
+ }
12
+
13
+ // An agent's reply as a commit message: without a code fence, a label or
14
+ // quotes, a subject without a final period, and any body after a blank line.
15
+ export function commitMessageFrom(reply: string) {
16
+ const text = reply.trim().replace(/^```[\w-]*\n([\s\S]*?)\n```$/, '$1').trim()
17
+ .replace(/^(?:\*\*)?commit message(?:\*\*)?\s*:\s*(?:\*\*)?[ \t]*/i, '');
18
+ const lines = text.split('\n'), first = lines.findIndex(line => line.trim());
19
+ if (first < 0) return '';
20
+ const subject = lines[first].trim().replace(/^#+\s+/, '').replace(/^\*\*(.+)\*\*$/, '$1').replace(/^(["'`])(.+)\1$/, '$2').replace(/\.$/, '').trim();
21
+ const body = lines.slice(first + 1).join('\n').trim();
22
+ return body ? `${subject}\n\n${body}` : subject;
23
+ }
@@ -1,4 +1,4 @@
1
- import type { FeedbackSummary } from './shared.ts';
1
+ import type { BuildFailure, FeedbackSummary } from './shared.ts';
2
2
 
3
3
  // What the agent receives when the author sends review feedback without a
4
4
  // message of their own. The chat shows the feedback, never this prompt.
@@ -6,6 +6,17 @@ export const reviewInstructions = 'Continue this task using the decisions and co
6
6
  // Earlier versions stored the prompt itself as the author's message.
7
7
  export const isFeedbackPrompt = (text: string) => text === reviewInstructions || text === 'Address the comments from this review round. Preserve accepted changes and do not reintroduce rejected edits.';
8
8
 
9
+ // What the agent receives when its own edits stop the review build. The chat
10
+ // shows the error, never this prompt.
11
+ export function buildFixPrompt(failure: BuildFailure) {
12
+ return [
13
+ 'Papilot could not build the PDF review after your last turn:',
14
+ [failure.error, ...failure.details].join('\n'),
15
+ failure.log ? `The compiler log is at ${failure.log}.` : '',
16
+ 'Fix this in the task worktree, then end your turn; Papilot builds the review again. Restore any file outside the review\'s scope to how it was when the task started, and mention in your summary any update that file still needs.',
17
+ ].filter(Boolean).join('\n\n');
18
+ }
19
+
9
20
  // A skill is a named set of instructions; the author's own words, if any, say where and how.
10
21
  export function skillPrompt(skill: { name: string; body: string; folder?: string }, request: string) {
11
22
  return [
@@ -10,7 +10,7 @@ import { ROOT, DATA, PORT, PUBLIC_ORIGIN, OWNER, VERSION } from './paths.ts';
10
10
  import { reviewClient, allowedReviewRoute, rewriteReviewHtml, rewriteReviewJson, confirmedReviewResponse, annotateReviewState } from './review.ts';
11
11
  import { authorize } from './security.ts';
12
12
  import { fullPaperPdf } from './pdf.ts';
13
- import { Manuscripts, annotationTask, gitCommit, gitDiff, gitRemote, gitStatus } from './manuscript.ts';
13
+ import { Manuscripts, annotationTask, gitAbortMerge, gitCommit, gitDiff, gitFinishMerge, gitIgnore, gitRemote, gitStatus } from './manuscript.ts';
14
14
  import { findSkill, listedSkill, loadSkills } from './skills.ts';
15
15
  import type { AgentId } from '../shared.ts';
16
16
 
@@ -72,6 +72,11 @@ app.post<{ Params: { id: string } }>('/api/tasks/:id/messages', async request =>
72
72
  const text = feedback || skill ? optional(b.text, 'Revision instructions') : string(b.text, 'Revision instructions');
73
73
  return workspace.enqueue(task.id, text, string(b.requestKey, 'Request ID', 100), true, { skill, feedback });
74
74
  });
75
+ // Commits what the author accepted from this task, with the message they confirmed.
76
+ app.post<{ Params: { id: string } }>('/api/tasks/:id/commit', request => {
77
+ const task = store.task(request.params.id), b = request.body as any;
78
+ return serialGit(task.projectId, () => workspace.commitTask(task.id, string(b.message, 'Commit message', 5000), b.push === true));
79
+ });
75
80
  app.post<{ Params: { id: string } }>('/api/tasks/:id/rename', request => workspace.renameTask(request.params.id, string((request.body as any).title, 'Task title', 120)));
76
81
  app.post<{ Params: { id: string } }>('/api/tasks/:id/interrupt', request => workspace.interrupt(request.params.id).then(() => ({ ok: true })));
77
82
  app.post<{ Params: { id: string } }>('/api/tasks/:id/delete', request => {
@@ -97,11 +102,35 @@ app.get<{ Params: { id: string }; Querystring: { path?: string } }>('/api/projec
97
102
  app.post<{ Params: { id: string } }>('/api/projects/:id/git/commit', request => {
98
103
  const project = store.project(request.params.id), b = request.body as any;
99
104
  const message = string(b.message, 'Commit message', 5000), paths = Array.isArray(b.paths) ? b.paths.filter((p: unknown) => typeof p === 'string').slice(0, 5000) : [];
100
- return serialGit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) }));
105
+ return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitCommit(project.path, message, paths), git: await gitStatus(project.path) })));
106
+ });
107
+ const paths = (value: unknown): string[] => Array.isArray(value) ? value.filter((p: unknown): p is string => typeof p === 'string').slice(0, 5000) : [];
108
+ // An agent writes a commit message for the chosen files; nothing is written.
109
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/message', request => {
110
+ const b = request.body as any;
111
+ return workspace.commitMessage(store.project(request.params.id).id, paths(b.paths), { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined });
112
+ });
113
+ // A merge in progress is resolved by an agent, committed, or abandoned.
114
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/resolve', request => {
115
+ const project = store.project(request.params.id), b = request.body as any;
116
+ return serialGit(project.id, () => workspace.resolveConflicts(project.id, { agent: b.agent === undefined ? undefined : agent(b.agent), model: typeof b.model === 'string' ? b.model : undefined }));
117
+ });
118
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/finish', request => {
119
+ const project = store.project(request.params.id);
120
+ return serialGit(project.id, () => workspace.exclusiveCommit(project.id, async () => ({ commit: await gitFinishMerge(project.path), git: await gitStatus(project.path) })));
121
+ });
122
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/abort', request => {
123
+ const project = store.project(request.params.id);
124
+ return serialGit(project.id, async () => { await gitAbortMerge(project.path); return { git: await gitStatus(project.path) }; });
125
+ });
126
+ // Chosen untracked build files go into .gitignore by kind.
127
+ app.post<{ Params: { id: string } }>('/api/projects/:id/git/ignore', request => {
128
+ const project = store.project(request.params.id);
129
+ return serialGit(project.id, async () => ({ git: await gitIgnore(project.path, paths((request.body as any).paths)) }));
101
130
  });
102
131
  app.post<{ Params: { id: string; action: string } }>('/api/projects/:id/git/:action', request => {
103
132
  const project = store.project(request.params.id), action = request.params.action;
104
- if (action !== 'fetch' && action !== 'pull' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
133
+ if (action !== 'fetch' && action !== 'pull' && action !== 'merge' && action !== 'push') throw Object.assign(new Error('Unknown Git action.'), { statusCode: 404 });
105
134
  return serialGit(project.id, async () => { await gitRemote(project.path, action); return { git: await gitStatus(project.path) }; });
106
135
  });
107
136
  app.post<{ Params: { id: string } }>('/api/projects/:id/manuscript', async request => ({ ...(await manuscripts.state(request.params.id, store.project(request.params.id).path, true)), annotations: store.annotations(request.params.id).filter(note => !note.taskId) }));
@@ -7,8 +7,9 @@ import path from 'node:path';
7
7
  import { promisify } from 'node:util';
8
8
  import { pathToFileURL } from 'node:url';
9
9
  import { ENGINE } from './paths.ts';
10
- import type { Annotation, GitStatus, ManuscriptBuild } from '../shared.ts';
10
+ import type { Annotation, GitFile, GitStatus, ManuscriptBuild } from '../shared.ts';
11
11
  import { notesInstructions } from '../notes.ts';
12
+ import { ignorePattern } from '../commit.ts';
12
13
 
13
14
  type Rect = [number, number, number, number];
14
15
 
@@ -57,8 +58,26 @@ export async function gitStatus(root: string): Promise<GitStatus> {
57
58
  git(root, ['remote']).catch(() => ''),
58
59
  ]);
59
60
  const [hash, subject, time] = head.trim().split('\0');
60
- return { ...parseStatus(status, numstat), head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()) };
61
+ const parsed = parseStatus(status, numstat), dir = gitDir(root);
62
+ // A merge in progress names the files whose conflict markers remain.
63
+ const merging = dir && fs.existsSync(path.join(dir, 'MERGE_HEAD'))
64
+ ? { message: readText(path.join(dir, 'MERGE_MSG')).split('\n')[0] || 'Merge', conflicts: parsed.files.filter(file => file.status === 'U' && conflictMarkers(root, file.path)).map(file => file.path) }
65
+ : null;
66
+ return { ...parsed, head: hash ? { hash, subject, date: Number(time) * 1000 } : null, remote: Boolean(remotes.trim()), merging };
61
67
  }
68
+ const readText = (file: string) => { try { return fs.readFileSync(file, 'utf8'); } catch { return ''; } };
69
+ // The repository's Git directory: .git, or where a .git file points.
70
+ function gitDir(root: string) {
71
+ try {
72
+ const dot = path.join(root, '.git');
73
+ return fs.statSync(dot).isFile() ? path.resolve(root, readText(dot).replace(/^gitdir:\s*/, '').trim()) : dot;
74
+ } catch { return null; }
75
+ }
76
+ export function mergeInProgress(root: string) {
77
+ const dir = gitDir(root);
78
+ return Boolean(dir && fs.existsSync(path.join(dir, 'MERGE_HEAD')));
79
+ }
80
+ export const conflictMarkers = (root: string, file: string) => /^(?:<{7}|>{7})(?: |$)/m.test(readText(path.join(root, file)));
62
81
 
63
82
  // Paths from the browser are matched against the changed files, and Git reads
64
83
  // them literally, so a name cannot act as a pathspec pattern.
@@ -69,17 +88,51 @@ const DIFF_LIMIT = 400_000;
69
88
  export async function gitDiff(root: string, file: string) {
70
89
  const entry = (await gitStatus(root)).files.find(item => item.path === file);
71
90
  if (!entry) throw Object.assign(new Error('That file has no uncommitted changes.'), { statusCode: 404 });
72
- const diff = entry.status === '?'
73
- // `git diff --no-index` exits with 1 when the files differ.
74
- ? await exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', file], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
75
- : await git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), file], literal);
91
+ const diff = await fileDiff(root, entry);
76
92
  return { path: file, diff: diff.slice(0, DIFF_LIMIT), truncated: diff.length > DIFF_LIMIT };
77
93
  }
94
+ function fileDiff(root: string, entry: GitFile) {
95
+ return entry.status === '?'
96
+ // `git diff --no-index` exits with 1 when the files differ.
97
+ ? exec('git', ['diff', '--no-index', '--no-color', '--', '/dev/null', entry.path], { cwd: root, maxBuffer: 64 * 1024 * 1024 }).then(r => r.stdout, (e: { stdout?: string }) => e.stdout || '')
98
+ : git(root, ['diff', '--no-color', '--find-renames', 'HEAD', '--', ...(entry.from ? [entry.from] : []), entry.path], literal);
99
+ }
100
+
101
+ // What a commit message is written from: the chosen files' diffs, each
102
+ // shortened so that a large change still fits, and the repository's recent
103
+ // commit subjects, whose style and language the message follows.
104
+ export async function commitChanges(root: string, paths: string[]) {
105
+ const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
106
+ if (!files.length) throw new Error('Select at least one changed file.');
107
+ let patch = '';
108
+ for (const [index, file] of files.entries()) {
109
+ if (patch.length > 40_000) { patch += `\n[${files.length - index} more changed files are not shown.]\n`; break; }
110
+ const diff = await fileDiff(root, file);
111
+ patch += diff.length > 8_000 ? `${diff.slice(0, 8_000)}\n[The rest of this file's diff is not shown.]\n` : diff;
112
+ }
113
+ const recent = (await git(root, ['log', '-8', '--format=%s']).catch(() => '')).split('\n').filter(Boolean);
114
+ return { files, patch, recent };
115
+ }
116
+
117
+ // Adds the patterns for the chosen untracked build files to the repository's
118
+ // .gitignore, one per kind of file and each only once, so they leave the list
119
+ // of changes. The .gitignore is then a change to commit like any other.
120
+ export async function gitIgnore(root: string, paths: string[]) {
121
+ const patterns = [...new Set((await gitStatus(root)).files.filter(item => item.status === '?' && paths.includes(item.path)).map(item => ignorePattern(item.path)).filter((pattern): pattern is string => Boolean(pattern)))];
122
+ if (!patterns.length) throw new Error('None of the chosen files is build output that can be ignored.');
123
+ const file = path.join(root, '.gitignore');
124
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
125
+ const present = new Set(current.split(/\r?\n/).map(line => line.trim()));
126
+ const missing = patterns.filter(pattern => !present.has(pattern) && !present.has('/' + pattern));
127
+ if (missing.length) fs.writeFileSync(file, `${current}${current && !current.endsWith('\n') ? '\n' : ''}${current.trim() ? '\n' : ''}# Build output\n${missing.join('\n')}\n`);
128
+ return gitStatus(root);
129
+ }
78
130
 
79
131
  // Commit exactly the chosen files as they are on disk. Changes staged
80
132
  // elsewhere for other files stay staged and out of this commit.
81
133
  export async function gitCommit(root: string, message: string, paths: string[]) {
82
- const files = (await gitStatus(root)).files.filter(item => paths.includes(item.path));
134
+ const status = await gitStatus(root), files = status.files.filter(item => paths.includes(item.path));
135
+ if (status.merging) throw new Error('Finish or abort the merge before committing other changes.');
83
136
  if (!files.length) throw new Error('Select at least one changed file to commit.');
84
137
  if (files.some(item => item.status === 'U')) throw new Error('Resolve the merge conflicts in the selected files before committing.');
85
138
  // A rename is already staged, so only its new path is added; the commit
@@ -91,13 +144,17 @@ export async function gitCommit(root: string, message: string, paths: string[])
91
144
 
92
145
  // Fetch, fast-forward pull and push use the host's Git credentials. Nothing
93
146
  // may prompt, so a missing credential fails instead of hanging.
94
- export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push') {
147
+ export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'merge' | 'push') {
95
148
  const status = await gitStatus(root);
96
149
  if (!status.remote) throw new Error('This repository has no remote.');
150
+ if (status.merging && action !== 'fetch') throw new Error('Finish or abort the merge first.');
97
151
  if (action === 'fetch') await gitAction(root, ['fetch', '--prune'], 120_000);
98
- else if (action === 'pull') {
152
+ else if (action === 'pull' || action === 'merge') {
99
153
  if (!status.upstream) throw new Error('This branch does not track a remote branch yet.');
100
- await gitAction(root, ['pull', '--ff-only'], 120_000);
154
+ // Pull only fast-forwards. Merge also joins a branch that has diverged
155
+ // from its upstream; conflicts leave the merge in progress to resolve.
156
+ if (action === 'pull') await gitAction(root, ['pull', '--ff-only'], 120_000);
157
+ else await gitAction(root, ['pull', '--no-rebase', '--ff', '--no-edit'], 120_000).catch(error => { if (!mergeInProgress(root)) throw error; });
101
158
  } else if (status.upstream) await gitAction(root, ['push'], 120_000);
102
159
  else {
103
160
  if (!status.branch) throw new Error('Check out a branch before pushing.');
@@ -106,6 +163,30 @@ export async function gitRemote(root: string, action: 'fetch' | 'pull' | 'push')
106
163
  }
107
164
  }
108
165
 
166
+ // A merge in progress ends with its commit once no conflict markers remain,
167
+ // or is abandoned, restoring the files as they were before it.
168
+ export async function gitFinishMerge(root: string) {
169
+ const status = await gitStatus(root);
170
+ if (!status.merging) throw new Error('No merge is in progress.');
171
+ if (status.merging.conflicts.length) throw new Error(`Resolve the conflicts in ${status.merging.conflicts.join(', ')} first.`);
172
+ const resolved = status.files.filter(item => item.status === 'U').map(item => item.path);
173
+ if (resolved.length) await gitAction(root, ['add', '-A', '--', ...resolved]);
174
+ await gitAction(root, ['commit', '--no-edit']);
175
+ return (await git(root, ['rev-parse', '--short', 'HEAD'])).trim();
176
+ }
177
+ export async function gitAbortMerge(root: string) {
178
+ if (!mergeInProgress(root)) throw new Error('No merge is in progress.');
179
+ await gitAction(root, ['merge', '--abort']);
180
+ }
181
+ // What an agent needs to resolve a merge's conflicts: the files with markers
182
+ // and the commits on each side.
183
+ export async function mergeConflicts(root: string) {
184
+ const status = await gitStatus(root);
185
+ if (!status.merging) throw new Error('No merge is in progress.');
186
+ const subjects = async (range: string) => (await git(root, ['log', '-12', '--format=%s', range]).catch(() => '')).split('\n').filter(Boolean);
187
+ return { files: status.merging.conflicts, branch: status.branch || 'HEAD', upstream: status.upstream || 'the remote branch', ours: await subjects('MERGE_HEAD..HEAD'), theirs: await subjects('HEAD..MERGE_HEAD') };
188
+ }
189
+
109
190
  // Git's own message, without the usage noise, becomes the error the user sees.
110
191
  async function gitAction(root: string, args: string[], timeout = 60_000) {
111
192
  try { return (await exec('git', args, { cwd: root, timeout, env: { ...process.env, ...literal, GIT_TERMINAL_PROMPT: '0' }, maxBuffer: 64 * 1024 * 1024 })).stdout; }
@@ -34,7 +34,8 @@ export function annotateReviewState(value: unknown, tasks: readonly Task[]): unk
34
34
  else if (task.status === 'building') decisionLock = 'Wait for the PDF build before accepting, rejecting, or undoing changes. You can still add comments.';
35
35
  else if (['running', 'waiting_input', 'queued'].includes(task.status)) decisionLock = 'Pause this task in Papilot before accepting, rejecting, or undoing changes. You can still add comments.';
36
36
  else if (task.pendingReview) decisionLock = 'Wait for the current review action before accepting, rejecting, or undoing changes. You can still add comments.';
37
- return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length ?? 0 } } : {}) };
37
+ // Changes the task has committed are in the manuscript too.
38
+ return { ...state, decisionLock, commentLock, ...(task ? { workspace: { status: task.status, unsent: task.review?.unsent ?? 0, comments: task.review?.comments ?? 0, accepted: task.review?.files?.length || (task.committed ? 1 : 0) } } : {}) };
38
39
  }
39
40
 
40
41
  export function allowedReviewRoute(route: string, method: string): boolean {
@@ -10,8 +10,10 @@ import { AGENT_NAMES, AntigravityAgent, ClaudeAgent, type AgentRuntime } from '.
10
10
  import { reviewClient, taskRoute, confirmedReviewResponse, type ReviewClient } from './review.ts';
11
11
  import { VERSION } from './paths.ts';
12
12
  import { findSkill, type SkillFile } from './skills.ts';
13
- import { reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
14
- import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary } from '../shared.ts';
13
+ import { buildFixPrompt, reviewInstructions, skillPrompt, summarizeFeedback } from '../conversation.ts';
14
+ import { commitMessageFrom } from '../commit.ts';
15
+ import { commitChanges, conflictMarkers, gitCommit, gitRemote, gitStatus, mergeConflicts, mergeInProgress } from './manuscript.ts';
16
+ import type { Task, Run, TimelineItem, PendingRequest, RuntimeStatus, Snapshot, PendingReview, AgentModel, Usage, AgentId, MessageMeta, FeedbackSummary, BuildFailure } from '../shared.ts';
15
17
 
16
18
  const execute = promisify(execFile);
17
19
  const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
@@ -35,8 +37,16 @@ export function accountUsage(response: any): Usage | null {
35
37
  return { plan: typeof limits.planType === 'string' ? limits.planType : null, windows, credits, reached: Boolean(limits.rateLimitReachedType) || response.ordinaryUsageAllowed === false };
36
38
  }
37
39
  const BUSY = new Set(['running', 'waiting_input', 'building', 'uncertain', 'deleting']);
40
+ // Review builds that fail on the agent's own edits (see the review engine's
41
+ // publish.ts), and the request keys of the turns that send them back to it.
42
+ const FIXABLE = /^(Some edits are outside the task scope|The revised document could not be compiled)/;
43
+ const BUILD_FIX = 'build-fix-';
38
44
 
39
45
  const AGENTS: AgentId[] = ['codex', 'claude', 'antigravity'];
46
+ // A commit message's agent sees the changes in its prompt and needs no tools.
47
+ const commitInstructions = 'You write Git commit messages for the repository of a LaTeX paper. Reply with the commit message only, without a preamble, quotes or a code fence: a subject line in the imperative mood under 72 characters and, only when the change needs more explanation, a blank line and a body of a few short plain sentences. Describe what changed in the paper, such as its wording, sections, equations, figures or references, rather than listing files. Follow the style and language of the recent commit subjects, or write in English when there are none. Everything you need is in the message: do not run commands, read files or edit anything.';
48
+ // A merge's agent edits the conflicted files in the manuscript checkout and nothing else.
49
+ const mergeInstructions = 'You resolve Git merge conflicts in the repository of a LaTeX paper. Edit only the files listed as conflicted, and in each one remove every conflict marker (<<<<<<<, ||||||| , =======, >>>>>>>) so that the text reads as one coherent version. Keep the changes from both sides wherever they are compatible; where both sides rewrote the same passage, combine their intent, and where they truly contradict each other, prefer the version that is more complete and correct and say so. Keep the LaTeX valid: balanced environments and braces, labels, references and citations. You may read any file to understand the context. Do not edit other files, and do not run Git commands that change the repository, such as add, commit, merge, checkout, reset, restore or stash: the author reviews the result and commits the merge.';
40
50
  const blankRuntime = (): RuntimeStatus => ({ connected: false, authenticated: false, account: null, error: null, models: [] });
41
51
 
42
52
  export class Workspace extends EventEmitter {
@@ -49,6 +59,8 @@ export class Workspace extends EventEmitter {
49
59
  private loaded = new Set<string>();
50
60
  private preparing = new Set<string>();
51
61
  private reviewWrites = new Set<string>();
62
+ // Papers with a commit in progress: no review write may change their files meanwhile.
63
+ private committing = new Set<string>();
52
64
  private commentWrites = new Map<string, number>();
53
65
  private deletions = new Set<string>();
54
66
  private publishing = new Set<string>();
@@ -145,9 +157,10 @@ export class Workspace extends EventEmitter {
145
157
  return this.store.task(task.id);
146
158
  } finally { this.preparing.delete(input.id); }
147
159
  }
148
- // The author's message, a skill run with or without one, or the review
149
- // feedback alone. The chat keeps what the author did; the agent gets the prompt.
150
- async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean } = {}) {
160
+ // The author's message, a skill run with or without one, the review
161
+ // feedback alone, or a build the agent broke. The chat keeps what happened;
162
+ // the agent gets the prompt.
163
+ async enqueue(taskId: string, text: string, requestKey: string, includeFeedback = true, options: { skill?: SkillFile; feedback?: boolean; build?: BuildFailure } = {}) {
151
164
  const task = this.store.task(taskId);
152
165
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
153
166
  const existing = this.store.runByKey(requestKey);
@@ -155,7 +168,7 @@ export class Workspace extends EventEmitter {
155
168
  if (task.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
156
169
  const pauseVersion = task.pauseVersion || 0;
157
170
  if (this.store.runs().some(r => r.taskId === taskId && r.status === 'queued')) throw new Error('This task already has queued instructions. Wait or pause it first.');
158
- let prompt = options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
171
+ let prompt = options.build ? buildFixPrompt(options.build) : options.feedback ? reviewInstructions : options.skill ? skillPrompt(options.skill, text) : text.trim(), cursor = task.feedbackCursor;
159
172
  let summary: FeedbackSummary | undefined;
160
173
  if (includeFeedback) {
161
174
  const client = await this.connectReview(this.store.project(task.projectId).path);
@@ -166,7 +179,7 @@ export class Workspace extends EventEmitter {
166
179
  if (task.error) prompt += '\n\nLatest task/review error:\n' + task.error + '\nThe latest accepted manuscript is at ' + this.store.project(task.projectId).path + '. You may read it to understand accepted changes, but must only edit your isolated task worktree. Prepare a revised proposal for another review; never write to or accept changes in the manuscript checkout.';
167
180
  }
168
181
  const run: Run = { id: randomUUID(), taskId, requestKey, prompt, feedbackCursor: cursor, status: 'queued', createdAt: Date.now(), finishedAt: null };
169
- const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}) };
182
+ const meta: MessageMeta = { ...(options.skill ? { skill: { id: options.skill.id, name: options.skill.name } } : {}), ...(summary ? { feedback: summary } : {}), ...(options.feedback ? { sendsFeedback: true } : {}), ...(options.build ? { build: options.build } : {}) };
170
183
  const saved = this.store.transaction(() => {
171
184
  // Feedback loading yields to other requests. Recheck identity, queue, and
172
185
  // task status together before committing; none of the earlier reads own a lock.
@@ -182,7 +195,7 @@ export class Workspace extends EventEmitter {
182
195
  if (current.status === 'uncertain') throw new Error('Check the task status before sending more instructions.');
183
196
  this.store.saveRun(run);
184
197
  if (!BUSY.has(current.status)) this.store.updateTask(taskId, { status: 'queued', error: null });
185
- this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
198
+ this.store.saveItem({ id: run.id, taskId, type: 'user', text: options.feedback || options.build ? '' : text.trim(), createdAt: run.createdAt, ...(Object.keys(meta).length ? { meta } : {}) });
186
199
  return run;
187
200
  });
188
201
  this.schedule();
@@ -205,12 +218,20 @@ export class Workspace extends EventEmitter {
205
218
  void this.startRun(run).catch(e => this.failRun(run, e));
206
219
  }
207
220
  }
208
- private instructions(task: Task) {
221
+ // The paths a task's review covers, or none when it covers the whole worktree.
222
+ private async reviewScope(task: Task): Promise<string[]> {
223
+ try {
224
+ const client = await this.connectReview(this.store.project(task.projectId).path);
225
+ const files: string[] = (await client.get(taskRoute(task.name))).scope?.files || [];
226
+ return files.some(file => file === '' || file === '.') ? [] : files;
227
+ } catch { return []; }
228
+ }
229
+ private instructions(task: Task, scope: string[] = []) {
209
230
  return `You are ${this.agentOf(task) === 'codex' ? 'the official Codex agent' : AGENT_NAMES[this.agentOf(task)]} running inside Papilot, a paper editing and review workspace.
210
231
  Your task is already created. Your editable working directory is ${task.worktree}.
211
232
  Original task: ${task.instructions}
212
233
  Only edit this isolated task worktree. Never edit the manuscript checkout, other task worktrees, or Papilot's own source/state. Never commit, push, stash, or accept/reject/undo review decisions.
213
- Papilot owns the LaTeX review lifecycle: it compiles and publishes automatically AFTER your turn finishes, and delivers review feedback in a new turn. Do not create another review task, invoke publish, or wait on feedback --wait. This application workflow replaces the standalone latex-review skill's publish-and-wait loop. End your turn after completing the requested edits, with a concise summary for the author of what you changed and why; do not describe Papilot's review workflow.
234
+ ${scope.length ? `The review covers only ${scope.join(', ')} in this worktree, and an edit to any other file stops the PDF build. Leave other files unchanged even when project instructions ask for a matching update; mention that update in your summary instead.\n` : ''}Papilot owns the LaTeX review lifecycle: it compiles and publishes automatically AFTER your turn finishes, and delivers review feedback in a new turn. Do not create another review task, invoke publish, or wait on feedback --wait. This application workflow replaces the standalone latex-review skill's publish-and-wait loop. End your turn after completing the requested edits, with a concise summary for the author of what you changed and why; do not describe Papilot's review workflow.
214
235
  Preserve meaning and mathematical correctness. Read relevant project instructions. Treat reviewer comments as instructions tied to their round, file and changes. Rejected edits must not be reintroduced unless explicitly requested. If there is a conflict with accepted text, explain it and propose a revision for a new review round; never overwrite the accepted manuscript.
215
236
  ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to you here and any attempt is refused, which ends your turn: read, search and edit files in the task worktree with your file tools only.' : 'You may run tools in the task worktree to inspect and validate your changes.'} If a decision requires the user, ask through the available user-input tool or explain it in your final response. Use English for your progress updates, questions, and final summaries unless the user explicitly requests another language. Preserve the manuscript language unless asked to translate it.`;
216
237
  }
@@ -221,7 +242,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
221
242
  this.store.saveRun({ ...run, status: 'running' });
222
243
  this.store.updateTask(task.id, { status: 'running', turnId: null, error: null });
223
244
  if (!task.threadId || !this.loaded.has(task.threadId)) {
224
- const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task), ...(task.model ? { model: task.model } : {}) };
245
+ const options = { cwd: task.worktree, approvalPolicy: 'on-request', sandbox: 'workspace-write', developerInstructions: this.instructions(task, await this.reviewScope(task)), ...(task.model ? { model: task.model } : {}) };
225
246
  const result = task.threadId
226
247
  ? await agent.request('thread/resume', { ...options, threadId: task.threadId, excludeTurns: true })
227
248
  : await agent.request('thread/start', options);
@@ -327,7 +348,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
327
348
  this.store.saveRun({ ...run, status, finishedAt: Date.now() });
328
349
  this.store.updateTask(task.id, { turnId: null, pendingRunId: null, status: status === 'completed' ? 'building' : status, error: p.turn.error?.message || null });
329
350
  this.schedule();
330
- if (status === 'completed') await this.publish(task.id, true);
351
+ if (status === 'completed') await this.publish(task.id, run);
331
352
  } else if (method === 'error' && !p.willRetry) {
332
353
  if (!this.active.has(task.id) || (eventTurn && !task.turnId)) return;
333
354
  this.store.updateTask(task.id, { error: p.error?.message || `${AGENT_NAMES[agent]} encountered an error.` });
@@ -438,6 +459,8 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
438
459
  }
439
460
  }
440
461
  if (this.active.has(taskId) || this.publishing.has(taskId) || BUSY.has(task.status) || task.status === 'queued') throw new Error('Pause the task before accepting, rejecting, or undoing changes. Wait for any PDF build to finish.');
462
+ if (this.committing.has(task.projectId)) throw new Error('A commit is in progress. Try again when it finishes.');
463
+ if (mergeInProgress(this.store.project(task.projectId).path)) throw new Error('Finish or abort the merge in the Git panel before accepting, rejecting, or undoing changes.');
441
464
  if (this.reviewWrites.has(taskId)) throw new Error('A review action is already in progress. Wait for it to finish.');
442
465
  this.reviewWrites.add(taskId);
443
466
  if (pending) this.store.updateTask(taskId, { pendingReview: { ...pending, previousStatus: task.status } });
@@ -451,17 +474,19 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
451
474
  }
452
475
  finally { this.reviewWrites.delete(taskId); this.schedule(); }
453
476
  }
454
- publish(taskId: string, automatic = false): Promise<void> {
455
- const task = this.store.task(taskId);
477
+ // A build is automatic after the agent's run `after` completes. When its
478
+ // edits break the build, the agent gets the error in a new turn, once.
479
+ publish(taskId: string, after?: Run): Promise<void> {
480
+ const task = this.store.task(taskId), automatic = Boolean(after);
456
481
  if (task.status === 'deleting') throw new Error('This task is being deleted.');
457
482
  if (this.active.has(taskId)) throw new Error('Pause Codex before building a review.');
458
483
  if (this.reviewWrites.has(taskId)) throw new Error('Wait for the current review action before building a review.');
459
484
  if (this.publishing.has(taskId) || (!automatic && (BUSY.has(task.status) || task.status === 'queued'))) throw new Error('This task is busy. Wait for it to finish or pause it first.');
460
485
  this.publishing.add(taskId);
461
486
  this.store.updateTask(taskId, { status: 'building', error: null });
462
- return this.buildReview(task).finally(() => { this.publishing.delete(taskId); this.schedule(); });
487
+ return this.buildReview(task, Boolean(after && !after.requestKey.startsWith(BUILD_FIX))).finally(() => { this.publishing.delete(taskId); this.schedule(); });
463
488
  }
464
- private async buildReview(task: Task) {
489
+ private async buildReview(task: Task, sendBack: boolean) {
465
490
  const taskId = task.id;
466
491
  try {
467
492
  const client = await this.connectReview(this.store.project(task.projectId).path);
@@ -472,7 +497,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
472
497
  const summary = await client.get(taskRoute(task.name));
473
498
  this.store.updateTask(taskId, { review: { ...this.store.task(taskId).review, state: summary.state, round: summary.round, counts: summary.counts, job: current } });
474
499
  if (current.status === 'done' || current.status === 'completed' || current.status === 'succeeded') break;
475
- if (current.status === 'failed') throw new Error([current.error, ...(current.details || [])].filter(Boolean).join('\n') || 'PDF build failed.');
500
+ if (current.status === 'failed') {
501
+ const failure: BuildFailure = { error: current.error || 'PDF build failed.', details: current.details || [], ...(current.log ? { log: current.log } : {}) };
502
+ // Instructions the author queued meanwhile go first; the build after them sends any error that remains.
503
+ if (sendBack && FIXABLE.test(failure.error) && await this.enqueue(taskId, '', BUILD_FIX + job.job, false, { build: failure }).then(() => true, () => false)) {
504
+ this.store.updateTask(taskId, { status: 'queued', error: null });
505
+ return;
506
+ }
507
+ throw new Error([failure.error, ...failure.details].join('\n'));
508
+ }
476
509
  await delay(1500);
477
510
  }
478
511
  this.store.updateTask(taskId, { status: this.store.runs().some(r => r.taskId === taskId && r.status === 'queued') ? 'queued' : 'reviewing', error: null });
@@ -486,7 +519,7 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
486
519
  const project = this.store.project(task.projectId), client = await this.connectReview(project.path);
487
520
  const summary = await client.get(taskRoute(task.name));
488
521
  const { unsent, comments } = await this.unsentReviewFeedback(client, this.store.task(taskId));
489
- const files = await acceptedFiles(project.path, task.name);
522
+ const files = await acceptedFiles(project.path, task.name, task.committed?.accepted);
490
523
  const current = this.store.task(taskId);
491
524
  if (current.status !== 'deleting') this.store.updateTask(taskId, { review: { state: summary.state, round: summary.round, counts: summary.counts, job: current.review?.job, unsent, comments, files } });
492
525
  } catch { /* The task remains visible even if its project is temporarily unavailable. */ }
@@ -500,6 +533,105 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
500
533
  const items = feedback.feedback || [];
501
534
  return { unsent: items.length, comments: items.filter(item => item.kind === 'comment' || item.kind === 'note').length };
502
535
  }
536
+ // The agent that writes a commit message or resolves a merge: the one the
537
+ // author chose, or else the first that is signed in, with the chosen model
538
+ // or the default one.
539
+ private helper(choice: { agent?: AgentId; model?: string }, task: string) {
540
+ const usable = (id: AgentId) => this.agents[id].ready && this.runtimes[id].authenticated;
541
+ const id = choice.agent && usable(choice.agent) ? choice.agent : AGENTS.find(usable);
542
+ if (!id) throw new Error(`Sign in to Codex, Claude or Antigravity to ${task}.`);
543
+ const models = this.runtimes[id].models;
544
+ return { id, model: models.find(item => item.id === choice.model) || models.find(item => item.isDefault) };
545
+ }
546
+ // One turn on a thread the agent does not keep, outside any task: its
547
+ // reply is returned and no chat shows it.
548
+ private async oneOff(id: AgentId, options: { cwd: string; sandbox: 'read-only' | 'workspace-write'; instructions: string; prompt: string; model?: AgentModel; effort?: string | null; timeout: number; what: string }) {
549
+ const agent = this.agents[id], name = AGENT_NAMES[id];
550
+ const thread = await agent.request('thread/start', { cwd: options.cwd, approvalPolicy: 'never', sandbox: options.sandbox, ephemeral: true, developerInstructions: options.instructions, ...(options.model ? { model: options.model.id } : {}) });
551
+ const threadId = thread.thread.id;
552
+ return new Promise<string>((resolve, reject) => {
553
+ let text = '', turnId = '';
554
+ const listen = (message: any) => {
555
+ const p = message.params || {};
556
+ if (p.threadId !== threadId) return;
557
+ if (message.method === 'item/completed' && p.item?.type === 'agentMessage') text = p.item.text || text;
558
+ else if (message.method === 'turn/completed') finish(p.turn?.status === 'completed' ? null : new Error(p.turn?.error?.message || `${name} stopped before it could ${options.what}.`));
559
+ };
560
+ const timer = setTimeout(() => {
561
+ if (turnId) agent.request('turn/interrupt', { threadId, turnId }).catch(() => undefined);
562
+ finish(new Error(`${name} did not ${options.what} in time. Try again.`));
563
+ }, options.timeout);
564
+ const finish = (error: Error | null) => { clearTimeout(timer); agent.off('notification', listen); if (error) reject(error); else resolve(text); };
565
+ agent.on('notification', listen);
566
+ agent.request('turn/start', { threadId, input: [{ type: 'text', text: options.prompt }], cwd: options.cwd, ...(options.effort ? { effort: options.effort } : {}) })
567
+ .then(result => { turnId = result?.turn?.id || ''; }, error => finish(error instanceof Error ? error : new Error(String(error))));
568
+ });
569
+ }
570
+ // Writes a commit message for the chosen manuscript files, like Cursor's
571
+ // generate button: one read-only turn with the lightest reasoning the model offers.
572
+ async commitMessage(projectId: string, paths: string[], choice: { agent?: AgentId; model?: string } = {}) {
573
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'write a commit message');
574
+ const { files, patch, recent } = await commitChanges(project.path, paths);
575
+ const tasks = this.store.tasks().filter(task => task.projectId === projectId && task.review?.files?.some(file => paths.includes(file))).map(task => task.title);
576
+ const prompt = [
577
+ recent.length ? `Recent commit subjects in this repository:\n${recent.map(subject => `- ${subject}`).join('\n')}` : 'This repository has no commits yet.',
578
+ tasks.length ? `Papilot tasks whose accepted changes are in these files:\n${tasks.map(title => `- ${title}`).join('\n')}` : '',
579
+ `Changed files:\n${files.map(file => `${file.status === '?' ? 'A' : file.status} ${file.from ? `${file.from} -> ` : ''}${file.path}`).join('\n')}`,
580
+ `Diff:\n${patch}`,
581
+ ].filter(Boolean).join('\n\n');
582
+ const reply = await this.oneOff(id, { cwd: project.path, sandbox: 'read-only', instructions: commitInstructions, prompt, model, effort: model?.efforts[0], timeout: 120000, what: 'write a commit message' });
583
+ const message = commitMessageFrom(reply);
584
+ if (!message) throw new Error(`${AGENT_NAMES[id]} did not return a commit message. Try again.`);
585
+ return { message, agent: id };
586
+ }
587
+ // Resolves a merge's conflicts in the manuscript checkout itself: the agent
588
+ // edits only the conflicted files, at the model's default reasoning, and
589
+ // leaves committing the merge to the author once they have read the diffs.
590
+ async resolveConflicts(projectId: string, choice: { agent?: AgentId; model?: string } = {}) {
591
+ const project = this.store.project(projectId), { id, model } = this.helper(choice, 'resolve conflicts');
592
+ const { files, branch, upstream, ours, theirs } = await mergeConflicts(project.path);
593
+ if (!files.length) throw new Error('No file has conflict markers left. Commit the merge.');
594
+ const list = (items: string[]) => items.length ? items.map(item => `- ${item}`).join('\n') : '- (none listed)';
595
+ const prompt = [
596
+ `${branch} is being merged with ${upstream} in ${project.path}, and these files have conflicts:\n${list(files)}`,
597
+ `Commits on ${branch} since the two diverged:\n${list(ours)}`,
598
+ `Commits from ${upstream} being merged in:\n${list(theirs)}`,
599
+ 'Resolve every conflict in those files, then reply with a short summary for the author: how you resolved each conflict, and any place where you had to choose one side.',
600
+ ].join('\n\n');
601
+ const summary = await this.oneOff(id, { cwd: project.path, sandbox: 'workspace-write', instructions: mergeInstructions, prompt, model, effort: model?.defaultEffort, timeout: 600000, what: 'resolve the conflicts' });
602
+ return { summary: summary.trim(), remaining: files.filter(file => conflictMarkers(project.path, file)), agent: id, git: await gitStatus(project.path) };
603
+ }
604
+ // Commits what the author accepted from this task since its last commit,
605
+ // with the message they confirmed, and pushes it if they asked. Other
606
+ // uncommitted files stay as they are; a file another task also changed is
607
+ // committed as it is on disk.
608
+ async commitTask(taskId: string, message: string, push = false) {
609
+ const task = this.store.task(taskId);
610
+ if (task.status === 'deleting') throw new Error('This task is being deleted.');
611
+ if (task.pendingReview) throw new Error('Wait for the current review action before committing.');
612
+ const project = this.store.project(task.projectId);
613
+ return this.exclusiveCommit(project.id, async () => {
614
+ const accepted = await acceptedCommit(project.path, task.name);
615
+ const files = accepted ? await acceptedFiles(project.path, task.name, this.store.task(taskId).committed?.accepted) : [];
616
+ const paths = (await gitStatus(project.path)).files.filter(file => files.includes(file.path)).map(file => file.path);
617
+ if (!paths.length) throw new Error('This task has no accepted changes left to commit.');
618
+ const hash = await gitCommit(project.path, message, paths);
619
+ this.store.updateTask(taskId, { committed: { hash, accepted: accepted!, at: Date.now() } });
620
+ void this.refresh(taskId);
621
+ // The commit stands even when the push fails; the author can push again.
622
+ let pushError: string | undefined;
623
+ if (push) try { await gitRemote(project.path, 'push'); } catch (error) { pushError = errorText(error); }
624
+ return { commit: hash, git: await gitStatus(project.path), ...(pushError ? { pushError } : {}) };
625
+ });
626
+ }
627
+ // A commit reads the working tree that accepting, rejecting and undoing
628
+ // write, so neither starts while the other runs on the same paper.
629
+ async exclusiveCommit<T>(projectId: string, work: () => Promise<T>): Promise<T> {
630
+ if (this.committing.has(projectId)) throw new Error('A commit is already in progress. Wait for it to finish.');
631
+ if ([...this.reviewWrites].some(id => this.store.task(id).projectId === projectId)) throw new Error('Wait for the current review action before committing.');
632
+ this.committing.add(projectId);
633
+ try { return await work(); } finally { this.committing.delete(projectId); }
634
+ }
503
635
  renameTask(taskId: string, title: string) {
504
636
  if (this.store.task(taskId).status === 'deleting') throw new Error('This task is being deleted.');
505
637
  return this.store.updateTask(taskId, { title });
@@ -544,10 +676,15 @@ ${this.agentOf(task) === 'antigravity' ? 'Shell commands are not available to yo
544
676
 
545
677
  // The review engine records each task's accepted text as a commit beside the
546
678
  // one it started from; their difference is what the task put in the
547
- // manuscript. A rename counts as both of its paths.
548
- export async function acceptedFiles(root: string, name: string) {
679
+ // manuscript, and the difference from the accepted text at the task's last
680
+ // commit is what it has added since. A rename counts as both of its paths.
681
+ export async function acceptedFiles(root: string, name: string, since?: string) {
549
682
  try {
550
- const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
683
+ const { stdout } = await execute('git', ['-C', root, 'diff', '--name-only', '--no-renames', '-z', since || `refs/latex-review/${name}/base`, `refs/latex-review/${name}/accepted`, '--']);
551
684
  return stdout.split('\0').filter(Boolean);
552
685
  } catch { return []; }
553
686
  }
687
+ export async function acceptedCommit(root: string, name: string) {
688
+ try { return (await execute('git', ['-C', root, 'rev-parse', '--verify', '-q', `refs/latex-review/${name}/accepted^{commit}`])).stdout.trim() || undefined; }
689
+ catch { return undefined; }
690
+ }