diffing 0.1.2 → 0.2.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.
Files changed (82) hide show
  1. package/README.md +75 -3
  2. package/dist/cli-agent-Vbs-ePHv.mjs +443 -0
  3. package/dist/cli.mjs +422 -10
  4. package/dist/client/assets/{angular-html-BABKYppC.js → angular-html-fdYbuxHF.js} +1 -1
  5. package/dist/client/assets/{angular-ts-CtXLuOQb.js → angular-ts-D3iFqR-R.js} +1 -1
  6. package/dist/client/assets/{apl-DOi69evI.js → apl-MnBV-iQN.js} +1 -1
  7. package/dist/client/assets/{astro-J8Em2tfW.js → astro-BrpK01E7.js} +1 -1
  8. package/dist/client/assets/{blade-B_tOnqRD.js → blade-CGmyBuwD.js} +1 -1
  9. package/dist/client/assets/{c-C0tRZWSi.js → c-xQnwtG9Y.js} +1 -1
  10. package/dist/client/assets/{cobol-Dj_rkgIY.js → cobol-ypVzOIQu.js} +1 -1
  11. package/dist/client/assets/{coffee-C6Vq4BFF.js → coffee-CrtE1uj2.js} +1 -1
  12. package/dist/client/assets/{cpp-Cno48fgF.js → cpp-DLd4XEKA.js} +1 -1
  13. package/dist/client/assets/{crystal-C7tmDnU1.js → crystal-CJYvpLt_.js} +1 -1
  14. package/dist/client/assets/{css-0v4o-iNQ.js → css-DrX2B9os.js} +1 -1
  15. package/dist/client/assets/{edge-CvULxRem.js → edge-BsQYk9OW.js} +1 -1
  16. package/dist/client/assets/{elixir-vRTauEw-.js → elixir-DU_VfGYR.js} +1 -1
  17. package/dist/client/assets/{elm-fLFTu1xn.js → elm-W4eOLEDv.js} +1 -1
  18. package/dist/client/assets/{erb-CtItcikB.js → erb-D9FdIJ2e.js} +1 -1
  19. package/dist/client/assets/{git-rebase-5MeRBXZD.js → git-rebase-BtPCXjbS.js} +1 -1
  20. package/dist/client/assets/{glimmer-js-B7yA-u8_.js → glimmer-js-BVfUGMUb.js} +1 -1
  21. package/dist/client/assets/{glimmer-ts-B2g1OaHc.js → glimmer-ts-BZEaHDZL.js} +1 -1
  22. package/dist/client/assets/{glsl-CqNPWp5Y.js → glsl-CSiNDuwt.js} +1 -1
  23. package/dist/client/assets/{graphql-CF78KbfF.js → graphql-Crf08Rhb.js} +1 -1
  24. package/dist/client/assets/{hack-Dz8fQ8O9.js → hack-DUiQuO20.js} +1 -1
  25. package/dist/client/assets/{haml-3Gbl257r.js → haml-BtvVTDRG.js} +1 -1
  26. package/dist/client/assets/{handlebars-5UZnh5L7.js → handlebars-Bu_cdZA_.js} +1 -1
  27. package/dist/client/assets/{html-JOaQiIkL.js → html-C4cun3zD.js} +1 -1
  28. package/dist/client/assets/{html-derivative-GyX6oYy3.js → html-derivative-CQnD80KV.js} +1 -1
  29. package/dist/client/assets/{http-t6uVAxgS.js → http-DuOJF9Kg.js} +1 -1
  30. package/dist/client/assets/{hurl-OLQZpg0E.js → hurl-DgrdYsYh.js} +1 -1
  31. package/dist/client/assets/{index-CghVgrAF.js → index-CZZd7iTh.js} +99 -68
  32. package/dist/client/assets/index-CkC-EvWo.css +1 -0
  33. package/dist/client/assets/{java-BqL_yaya.js → java-D-vtFiv8.js} +1 -1
  34. package/dist/client/assets/{javascript-CxBNAxR5.js → javascript-CFYFXvdN.js} +1 -1
  35. package/dist/client/assets/{jinja-CuaK033Z.js → jinja-fZvIg2_s.js} +1 -1
  36. package/dist/client/assets/{jison-aL_w2vvo.js → jison-DEzxLq5g.js} +1 -1
  37. package/dist/client/assets/{json-Byruj-EC.js → json-QM4VBqMC.js} +1 -1
  38. package/dist/client/assets/{jsx-CosY06uG.js → jsx-DLzUM_cC.js} +1 -1
  39. package/dist/client/assets/{julia-u1y7fV63.js → julia-Dwt1SIpn.js} +1 -1
  40. package/dist/client/assets/{just-DS4T9P_K.js → just-Dwp-hLcL.js} +1 -1
  41. package/dist/client/assets/{latex-C9c2IWW4.js → latex-CMF1JViz.js} +1 -1
  42. package/dist/client/assets/{liquid-DcXQgkEV.js → liquid-czkpOZWY.js} +1 -1
  43. package/dist/client/assets/{lua-DWe0hUn1.js → lua-DE5PD0Vh.js} +1 -1
  44. package/dist/client/assets/{marko-CIW7o2up.js → marko-DHeEvZiW.js} +1 -1
  45. package/dist/client/assets/{mdc-DKjGzb4k.js → mdc-B941liUQ.js} +1 -1
  46. package/dist/client/assets/{nginx-CBXzDlkk.js → nginx-Pb9t8c-r.js} +1 -1
  47. package/dist/client/assets/{nim-CBf_vdee.js → nim-MMUvW_R1.js} +1 -1
  48. package/dist/client/assets/{perl-xC9qc39I.js → perl-DXwXRmuC.js} +1 -1
  49. package/dist/client/assets/{php-n4vnn5oz.js → php-D42kv59r.js} +1 -1
  50. package/dist/client/assets/{pug-Db536lA_.js → pug-v0iesski.js} +1 -1
  51. package/dist/client/assets/{qml-CrATLnNN.js → qml-spxQQ-ei.js} +1 -1
  52. package/dist/client/assets/{r-DBQ-dydl.js → r-CobWTBG9.js} +1 -1
  53. package/dist/client/assets/{razor-C_wvK1iP.js → razor-B-_Ww3MB.js} +1 -1
  54. package/dist/client/assets/{regexp-DowFj0Xd.js → regexp-BJMVs8R0.js} +1 -1
  55. package/dist/client/assets/{rst-rY4UPiwe.js → rst-BHcaWsY0.js} +1 -1
  56. package/dist/client/assets/{ruby-C4cISY4u.js → ruby-BCMT6kog.js} +1 -1
  57. package/dist/client/assets/{sas-ui1PNNkD.js → sas-DHfZnd3R.js} +1 -1
  58. package/dist/client/assets/{scss-BZrs95TC.js → scss-DjDsJvDO.js} +1 -1
  59. package/dist/client/assets/{shellscript-D9YVFS67.js → shellscript-B6qaIiU-.js} +1 -1
  60. package/dist/client/assets/{shellsession-DK7LMzYg.js → shellsession-ByCeir2b.js} +1 -1
  61. package/dist/client/assets/{soy-CNkc-Oji.js → soy-C6WpnMSF.js} +1 -1
  62. package/dist/client/assets/{sql-BvRdqBMa.js → sql-BIvSNLV_.js} +1 -1
  63. package/dist/client/assets/{stata-Bawjgtiz.js → stata-PG9iZ--I.js} +1 -1
  64. package/dist/client/assets/{surrealql-7v7lT5jp.js → surrealql-CmvCzG09.js} +1 -1
  65. package/dist/client/assets/{svelte-p7w_kluv.js → svelte-ChumG88s.js} +1 -1
  66. package/dist/client/assets/{templ-0Gi_yZZr.js → templ-CmzuRY_a.js} +1 -1
  67. package/dist/client/assets/{tex-nZ_J3lks.js → tex-DL8t-_eJ.js} +1 -1
  68. package/dist/client/assets/{ts-tags-CuG-Aj5j.js → ts-tags-BFfE0Egm.js} +1 -1
  69. package/dist/client/assets/{tsx-Cc7GNb_V.js → tsx-C1bFyHcE.js} +1 -1
  70. package/dist/client/assets/{twig-BBlmihVM.js → twig-DkIluZEQ.js} +1 -1
  71. package/dist/client/assets/{typescript-aiXwsZhm.js → typescript-Gc86Bc3j.js} +1 -1
  72. package/dist/client/assets/{vue-Cs5tk2kn.js → vue-C88JmL53.js} +1 -1
  73. package/dist/client/assets/{vue-html-BEI_B8Ml.js → vue-html-Cry4aksC.js} +1 -1
  74. package/dist/client/assets/{vue-vine-CpWdOkP4.js → vue-vine-zlDkA3Ui.js} +1 -1
  75. package/dist/client/assets/{xml-madLQSkS.js → xml-NvwpIFDy.js} +1 -1
  76. package/dist/client/assets/{xsl-CqZR37V9.js → xsl-CDjXvY9w.js} +1 -1
  77. package/dist/client/assets/{yaml-DHr9ndbK.js → yaml-Bh-LPjcH.js} +1 -1
  78. package/dist/client/index.html +2 -2
  79. package/dist/mcp-DKlThflu.mjs +253 -0
  80. package/dist/server-lock-CAdqFSfl.mjs +691 -0
  81. package/package.json +1 -1
  82. package/dist/client/assets/index-CmfB2R-C.css +0 -1
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # diffing
2
2
 
