@macintacos/caret 0.13.0 → 1.0.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 (112) hide show
  1. package/.claude-plugin/plugin.json +9 -2
  2. package/README.md +61 -24
  3. package/bin/caret-launcher +208 -0
  4. package/commands/demo.md +11 -10
  5. package/dist/cli.js +49418 -27514
  6. package/hooks/hooks.json +16 -0
  7. package/opencode/caret.plugin.ts +173 -277
  8. package/opencode/commands/demo.md +9 -20
  9. package/opencode/index.ts +1 -2
  10. package/opencode/review-bridge.ts +241 -0
  11. package/package.json +9 -6
  12. package/templates/demo.md +89 -0
  13. package/ui/dist/assets/{angular-html-JsdW_WcC.js → angular-html-BrP7X_fT.js} +1 -1
  14. package/ui/dist/assets/{angular-ts-DB5trF48.js → angular-ts-CWSXPJ8_.js} +1 -1
  15. package/ui/dist/assets/{apl-BHONMsFL.js → apl-DcDu9VfL.js} +1 -1
  16. package/ui/dist/assets/{astro-Ds0Hdj6N.js → astro-ZWhUFE02.js} +1 -1
  17. package/ui/dist/assets/{blade-u3yPcwZm.js → blade-Bnfid1aX.js} +1 -1
  18. package/ui/dist/assets/{c-DrTWjY43.js → c-DGhlaWFa.js} +1 -1
  19. package/ui/dist/assets/{chapel-BQ3j01mw.js → chapel-B80nA-Wv.js} +1 -1
  20. package/ui/dist/assets/{cobol-CYMPXcxw.js → cobol-BxPmMgGq.js} +1 -1
  21. package/ui/dist/assets/{coffee-BTAQjKY2.js → coffee-qvFLvdPC.js} +1 -1
  22. package/ui/dist/assets/{cpp-Bi8OcMQs.js → cpp-BGBu9Yb6.js} +1 -1
  23. package/ui/dist/assets/{crystal-BeA91aRl.js → crystal-CV45eR73.js} +1 -1
  24. package/ui/dist/assets/{css-Bxrmub2j.js → css-CBPwQP3-.js} +1 -1
  25. package/ui/dist/assets/{dist-ChMSH6xV.js → dist-1bUsa9So.js} +1 -1
  26. package/ui/dist/assets/dist-418qtW5O.js +2 -0
  27. package/ui/dist/assets/dist-BafAgSat.js +1 -0
  28. package/ui/dist/assets/{dist-CBxRdWpx.js → dist-BubrR0xG.js} +1 -1
  29. package/ui/dist/assets/{dist-DHAfbOzU.js → dist-CfuU_6ln.js} +1 -1
  30. package/ui/dist/assets/{dist-C5v67AiS.js → dist-Cj-JdNZd.js} +1 -1
  31. package/ui/dist/assets/{dist-CGl7bSLs.js → dist-CjIRqXSt.js} +1 -1
  32. package/ui/dist/assets/{dist-DQgprm5S.js → dist-CpcSX-YP.js} +1 -1
  33. package/ui/dist/assets/{dist-C_lXyw3s.js → dist-D0Ats_4w.js} +1 -1
  34. package/ui/dist/assets/dist-DOswrDst.js +12 -0
  35. package/ui/dist/assets/{dist-CR6NZa0h.js → dist-DWzXdmaf.js} +1 -1
  36. package/ui/dist/assets/{dist-5Bv2mYWj.js → dist-DXn_f-MA.js} +1 -1
  37. package/ui/dist/assets/{dist-B0Do-tNS.js → dist-Dfm7Znx4.js} +1 -1
  38. package/ui/dist/assets/{dist-DH1_ZbAP.js → dist-Dj96YS5k.js} +1 -1
  39. package/ui/dist/assets/{dist-sQ_pTc4a.js → dist-Dz8bokki.js} +1 -1
  40. package/ui/dist/assets/{dist-D_g9f9rz.js → dist-Lc2Es7Pl.js} +1 -1
  41. package/ui/dist/assets/{dist-Dt1D8SWK.js → dist-TxQM6vOw.js} +1 -1
  42. package/ui/dist/assets/{dist-Bz_ap3g2.js → dist-cV3QBg-W.js} +1 -1
  43. package/ui/dist/assets/{edge-CM4DKqp3.js → edge-Co6ItTYI.js} +1 -1
  44. package/ui/dist/assets/{elixir-D76HLRzC.js → elixir-0YZEZ0gv.js} +1 -1
  45. package/ui/dist/assets/{elm-BYD2LdpG.js → elm-BkRJOygl.js} +1 -1
  46. package/ui/dist/assets/{erb-Depv6q0i.js → erb-BI2P49U3.js} +1 -1
  47. package/ui/dist/assets/{git-rebase-By6KsZra.js → git-rebase-Bq5iIXzt.js} +1 -1
  48. package/ui/dist/assets/{glimmer-js-B9OgS597.js → glimmer-js-BbOUbDIt.js} +1 -1
  49. package/ui/dist/assets/{glimmer-ts-BQ9yXV5i.js → glimmer-ts-CNnsk0yJ.js} +1 -1
  50. package/ui/dist/assets/{glsl-CgWn_pPL.js → glsl-BGnMm0dJ.js} +1 -1
  51. package/ui/dist/assets/{graphql-C6705FXv.js → graphql-7Z7p0nC9.js} +1 -1
  52. package/ui/dist/assets/{hack-CuMDN2go.js → hack-DqHfN4B0.js} +1 -1
  53. package/ui/dist/assets/{haml-BiSYPvKJ.js → haml-3nZzM1j8.js} +1 -1
  54. package/ui/dist/assets/{handlebars-C1r5dETi.js → handlebars-D76GLv5_.js} +1 -1
  55. package/ui/dist/assets/{html-iBfWgjFQ.js → html-KRo6nO5U.js} +1 -1
  56. package/ui/dist/assets/{html-derivative-clnDfVNs.js → html-derivative-45dsHZ7G.js} +1 -1
  57. package/ui/dist/assets/{http-hT30HceR.js → http-Cd6yAfd3.js} +1 -1
  58. package/ui/dist/assets/{hurl-DQ0AcU20.js → hurl-DMRd-22O.js} +1 -1
  59. package/ui/dist/assets/index-CAl4rX7_.css +1 -0
  60. package/ui/dist/assets/{index-CjqVXWlr.js → index-DqZ7qZxG.js} +366 -317
  61. package/ui/dist/assets/{java-Dm4xXBhF.js → java-C5BRRLzP.js} +1 -1
  62. package/ui/dist/assets/{javascript-PW_29aC7.js → javascript-BEwOFxBm.js} +1 -1
  63. package/ui/dist/assets/{jinja-grwkrR8u.js → jinja-DC6SxQM2.js} +1 -1
  64. package/ui/dist/assets/{jison-DFY8lcfy.js → jison-BpM_G12u.js} +1 -1
  65. package/ui/dist/assets/{json-C34uLNh4.js → json-DhXVH33S.js} +1 -1
  66. package/ui/dist/assets/{jsx-CWBlf8VC.js → jsx-QIa_kGnJ.js} +1 -1
  67. package/ui/dist/assets/{julia-DCj8-aMw.js → julia-BuS1ndKd.js} +1 -1
  68. package/ui/dist/assets/{just-B_yigvdA.js → just-Dms5nmY6.js} +1 -1
  69. package/ui/dist/assets/{latex-QDkoREbu.js → latex-d0FSRalE.js} +1 -1
  70. package/ui/dist/assets/{liquid-BgxQ_Ybv.js → liquid-Bq_YWguE.js} +1 -1
  71. package/ui/dist/assets/{lua-B7jopNUe.js → lua-CqusOKbI.js} +1 -1
  72. package/ui/dist/assets/{marko-ClWMCpy4.js → marko-CfvdDxZz.js} +1 -1
  73. package/ui/dist/assets/{mdc-B5qjhcv5.js → mdc-B_vZdcRe.js} +1 -1
  74. package/ui/dist/assets/{nginx-BLvCl2vo.js → nginx-YhyPrYEc.js} +1 -1
  75. package/ui/dist/assets/{nim-D1vVna5G.js → nim-UGiA-q4U.js} +1 -1
  76. package/ui/dist/assets/{org-CQUu5Xjo.js → org-BWLiCWoV.js} +1 -1
  77. package/ui/dist/assets/{perl-Cn9pqCpd.js → perl-CXeAyAx6.js} +1 -1
  78. package/ui/dist/assets/{php-DQA4j1w3.js → php-C0Y5OILf.js} +1 -1
  79. package/ui/dist/assets/{pug-Dcu9qjZu.js → pug-pavIHhZT.js} +1 -1
  80. package/ui/dist/assets/{qml-CFaCL8zR.js → qml-CbMVL_Hw.js} +1 -1
  81. package/ui/dist/assets/{r-BeWyCF96.js → r-VbESC49D.js} +1 -1
  82. package/ui/dist/assets/{razor-DhTjYPP2.js → razor-B4zmReq6.js} +1 -1
  83. package/ui/dist/assets/{regexp-BZBzV8k3.js → regexp-CvzL3gAC.js} +1 -1
  84. package/ui/dist/assets/{rst-CPeugCoY.js → rst-B0YBcjx2.js} +1 -1
  85. package/ui/dist/assets/{ruby-S1Kev63y.js → ruby-BFx1yg1l.js} +1 -1
  86. package/ui/dist/assets/{sas-DzuTM5FV.js → sas-Bdz0EWxy.js} +1 -1
  87. package/ui/dist/assets/{scss-CimGOfjN.js → scss-Ar5qG57q.js} +1 -1
  88. package/ui/dist/assets/{shellscript-BTeF_2ej.js → shellscript-BxZn-g_C.js} +1 -1
  89. package/ui/dist/assets/{shellsession-DFdgXQ-h.js → shellsession-DdGjCUDJ.js} +1 -1
  90. package/ui/dist/assets/{soy-CJHYVIlT.js → soy-DNbG_b-4.js} +1 -1
  91. package/ui/dist/assets/{sql-HmJC1LPx.js → sql-BbO6oYzq.js} +1 -1
  92. package/ui/dist/assets/{stata-C1JKV9ST.js → stata-CrT9A-Oi.js} +1 -1
  93. package/ui/dist/assets/{surrealql-DdWTVOJW.js → surrealql-_tFV7pCn.js} +1 -1
  94. package/ui/dist/assets/{svelte-B3DUwjVb.js → svelte-ZdvR-T2J.js} +1 -1
  95. package/ui/dist/assets/{templ-Dx_nsS-N.js → templ-CpOqX9Rk.js} +1 -1
  96. package/ui/dist/assets/{tex-BHbvXPLE.js → tex-BnYgu1Tm.js} +1 -1
  97. package/ui/dist/assets/{ts-tags-BV7lCDbe.js → ts-tags-CII1fKoY.js} +1 -1
  98. package/ui/dist/assets/{tsx-BYZjS03A.js → tsx-CPIX6Zti.js} +1 -1
  99. package/ui/dist/assets/{twig-DAWYqd9-.js → twig-Cpr1_Fdx.js} +1 -1
  100. package/ui/dist/assets/{typescript-BBlnjAR4.js → typescript-CS1A4fih.js} +1 -1
  101. package/ui/dist/assets/{typst-BHcXb2UY.js → typst-BEc_7GFB.js} +1 -1
  102. package/ui/dist/assets/{vue-YvTBU6lw.js → vue-DLe2p-nw.js} +1 -1
  103. package/ui/dist/assets/{vue-html-Dy8_dd5p.js → vue-html-CBsH80UM.js} +1 -1
  104. package/ui/dist/assets/{vue-vine-BLSdEcQ9.js → vue-vine-C6KcK89Z.js} +1 -1
  105. package/ui/dist/assets/{xml-DEOP2atk.js → xml-CMmb8ycU.js} +1 -1
  106. package/ui/dist/assets/{xsl-D__kBjbu.js → xsl-vCAAt3o3.js} +1 -1
  107. package/ui/dist/assets/{yaml-D2r1aQ0B.js → yaml-Be7JvL54.js} +1 -1
  108. package/ui/dist/index.html +3 -3
  109. package/ui/dist/assets/dist-CiBs4R_k.js +0 -12
  110. package/ui/dist/assets/dist-DqAJNUky.js +0 -2
  111. package/ui/dist/assets/dist-IqwiD_4c.js +0 -1
  112. package/ui/dist/assets/index-Dn0ZHhG9.css +0 -1
