papilot 0.8.0 → 0.8.1

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 (39) hide show
  1. package/README.md +52 -121
  2. package/README.zh-CN.md +51 -120
  3. package/bin/doctor.mjs +7 -5
  4. package/dist/assets/{_virtual_papilot-licenses-D7ro6ecN.js → _virtual_papilot-licenses-CMfCtEYQ.js} +1 -1
  5. package/dist/assets/index-BlLyIqT9.js +265 -0
  6. package/dist/assets/index-DVZtyZvu.css +1 -0
  7. package/dist/assets/{math-V9uUaUzA.js → math-Bu3xEyrC.js} +1 -1
  8. package/dist/assets/{pdf-view-Bnb2uIBE.js → pdf-view-PbkCuTZ-.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/examples/papilot-paper/preview/main.pdf +0 -0
  11. package/examples/papilot-paper/preview/main.synctex.gz +0 -0
  12. package/package.json +9 -3
  13. package/src/server/acp.ts +35 -2
  14. package/src/server/agents.ts +1 -1
  15. package/src/server/app.ts +3 -3
  16. package/src/server/desktop-demo.ts +38 -0
  17. package/src/server/manuscript.ts +18 -0
  18. package/src/server/reconcile.ts +20 -0
  19. package/src/server/review.ts +3 -2
  20. package/src/server/start.ts +19 -4
  21. package/src/server/updates.ts +7 -1
  22. package/src/server/workspace.ts +54 -13
  23. package/src/shared.ts +17 -9
  24. package/vendor/latex-review/dist/web/assets/{index-7VKAte5x.js → index-CXdnUjct.js} +2 -2
  25. package/vendor/latex-review/dist/web/index.html +1 -1
  26. package/vendor/latex-review/src/core/current-commit.ts +104 -0
  27. package/vendor/latex-review/src/core/delivery.ts +62 -16
  28. package/vendor/latex-review/src/core/git.ts +2 -1
  29. package/vendor/latex-review/src/core/publish.ts +7 -1
  30. package/vendor/latex-review/src/core/reconcile.ts +101 -0
  31. package/vendor/latex-review/src/core/review.ts +5 -2
  32. package/vendor/latex-review/src/core/store.ts +1 -0
  33. package/vendor/latex-review/src/core/tasks.ts +1 -1
  34. package/vendor/latex-review/src/server.ts +12 -1
  35. package/vendor/latex-review/src/types.ts +5 -2
  36. package/vendor/latex-review/src/web/review.tsx +7 -5
  37. package/vendor/latex-review/src/web/zh-CN.ts +4 -2
  38. package/dist/assets/index-Cc-awdNn.css +0 -1
  39. package/dist/assets/index-D_VX51no.js +0 -265
@@ -9,7 +9,7 @@
9
9
  rel="icon"
10
10
  href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Crect width='16' height='16' rx='3' fill='%232b6e5c'/%3E%3Cpath d='M4 8.5l2.5 2.5L12 5' stroke='white' stroke-width='2' fill='none'/%3E%3C/svg%3E"
11
11
  />
12
- <script type="module" crossorigin src="/assets/index-7VKAte5x.js"></script>
12
+ <script type="module" crossorigin src="/assets/index-CXdnUjct.js"></script>
13
13
  <link rel="stylesheet" crossorigin href="/assets/index-DEz-LsGl.css">
14
14
  </head>
15
15
  <body>
@@ -0,0 +1,104 @@
1
+ import fs from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import type { Repo } from "./git.ts";
6
+ import { merge3 } from "./git.ts";
7
+ import type { Delivery } from "../types.ts";
8
+
9
+ export interface CombinedCommitReview { token: string; files: string[]; diff: string }
10
+ export class CommitNeedsReview extends Error {
11
+ constructor(public review: CombinedCommitReview) { super("Earlier edits to the same passages need to be included. Review the combined commit or apply without committing."); }
12
+ }
13
+
14
+ // Project only the task delta onto HEAD. The worktree and staging area are
15
+ // separate destinations: neither is used as the content of the commit.
16
+ export function planCurrentCommit(repo: Repo, data: Delivery, before: string, files: string[], message: string, confirmation?: string) {
17
+ const ref = repo.text(["symbolic-ref", "-q", "HEAD"], { check: false });
18
+ if (!ref.startsWith("refs/heads/")) throw new Error("Open a branch to commit this task, or apply it without committing.");
19
+ const parent = repo.rev("HEAD"), index = repo.text(["write-tree"]);
20
+ const base = repo.blobs(before, files), accepted = repo.blobs(data.accepted, files), head = repo.blobs(parent, files), staged = repo.blobs(index, files);
21
+ const committed: Record<string, Buffer | null> = {}, nextIndex: Record<string, Buffer | null> = {};
22
+ const dependencies: string[] = [];
23
+ for (const file of files) {
24
+ let [content, conflict] = merge3(head[file], base[file], accepted[file]);
25
+ if (conflict) {
26
+ // Include only the overlapping earlier passages, never the whole dirty
27
+ // file. This is a proposal until the author confirms its exact diff.
28
+ [content, conflict] = merge3(accepted[file], base[file], head[file], true);
29
+ if (conflict) throw new Error(`The earlier changes in ${file} cannot be included automatically. Apply to workspace to save your reviewed result without a commit.`);
30
+ dependencies.push(file);
31
+ }
32
+ let [combined, indexConflict] = merge3(staged[file], head[file], content);
33
+ if (indexConflict && dependencies.includes(file) && !merge3(staged[file], base[file], accepted[file])[1])
34
+ [combined, indexConflict] = merge3(content, head[file], staged[file], true);
35
+ if (indexConflict) throw new Error(`A different version of ${file} is already selected for another commit. Apply to workspace to save this result without changing that selection.`);
36
+ committed[file] = content;
37
+ nextIndex[file] = combined;
38
+ }
39
+ const tree = repo.treeWith(parent, committed);
40
+ if (tree === repo.treeId(parent)) throw new Error("These changes are already in the current commit. Apply without committing to finish this task.");
41
+ if (dependencies.length || confirmation) {
42
+ const token = createHash("sha256").update(JSON.stringify([ref, parent, index, before, data.accepted, tree, data.writes])).digest("hex");
43
+ if (confirmation !== token) {
44
+ const diff = repo.text(["diff", "--no-ext-diff", "--no-textconv", parent, tree, "--"]);
45
+ if (diff.length > 500000) throw new Error("The combined changes are too large to preview here. Apply to workspace and review them in Git before committing.");
46
+ throw new CommitNeedsReview({ token, files: dependencies, diff });
47
+ }
48
+ }
49
+ data.branch = ref.slice("refs/heads/".length);
50
+ data.parent = parent;
51
+ data.indexBefore = index;
52
+ data.indexAfter = repo.treeWith(index, nextIndex);
53
+ data.commit = repo.text(["commit-tree", tree, "-p", parent, "-m", message.trim()]);
54
+ }
55
+
56
+ // Hold Git's index lock while checking and installing the recorded result.
57
+ // CAS on the branch prevents updating the wrong or concurrently moved HEAD.
58
+ // A retry recognizes our commit and completes its files/index without a
59
+ // duplicate commit. Unrelated staged entries (including their flags) survive.
60
+ export function finishCurrentCommit(repo: Repo, data: Delivery, validateFiles: () => void, writeFiles: () => void) {
61
+ const index = path.resolve(repo.root, repo.text(["rev-parse", "--git-path", "index"]));
62
+ const lock = index + ".lock";
63
+ let fd: number;
64
+ try { fd = fs.openSync(lock, "wx", fs.existsSync(index) ? fs.statSync(index).mode & 0o777 : 0o644); }
65
+ catch (error) {
66
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error("Another Git operation is using the staging area. Wait for it to finish, then retry.");
67
+ throw error;
68
+ }
69
+ let temp: string | undefined;
70
+ try {
71
+ temp = fs.mkdtempSync(path.join(os.tmpdir(), "papilot-task-index-"));
72
+ const ref = `refs/heads/${data.branch}`;
73
+ if (repo.text(["symbolic-ref", "-q", "HEAD"], { check: false }) !== ref)
74
+ throw new Error("The current branch changed. Return to the original branch before retrying this task commit.");
75
+ const head = repo.rev("HEAD");
76
+ if (head !== data.parent && head !== data.commit)
77
+ throw new Error("The current branch moved during this task commit. Nothing else was changed; inspect the Git history before retrying.");
78
+ const target = path.join(temp, "index");
79
+ fs.copyFileSync(index, target);
80
+ const env = { ...process.env, GIT_INDEX_FILE: target };
81
+ const currentIndex = repo.text(["write-tree"], { env });
82
+ if (currentIndex !== data.indexBefore && currentIndex !== data.indexAfter)
83
+ throw new Error("The staged changes changed during this task commit. Nothing else was changed; restore the previous staging selection before retrying.");
84
+ validateFiles();
85
+ if (currentIndex !== data.indexAfter) {
86
+ const changed = repo.changedFiles(data.indexBefore!, data.indexAfter!);
87
+ for (const file of changed) {
88
+ const blob = repo.blob(data.indexAfter!, file);
89
+ if (blob === null) repo.run(["update-index", "--force-remove", "--", file], { env });
90
+ else repo.run(["update-index", "--add", "--cacheinfo", `${repo.mode(data.indexAfter!, file)},${repo.hashObject(blob)},${file}`], { env });
91
+ }
92
+ }
93
+ if (repo.text(["write-tree"], { env }) !== data.indexAfter) throw new Error("Could not prepare the task's staging result.");
94
+ fs.writeFileSync(fd, fs.readFileSync(target));
95
+ fs.fsyncSync(fd);
96
+ if (head !== data.commit) repo.run(["update-ref", "-m", "Papilot: commit reviewed task", ref, data.commit!, data.parent!]);
97
+ writeFiles();
98
+ fs.renameSync(lock, index);
99
+ } finally {
100
+ fs.closeSync(fd);
101
+ fs.rmSync(lock, { force: true });
102
+ if (temp) fs.rmSync(temp, { recursive: true, force: true });
103
+ }
104
+ }
@@ -4,8 +4,11 @@ import type { App } from "../server.ts";
4
4
  import type { Delivery } from "../types.ts";
5
5
  import { merge3 } from "./git.ts";
6
6
  import { readBytes, same, writeOrDelete } from "./files.ts";
7
+ import { planCurrentCommit, finishCurrentCommit, CommitNeedsReview } from "./current-commit.ts";
7
8
  import { safePath } from "./review.ts";
8
9
 
10
+ class DeliveryConflict extends Error {}
11
+
9
12
  const encode = (data: Buffer | null) => data?.toString("base64") ?? null;
10
13
  const decode = (data: string | null) => data === null ? null : Buffer.from(data, "base64");
11
14
 
@@ -24,16 +27,50 @@ export class Deliveries {
24
27
  const last = this.get(name);
25
28
  if (last && !last.done) throw new Error("Finish the pending delivery before changing this review. Retry the same destination.");
26
29
  }
27
- deliver(name: string, mode: Delivery["mode"], message = "") {
30
+ apply(name: string, keepCurrent = false, mode?: "workspace" | "commit", message?: string, confirmation?: string) {
31
+ const active = this.app.reconciliation.get(name);
32
+ mode ??= active?.intent?.mode ?? "workspace";
33
+ message ??= active?.intent?.message ?? "";
34
+ if (mode !== "workspace" && mode !== "commit") throw new Error("Choose a task commit or apply without committing.");
35
+ if (mode === "commit" && (!message.trim() || message.length > 5000)) throw new Error("Write a commit message (at most 5,000 characters).");
36
+ const last = this.get(name);
37
+ if (last && !last.done) {
38
+ const delivery = this.finish(name, last);
39
+ if (last.mode !== "branch") return { delivery };
40
+ }
41
+ if (keepCurrent) {
42
+ const task = this.app.tasks.get(name);
43
+ if (!active?.ready || task.accepted !== active.reviewBase || this.app.repo.changedFiles(task.accepted, this.app.tasks.snapshot(task)).length)
44
+ throw new Error("Review the merge suggestion before applying it, or ask the agent to keep the latest wording.");
45
+ }
46
+ if (active) this.app.reconciliation.intent(name, { mode, message });
47
+ if (!keepCurrent && active && (!active.ready || (this.app.review.latestRound(name)?.number ?? 0) <= active.round))
48
+ return { reconciliation: this.app.reconciliation.prepare(name, this.app.reconciliation.get(name)!) };
49
+ try { return { delivery: this.deliver(name, mode, message, keepCurrent, confirmation) }; }
50
+ catch (error) {
51
+ if (error instanceof CommitNeedsReview) return { commitReview: error.review };
52
+ if (!(error instanceof DeliveryConflict)) throw error;
53
+ const reconciliation = this.app.reconciliation.start(name, keepCurrent, { mode, message });
54
+ if (reconciliation) return { reconciliation };
55
+ return { delivery: this.deliver(name, mode, message, keepCurrent, confirmation) };
56
+ }
57
+ }
58
+ deliver(name: string, mode: Delivery["mode"], message = "", keepCurrent = false, confirmation?: string) {
28
59
  const app = this.app, task = app.tasks.get(name), last = this.get(name);
29
60
  if (!task.deferred) throw new Error("This task already applies accepted changes to the manuscript.");
30
- if (mode !== "workspace" && mode !== "branch") throw new Error("Choose a working directory or task branch.");
31
- if (last && last.mode !== mode) throw new Error("Continue using this task's original destination, or start a new task.");
32
- if (last && (!last.done || last.accepted === task.accepted)) return this.finish(name, last);
61
+ if (mode !== "workspace" && mode !== "commit" && mode !== "branch") throw new Error("Choose a working directory or task branch.");
62
+ const reconciliation = app.reconciliation.get(name);
63
+ if (reconciliation && (!reconciliation.ready || mode === "branch" || (!keepCurrent && (app.review.latestRound(name)?.number ?? 0) <= reconciliation.round)))
64
+ throw new Error("Review the merge suggestion before applying it to the manuscript.");
65
+ // Older task-branch commits remain available, but can now be applied
66
+ // through the same manuscript action. Never move or merge those branches.
67
+ if (last && last.mode !== mode && (!last.done || mode === "branch"))
68
+ throw new Error("Finish the pending delivery before applying to the manuscript.");
69
+ if (last && last.mode === mode && (!last.done || (!reconciliation && last.accepted === task.accepted))) return this.finish(name, last);
33
70
  if (app.publisher.busy(name) || app.review.counts(name).pending) throw new Error("Finish reviewing every change before choosing a destination.");
34
- const before = last?.accepted ?? task.base;
71
+ const before = reconciliation?.base ?? (last && last.mode !== "branch" ? last.accepted : task.base);
35
72
  const files = app.repo.changedFiles(before, task.accepted);
36
- if (!files.length) throw new Error("This task has no accepted changes left to deliver.");
73
+ if (!files.length && !reconciliation) throw new Error("This task has no accepted changes left to deliver.");
37
74
  const base = app.repo.blobs(before, files), accepted = app.repo.blobs(task.accepted, files);
38
75
  const data: Delivery = { mode, accepted: task.accepted, seq: app.store.one<{ seq: number }>("SELECT MAX(seq) AS seq FROM actions WHERE task=?", name)!.seq, done: false, writes: [] };
39
76
  const contents: Record<string, Buffer | null> = {};
@@ -55,14 +92,16 @@ export class Deliveries {
55
92
  }
56
93
  for (const file of files) {
57
94
  const [merged, conflict] = merge3(current[file], base[file], accepted[file]);
58
- if (conflict) throw new Error(`Cannot deliver ${file}: it conflicts with the destination. Nothing was written. Revise the task and try again.`);
95
+ if (conflict) throw new DeliveryConflict(`Cannot deliver ${file}: it conflicts with the destination. Nothing was written. Revise the task and try again.`);
59
96
  contents[file] = merged;
60
- if (mode === "workspace" && !same(current[file], merged)) data.writes.push({ file, before: encode(current[file]), after: encode(merged) });
97
+ if (mode !== "branch" && !same(current[file], merged)) data.writes.push({ file, before: encode(current[file]), after: encode(merged) });
61
98
  }
62
99
  if (mode === "branch") {
63
100
  const tree = app.repo.treeWith(data.parent!, contents);
64
101
  data.commit = app.repo.text(["commit-tree", tree, "-p", data.parent!, "-m", message.trim()]);
65
102
  }
103
+ if (mode === "commit" && (!message.trim() || message.length > 5000)) throw new Error("Write a commit message (at most 5,000 characters).");
104
+ if (mode === "commit") planCurrentCommit(app.repo, data, before, files, message, confirmation);
66
105
  this.save(name, data);
67
106
  return this.finish(name, data);
68
107
  }
@@ -75,7 +114,7 @@ export class Deliveries {
75
114
  throw new Error("Finish or abort the merge before applying changes.");
76
115
  }
77
116
  finish(name: string, data: Delivery) {
78
- if (data.done) return this.result(data);
117
+ if (data.done) { if (data.mode !== "branch") this.app.reconciliation.clear(name); return this.result(data); }
79
118
  const app = this.app;
80
119
  if (data.mode === "branch") {
81
120
  const ref = `refs/heads/${data.branch}`;
@@ -87,14 +126,19 @@ export class Deliveries {
87
126
  } else {
88
127
  this.assertNoMerge();
89
128
  const writes = data.writes.map(write => ({ ...write, target: safePath(app.root, write.file) }));
90
- for (const write of writes) {
91
- const current = readBytes(write.target);
92
- if (!same(current, decode(write.before)) && !same(current, decode(write.after)))
93
- throw new Error(`Cannot finish delivery: ${write.file} changed. Restore it to its state before delivery and retry.`);
94
- }
95
- for (const write of writes) writeOrDelete(write.target, decode(write.after));
129
+ const validate = () => {
130
+ for (const write of writes) {
131
+ const current = readBytes(write.target);
132
+ if (!same(current, decode(write.before)) && !same(current, decode(write.after)))
133
+ throw new Error(`Cannot finish delivery: ${write.file} changed. Restore it to its state before delivery and retry.`);
134
+ }
135
+ };
136
+ const applyFiles = () => { for (const write of writes) writeOrDelete(write.target, decode(write.after)); };
137
+ if (data.mode === "commit") finishCurrentCommit(app.repo, data, validate, applyFiles);
138
+ else { validate(); applyFiles(); }
96
139
  }
97
140
  this.save(name, { ...data, done: true, writes: [] });
141
+ if (data.mode !== "branch") this.app.reconciliation.clear(name);
98
142
  return this.result(data);
99
143
  }
100
144
  result(data: Delivery) {
@@ -103,7 +147,9 @@ export class Deliveries {
103
147
  }
104
148
  recover() {
105
149
  for (const row of this.app.store.all<{ task: string; data: Delivery }>("SELECT * FROM deliveries")) {
106
- if (!row.data.done) try { this.finish(row.task, row.data); } catch { /* Retain the journal for an explicit retry. */ }
150
+ if (row.data.done && row.data.mode !== "branch" && this.app.reconciliation.get(row.task) && this.app.tasks.get(row.task).accepted === row.data.accepted)
151
+ this.app.reconciliation.clear(row.task);
152
+ else if (!row.data.done) try { this.finish(row.task, row.data); } catch { /* Retain the journal for an explicit retry. */ }
107
153
  }
108
154
  }
109
155
  }
@@ -297,6 +297,7 @@ export function merge3(
297
297
  ours: Buffer | null,
298
298
  base: Buffer | null,
299
299
  theirs: Buffer | null,
300
+ favorCurrent = false,
300
301
  ): [Buffer | null, boolean] {
301
302
  if (same(ours, base) || same(ours, theirs)) return [theirs, false];
302
303
  if (same(theirs, base)) return [ours, false];
@@ -312,7 +313,7 @@ export function merge3(
312
313
  path.join(folder, name),
313
314
  );
314
315
  [ours, base, theirs].forEach((data, i) => fs.writeFileSync(files[i], data));
315
- const result = run(gitBinary(), ["merge-file", "-p", ...files], {
316
+ const result = run(gitBinary(), ["merge-file", "-p", ...(favorCurrent ? ["--ours"] : []), ...files], {
316
317
  check: false,
317
318
  });
318
319
  return [result.stdout, result.status !== 0];
@@ -321,6 +321,7 @@ export class Publisher {
321
321
  const app = this.app,
322
322
  name = job.task,
323
323
  project = app.project;
324
+ if (app.reconciliation.get(name)?.ready === false) throw new Error("Retry applying to finish preparing the merge review.");
324
325
  let task = app.tasks.get(name);
325
326
  if (!fs.existsSync(task.worktree))
326
327
  throw new PublishError(
@@ -335,13 +336,18 @@ export class Publisher {
335
336
  latest = app.review.latestRound(name),
336
337
  decided = app.review.lastChange(name),
337
338
  violations = scopeViolations(app.repo, task.base, after, task.scope);
339
+ const reconciliation = app.reconciliation.get(name);
340
+ if (reconciliation) {
341
+ for (const file of app.repo.changedFiles(task.base, after))
342
+ if (!reconciliation.files.includes(file) && !violations.includes(file)) violations.push(file);
343
+ }
338
344
  if (violations.length)
339
345
  throw new PublishError(
340
346
  "Some edits are outside the task scope: " +
341
347
  violations.slice(0, 10).join(", "),
342
348
  [
343
349
  "Allowed: " +
344
- (task.scope.sections.length
350
+ (reconciliation ? reconciliation.files : task.scope.sections.length
345
351
  ? task.scope.sections.map((s) => `${s.number} ${s.title}`)
346
352
  : task.scope.paths
347
353
  ).join(", "),
@@ -0,0 +1,101 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { App } from "../server.ts";
3
+ import { merge3 } from "./git.ts";
4
+ import { readBytes, same, writeOrDelete } from "./files.ts";
5
+ import { safePath } from "./review.ts";
6
+ import { GENERATED, revisionReader } from "./tasks.ts";
7
+ import { buildOutline, resolveSections, selectedRanges } from "./outline.ts";
8
+
9
+ export interface Reconciliation {
10
+ id: string;
11
+ round: number;
12
+ files: string[];
13
+ sourceBase: string;
14
+ sourceAccepted: string;
15
+ base: string;
16
+ reviewBase: string;
17
+ worktreeBefore: string;
18
+ ready: boolean;
19
+ intent?: { mode: "workspace" | "commit"; message: string };
20
+ }
21
+
22
+ // A conflict is another proposal in the same task. The manuscript and its
23
+ // index stay untouched; only the private proposal is rebased for PDF review.
24
+ export class Reconciliations {
25
+ constructor(public app: App) {}
26
+ get(name: string) {
27
+ return this.app.store.one<{ data: Reconciliation }>("SELECT data FROM reconciliations WHERE task=?", name)?.data;
28
+ }
29
+ intent(name: string, intent: NonNullable<Reconciliation["intent"]>) {
30
+ const data = { ...this.get(name)!, intent };
31
+ this.app.store.execute("UPDATE reconciliations SET data=? WHERE task=?", JSON.stringify(data), name);
32
+ return data;
33
+ }
34
+ clear(name: string) {
35
+ this.app.store.execute("DELETE FROM reconciliations WHERE task=?", name);
36
+ }
37
+ start(name: string, force = false, intent?: Reconciliation["intent"]) {
38
+ const app = this.app, task = app.tasks.get(name), previous = this.get(name);
39
+ if (previous && (!previous.ready || (!force && (app.review.latestRound(name)?.number ?? 0) <= previous.round)))
40
+ return this.prepare(name, previous);
41
+ const worktreeBefore = app.tasks.snapshot(task);
42
+ if (app.repo.changedFiles(task.accepted, worktreeBefore).length)
43
+ throw new Error("Review the latest task edits before applying the manuscript changes.");
44
+ const delivered = app.delivery.get(name);
45
+ const sourceBase = previous?.base ?? (delivered && delivered.mode !== "branch" ? delivered.accepted : task.base);
46
+ const base = app.repo.snapshot([app.project.source_dir, ...task.scope.paths], GENERATED);
47
+ const changed = app.repo.changedFiles(sourceBase, task.accepted);
48
+ const old = app.repo.blobs(sourceBase, changed), accepted = app.repo.blobs(task.accepted, changed), current = app.repo.blobs(base, changed);
49
+ const files: string[] = [], contents: Record<string, Buffer | null> = {};
50
+ for (const file of changed) {
51
+ const [merged, conflict] = merge3(current[file], old[file], accepted[file]);
52
+ if (!conflict) contents[file] = merged;
53
+ else {
54
+ files.push(file);
55
+ // Keep clean hunks even in a file with conflicts. At overlapping
56
+ // hunks keep the latest manuscript as the baseline for human review.
57
+ const [kept, failed] = merge3(current[file], old[file], accepted[file], true);
58
+ contents[file] = failed ? current[file] : kept;
59
+ }
60
+ }
61
+ if (!files.length) return null; // The author may have resolved it meanwhile.
62
+ const reviewBase = app.repo.commitWith(base, contents, "latex-review: prepare merge review");
63
+ const data: Reconciliation = { id: randomUUID(), round: app.review.latestRound(name)?.number ?? 0, files, sourceBase, sourceAccepted: task.accepted, base, reviewBase, worktreeBefore, ready: false, ...(intent ? { intent } : {}) };
64
+ for (const file of app.repo.changedFiles(worktreeBefore, reviewBase)) safePath(task.worktree, file);
65
+ // Keep the three versions available to the agent and across Git GC.
66
+ for (const [kind, rev] of Object.entries({ original: sourceBase, proposed: task.accepted, current: base, before: worktreeBefore, review: reviewBase }))
67
+ app.repo.updateRef(`refs/latex-review/${name}/merge/${data.id}/${kind}`, rev);
68
+ app.store.execute("INSERT OR REPLACE INTO reconciliations(task,data) VALUES(?,?)", name, JSON.stringify(data));
69
+ return this.prepare(name, data);
70
+ }
71
+ prepare(name: string, data: Reconciliation) {
72
+ if (data.ready) return data;
73
+ const app = this.app, task = app.tasks.get(name);
74
+ const files = app.repo.changedFiles(data.worktreeBefore, data.reviewBase);
75
+ const before = app.repo.blobs(data.worktreeBefore, files), after = app.repo.blobs(data.reviewBase, files);
76
+ const writes = files.map(file => ({ file, path: safePath(task.worktree, file) }));
77
+ for (const write of writes) {
78
+ const current = readBytes(write.path);
79
+ if (!same(current, before[write.file]) && !same(current, after[write.file]))
80
+ throw new Error("The task changed while preparing its merge review. Keep those edits and retry after reviewing them.");
81
+ }
82
+ let scope = task.scope;
83
+ if (scope.sections.length) {
84
+ const outline = buildOutline(revisionReader(app.repo, data.reviewBase, app.project.doc_dir), app.project.main);
85
+ const [ranges, sections] = selectedRanges(outline.nodes, resolveSections(outline.nodes, scope.sections.map(section => section.id.replace(/^label:/, ""))));
86
+ scope = { ranges, sections, paths: Object.keys(ranges).sort() };
87
+ }
88
+ for (const write of writes) writeOrDelete(write.path, after[write.file]);
89
+ app.repo.updateRef(`refs/latex-review/${name}/base`, data.reviewBase);
90
+ app.repo.updateRef(`refs/latex-review/${name}/accepted`, data.reviewBase);
91
+ app.store.transaction(() => {
92
+ app.store.execute("UPDATE tasks SET base=?,accepted=?,scope=? WHERE name=?", data.reviewBase, data.reviewBase, JSON.stringify(scope), name);
93
+ app.store.execute("UPDATE reconciliations SET data=? WHERE task=?", JSON.stringify({ ...data, ready: true }), name);
94
+ });
95
+ return { ...data, ready: true };
96
+ }
97
+ recover() {
98
+ for (const row of this.app.store.all<{ task: string; data: Reconciliation }>("SELECT * FROM reconciliations"))
99
+ if (!row.data.ready) try { this.prepare(row.task, row.data); } catch { /* Retry without overwriting intervening edits. */ }
100
+ }
101
+ }
@@ -121,8 +121,8 @@ export class Review {
121
121
  "SELECT * FROM actions WHERE task=? AND status='applied' ORDER BY seq DESC LIMIT 1",
122
122
  name,
123
123
  );
124
- const delivered = this.app.delivery.get(name);
125
- return action && latest && action.round === latest.number && (!delivered || action.seq > delivered.seq) ? action : null;
124
+ const delivered = this.app.delivery.get(name), reconciliation = this.app.reconciliation.get(name);
125
+ return action && latest && (!reconciliation || action.round > reconciliation.round) && action.round === latest.number && (!delivered || action.seq > delivered.seq) ? action : null;
126
126
  }
127
127
  summary(name: string): Summary {
128
128
  const task = this.app.tasks.get(name),
@@ -367,6 +367,9 @@ export class Review {
367
367
  const task = this.app.tasks.get(name),
368
368
  kind = data.action;
369
369
  this.app.delivery.assertReady(name);
370
+ const reconciliation = this.app.reconciliation.get(name);
371
+ if (reconciliation && (!reconciliation.ready || data.round <= reconciliation.round))
372
+ throw new Error("Review the new merge suggestion before changing decisions.");
370
373
  if (!Object.hasOwn(DECIDED, kind))
371
374
  throw new Error("The action must be accept, reject or comment.");
372
375
  const identity = data.id || randomUUID();
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  const SCHEMA = `
5
5
  CREATE TABLE IF NOT EXISTS tasks(name TEXT PRIMARY KEY,title TEXT NOT NULL,worktree TEXT NOT NULL,base TEXT NOT NULL,accepted TEXT NOT NULL,scope TEXT NOT NULL,created REAL NOT NULL,closed REAL);
6
+ CREATE TABLE IF NOT EXISTS reconciliations(task TEXT PRIMARY KEY,data TEXT NOT NULL);
6
7
  CREATE TABLE IF NOT EXISTS deliveries(task TEXT PRIMARY KEY,data TEXT NOT NULL);
7
8
  CREATE TABLE IF NOT EXISTS rounds(task TEXT NOT NULL,number INTEGER NOT NULL,before TEXT NOT NULL,after TEXT NOT NULL,units TEXT NOT NULL,summary TEXT NOT NULL,created REAL NOT NULL,PRIMARY KEY(task,number));
8
9
  CREATE TABLE IF NOT EXISTS actions(seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,task TEXT NOT NULL,round INTEGER NOT NULL,kind TEXT NOT NULL,changes TEXT NOT NULL,body TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,detail TEXT NOT NULL DEFAULT '{}',created REAL NOT NULL);
@@ -155,7 +155,7 @@ export class Tasks {
155
155
  name,
156
156
  );
157
157
  app.store.execute("DELETE FROM tasks WHERE name=?", name);
158
- for (const table of ["rounds", "actions", "messages", "jobs", "deliveries"])
158
+ for (const table of ["rounds", "actions", "messages", "jobs", "deliveries", "reconciliations"])
159
159
  app.store.execute(`DELETE FROM ${table} WHERE task=?`, name);
160
160
  app.store.execute(
161
161
  "INSERT INTO tasks(name,title,worktree,base,accepted,scope,created,deferred) VALUES(?,?,?,?,?,?,?,?)",
@@ -10,6 +10,7 @@ import { CONFIG_NAME, loadProject, type Project } from "./core/config.ts";
10
10
  import { Store } from "./core/store.ts";
11
11
  import { Tasks } from "./core/tasks.ts";
12
12
  import { Review } from "./core/review.ts";
13
+ import { Reconciliations } from "./core/reconcile.ts";
13
14
  import { Deliveries } from "./core/delivery.ts";
14
15
  import { more, PublishError, Publisher } from "./core/publish.ts";
15
16
  import { view, type ViewRequest } from "./core/view.ts";
@@ -29,6 +30,7 @@ export class App {
29
30
  tasks: Tasks;
30
31
  review: Review;
31
32
  delivery: Deliveries;
33
+ reconciliation: Reconciliations;
32
34
  publisher: Publisher;
33
35
  references: ReferenceChecker;
34
36
  confirmations: Confirmations;
@@ -42,6 +44,7 @@ export class App {
42
44
  this.tasks = new Tasks(this);
43
45
  this.review = new Review(this);
44
46
  this.delivery = new Deliveries(this);
47
+ this.reconciliation = new Reconciliations(this);
45
48
  this.publisher = new Publisher(this);
46
49
  this.references = new ReferenceChecker(
47
50
  path.join(this.state, "references.json"),
@@ -51,6 +54,7 @@ export class App {
51
54
  );
52
55
  this.review.recover();
53
56
  this.delivery.recover();
57
+ this.reconciliation.recover();
54
58
  }
55
59
  get project() {
56
60
  const config = path.join(this.root, CONFIG_NAME),
@@ -67,11 +71,13 @@ export class App {
67
71
  taskInfo(name: string) {
68
72
  const task = this.tasks.get(name),
69
73
  delivery = this.delivery.get(name),
70
- scope = task.scope;
74
+ reconciliation = this.reconciliation.get(name),
75
+ scope = reconciliation ? { ...task.scope, paths: reconciliation.files, ranges: null } : task.scope;
71
76
  return {
72
77
  ...this.review.summary(name),
73
78
  deferred: Boolean(task.deferred),
74
79
  delivery: delivery ? { ...this.delivery.result(delivery), pending: !delivery.done } : null,
80
+ reconciliation: reconciliation ?? null,
75
81
  worktree: task.worktree,
76
82
  url: this.page(name),
77
83
  viewing: now() - (this.viewers.get(name) ?? 0) < 15,
@@ -337,6 +343,11 @@ export function createServer(app: App) {
337
343
  { schema: { body: { ...objectSchema, additionalProperties: false, properties: { locations: { type: "array", maxItems: 8, items: { type: "string", maxLength: 1000 } }, pages: { type: "array", maxItems: 8, items: { type: "string", maxLength: 40 } }, accepted: { type: "boolean" } } } } },
338
344
  (request) => view(app, request.params.name, request.body),
339
345
  );
346
+ server.post<{ Params: { name: string }; Body: { keepCurrent?: boolean; mode?: "workspace" | "commit"; message?: string; confirmation?: string } }>(
347
+ "/api/tasks/:name/apply",
348
+ { schema: { body: { ...objectSchema, additionalProperties: false, properties: { confirmation: { type: "string", maxLength: 100 }, keepCurrent: { type: "boolean" }, mode: { enum: ["workspace", "commit"] }, message: { type: "string", maxLength: 5000 } } } } },
349
+ request => app.delivery.apply(request.params.name, request.body.keepCurrent === true, request.body.mode, request.body.message, request.body.confirmation),
350
+ );
340
351
  server.post<{ Params: { name: string }; Body: { mode: "workspace" | "branch"; message?: string } }>(
341
352
  "/api/tasks/:name/deliver",
342
353
  { schema: { body: { ...objectSchema, required: ["mode"], properties: { mode: { enum: ["workspace", "branch"] }, message: { type: "string", maxLength: 5000 } } } } },
@@ -50,12 +50,14 @@ export interface Task {
50
50
  closed: number | null;
51
51
  }
52
52
  export interface Delivery {
53
- mode: "workspace" | "branch";
53
+ mode: "workspace" | "commit" | "branch";
54
54
  accepted: string;
55
55
  seq: number;
56
56
  branch?: string;
57
57
  commit?: string;
58
58
  parent?: string;
59
+ indexBefore?: string;
60
+ indexAfter?: string;
59
61
  previous?: string;
60
62
  done: boolean;
61
63
  writes: { file: string; before: string | null; after: string | null }[];
@@ -422,8 +424,9 @@ export interface PageState {
422
424
  * and how many manuscript files this task's accepted changes touched.
423
425
  */
424
426
  workspace?: {
427
+ merging?: boolean;
425
428
  deferred?: boolean;
426
- destination?: "workspace" | "branch";
429
+ destination?: "workspace" | "commit" | "branch";
427
430
  status: string;
428
431
  unsent: number;
429
432
  comments?: number;
@@ -1354,7 +1354,7 @@ export function ReviewApp({
1354
1354
  : state?.workspace?.status === "building" ||
1355
1355
  state?.state === "building"
1356
1356
  ? "Preparing the next review…"
1357
- : "All changes reviewed",
1357
+ : state?.workspace?.merging ? "Merge review" : "All changes reviewed",
1358
1358
  )}
1359
1359
  </strong>
1360
1360
  <span>
@@ -1365,14 +1365,16 @@ export function ReviewApp({
1365
1365
  state?.workspace?.status || "",
1366
1366
  ) || state?.state === "building"
1367
1367
  ? "New changes will appear here when ready."
1368
- : state?.workspace?.comments
1368
+ : state?.workspace?.merging
1369
+ ? "Your manuscript is unchanged until you save the merged result."
1370
+ : state?.workspace?.comments
1369
1371
  ? "Your comments are saved. Send feedback so the agent can revise."
1370
1372
  : state?.workspace?.deferred
1371
1373
  ? state.workspace.accepted
1372
- ? "Accepted changes are saved in this task. Choose where to save them below."
1374
+ ? "Choose how to save the accepted changes to your manuscript."
1373
1375
  : state.workspace.destination === "branch"
1374
- ? "Accepted changes are committed to the task branch."
1375
- : state.workspace.destination === "workspace"
1376
+ ? "Choose how to save the accepted changes to your manuscript."
1377
+ : (state.workspace.destination === "workspace" || state.workspace.destination === "commit")
1376
1378
  ? "Accepted changes are in your manuscript."
1377
1379
  : "There are no accepted changes to save."
1378
1380
  : state?.workspace?.accepted
@@ -1,7 +1,9 @@
1
1
  // Simplified Chinese for the review viewer, keyed by the English text.
2
2
  export const zhCN: Record<string, string> = {
3
- "Accepted changes are saved in this task. Choose where to save them below.": "已接受的改动保存在此任务中,请在下方选择保存方式。",
4
- "Accepted changes are committed to the task branch.": "已接受的改动已提交到任务分支。",
3
+ "Merge review": "合并审阅",
4
+ "Your manuscript is unchanged until you save the merged result.": "保存合并结果前,论文保持不变。",
5
+ "Wait for the merge suggestion before changing review decisions.": "请等待合并建议生成后再审核。",
6
+ "Choose how to save the accepted changes to your manuscript.": "请选择如何将已接受的改动保存到论文。",
5
7
  "There are no accepted changes to save.": "没有需要保存的已接受改动。",
6
8
 
7
9
  // Overview