3
- A local-first code review tool and double-sided bridge designed for the modern AI coding agent workflow. Review AI-generated changes in a high-fidelity, GitHub-like web UI, leave inline comments, and hand them back to your coding agent to fix in real time.
3
+ A local-first code review tool and double-sided bridge designed for the modern AI coding agent workflow. Review AI-generated changes in a high-fidelity, GitHub-like web UI, leave inline comments, and hand them back to your coding agent to fix in real time — and review the agent's **plan** the same way *before* it writes any code, approving, rejecting, or requesting changes on specific lines and sections.
4
4
 
5
5
  <img width="1840" height="1196" alt="image" src="https://github.com/user-attachments/assets/767d42ed-a497-4b21-aca7-35be8b9a7006" />
6
6
 
@@ -189,6 +189,14 @@ diffing comments [--open] [--json] # One-shot query of the comments database
189
189
  diffing reply <id> --body "..." # Post an agent response or explanation
190
190
  diffing resolve <id> # Mark a comment resolved, updating the UI live
191
191
  diffing url # Retrieve the active server base URL
192
+
193
+ # Plan review (review a markdown plan before any code is written)
194
+ diffing plan submit <file> [--title T] [--model M] [--id <id>] [--wait] # Submit/resubmit a plan; prints its id
195
+ diffing plan await [--timeout N] # Block until the human approves/rejects/requests-changes; outputs the verdict XML
196
+ diffing plan list [--json] # List submitted plans with their verdicts
197
+ diffing plan show [<id>] [--json] # Show one plan as <plan-review> XML (latest if omitted)
198
+ diffing plan reply <commentId> --body "..." # Reply to an inline plan comment
199
+ diffing plan resolve <commentId> # Mark a plan comment resolved
192
200
  ```
193
201
 
194
202
  ### B. Model Context Protocol (MCP) Server
@@ -204,17 +212,18 @@ If your agent supports MCP (such as Cursor, Claude Desktop, or Gemini), configur
204
212
  }
205
213
  }
206
214
  ```