package/hooks/hooks.json CHANGED
@@ -20,6 +20,11 @@
20
20
  "type": "command",
21
21
  "command": "${CLAUDE_PLUGIN_ROOT}/bin/caret prewarm",
22
22
  "timeout": 30
23
+ },
24
+ {
25
+ "type": "command",
26
+ "command": "${CLAUDE_PLUGIN_ROOT}/bin/caret steer",
27
+ "timeout": 10
23
28
  }
24
29
  ]
25
30
  },
@@ -33,6 +38,17 @@
33
38
  }
34
39
  ]
35
40
  }
41
+ ],
42
+ "UserPromptSubmit": [
43
+ {
44
+ "hooks": [
45
+ {
46
+ "type": "command",
47
+ "command": "${CLAUDE_PLUGIN_ROOT}/bin/caret steer",
48
+ "timeout": 10
49
+ }
50
+ ]
51
+ }
36
52
  ]
37
53
  }
38
54
  }
@@ -2,13 +2,11 @@
2
2
  // shaped: it loads this in-process module and lets it register tools and mutate
3
3
  // config. caret has no native plan-approval gate to intercept here (OpenCode has
4
4
  // no ExitPlanMode equivalent), so this plugin REGISTERS its own plan-review tool,
5
- // steers the Plan agent to call it, and
6
- // runs the review synchronously inside the tool's execute(): it spawns
7
- // `caret review` (CARET_AGENT=opencode) with a caret-defined envelope on stdin,
8
- // blocks until the human decides in caret's browser UI, and returns the approval
9
- // or change-request string as the tool result. The whole caret daemon/review
10
- // pipeline is reused unchanged — this plugin is the OpenCode-side counterpart to
11
- // Claude Code's hooks.json, which likewise spawns `caret review`.
5
+ // steers the Plan agent to call it, and runs the review synchronously inside the
6
+ // tool's execute() by spawning `caret review` (CARET_AGENT=opencode). The whole
7
+ // caret daemon/review pipeline is reused unchanged — this plugin is the
8
+ // OpenCode-side counterpart to Claude Code's hooks.json, which likewise spawns
9
+ // `caret review`.
12
10
  //
