@macintacos/caret 0.14.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.
- package/.claude-plugin/plugin.json +9 -2
- package/README.md +48 -24
- package/bin/caret-launcher +208 -0
- package/commands/demo.md +11 -10
- package/dist/cli.js +49415 -27743
- package/hooks/hooks.json +16 -0
- package/opencode/caret.plugin.ts +173 -277
- package/opencode/commands/demo.md +9 -20
- package/opencode/index.ts +1 -2
- package/opencode/review-bridge.ts +241 -0
- package/package.json +9 -6
- package/templates/demo.md +89 -0
- package/ui/dist/assets/{angular-html-C0XElF0f.js → angular-html-BrP7X_fT.js} +1 -1
- package/ui/dist/assets/{angular-ts-BVnV1Yg5.js → angular-ts-CWSXPJ8_.js} +1 -1
- package/ui/dist/assets/{apl-BFyMAB9c.js → apl-DcDu9VfL.js} +1 -1
- package/ui/dist/assets/{astro-DAtaAFYq.js → astro-ZWhUFE02.js} +1 -1
- package/ui/dist/assets/{blade-BoZH76v_.js → blade-Bnfid1aX.js} +1 -1
- package/ui/dist/assets/{c-DyRawzd0.js → c-DGhlaWFa.js} +1 -1
- package/ui/dist/assets/{chapel-CAaCMdek.js → chapel-B80nA-Wv.js} +1 -1
- package/ui/dist/assets/{cobol-BjXiLsat.js → cobol-BxPmMgGq.js} +1 -1
- package/ui/dist/assets/{coffee-DvjcTlIE.js → coffee-qvFLvdPC.js} +1 -1
- package/ui/dist/assets/{cpp-sRP7rZGr.js → cpp-BGBu9Yb6.js} +1 -1
- package/ui/dist/assets/{crystal-CaRkbKnA.js → crystal-CV45eR73.js} +1 -1
- package/ui/dist/assets/{css-Igh3vvbt.js → css-CBPwQP3-.js} +1 -1
- package/ui/dist/assets/{dist-B5Dwvt_o.js → dist-1bUsa9So.js} +1 -1
- package/ui/dist/assets/dist-418qtW5O.js +2 -0
- package/ui/dist/assets/dist-BafAgSat.js +1 -0
- package/ui/dist/assets/{dist-CBxRdWpx.js → dist-BubrR0xG.js} +1 -1
- package/ui/dist/assets/{dist-DHAfbOzU.js → dist-CfuU_6ln.js} +1 -1
- package/ui/dist/assets/{dist-CF48ImGm.js → dist-Cj-JdNZd.js} +1 -1
- package/ui/dist/assets/{dist-CGl7bSLs.js → dist-CjIRqXSt.js} +1 -1
- package/ui/dist/assets/{dist-D4FUs8dC.js → dist-CpcSX-YP.js} +1 -1
- package/ui/dist/assets/{dist-C_lXyw3s.js → dist-D0Ats_4w.js} +1 -1
- package/ui/dist/assets/dist-DOswrDst.js +12 -0
- package/ui/dist/assets/{dist-CF4FIhxu.js → dist-DWzXdmaf.js} +1 -1
- package/ui/dist/assets/{dist-5Bv2mYWj.js → dist-DXn_f-MA.js} +1 -1
- package/ui/dist/assets/{dist-lE2sJQyW.js → dist-Dfm7Znx4.js} +1 -1
- package/ui/dist/assets/{dist-BkB3Dkvt.js → dist-Dj96YS5k.js} +1 -1
- package/ui/dist/assets/{dist-CZeI5UV9.js → dist-Dz8bokki.js} +1 -1
- package/ui/dist/assets/{dist-D_g9f9rz.js → dist-Lc2Es7Pl.js} +1 -1
- package/ui/dist/assets/{dist-Dt1D8SWK.js → dist-TxQM6vOw.js} +1 -1
- package/ui/dist/assets/{dist-CYIXdHF6.js → dist-cV3QBg-W.js} +1 -1
- package/ui/dist/assets/{edge-CvHTOXgI.js → edge-Co6ItTYI.js} +1 -1
- package/ui/dist/assets/{elixir-CtokNUaA.js → elixir-0YZEZ0gv.js} +1 -1
- package/ui/dist/assets/{elm-B34p-CC5.js → elm-BkRJOygl.js} +1 -1
- package/ui/dist/assets/{erb-FjaGN5LV.js → erb-BI2P49U3.js} +1 -1
- package/ui/dist/assets/{git-rebase-BFUCuD7O.js → git-rebase-Bq5iIXzt.js} +1 -1
- package/ui/dist/assets/{glimmer-js-CRs6sgzF.js → glimmer-js-BbOUbDIt.js} +1 -1
- package/ui/dist/assets/{glimmer-ts-Bkt0v-q2.js → glimmer-ts-CNnsk0yJ.js} +1 -1
- package/ui/dist/assets/{glsl-Ckh3d22M.js → glsl-BGnMm0dJ.js} +1 -1
- package/ui/dist/assets/{graphql-IqxoIj3x.js → graphql-7Z7p0nC9.js} +1 -1
- package/ui/dist/assets/{hack-DG1p8zpS.js → hack-DqHfN4B0.js} +1 -1
- package/ui/dist/assets/{haml-DA2jQcOV.js → haml-3nZzM1j8.js} +1 -1
- package/ui/dist/assets/{handlebars-CqT0epoo.js → handlebars-D76GLv5_.js} +1 -1
- package/ui/dist/assets/{html-CoTd-Axz.js → html-KRo6nO5U.js} +1 -1
- package/ui/dist/assets/{html-derivative-DDt5A1xL.js → html-derivative-45dsHZ7G.js} +1 -1
- package/ui/dist/assets/{http-TEp9u8BR.js → http-Cd6yAfd3.js} +1 -1
- package/ui/dist/assets/{hurl-OLD-GtJJ.js → hurl-DMRd-22O.js} +1 -1
- package/ui/dist/assets/index-CAl4rX7_.css +1 -0
- package/ui/dist/assets/{index-BucWCjoT.js → index-DqZ7qZxG.js} +367 -318
- package/ui/dist/assets/{java-DYZ_BnxQ.js → java-C5BRRLzP.js} +1 -1
- package/ui/dist/assets/{javascript-B4vvB79r.js → javascript-BEwOFxBm.js} +1 -1
- package/ui/dist/assets/{jinja-BTulBbvB.js → jinja-DC6SxQM2.js} +1 -1
- package/ui/dist/assets/{jison-CDVJmcGj.js → jison-BpM_G12u.js} +1 -1
- package/ui/dist/assets/{json-DahQZwqh.js → json-DhXVH33S.js} +1 -1
- package/ui/dist/assets/{jsx-Dzcw0XhS.js → jsx-QIa_kGnJ.js} +1 -1
- package/ui/dist/assets/{julia-BB_aSeOr.js → julia-BuS1ndKd.js} +1 -1
- package/ui/dist/assets/{just-C3IsNIar.js → just-Dms5nmY6.js} +1 -1
- package/ui/dist/assets/{latex-D7dYt9yW.js → latex-d0FSRalE.js} +1 -1
- package/ui/dist/assets/{liquid-CO9g2vFd.js → liquid-Bq_YWguE.js} +1 -1
- package/ui/dist/assets/{lua-D6OK3PTP.js → lua-CqusOKbI.js} +1 -1
- package/ui/dist/assets/{marko-YPWiYUQ_.js → marko-CfvdDxZz.js} +1 -1
- package/ui/dist/assets/{mdc-DoQ9fqeC.js → mdc-B_vZdcRe.js} +1 -1
- package/ui/dist/assets/{nginx-BuX_dG5x.js → nginx-YhyPrYEc.js} +1 -1
- package/ui/dist/assets/{nim-oEF2CEaX.js → nim-UGiA-q4U.js} +1 -1
- package/ui/dist/assets/{org-C1kwr3b8.js → org-BWLiCWoV.js} +1 -1
- package/ui/dist/assets/{perl-CzZcVIM9.js → perl-CXeAyAx6.js} +1 -1
- package/ui/dist/assets/{php-C_i1TWmy.js → php-C0Y5OILf.js} +1 -1
- package/ui/dist/assets/{pug-Oz2JZVGZ.js → pug-pavIHhZT.js} +1 -1
- package/ui/dist/assets/{qml-DWZFG7aa.js → qml-CbMVL_Hw.js} +1 -1
- package/ui/dist/assets/{r-CKwXeGZ_.js → r-VbESC49D.js} +1 -1
- package/ui/dist/assets/{razor-DT1mg2x6.js → razor-B4zmReq6.js} +1 -1
- package/ui/dist/assets/{regexp-C2K6ptTb.js → regexp-CvzL3gAC.js} +1 -1
- package/ui/dist/assets/{rst-C5gmF0mo.js → rst-B0YBcjx2.js} +1 -1
- package/ui/dist/assets/{ruby-CrEJdgPT.js → ruby-BFx1yg1l.js} +1 -1
- package/ui/dist/assets/{sas-D1TGFNd_.js → sas-Bdz0EWxy.js} +1 -1
- package/ui/dist/assets/{scss-DBO_Sp1C.js → scss-Ar5qG57q.js} +1 -1
- package/ui/dist/assets/{shellscript-QIWVo1P5.js → shellscript-BxZn-g_C.js} +1 -1
- package/ui/dist/assets/{shellsession-iBMCyJwd.js → shellsession-DdGjCUDJ.js} +1 -1
- package/ui/dist/assets/{soy-NHBvJjBh.js → soy-DNbG_b-4.js} +1 -1
- package/ui/dist/assets/{sql-Yf21hpnW.js → sql-BbO6oYzq.js} +1 -1
- package/ui/dist/assets/{stata-CluZWHbV.js → stata-CrT9A-Oi.js} +1 -1
- package/ui/dist/assets/{surrealql-Bu2dU1Qj.js → surrealql-_tFV7pCn.js} +1 -1
- package/ui/dist/assets/{svelte-Owb3qG7g.js → svelte-ZdvR-T2J.js} +1 -1
- package/ui/dist/assets/{templ-DScnsiaP.js → templ-CpOqX9Rk.js} +1 -1
- package/ui/dist/assets/{tex-DvCQ8DJI.js → tex-BnYgu1Tm.js} +1 -1
- package/ui/dist/assets/{ts-tags-Fh6nOEbM.js → ts-tags-CII1fKoY.js} +1 -1
- package/ui/dist/assets/{tsx-C_2AyD6m.js → tsx-CPIX6Zti.js} +1 -1
- package/ui/dist/assets/{twig-DjT0rOpv.js → twig-Cpr1_Fdx.js} +1 -1
- package/ui/dist/assets/{typescript-NDIZTg4J.js → typescript-CS1A4fih.js} +1 -1
- package/ui/dist/assets/{typst-DNJZJFvq.js → typst-BEc_7GFB.js} +1 -1
- package/ui/dist/assets/{vue-BTZXwXJ_.js → vue-DLe2p-nw.js} +1 -1
- package/ui/dist/assets/{vue-html-CBhLp-FO.js → vue-html-CBsH80UM.js} +1 -1
- package/ui/dist/assets/{vue-vine-KGJI1qvi.js → vue-vine-C6KcK89Z.js} +1 -1
- package/ui/dist/assets/{xml-CeV6vl7T.js → xml-CMmb8ycU.js} +1 -1
- package/ui/dist/assets/{xsl-CibkluSG.js → xsl-vCAAt3o3.js} +1 -1
- package/ui/dist/assets/{yaml-D89KUdIt.js → yaml-Be7JvL54.js} +1 -1
- package/ui/dist/index.html +3 -3
- package/ui/dist/assets/dist-BdaKUV8X.js +0 -2
- package/ui/dist/assets/dist-CiBs4R_k.js +0 -12
- package/ui/dist/assets/dist-YE9up3VR.js +0 -1
- package/ui/dist/assets/index-Djrj2rps.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
|
}
|
package/opencode/caret.plugin.ts
CHANGED
|
@@ -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
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
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
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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.
|
|
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
|
|
364
|
-
*
|
|
365
|
-
*
|
|
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
|
|
407
|
-
*
|
|
408
|
-
*
|
|
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
|
|
449
|
-
"It opens caret's visual review UI in the browser; the user approves or requests changes.
|
|
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.
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
*
|
|
590
|
-
*
|
|
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)
|
|
608
|
-
* the tool's execute(), the daemon warm, and the startup
|
|
609
|
-
* unit-testable; the default export wires the production runners and
|
|
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
|
|
627
|
-
//
|
|
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
|
-
//
|
|
631
|
-
//
|
|
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
|
-
//
|
|
646
|
-
// caret_review_plan call doesn't pay the cold-spawn cost.
|
|
647
|
-
//
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
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,
|
|
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
|
-
.
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
718
|
-
//
|
|
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
|
|
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)
|
|
748
|
-
* update checker
|
|
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, {
|