207
- Exposes four powerful tools directly to your agent: `await_review`, `list_comments`, `reply_to_comment`, and `resolve_comment`.
215
+ Exposes tools directly to your agent for both review flows — diff review: `await_review`, `list_comments`, `reply_to_comment`, `resolve_comment`; and plan review: `submit_plan`, `await_plan_review`, `list_plans`, `get_plan`, `reply_to_plan_comment`, `resolve_plan_comment`.
208
216
 
209
217
  ### C. Agent Skills
210
218
  You can install diffing skills directly into your AI coding assistant:
211
219
  ```bash
212
220
  npx skills add ahmedragab20/diffing
213
221
  ```
214
- Provides three primary commands to coordinate reviews:
222
+ Provides commands to coordinate reviews:
215
223
  1. **`/diffing-start-review`** — Launches the review server.
216
224
  2. **`/diffing-finish-review`** — Blocks the agent using `await-review` until comments are sent, then applies requested edits.
217
225
  3. **`/diffing-review`** — Combined launch-and-wait flow.
226
+ 4. **`/diffing-plan-review`** — Submit a markdown plan, block until the human approves/rejects/requests changes, then proceed or revise.
218
227
 
219
228
  ### Send Review Popover
220
229
  A GitHub-style "finish your review" popover with inline editing of each comment, an optional general/overall comment, and a visual indicator when an agent is waiting. The **"Copy comments"** toolbar button serializes all comments to the XML spec and copies them to the clipboard.
@@ -227,6 +236,67 @@ A `ReviewSession` class with a monotonic `round` counter and race-guard logic en
227
236
 
228
237
  ---
229
238
 