13
11
  // Subagent-bypass mitigation: OpenCode's tool.execute.before does not fire for
14
12
  // subagent tool calls, so caret does NOT rely on a hook to gate subagents. The
@@ -17,24 +15,31 @@
17
15
  // tool body re-checks the calling session's shape — defense in depth. Any PRIMARY
18
16
  // agent may ask for a review; only the plan agent is steered toward it.
19
17
  //
20
- // This file runs in-process inside OpenCode. It ships in the @macintacos/caret npm
21
- // package; OpenCode loads it when the package is in the user's `plugin` array, and
22
- // it resolves its own binary and version at runtime from that package. (The legacy
23
- // file-deploy path substitutes the two __CARET_*__ markers instead.) It stays
24
- // self-contained: its only imports are node builtins (child_process, fs, url) and
25
- // @opencode-ai/plugin (resolved by OpenCode at runtime). Live in-OpenCode
26
- // verification of the exact ctx/tool/config shapes is recorded in
27
- // doc/agents/opencode-integration.md § Verified vs. follow-up, which also names what
28
- // remains unverified; the pure logic below is covered by test/opencode/.
18
+ // It ships in the @macintacos/caret npm package; OpenCode loads it when the package
19
+ // is in the user's `plugin` array, and it resolves its own binary and version at
20
+ // runtime from that package. (The legacy file-deploy path substitutes the two
21
+ // __CARET_*__ markers instead.) It stays self-contained: its only imports are node
22
+ // builtins, its sibling review-bridge.ts, and @opencode-ai/plugin (resolved by OpenCode
23
+ // at runtime). Which ctx/tool/config shapes are live-verified and which are not:
24
+ // doc/agents/opencode-integration.md § Verified vs. follow-up.
29
25
 
30
26
  import { spawn } from "node:child_process";
31
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
27
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
32
28
  import { homedir } from "node:os";
33
- import { dirname } from "node:path";
29
+ import { dirname, relative, resolve } from "node:path";
34
30
  import { fileURLToPath } from "node:url";
35
31
 
36
32
  import { type Hooks, type Plugin, tool } from "@opencode-ai/plugin";
37
33
 
34
+ import {
35
+ buildEnvelope,
36
+ decisionText,
37
+ nodeSpawnRunner,
38
+ PLAN_TITLE_INSTRUCTION,
39
+ runReviewViaCaret,
40
+ type SpawnRunner,
41
+ } from "./review-bridge.ts";
42
+
38
43
  /** Install-time markers. The legacy file-deploy path substituted these with the
39
44
  * resolved caret version and binary path; the array install leaves them as
40
45
  * placeholders, so the resolvers below fall back to the package that ships this
@@ -83,73 +88,10 @@ export const REVIEW_TOOL = "caret_review_plan";
83
88
  * the review tool and warms the daemon for. */
84
89
  export const PLANNING_AGENTS = ["plan"] as const;
85
90
 
86
- export interface CaretDecision {
87
- behavior: "allow" | "deny";
88
- feedback?: string;
89
- }
90
-
91
91
  // ---------------------------------------------------------------------------
92
92
  // Pure helpers (exported for unit tests)
93
93
  // ---------------------------------------------------------------------------
94
94
 
95
- /** The first markdown heading in the plan, used as the review title — or
96
- * undefined when the plan has no `# ` heading. */
97
- export function planTitle(plan: string): string | undefined {
98
- for (const line of plan.split("\n")) {
99
- const m = line.match(/^#\s+(.+?)\s*$/);
100
- if (m?.[1]) return m[1];
101
- }
102
- return undefined;
103
- }
104
-
105
- /** Build the caret review envelope `caret review` (CARET_AGENT=opencode) parses.
106
- * Mirrors the snake_case session/cwd shape the opencode adapter's parseHookInput
107
- * reads — both ends are caret-owned. */
108
- export function buildEnvelope(
109
- plan: string,
110
- ctx: { sessionID?: string; directory?: string },
111
- ): string {
112
- return JSON.stringify({
113
- session_id: ctx.sessionID,
114
- cwd: ctx.directory,
115
- tool_input: { plan, title: planTitle(plan) },
116
- });
117
- }
118
-
119
- /** Parse the single decision JSON line `caret review` prints on stdout. Fail-safe:
120
- * anything unrecognized or unparseable becomes a deny — shipping an unreviewed
121
- * plan is the one outcome caret never allows. */
122
- export function parseDecision(stdout: string): CaretDecision {
123
- const line =
124
- stdout
125
- .trim()
126
- .split("\n")
127
- .map((s) => s.trim())
128
- .filter(Boolean)
129
- .at(-1) ?? "";
130
- try {
131
- const d = JSON.parse(line) as { behavior?: unknown; feedback?: unknown };
132
- if (d.behavior === "allow") {
133
- // Reviewer notes (EXC-791) ride the allow; surface them to the agent below.
134
- const notes = typeof d.feedback === "string" ? d.feedback.trim() : "";
135
- return notes ? { behavior: "allow", feedback: notes } : { behavior: "allow" };
136
- }
137
- if (d.behavior === "deny") {
138
- return {
139
- behavior: "deny",
140
- feedback: typeof d.feedback === "string" ? d.feedback : "Plan changes requested.",
141
- };
142
- }
143
- return failsafeDeny("caret: unrecognized review decision — denying to fail safe.");
144
- } catch {
145
- return failsafeDeny("caret: could not parse the review decision — denying to fail safe.");
146
- }
147
- }
148
-
149
- function failsafeDeny(feedback: string): CaretDecision {
150
- return { behavior: "deny", feedback };
151
- }
152
-
153
95
  /** True for the planning agent(s) caret treats specially — the planning steer and
154
96
  * the daemon warm-up both fire only for them. Not a permission check: the review
155
97
  * tool itself is open to every primary agent. */
@@ -157,65 +99,12 @@ export function isPlanningAgent(agent: string | undefined): boolean {
157
99
  return agent !== undefined && (PLANNING_AGENTS as readonly string[]).includes(agent);
158
100
  }
159
101
 
160
- /** Tool result returned to the agent on approval. Optional reviewer notes
161
- * (EXC-791) are folded in as a clearly-labeled section the agent incorporates as
162
- * it implements — the plan is already approved, so no re-planning round. The
163
- * OpenCode agent holds the plan in its own tool args (there is no plan file to
164
- * append to), so this tool result is the delivery channel. */
165
- export function approvedMessage(notes?: string): string {
166
- const base = "caret: the user APPROVED this plan. Proceed with the implementation as planned.";
167
- const trimmed = notes?.trim();
168
- if (!trimmed) return base;
169
- return [
170
- "caret: the user APPROVED this plan.",
171
- "",
172
- "They added notes to fold into your work — incorporate them as you implement; no need to re-plan:",
173
- "",
174
- "## Notes from the user",
175
- "",
176
- trimmed,
177
- "",
178
- "Proceed with the implementation.",
179
- ].join("\n");
180
- }
181
-
182
- /** Tool result returned to the agent on a change request: the reviewer feedback
183
- * and a resubmit instruction. The plan itself is NOT echoed — the agent already
184
- * has it in its own `caret_review_plan` tool-call args, so re-pasting the full
185
- * plan every round is redundant. A feedback line reference indexes the plan
186
- * version caret stored, and the abbreviated quote paired with it is what the agent
187
- * matches against its own text. `abbreviate` in ui/src/lib/feedback.ts builds that
188
- * quote. The stored version is rumdl-reflowed to 90 columns at ingest
189
- * (src/plan/markdown.ts); on this path there is no plan file to mirror it back to,
190
- * so the numbers need not line up with the agent's copy at all. (Pinned across its
191
- * three surfaces by test/structure/line-anchor-claim.test.ts.) */
192
- export function deniedMessage(feedback: string): string {
193
- return [
194
- "caret: the user requested CHANGES to this plan.",
195
- "",
196
- "Feedback:",
197
- feedback,
198
- "",
199
- `Revise the plan accordingly, then call \`${REVIEW_TOOL}\` again with the updated plan.`,
200
- ].join("\n");
201
- }
202
-
203
- /** Extract caret's review URL from the child's stderr text. Core writes
204
- * `caret: review this plan at <url>\n` (src/review/orchestrate.ts); both ends are caret-owned,
205
- * so this regex is coupled to that one line by design. The trailing `\s` match
206
- * means a stderr chunk cut off mid-URL (before the newline) yields nothing rather
207
- * than a truncated URL — the match only fires once the whole line has arrived. */
208
- export function parseReviewUrl(stderr: string): string | undefined {
209
- return stderr.match(/caret: review this plan at (\S+)\s/)?.[1];
210
- }
211
-
212
102
  /** The label caret puts in the review toast's TITLE. The review URL goes in the
213
- * toast MESSAGE (on its own line), not concatenated after this — OpenCode's toast
214
- * word-wraps, and a URL sharing a line with this prefix breaks across the wrap and
215
- * stops being terminal-clickable. Title + message-alone keeps the (short) URL whole
216
- * on its own full-width line (EXC-691). caret owns this toast surface because
217
- * OpenCode renders `caret_review_plan` as a generic tool whose running state never
218
- * shows the tool's `metadata` title. */
103
+ * toast MESSAGE alone, not concatenated after this — OpenCode's toast word-wraps,
104
+ * and a URL sharing a line with this prefix breaks across the wrap and stops being
105
+ * terminal-clickable (EXC-691). caret owns this toast surface because OpenCode
106
+ * renders `caret_review_plan` as a generic tool whose running state never shows the
107
+ * tool's `metadata` title. */
219
108
  const REVIEW_TOAST_TITLE = "caret: review this plan";
220
109
 
221
110
  /** OpenCode's plugin client, structurally narrowed to the calls caret makes.
@@ -281,6 +170,48 @@ async function isSubagentSession(client: SessionClient, sessionID: string): Prom
281
170
  }
282
171
  }
283
172
 
173
+ type PlanSource = { plan: string; planFilePath?: string };
174
+
175
+ /** The plan the review tool submits, from exactly one of its `plan` / `path` args (an
176
+ * empty string counts as absent). A `path` resolves against the session directory and
177
+ * must be a readable `.md` file — the same check the core's plan-file write-back makes,
178
+ * repeated here because this plugin cannot import src/. `readPlanFile` returns the
179
+ * text, or undefined when absPath is not a readable regular file. */
180
+ export function resolvePlanSource(
181
+ args: { plan?: string; path?: string },
182
+ directory: string,
183
+ readPlanFile: (absPath: string) => string | undefined,
184
+ ): PlanSource | { error: string } {
185
+ const plan = args.plan || undefined;
186
+ const path = args.path || undefined;
187
+ const notExactlyOne = {
188
+ error: `caret: ${REVIEW_TOOL} takes exactly one of \`plan\` or \`path\`. Pass the plan file as \`path\`, or the plan inline as \`plan\`.`,
189
+ };
190
+ if (path === undefined) return plan === undefined ? notExactlyOne : { plan };
191
+ if (plan !== undefined) return notExactlyOne;
192
+ const planFilePath = resolve(directory, path);
193
+ if (!planFilePath.endsWith(".md")) {
194
+ return {
195
+ error: `caret: ${REVIEW_TOOL} needs a markdown (.md) file as \`path\`, not ${planFilePath}. Write the plan to a .md file, or pass it inline as \`plan\`.`,
196
+ };
197
+ }
198
+ const text = readPlanFile(planFilePath);
199
+ if (text === undefined) {
200
+ return {
201
+ error: `caret: ${REVIEW_TOOL} could not read ${planFilePath} as a regular file. Write the plan there first, or pass it inline as \`plan\`.`,
202
+ };
203
+ }
204
+ return { plan: text, planFilePath };
205
+ }
206
+
207
+ function readRegularFile(absPath: string): string | undefined {
208
+ try {
209
+ return statSync(absPath).isFile() ? readFileSync(absPath, "utf-8") : undefined;
210
+ } catch {
211
+ return undefined;
212
+ }
213
+ }
214
+
284
215
  // --- version-check toast (EXC-794) -----------------------------------------
285
216
 
286
217
  /** caret's latest-release endpoint. Unauthenticated GitHub API — throttled to at
@@ -291,7 +222,7 @@ const LATEST_RELEASE_URL = "https://api.github.com/repos/macintacos/caret/releas
291
222
  const UPDATE_TOAST_MS = 5_000;
292
223
  /** Minimum gap between update checks. The nudge is a convenience, not a security
293
224
  * fix, so checking once a day is plenty — and it keeps the network hit off every
294
- * OpenCode start. The last-check time is persisted in a small throttle file. */
225
+ * OpenCode start. */
295
226
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60_000;
296
227
 
297
228
  /** Semver triple `[major, minor, patch]`, or null when `v` is not `X.Y.Z` (an
@@ -360,10 +291,9 @@ export function shouldCheckForUpdate(lastCheckMs: number | null, nowMs: number):
360
291
  }
361
292
 
362
293
  /** Absolute path of the throttle file holding the last-check epoch-ms. Lives under
363
- * caret's state dir ($XDG_STATE_HOME/caret or ~/.local/state/caret), matching where
364
- * caret keeps its other small machine-global markers (prefs.json). Pure so the
365
- * location is testable; the plugin stays self-contained and cannot import
366
- * src/config/paths.ts, so the convention is mirrored here by hand. */
294
+ * caret's state dir, beside caret's other small machine-global markers (prefs.json).
295
+ * The plugin stays self-contained and cannot import src/config/paths.ts, so that
296
+ * convention is mirrored here by hand. */
367
297
  export function updateCheckCachePath(
368
298
  env: Record<string, string | undefined>,
369
299
  home: string,
@@ -403,12 +333,9 @@ function fileUpdateCache(path: string): UpdateCache {
403
333
 
404
334
  /** Best-effort startup update check: fetch caret's latest GitHub release and, if it
405
335
  * is newer than this plugin's version, toast a nudge with a link. Throttled to at
406
- * most once a day via the injected cache — the check time is stamped up front, so
407
- * even an offline/failed start backs off a full day rather than hitting the network
408
- * on every restart. Never throws and never blocks plugin load — a network error, a
409
- * non-200, an unusable body, the throttle, or the `CARET_OPENCODE_NO_UPDATE_CHECK`
410
- * opt-out all resolve silently. `fetchImpl`/`url`/`now`/`cache` are injected so it is
411
- * unit-testable without the network or the filesystem. */
336
+ * most once a day via the injected cache. Never throws and never blocks plugin load
337
+ * — the `CARET_OPENCODE_NO_UPDATE_CHECK` opt-out and every failure resolve
338
+ * silently. */
412
339
  export async function realUpdateChecker(
413
340
  client: ToastClient,
414
341
  opts: {
@@ -439,14 +366,39 @@ export async function realUpdateChecker(
439
366
  }
440
367
  }
441
368
 
369
+ /** Where the planning steer sends the plan agent's plan file: `[opencode] plans_dir`
370
+ * from caret's config.toml (a leading `~` expanded), else OpenCode's data-dir
371
+ * `plans/`, which OpenCode's plan agent may edit. The config path mirrors
372
+ * src/config/paths.ts configFile() by hand, since the plugin cannot import src/. */
373
+ export function resolvePlansDir(opts: {
374
+ env: Record<string, string | undefined>;
375
+ home: string;
376
+ readFile: (path: string) => string;
377
+ }): string {
378
+ const configFile =
379
+ opts.env.CARET_CONFIG_FILE ||
380
+ `${opts.env.XDG_CONFIG_HOME || `${opts.home}/.config`}/caret/config.toml`;
381
+ try {
382
+ const config = Bun.TOML.parse(opts.readFile(configFile)) as {
383
+ opencode?: { plans_dir?: unknown };
384
+ };
385
+ const dir = config.opencode?.plans_dir;
386
+ if (typeof dir === "string" && dir) return dir.replace(/^~(?=\/|$)/, opts.home);
387
+ } catch {
388
+ // An absent or malformed config.toml leaves the default.
389
+ }
390
+ return `${opts.env.XDG_DATA_HOME || `${opts.home}/.local/share`}/opencode/plans`;
391
+ }
392
+
442
393
  /** The planning-prompt steer appended to the system array so the Plan agent
443
394
  * submits its plan to caret instead of calling the native plan_exit. */
444
- export function planningSteer(): string {
395
+ export function planningSteer(plansDir: string): string {
445
396
  return [
446
397
  "## Plan review (caret)",
447
398
  "",
448
- `When you have a plan ready for the user, do NOT call plan_exit. Instead call the \`${REVIEW_TOOL}\` tool with your full plan (markdown) as the \`plan\` argument.`,
449
- "It opens caret's visual review UI in the browser; the user approves or requests changes. Any change request comes back as the tool result — revise the plan and call the tool again until it is approved.",
399
+ `When you have a plan ready for the user, do NOT call plan_exit. Instead write the plan as markdown to a file in \`${plansDir}/\` (a directory you may write to), for example \`${plansDir}/<short-name>.md\`, and call the \`${REVIEW_TOOL}\` tool with that file as the \`path\` argument.`,
400
+ "It opens caret's visual review UI in the browser; the user approves or requests changes. A change request comes back as the tool result: re-read the file, revise it with targeted edits rather than rewriting it, and call the tool again with the same `path` until it is approved. An approved plan is already saved in that file.",
401
+ PLAN_TITLE_INSTRUCTION,
450
402
  ].join("\n");
451
403
  }
452
404
 
@@ -499,97 +451,16 @@ function ensurePermission(agent: LooseAgent): Record<string, unknown> {
499
451
  return agent.permission as Record<string, unknown>;
500
452
  }
501
453
 
502
- // --- the spawn bridge ----------------------------------------------------------
503
-
504
- /** Runs `caret review`, returning its captured stdout. Injected so execute() is
505
- * unit-testable without spawning a real process. `onStderr` streams the child's
506
- * stderr chunks as they arrive, so the caller can surface the review URL the core
507
- * prints there while the review is still pending. */
508
- export type SpawnRunner = (
509
- bin: string,
510
- env: Record<string, string | undefined>,
511
- stdin: string,
512
- onStderr?: (chunk: string) => void,
513
- ) => Promise<{ stdout: string; exitCode: number }>;
514
-
515
- /** Spawn `caret review` with the review envelope on stdin and CARET_AGENT=opencode,
516
- * then parse its decision line. Any spawn failure fails safe to a deny. `onUrl`, if
517
- * given, fires once with the review URL the moment core prints it on stderr — the
518
- * caller surfaces it inside the tool block so it clears on decision (EXC-691). */
519
- export async function runReviewViaCaret(
520
- envelope: string,
521
- opts: { bin: string; run: SpawnRunner; onUrl?: (url: string) => void },
522
- ): Promise<CaretDecision> {
523
- try {
524
- // Accumulate stderr and report the URL once — the line may arrive split
525
- // across chunks, and only the first occurrence matters.
526
- const { onUrl } = opts;
527
- let stderrBuf = "";
528
- let urlSent = false;
529
- const onStderr = onUrl
530
- ? (chunk: string) => {
531
- if (urlSent) return;
532
- stderrBuf += chunk;
533
- const url = parseReviewUrl(stderrBuf);
534
- if (url) {
535
- urlSent = true;
536
- // Best-effort: surfacing the URL must never crash or fail-safe-deny
537
- // the review. This fires on the stderr `data` event while we're
538
- // suspended at `await opts.run(...)`, so a throw here would escape the
539
- // try/catch below as an uncaughtException rather than a deny.
540
- try {
541
- onUrl(url);
542
- } catch {
543
- // swallow — the review decision is what matters.
544
- }
545
- }
546
- }
547
- : undefined;
548
- const { stdout } = await opts.run(
549
- opts.bin,
550
- { ...process.env, CARET_AGENT: "opencode" },
551
- envelope,
552
- onStderr,
553
- );
554
- return parseDecision(stdout);
555
- } catch (e) {
556
- const message = e instanceof Error ? e.message : String(e);
557
- return failsafeDeny(`caret: review failed to run (${message}) — denying to fail safe.`);
558
- }
559
- }
560
-
561
- /** Production runner: spawn the caret binary's `review` subcommand. stderr is
562
- * PIPED (not inherited) and streamed to `onStderr`: inheriting it leaked core's
563
- * "review this plan at <url>" line straight into OpenCode's TUI scrollback, where
564
- * the renderer never owns it and it lingered after the decision (EXC-691). We now
565
- * parse that URL and surface it as a caret-owned toast instead; the child logs
566
- * diagnostics to caret.log, so dropping the rest of stderr loses nothing. */
567
- const nodeSpawnRunner: SpawnRunner = (bin, env, stdin, onStderr) =>
568
- new Promise((resolve, reject) => {
569
- const child = spawn(bin, ["review"], { env, stdio: ["pipe", "pipe", "pipe"] });
570
- let stdout = "";
571
- child.stdout.on("data", (chunk) => {
572
- stdout += chunk.toString();
573
- });
574
- child.stderr.on("data", (chunk) => onStderr?.(chunk.toString()));
575
- child.on("error", reject);
576
- child.on("close", (code) => resolve({ stdout, exitCode: code ?? 0 }));
577
- child.stdin.write(stdin);
578
- child.stdin.end();
579
- });
580
-
581
454
  /** Warms the caret daemon ahead of a review. Injected so the chat.message hook is
582
455
  * unit-testable without spawning a process. */
583
456
  export type WarmRunner = (bin: string) => void;
584
457
 
585
458
  /** Production warm: `caret prewarm`, detached and unref'd so it never holds up the
586
- * turn. Output is discarded — the child logs to caret.log. Two non-obvious
587
- * requirements, both spelled out in `doc/agents/opencode-integration.md` § Daemon
588
- * warm-up: CARET_AGENT rides along because this spawn is what stands the daemon
589
- * up and the daemon picks its adapter from that env; and the 'error' handler is
590
- * mandatory because spawn emits 'error' ASYNCHRONOUSLY (ENOENT on a bad bin),
591
- * where the hook's synchronous try/catch cannot see it and an unhandled event
592
- * would take OpenCode's whole process down. */
459
+ * turn. Output is discarded — the child logs to caret.log. CARET_AGENT rides along
460
+ * because this spawn is what stands the daemon up and the daemon picks its adapter
461
+ * from that env; the 'error' handler is mandatory because spawn emits 'error'
462
+ * ASYNCHRONOUSLY (ENOENT on a bad bin), where the hook's synchronous try/catch
463
+ * cannot see it and an unhandled event would take OpenCode's whole process down. */
593
464
  const nodeWarmRunner: WarmRunner = (bin) => {
594
465
  const child = spawn(bin, ["prewarm"], {
595
466
  stdio: "ignore",
@@ -604,16 +475,17 @@ const nodeWarmRunner: WarmRunner = (bin) => {
604
475
  // The plugin
605
476
  // ---------------------------------------------------------------------------
606
477
 
607
- /** Build the caret OpenCode plugin. The DI seam (bin/run/warm/checkUpdate) keeps
608
- * the tool's execute(), the daemon warm, and the startup update check
609
- * unit-testable; the default export wires the production runners and update
610
- * checker. */
478
+ /** Build the caret OpenCode plugin. The DI seam (bin/run/warm/checkUpdate/plansDir)
479
+ * keeps the tool's execute(), the daemon warm, the planning steer, and the startup
480
+ * update check unit-testable; the default export wires the production runners and
481
+ * update checker. */
611
482
  export function createCaretPlugin(
612
483
  opts: {
613
484
  bin?: string;
614
485
  run?: SpawnRunner;
615
486
  warm?: WarmRunner;
616
487
  checkUpdate?: (client: ToastClient) => void;
488
+ plansDir?: string;
617
489
  } = {},
618
490
  ): Plugin {
619
491
  const bin =
@@ -621,14 +493,20 @@ export function createCaretPlugin(
621
493
  resolveCaretBin({ env: process.env, marker: CARET_BIN, importMetaUrl: import.meta.url });
622
494
  const run = opts.run ?? nodeSpawnRunner;
623
495
  const warm = opts.warm ?? nodeWarmRunner;
496
+ const plansDir =
497
+ opts.plansDir ??
498
+ resolvePlansDir({
499
+ env: process.env,
500
+ home: homedir(),
501
+ readFile: (p) => readFileSync(p, "utf-8"),
502
+ });
624
503
 
625
504
  return async (input) => {
626
- // OpenCode gives every plugin its SDK client; caret uses it to surface the
627
- // review link as a toast (EXC-691) and to read the calling session's shape.
628
- // Narrowed structurally for skew-safety.
505
+ // The SDK client OpenCode hands every plugin — caret uses it for the review-link
506
+ // toast (EXC-691) and to read the calling session's shape.
629
507
  const client = (input as { client?: CaretClient }).client;
630
- // Best-effort startup update nudge (EXC-794); fire-and-forget so it never
631
- // delays plugin load. Only the production default export wires this.
508
+ // Startup update nudge (EXC-794), fire-and-forget. Only the production default
509
+ // export wires this.
632
510
  if (opts.checkUpdate) opts.checkUpdate(client);
633
511
  // The agent driving each session, recorded by chat.message and read by
634
512
  // system.transform, which carries no agent of its own. Why a map is the only
@@ -637,21 +515,18 @@ export function createCaretPlugin(
637
515
  // module-global, so a test constructs a fresh one.
638
516
  const sessionAgents = new Map<string, string>();
639
517
  const hooks: Hooks = {
640
- // Restrict the review tool to primary agents (see applyCaretConfig).
641
518
  config: async (config) => {
642
519
  applyCaretConfig(config as unknown as LooseConfig);
643
520
  },
644
- // Two jobs. (1) Record the session's agent, ALWAYS — it is what the steer
645
- // below gates on. (2) Warm the daemon, for the PLAN AGENT ONLY, so the first
646
- // caret_review_plan call doesn't pay the cold-spawn cost. This is
647
- // OpenCode's counterpart to Claude Code's PostToolUse/EnterPlanMode
648
- // prewarm hook — the closest-to-submission signal the plugin API offers.
649
- // Deliberately per-message and unthrottled: the daemon idle-exits after
650
- // [daemon].idle_ms (60s default), so a once-per-session warm would be
651
- // dead long before the plan lands. The warm stays plan-only even though any
652
- // primary agent may call the tool: the plan agent is the one whose turn
653
- // reliably ends in a review, and warming on every build message would spawn
654
- // a process on the session's busiest traffic to save ~0.4s in the rare case.
521
+ // Two jobs. (1) Record the session's agent, ALWAYS — it is what the steer below
522
+ // gates on. (2) Warm the daemon, for the PLAN AGENT ONLY, so the first
523
+ // caret_review_plan call doesn't pay the cold-spawn cost. Deliberately per-message
524
+ // and unthrottled: the daemon idle-exits after [daemon].idle_ms (60s default; a
525
+ // resident daemon never does), so a once-per-session warm would be dead
526
+ // long before the plan lands. The warm stays plan-only even though any primary agent
527
+ // may call the tool: the plan agent is the one whose turn reliably ends in a review,
528
+ // and warming on every build message would spawn a process on the session's busiest
529
+ // traffic to save ~0.4s in the rare case.
655
530
  "chat.message": async (input) => {
656
531
  // A message whose agent is unknown must not clobber the recorded one.
657
532
  if (input.sessionID && input.agent) sessionAgents.set(input.sessionID, input.agent);
@@ -672,9 +547,8 @@ export function createCaretPlugin(
672
547
  // it points plan_exit — permitted only on the plan agent — at caret.
673
548
  "experimental.chat.system.transform": async (input, output) => {
674
549
  const agent = input.sessionID ? sessionAgents.get(input.sessionID) : undefined;
675
- if (isPlanningAgent(agent)) output.system.push(planningSteer());
550
+ if (isPlanningAgent(agent)) output.system.push(planningSteer(plansDir));
676
551
  },
677
- // Redirect the native plan_exit tool's description toward caret's tool.
678
552
  "tool.definition": async (input, output) => {
679
553
  if (input.toolID === "plan_exit") {
680
554
  output.description = `Do not call this tool. Call ${REVIEW_TOOL} instead — it opens caret's visual plan-review UI for human approval.`;
@@ -683,27 +557,52 @@ export function createCaretPlugin(
683
557
  tool: {
684
558
  [REVIEW_TOOL]: tool({
685
559
  description:
686
- "Submit the current plan to caret for human review in a local browser UI. Blocks until the user approves or requests changes. On a change request, revise the plan and call this tool again with the updated plan.",
560
+ "Submit the current plan to caret for human review in a local browser UI. For plans only: caret presents what it receives as a plan, so do not use it for other documents or questions. Pass exactly one of `path` (preferred: a markdown file you write once and revise with edits) or `plan` (the plan inline). Blocks until the user approves or requests changes. On a change request, follow the result's instructions and call this tool again. Do not implement the plan until a call returns an approval.",
687
561
  args: {
688
562
  plan: tool.schema
689
563
  .string()
690
- .describe("The complete plan, as markdown, to present for human review."),
564
+ .optional()
565
+ .describe(
566
+ "The complete plan, as markdown, to present for human review. The inline alternative to `path`.",
567
+ ),
568
+ path: tool.schema
569
+ .string()
570
+ .optional()
571
+ .describe(
572
+ `Preferred. A markdown (.md) file holding the complete plan, absolute or relative to the session directory. (OpenCode's plan agent writes its plan files in ${plansDir}/.)`,
573
+ ),
691
574
  },
692
575
  async execute(args, context) {
693
576
  if (await isSubagentSession(client, context.sessionID)) {
694
577
  return `${REVIEW_TOOL} is available to primary agents only; this call came from a subagent session. Continue without caret review, or hand the plan back to the primary agent to submit.`;
695
578
  }
696
- const envelope = buildEnvelope(args.plan, {
579
+ const source = resolvePlanSource(args, context.directory, readRegularFile);
580
+ if ("error" in source) return source.error;
581
+ if (source.planFilePath) {
582
+ // caret rewrites this file, and the model chose the path, so OpenCode's own
583
+ // edit permission must allow it.
584
+ try {
585
+ await context.ask({
586
+ permission: "edit",
587
+ patterns: [relative(context.worktree, source.planFilePath)],
588
+ always: ["*"],
589
+ metadata: { filepath: source.planFilePath },
590
+ });
591
+ } catch {
592
+ return `caret: ${REVIEW_TOOL} was not permitted to edit ${source.planFilePath}, which a path review rewrites. Write the plan in \`${plansDir}/\` and pass that file as \`path\`, or pass the plan inline as \`plan\`.`;
593
+ }
594
+ }
595
+ const envelope = buildEnvelope(source.plan, {
697
596
  sessionID: context.sessionID,
698
597
  directory: context.directory,
598
+ planFilePath: source.planFilePath,
699
599
  });
700
600
  let linkShown = false;
701
601
  const decision = await runReviewViaCaret(envelope, {
702
- bin,
602
+ command: [bin, "review"],
603
+ agent: "opencode",
703
604
  run,
704
- // Show the review URL as a toast while the plan is pending. The URL
705
- // is the message ALONE (label in the title) so it renders on its own
706
- // full-width line and word-wraps whole, staying clickable (EXC-691).
605
+ // Show the review URL as a toast while the plan is pending.
707
606
  onUrl: (url) => {
708
607
  linkShown = true;
709
608
  showToast(client, {
@@ -714,9 +613,8 @@ export function createCaretPlugin(
714
613
  });
715
614
  },
716
615
  });
717
- // Clear the pending review-link toast on decision by superseding it
718
- // with a brief decision toast — OpenCode's toast surface is single-slot
719
- // with no hide API, so a fresh toast replaces the link (EXC-691).
616
+ // Supersede the pending review-link toast with a brief decision toast —
617
+ // the surface is single-slot with no hide API (EXC-691).
720
618
  if (linkShown) {
721
619
  showToast(
722
620
  client,
@@ -733,9 +631,7 @@ export function createCaretPlugin(
733
631
  },
734
632
  );
735
633
  }
736
- return decision.behavior === "allow"
737
- ? approvedMessage(decision.feedback)
738
- : deniedMessage(decision.feedback ?? "Plan changes requested.");
634
+ return decisionText(decision, REVIEW_TOOL, source.planFilePath);
739
635
  },
740
636
  }),
741
637
  },
@@ -744,8 +640,8 @@ export function createCaretPlugin(
744
640
  };
745
641
  }
746
642
 
747
- /** The plugin OpenCode loads (bare default-export function). Wires the production
748
- * update checker: on load, check caret's latest release and toast if behind. */
643
+ /** The plugin OpenCode loads (bare default-export function), wiring the production
644
+ * update checker. */
749
645
  const CaretPlugin: Plugin = createCaretPlugin({
750
646
  checkUpdate: (client) => {
751
647
  void realUpdateChecker(client, {