239
+ ## Plan Review
240
+
241
+ Review **any agent plan** — not just code. When an AI agent produces a plan
242
+ (an implementation outline, a design proposal, a migration strategy), `diffing`
243
+ renders the markdown line-by-line so you can comment on specific lines or
244
+ sections and **approve**, **request changes**, or **reject** it — then hands the
245
+ structured verdict back to the waiting agent. It's the "agent waits, human
246
+ releases" handoff, applied *before* any code is written.
247
+
248
+ ```text
249
+ 1. The agent submits a markdown plan and blocks (diffing plan submit … --wait).
250
+ 2. You open the "Plans" tab, read the plan, and comment on lines/sections.
251
+ 3. You click Approve / Request changes / Reject (with an optional overall note).
252
+ 4. The agent wakes instantly, receives the verdict + comments as <plan-review> XML,
253
+ and proceeds, revises-and-resubmits, or stops accordingly.
254
+ ```
255
+
256
+ - **Renders any markdown plan** — the plan body is shown via `@pierre/diffs` with
257
+ full syntax highlighting and line numbers, so it's addressable like a diff.
258
+ - **Line, range & section comments** — hover the gutter `+` or select a line range
259
+ to comment; each comment auto-captures its enclosing markdown heading (section)
260
+ and the exact anchored text for the agent.
261
+ - **General comments** — attach notes scoped to the whole plan.
262
+ - **Three-way verdict** — Approve / Request changes / Reject, GitHub-style, with
263
+ an optional overall comment. Resubmitting a revised plan (same id) bumps the
264
+ version and re-opens it for review.
265
+ - **Live "Plans" badge** — the diff toolbar shows a badge counting plans awaiting
266
+ your review, with a green dot when an agent is connected and waiting.
267
+ - **Same channels everywhere** — drive it from the CLI (`diffing plan …`), the
268
+ MCP tools (`submit_plan`, `await_plan_review`, …), or the HTTP API
269
+ (`POST /api/plans`, `POST /api/plans/:id/decision`, `GET /api/plan-review/await`).
270
+
271
+ ### Plan Review XML Specification
272
+
273
+ When a plan verdict is handed to a waiting agent, it is serialized into a
274
+ self-documenting `<plan-review>` envelope:
275
+
276
+ ```xml
277
+ <plan-review>
278
+ <instructions>…how to act on the verdict; how to reply/resolve/resubmit…</instructions>
279
+ <plan id="…" title="…" version="2" decision="changes-requested" decided-at="2026-05-29T18:52:56.053Z">
280
+ <decision-summary><![CDATA[The reviewer REQUESTED CHANGES. Revise the plan…]]></decision-summary>
281
+ <decision-comment><![CDATA[Tighten the Phase 2 scope.]]></decision-comment>
282
+ <plan-body><![CDATA[# My Plan
283
+ ## Phase 1
284
+ …full markdown of the plan being reviewed…]]></plan-body>
285
+ <comments>
286
+ <comment id="c1" line="4" section="Phase 1" status="open" created-at="2026-05-29T18:52:29.557Z">
287
+ <context><![CDATA[Do the first thing]]></context>
288
+ <body><![CDATA[Clarify what "the first thing" is.]]></body>
289
+ <replies>
290
+ <reply id="r1" created-at="…" role="agent" model="claude-opus-4-8"><![CDATA[Will do — splitting into 1a/1b.]]></reply>
291
+ </replies>
292
+ </comment>
293
+ </comments>
294
+ </plan>
295
+ </plan-review>
296
+ ```
297
+
298
+ ---
299
+
230
300
  ## Inline Comment System
231
301
 
232
302
  Rich, real-time comment threads directly on diff lines:
@@ -408,6 +478,8 @@ For advanced features, internal API endpoints, sequence specifications, and conf
408
478
 
409
479
  ---
410
480
 
481
+ 👉🏻 The project was intially forked from **wong2/diffx**
482
+
411
483
  ## License
412
484
 
413
485
  MIT
@@ -0,0 +1,443 @@
1
+ import { c as formatComments, n as readServerLock, o as formatPlanReview, t as isLockAlive } from "./server-lock-CAdqFSfl.mjs";
2
+ import { parseArgs } from "node:util";
3
+ import { readFile } from "node:fs/promises";
4
+ //#region src/cli-agent.ts
5
+ /**
6
+ * Agent-facing `diffing` subcommands. These make the user→agent handoff
7
+ * port-agnostic: each resolves the running server via the per-repo lockfile
8
+ * (`server.json`) so any agent with a shell — or a human — can drive the loop
9
+ * without being told a port.
10
+ *
11
+ * diffing await-review block until the human clicks "Send to agent"
12
+ * diffing reply <id> post an agent reply to a comment
13
+ * diffing resolve <id> mark a comment resolved
14
+ * diffing comments dump the current comments (XML or JSON)
15
+ */
16
+ const EXIT_OK = 0;
17
+ const EXIT_AWAIT_TIMEOUT = 2;
18
+ const EXIT_NO_SERVER = 3;
19
+ const EXIT_NOT_FOUND = 4;
20
+ const EXIT_USAGE = 5;
21
+ /** Resolve the running server's base URL from the lockfile, or exit cleanly. */
22
+ function baseUrl() {
23
+ const lock = readServerLock();
24
+ if (!lock || !isLockAlive(lock)) {
25
+ console.error("No diffing server running for this repo. Start one with `diffing`.");
26
+ process.exit(EXIT_NO_SERVER);
27
+ }
28
+ return `http://${lock.host === "0.0.0.0" || lock.host === "::" ? "127.0.0.1" : lock.host}:${lock.port}`;
29
+ }
30
+ async function readStdin() {
31
+ const chunks = [];
32
+ for await (const chunk of process.stdin) chunks.push(chunk);
33
+ return Buffer.concat(chunks).toString("utf-8");
34
+ }
35
+ async function awaitReview(args) {
36
+ const { values } = parseArgs({
37
+ args,
38
+ options: {
39
+ timeout: {
40
+ type: "string",
41
+ short: "t"
42
+ },
43
+ since: { type: "string" }
44
+ },
45
+ allowPositionals: false
46
+ });
47
+ const totalBudgetMs = (values.timeout ? Number(values.timeout) : 570) * 1e3;
48
+ const base = baseUrl();
49
+ let sinceRound = 0;
50
+ try {
51
+ sinceRound = (await fetch(`${base}/api/review/status`).then((r) => r.json())).round ?? 0;
52
+ } catch {}
53
+ const deadline = Date.now() + totalBudgetMs;
54
+ while (Date.now() < deadline) {
55
+ let res;
56
+ try {
57
+ res = await fetch(`${base}/api/review/await?timeoutMs=25000&sinceRound=${sinceRound}`, { signal: AbortSignal.timeout(3e4) });
58
+ } catch (err) {
59
+ if (err?.name === "TimeoutError") continue;
60
+ console.error(`Failed to reach diffing server: ${err?.message ?? err}`);
61
+ return EXIT_NO_SERVER;
62
+ }
63
+ const result = await res.json();
64
+ if (result.status === "released") {
65
+ process.stdout.write(result.payload.commentXml + "\n");
66
+ console.error(`DIFFING_REVIEW_ROUND=${result.payload.round}`);
67
+ return EXIT_OK;
68
+ }
69
+ sinceRound = result.round ?? sinceRound;
70
+ }
71
+ console.error("DIFFING_AWAIT_TIMEOUT");
72
+ console.error("No review sent within the timeout. Run `diffing await-review` again to keep waiting.");
73
+ return EXIT_AWAIT_TIMEOUT;
74
+ }
75
+ async function reply(args) {
76
+ const { values, positionals } = parseArgs({
77
+ args,
78
+ options: {
79
+ body: {
80
+ type: "string",
81
+ short: "b"
82
+ },
83
+ model: {
84
+ type: "string",
85
+ short: "m"
86
+ }
87
+ },
88
+ allowPositionals: true
89
+ });
90
+ const commentId = positionals[0];
91
+ if (!commentId) {
92
+ console.error("Usage: diffing reply <commentId> --body <text> [--model <name>]");
93
+ return EXIT_USAGE;
94
+ }
95
+ let body = values.body;
96
+ if (body === "-" || body === void 0) body = (await readStdin()).trim();
97
+ if (!body) {
98
+ console.error("A reply body is required (--body <text> or pipe via stdin).");
99
+ return EXIT_USAGE;
100
+ }
101
+ const res = await fetch(`${baseUrl()}/api/comments/${commentId}/replies`, {
102
+ method: "POST",
103
+ headers: { "Content-Type": "application/json" },
104
+ body: JSON.stringify({
105
+ body,
106
+ role: "agent",
107
+ model: values.model
108
+ })
109
+ });
110
+ if (res.status === 404) {
111
+ console.error(`Comment ${commentId} not found.`);
112
+ return EXIT_NOT_FOUND;
113
+ }
114
+ if (!res.ok) {
115
+ console.error(`Failed to reply: HTTP ${res.status}`);
116
+ return 1;
117
+ }
118
+ console.error(`Replied to ${commentId}.`);
119
+ return EXIT_OK;
120
+ }
121
+ async function resolve(args) {
122
+ const { positionals } = parseArgs({
123
+ args,
124
+ allowPositionals: true,
125
+ options: {}
126
+ });
127
+ const commentId = positionals[0];
128
+ if (!commentId) {
129
+ console.error("Usage: diffing resolve <commentId>");
130
+ return EXIT_USAGE;
131
+ }
132
+ const res = await fetch(`${baseUrl()}/api/comments/${commentId}`, {
133
+ method: "PUT",
134
+ headers: { "Content-Type": "application/json" },
135
+ body: JSON.stringify({ status: "resolved" })
136
+ });
137
+ if (res.status === 404) {
138
+ console.error(`Comment ${commentId} not found.`);
139
+ return EXIT_NOT_FOUND;
140
+ }
141
+ if (!res.ok) {
142
+ console.error(`Failed to resolve: HTTP ${res.status}`);
143
+ return 1;
144
+ }
145
+ console.error(`Resolved ${commentId}.`);
146
+ return EXIT_OK;
147
+ }
148
+ async function url() {
149
+ process.stdout.write(baseUrl() + "\n");
150
+ return EXIT_OK;
151
+ }
152
+ async function comments(args) {
153
+ const { values } = parseArgs({
154
+ args,
155
+ options: {
156
+ open: { type: "boolean" },
157
+ json: { type: "boolean" }
158
+ },
159
+ allowPositionals: false
160
+ });
161
+ const all = await fetch(`${baseUrl()}/api/comments`).then((r) => r.json());
162
+ const selected = values.open ? all.filter((c) => c.status === "open") : all;
163
+ if (values.json) process.stdout.write(JSON.stringify(selected, null, 2) + "\n");
164
+ else process.stdout.write(formatComments(selected) + "\n");
165
+ return EXIT_OK;
166
+ }
167
+ /** Derive a human title from a plan's first heading or non-empty line. */
168
+ function deriveTitle(body) {
169
+ for (const raw of body.split("\n")) {
170
+ const line = raw.trim();
171
+ if (!line) continue;
172
+ const heading = /^#{1,6}\s+(.*)$/.exec(line);
173
+ return (heading ? heading[1] : line).slice(0, 120);
174
+ }
175
+ return "Untitled plan";
176
+ }
177
+ /** Long-poll the plan-review handoff until a decision arrives or time runs out. */
178
+ async function pollPlanDecision(base, totalBudgetMs, seedSince) {
179
+ let sinceRound = seedSince ?? 0;
180
+ if (seedSince === void 0) try {
181
+ sinceRound = (await fetch(`${base}/api/plan-review/status`).then((r) => r.json())).round ?? 0;
182
+ } catch {}
183
+ const deadline = Date.now() + totalBudgetMs;
184
+ while (Date.now() < deadline) {
185
+ let res;
186
+ try {
187
+ res = await fetch(`${base}/api/plan-review/await?timeoutMs=25000&sinceRound=${sinceRound}`, { signal: AbortSignal.timeout(3e4) });
188
+ } catch (err) {
189
+ if (err?.name === "TimeoutError") continue;
190
+ console.error(`Failed to reach diffing server: ${err?.message ?? err}`);
191
+ return EXIT_NO_SERVER;
192
+ }
193
+ const result = await res.json();
194
+ if (result.status === "released") {
195
+ process.stdout.write(result.payload.reviewXml + "\n");
196
+ console.error(`DIFFING_PLAN_DECISION=${result.payload.decision}`);
197
+ console.error(`DIFFING_PLAN_ROUND=${result.payload.round}`);
198
+ return EXIT_OK;
199
+ }
200
+ sinceRound = result.round ?? sinceRound;
201
+ }
202
+ console.error("DIFFING_PLAN_AWAIT_TIMEOUT");
203
+ console.error("No plan decision within the timeout. Run `diffing plan await` again to keep waiting.");
204
+ return EXIT_AWAIT_TIMEOUT;
205
+ }
206
+ async function planSubmit(args) {
207
+ const { values, positionals } = parseArgs({
208
+ args,
209
+ options: {
210
+ title: { type: "string" },
211
+ source: {
212
+ type: "string",
213
+ short: "s"
214
+ },
215
+ model: {
216
+ type: "string",
217
+ short: "m"
218
+ },
219
+ id: { type: "string" },
220
+ wait: {
221
+ type: "boolean",
222
+ short: "w"
223
+ },
224
+ timeout: {
225
+ type: "string",
226
+ short: "t"
227
+ }
228
+ },
229
+ allowPositionals: true
230
+ });
231
+ const file = positionals[0];
232
+ let body;
233
+ if (!file || file === "-") body = await readStdin();
234
+ else try {
235
+ body = await readFile(file, "utf-8");
236
+ } catch (err) {
237
+ console.error(`Failed to read plan file ${file}: ${err?.message ?? err}`);
238
+ return EXIT_USAGE;
239
+ }
240
+ body = body.replace(/\r\n/g, "\n");
241
+ if (!body.trim()) {
242
+ console.error("A plan body is required (pass a markdown file path or pipe via stdin).");
243
+ return EXIT_USAGE;
244
+ }
245
+ const title = values.title || deriveTitle(body);
246
+ const base = baseUrl();
247
+ let sinceRound = 0;
248
+ if (values.wait) try {
249
+ sinceRound = (await fetch(`${base}/api/plan-review/status`).then((r) => r.json())).round ?? 0;
250
+ } catch {}
251
+ const res = await fetch(`${base}/api/plans`, {
252
+ method: "POST",
253
+ headers: { "Content-Type": "application/json" },
254
+ body: JSON.stringify({
255
+ id: values.id,
256
+ title,
257
+ body,
258
+ source: values.source,
259
+ model: values.model
260
+ })
261
+ });
262
+ if (!res.ok) {
263
+ console.error(`Failed to submit plan: HTTP ${res.status}`);
264
+ return 1;
265
+ }
266
+ const plan = await res.json();
267
+ console.error(`Submitted plan ${plan.id} (v${plan.version}) — review at ${base}/plan/${plan.id}`);
268
+ if (!values.wait) {
269
+ process.stdout.write(plan.id + "\n");
270
+ return EXIT_OK;
271
+ }
272
+ return pollPlanDecision(base, (values.timeout ? Number(values.timeout) : 570) * 1e3, sinceRound);
273
+ }
274
+ async function planAwait(args) {
275
+ const { values } = parseArgs({
276
+ args,
277
+ options: { timeout: {
278
+ type: "string",
279
+ short: "t"
280
+ } },
281
+ allowPositionals: false
282
+ });
283
+ const totalBudgetMs = (values.timeout ? Number(values.timeout) : 570) * 1e3;
284
+ return pollPlanDecision(baseUrl(), totalBudgetMs);
285
+ }
286
+ async function planList(args) {
287
+ const { values } = parseArgs({
288
+ args,
289
+ options: { json: { type: "boolean" } },
290
+ allowPositionals: false
291
+ });
292
+ const all = await fetch(`${baseUrl()}/api/plans`).then((r) => r.json());
293
+ if (values.json) {
294
+ process.stdout.write(JSON.stringify(all, null, 2) + "\n");
295
+ return EXIT_OK;
296
+ }
297
+ if (all.length === 0) {
298
+ console.error("No plans submitted yet.");
299
+ return EXIT_OK;
300
+ }
301
+ for (const p of all) {
302
+ const open = (p.comments ?? []).filter((c) => c.status === "open").length;
303
+ process.stdout.write(`${p.id}\t[${p.decision}]\tv${p.version}\t${open} open comment(s)\t${p.title}\n`);
304
+ }
305
+ return EXIT_OK;
306
+ }
307
+ async function planShow(args) {
308
+ const { values, positionals } = parseArgs({
309
+ args,
310
+ options: { json: { type: "boolean" } },
311
+ allowPositionals: true
312
+ });
313
+ const base = baseUrl();
314
+ let planId = positionals[0];
315
+ if (!planId) {
316
+ const all = await fetch(`${base}/api/plans`).then((r) => r.json());
317
+ if (all.length === 0) {
318
+ console.error("No plans submitted yet.");
319
+ return EXIT_NOT_FOUND;
320
+ }
321
+ planId = all[all.length - 1].id;
322
+ }
323
+ const res = await fetch(`${base}/api/plans/${planId}`);
324
+ if (res.status === 404) {
325
+ console.error(`Plan ${planId} not found.`);
326
+ return EXIT_NOT_FOUND;
327
+ }
328
+ const plan = await res.json();
329
+ if (values.json) process.stdout.write(JSON.stringify(plan, null, 2) + "\n");
330
+ else process.stdout.write(formatPlanReview(plan) + "\n");
331
+ return EXIT_OK;
332
+ }
333
+ /** Locate which plan owns a given comment id (comment ids are globally unique). */
334
+ async function findCommentPlan(base, commentId) {
335
+ return (await fetch(`${base}/api/plans`).then((r) => r.json())).find((p) => (p.comments ?? []).some((c) => c.id === commentId)) ?? null;
336
+ }
337
+ async function planReply(args) {
338
+ const { values, positionals } = parseArgs({
339
+ args,
340
+ options: {
341
+ body: {
342
+ type: "string",
343
+ short: "b"
344
+ },
345
+ model: {
346
+ type: "string",
347
+ short: "m"
348
+ }
349
+ },
350
+ allowPositionals: true
351
+ });
352
+ const commentId = positionals[0];
353
+ if (!commentId) {
354
+ console.error("Usage: diffing plan reply <commentId> --body <text> [--model <name>]");
355
+ return EXIT_USAGE;
356
+ }
357
+ let body = values.body;
358
+ if (body === "-" || body === void 0) body = (await readStdin()).trim();
359
+ if (!body) {
360
+ console.error("A reply body is required (--body <text> or pipe via stdin).");
361
+ return EXIT_USAGE;
362
+ }
363
+ const base = baseUrl();
364
+ const plan = await findCommentPlan(base, commentId);
365
+ if (!plan) {
366
+ console.error(`Plan comment ${commentId} not found.`);
367
+ return EXIT_NOT_FOUND;
368
+ }
369
+ const res = await fetch(`${base}/api/plans/${plan.id}/comments/${commentId}/replies`, {
370
+ method: "POST",
371
+ headers: { "Content-Type": "application/json" },
372
+ body: JSON.stringify({
373
+ body,
374
+ role: "agent",
375
+ model: values.model
376
+ })
377
+ });
378
+ if (!res.ok) {
379
+ console.error(`Failed to reply: HTTP ${res.status}`);
380
+ return 1;
381
+ }
382
+ console.error(`Replied to plan comment ${commentId}.`);
383
+ return EXIT_OK;
384
+ }
385
+ async function planResolve(args) {
386
+ const { positionals } = parseArgs({
387
+ args,
388
+ allowPositionals: true,
389
+ options: {}
390
+ });
391
+ const commentId = positionals[0];
392
+ if (!commentId) {
393
+ console.error("Usage: diffing plan resolve <commentId>");
394
+ return EXIT_USAGE;
395
+ }
396
+ const base = baseUrl();
397
+ const plan = await findCommentPlan(base, commentId);
398
+ if (!plan) {
399
+ console.error(`Plan comment ${commentId} not found.`);
400
+ return EXIT_NOT_FOUND;
401
+ }
402
+ const res = await fetch(`${base}/api/plans/${plan.id}/comments/${commentId}`, {
403
+ method: "PUT",
404
+ headers: { "Content-Type": "application/json" },
405
+ body: JSON.stringify({ status: "resolved" })
406
+ });
407
+ if (!res.ok) {
408
+ console.error(`Failed to resolve: HTTP ${res.status}`);
409
+ return 1;
410
+ }
411
+ console.error(`Resolved plan comment ${commentId}.`);
412
+ return EXIT_OK;
413
+ }
414
+ async function plan(args) {
415
+ const action = args[0];
416
+ const rest = args.slice(1);
417
+ switch (action) {
418
+ case "submit": return planSubmit(rest);
419
+ case "await": return planAwait(rest);
420
+ case "list": return planList(rest);
421
+ case "show": return planShow(rest);
422
+ case "reply": return planReply(rest);
423
+ case "resolve": return planResolve(rest);
424
+ default:
425
+ console.error("Usage: diffing plan <submit|await|list|show|reply|resolve> [...]");
426
+ return EXIT_USAGE;
427
+ }
428
+ }
429
+ async function runSubcommand(name, args) {
430
+ switch (name) {
431
+ case "await-review": return awaitReview(args);
432
+ case "reply": return reply(args);
433
+ case "resolve": return resolve(args);
434
+ case "comments": return comments(args);
435
+ case "url": return url();
436
+ case "plan": return plan(args);
437
+ default:
438
+ console.error(`Unknown subcommand: ${name}`);
439
+ return EXIT_USAGE;
440
+ }
441
+ }
442
+ //#endregion
443
+ export { runSubcommand };