@macintacos/caret 1.8.1 → 1.9.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 +1 -1
  2. package/README.md +34 -7
  3. package/bin/caret-launcher +15 -10
  4. package/{opencode → bridge}/review-bridge.ts +153 -12
  5. package/commands/doctor.md +4 -0
  6. package/dist/cli.js +23980 -23370
  7. package/opencode/caret.core.ts +26 -113
  8. package/opencode/caret.plugin.ts +11 -5
  9. package/opencode/caret.plugin.v2.ts +12 -4
  10. package/opencode/caret.tui.ts +6 -18
  11. package/opencode/commands/demo.md +1 -1
  12. package/opencode/commands/doctor.md +4 -0
  13. package/package.json +18 -3
  14. package/pi/caret.ts +145 -0
  15. package/pi/demo.ts +79 -0
  16. package/pi/doctor.ts +149 -0
  17. package/pi/follow-up.ts +19 -0
  18. package/pi/gate.ts +49 -0
  19. package/pi/notice.ts +127 -0
  20. package/pi/plan.ts +182 -0
  21. package/pi/render.ts +51 -0
  22. package/pi/state.ts +74 -0
  23. package/pi/tool.ts +150 -0
  24. package/ui/dist/assets/{angular-html-BHGQgfyp.js → angular-html-DTAmLsI1.js} +1 -1
  25. package/ui/dist/assets/{angular-ts-CjJOXiTk.js → angular-ts-CnmIqVSv.js} +1 -1
  26. package/ui/dist/assets/{apl-BKCXbKuk.js → apl-BRrI8o6u.js} +1 -1
  27. package/ui/dist/assets/{astro-CbIGh4kW.js → astro-ws1SFlqZ.js} +1 -1
  28. package/ui/dist/assets/{blade-Dlz3TW50.js → blade-BAfome-F.js} +1 -1
  29. package/ui/dist/assets/{c-CNaIUpZJ.js → c-Cb8iBkqY.js} +1 -1
  30. package/ui/dist/assets/{chapel-BSTn-HqM.js → chapel-CwNxsGmt.js} +1 -1
  31. package/ui/dist/assets/{cobol-Ch_1nBvr.js → cobol-C6senUsW.js} +1 -1
  32. package/ui/dist/assets/{coffee-DlKg6sYw.js → coffee-BNmOwO3K.js} +1 -1
  33. package/ui/dist/assets/{cpp-CPx3-f9p.js → cpp-BBfuAVc8.js} +1 -1
  34. package/ui/dist/assets/{crystal-Cgvix66v.js → crystal-BIenSyu_.js} +1 -1
  35. package/ui/dist/assets/{css-CwPT7Xr2.js → css-BM7X2qQt.js} +1 -1
  36. package/ui/dist/assets/{dist-DH3vs96X.js → dist-8Qj1RsyX.js} +1 -1
  37. package/ui/dist/assets/{dist-DFQEDf6Q.js → dist-BBj5r4JZ.js} +1 -1
  38. package/ui/dist/assets/{dist-D2BIUS34.js → dist-B_l6M6p7.js} +1 -1
  39. package/ui/dist/assets/{dist-BdIbTHZ2.js → dist-Bsjq6LzO.js} +1 -1
  40. package/ui/dist/assets/{dist-BqPfD9yL.js → dist-Bxb_HUlD.js} +1 -1
  41. package/ui/dist/assets/{dist-Dze-hH7D.js → dist-C7V4XWMN.js} +1 -1
  42. package/ui/dist/assets/{dist-Cq6qfECE.js → dist-Cq8hWpOn.js} +1 -1
  43. package/ui/dist/assets/{dist-C6pK9dBg.js → dist-fytLbOcF.js} +1 -1
  44. package/ui/dist/assets/{dist-B7Ila1Oa.js → dist-zbP7SV6c.js} +1 -1
  45. package/ui/dist/assets/{dist-nsVj3Kzw.js → dist-zezPBQmO.js} +1 -1
  46. package/ui/dist/assets/{edge-C6856TSk.js → edge-BlclzlRw.js} +1 -1
  47. package/ui/dist/assets/{elixir-nUsO5D1t.js → elixir-BYumuPVp.js} +1 -1
  48. package/ui/dist/assets/{elm-BWmeidnG.js → elm-DNBykypS.js} +1 -1
  49. package/ui/dist/assets/{erb-DgYxypnH.js → erb-Cg-GLw-3.js} +1 -1
  50. package/ui/dist/assets/{git-rebase-Bu77Zji8.js → git-rebase-DcwetDCn.js} +1 -1
  51. package/ui/dist/assets/{glimmer-js-C-Er2CH_.js → glimmer-js-DwUQB02R.js} +1 -1
  52. package/ui/dist/assets/{glimmer-ts-DHJp3ZV-.js → glimmer-ts-DNxDwzad.js} +1 -1
  53. package/ui/dist/assets/{glsl-CkN2-uXa.js → glsl-CTQvGLl-.js} +1 -1
  54. package/ui/dist/assets/{graphql-DM_zS2Al.js → graphql-6N6VWnCL.js} +1 -1
  55. package/ui/dist/assets/{hack-BjOgct5l.js → hack-BIWJ2t_-.js} +1 -1
  56. package/ui/dist/assets/{haml-CY4ICINw.js → haml-CTaug-6I.js} +1 -1
  57. package/ui/dist/assets/{handlebars-DLsdnsgP.js → handlebars-EciX6Xtm.js} +1 -1
  58. package/ui/dist/assets/{html-Cw-p-zYu.js → html-5ZVMwxqg.js} +1 -1
  59. package/ui/dist/assets/{html-derivative-CDmHYwVW.js → html-derivative-Bo7PACv6.js} +1 -1
  60. package/ui/dist/assets/{http-Bqbe5jqD.js → http-D14aglwt.js} +1 -1
  61. package/ui/dist/assets/{hurl-Ca-chcCT.js → hurl-DGa-Lc1_.js} +1 -1
  62. package/ui/dist/assets/index-BocyxbG2.css +1 -0
  63. package/ui/dist/assets/{index-DLUt2ehA.js → index-Do48mTUl.js} +226 -225
  64. package/ui/dist/assets/{java-0P1dqfZ8.js → java-BUpF6706.js} +1 -1
  65. package/ui/dist/assets/{javascript-VI2-fvZG.js → javascript-CU7AY0Mj.js} +1 -1
  66. package/ui/dist/assets/{jinja-Dz73I98G.js → jinja-OREkRLMJ.js} +1 -1
  67. package/ui/dist/assets/{jison-BtT1z3MS.js → jison-Zf3gmndI.js} +1 -1
  68. package/ui/dist/assets/{json-C4tTi3F5.js → json-C8E_SzMr.js} +1 -1
  69. package/ui/dist/assets/{jsx-tTJjMA8e.js → jsx-CHMjfgSc.js} +1 -1
  70. package/ui/dist/assets/{julia-rokLSeEC.js → julia-DnO66Wng.js} +1 -1
  71. package/ui/dist/assets/{just-qeFPHb0X.js → just-BUkZWNEk.js} +1 -1
  72. package/ui/dist/assets/{latex-Beecofx6.js → latex-eFDA2ndR.js} +1 -1
  73. package/ui/dist/assets/{liquid-DAA5lUdB.js → liquid-BqLuL6hU.js} +1 -1
  74. package/ui/dist/assets/{lua-BsloMLJI.js → lua-CKNxXI-W.js} +1 -1
  75. package/ui/dist/assets/{marko-BohJpFVB.js → marko-CM7Qyeyg.js} +1 -1
  76. package/ui/dist/assets/{mdc-DcoaHcos.js → mdc-DQnmyYku.js} +1 -1
  77. package/ui/dist/assets/{nginx-rrqwgIhx.js → nginx-B7PJAw6W.js} +1 -1
  78. package/ui/dist/assets/{nim-bmLsCyNQ.js → nim-CmtAHkVL.js} +1 -1
  79. package/ui/dist/assets/{org-DBqD3zir.js → org-DXNJ0F8G.js} +1 -1
  80. package/ui/dist/assets/{perl-DTcxBbd6.js → perl-DCIWxaEc.js} +1 -1
  81. package/ui/dist/assets/{php-CtncUr2-.js → php-Dc66Nx9S.js} +1 -1
  82. package/ui/dist/assets/{pug-CIvCXkQY.js → pug-C_S0MlW-.js} +1 -1
  83. package/ui/dist/assets/{qml-NvdQJP41.js → qml-VqOrS5Wl.js} +1 -1
  84. package/ui/dist/assets/{r-CTxQa3JJ.js → r-Dl8VhfSg.js} +1 -1
  85. package/ui/dist/assets/{razor-6oObkjRl.js → razor-BEpGMKEn.js} +1 -1
  86. package/ui/dist/assets/{regexp-BoEgdKzz.js → regexp-X9VkDePO.js} +1 -1
  87. package/ui/dist/assets/{rst-C1BRN5ux.js → rst-CQz_65m4.js} +1 -1
  88. package/ui/dist/assets/{ruby-ll_ilmOa.js → ruby-DcUs-Hjd.js} +1 -1
  89. package/ui/dist/assets/{sas-nmRD3ilr.js → sas-0Aya4BIJ.js} +1 -1
  90. package/ui/dist/assets/{scss-DaWVZ0iP.js → scss-BiYXJFG-.js} +1 -1
  91. package/ui/dist/assets/{shellscript-CT8Jobwl.js → shellscript-D_GiG2j2.js} +1 -1
  92. package/ui/dist/assets/{shellsession-CxpwtalA.js → shellsession-CJaG9Dka.js} +1 -1
  93. package/ui/dist/assets/{soy-DEBXlOYH.js → soy-qeuP0wdW.js} +1 -1
  94. package/ui/dist/assets/{sql-3mfonMZP.js → sql-DjtVrjTT.js} +1 -1
  95. package/ui/dist/assets/{stata-DaVf6BI8.js → stata-CTtxT0Lq.js} +1 -1
  96. package/ui/dist/assets/{surrealql-BTM-6O5Q.js → surrealql-CLiRfZ3-.js} +1 -1
  97. package/ui/dist/assets/{svelte-CDPuyZop.js → svelte-DojibMCX.js} +1 -1
  98. package/ui/dist/assets/{templ-CbDJ7JLK.js → templ-BccZaOLv.js} +1 -1
  99. package/ui/dist/assets/{tex-DsKYCwQw.js → tex-DvrCGTlw.js} +1 -1
  100. package/ui/dist/assets/{ts-tags-BYhIdk0p.js → ts-tags-Dv3BB8TA.js} +1 -1
  101. package/ui/dist/assets/{tsx-BjLM2BRB.js → tsx-DogZPQbY.js} +1 -1
  102. package/ui/dist/assets/{twig-DqinkzV-.js → twig-DY6_W6um.js} +1 -1
  103. package/ui/dist/assets/{typescript-DH1pT4ZK.js → typescript-DwP-qhUH.js} +1 -1
  104. package/ui/dist/assets/{typst-BBE0SKCu.js → typst-DW555l0v.js} +1 -1
  105. package/ui/dist/assets/{vue-DIIUy5X_.js → vue-OZGLIqo2.js} +1 -1
  106. package/ui/dist/assets/{vue-html-BLERoY9d.js → vue-html-CjmVpLjs.js} +1 -1
  107. package/ui/dist/assets/{vue-vine-CGIG_xts.js → vue-vine-Cye_YqnL.js} +1 -1
  108. package/ui/dist/assets/{xml-BqbRoduq.js → xml-eBWMgcbX.js} +1 -1
  109. package/ui/dist/assets/{xsl-DZMfS9NA.js → xsl-BdSZmEF6.js} +1 -1
  110. package/ui/dist/assets/{yaml-BM5LLWf8.js → yaml-CbCy6UZM.js} +1 -1
  111. package/ui/dist/index.html +2 -2
  112. package/ui/dist/assets/index-P_fbn8LU.css +0 -1
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "caret",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "Review, annotate, and approve Claude Code plans in a local web UI.",
5
5
  "author": { "name": "macintacos" },
6
6
  "homepage": "https://github.com/macintacos/caret",
package/README.md CHANGED
@@ -35,7 +35,8 @@ Assuming that you installed it as a long-lived service (the default), you can na
35
35
 
36
36
  After installing:
37
37
 
38
- 1. **Restart the agent.** OpenCode installs the plugin package on its next start.
38
+ 1. **Restart the agent.** OpenCode installs the plugin package on its next start; in pi,
39
+ run `/reload` or restart it.
39
40
  2. **Run `/caret:demo`.** It presents a short demo plan that points at files in the repo
40
41
  you run it from, so you can exercise the whole flow before a real one arrives.
41
42
  3. **Run `/caret:plan <what you want done>`** to plan a real change and review it in
@@ -83,9 +84,15 @@ check = false
83
84
  loads caret but can't review a plan file the agent wrote, so the agent sends the plan text
84
85
  instead. See [the OpenCode adapter](doc/ARCHITECTURE.md#the-opencode-adapter) for the
85
86
  by-hand equivalents, for pinning a version in OpenCode's plugin array (`plugin` on v1,
86
- `plugins` on v2), and for what each agent's install touches;
87
+ `plugins` on v2); [`caret install`](doc/ARCHITECTURE.md#installing-with-caret-install)
88
+ covers what each agent's install touches;
87
89
  [the Claude Code adapter](doc/ARCHITECTURE.md#the-claude-code-adapter) covers the hooks
88
- `caret` registers there.
90
+ `caret` registers there. `caret install` also installs into pi 1.5.0 or later when `pi` is
91
+ on your PATH; run `/reload` in pi or restart it to pick caret up. `caret install` and
92
+ `caret doctor` manage only pi's user-scope packages and ignore a project-scope install
93
+ (`pi install -l`). OpenCode and pi both load caret from its npm package, which runs on
94
+ bun, so `bun` must be on the PATH they run with. See
95
+ [the pi adapter](doc/ARCHITECTURE.md#the-pi-adapter) for the by-hand equivalents.
89
96
 
90
97
  ## Using `caret`
91
98
 
@@ -103,13 +110,32 @@ your browser instead of the terminal prompt. There you:
103
110
  > You don't have to wait to be intercepted: `/caret:plan <what you want done>` asks the
104
111
  > agent for a plan and routes it straight to the review UI. In OpenCode it runs on the
105
112
  > `plan` agent, so switch to `build` to implement once you approve. A skill of your own
106
- > can do the same with the plan-review tool `caret` gives both agents — `review_plan` in
107
- > Claude Code (from the plugin's MCP server) and `caret_review_plan` in OpenCode. The tool
108
- > is for plans only; see
113
+ > can do the same with the plan-review tool `caret` gives each agent — `review_plan` in
114
+ > Claude Code (from the plugin's MCP server) and `caret_review_plan` in OpenCode and pi.
115
+ > The tool is for plans only; see
109
116
  > [`doc/ARCHITECTURE.md`](doc/ARCHITECTURE.md#calling-the-review-tool-from-your-own-skill).
110
117
  > In OpenCode v2, `/caret` or `<leader>p` (`ctrl+x p` by default) opens the plan waiting
111
118
  > in the session; rebind the key as `caret.open` under OpenCode's `keybinds`.
112
119
 
120
+ ### Using `caret` in pi
121
+
122
+ pi has no plan mode of its own, so `caret` brings a small one:
123
+
124
+ 1. `/caret:plan <what you want done>` turns on planning mode. The agent researches, writes
125
+ its plan into pi's plans folder (`~/.pi/agent/plans` by default), and sends it to
126
+ `caret`. Until you approve, `caret` blocks the agent's `edit` and `write` tools outside
127
+ that folder; shell commands are only discouraged. While planning, `/caret:plan` with no
128
+ task turns planning off. Outside planning, a bare `/caret:plan` plans what you asked
129
+ for earlier in the session.
130
+ 2. While a review waits, a notice sits above the editor and the footer reads
131
+ `^ plan waiting`. `/caret` or `ctrl+alt+o` opens the plan in your browser. In
132
+ Terminal.app or iTerm2, set Option to act as Meta (Esc+) for the shortcut to reach pi.
133
+ 3. Approve, and the agent implements. Request changes, and it revises and resubmits. Esc
134
+ in pi cancels a waiting review, and the agent is told not to implement the plan.
135
+
136
+ `/caret:demo` presents a demo plan, and `/caret:doctor` runs `caret doctor` and shows its
137
+ report in the session (see [Diagnostics](#diagnostics)).
138
+
113
139
  ## Configuration
114
140
 
115
141
  `caret` runs with sensible defaults and needs no configuration. To tune it — the daemon
@@ -130,7 +156,8 @@ to the reference page that answers your question.
130
156
  Two more live at the repo root:
131
157
 
132
158
  - [CONTRIBUTING.md](CONTRIBUTING.md) — develop `caret` locally: setup, the `mise`
133
- workflow, and where tests live.
159
+ workflow, and where tests live. PRs run the full eight-check gate in CI;
160
+ `mise run preflight` is the fast local one.
134
161
  - [AGENTS.md](AGENTS.md) — for coding agents: routes a change to the rules-of-the-road
135
162
  that govern it.
136
163
 
@@ -2,9 +2,9 @@
2
2
  #
3
3
  # The stable launcher a launchd plist or systemd unit names (EXC-1160). Those
4
4
  # units must name an absolute executable path, but caret's runtime lives at a
5
- # versioned path that changes on every upgrade — and both Claude Code and
6
- # OpenCode replace their copy on their own schedule, without running
7
- # `caret install`. So the unit names this file, which caret owns at
5
+ # versioned path that changes on every upgrade — and Claude Code, OpenCode,
6
+ # and pi (`pi update`) each replace their copy on their own schedule, without
7
+ # running `caret install`. So the unit names this file, which caret owns at
8
8
  # $XDG_STATE_HOME/caret/bin/caret, and resolution happens here at exec time.
9
9
  #
10
10
  # Bash rather than TypeScript for the same reason bin/caret is: it has to locate
@@ -33,6 +33,9 @@ claude_roots="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/plugins/cache/caret/caret"
33
33
  opencode_cache="${XDG_CACHE_HOME:-$HOME/.cache}/opencode"
34
34
  opencode_v1_roots="$opencode_cache/packages/@macintacos"
35
35
  opencode_v2_roots="$opencode_cache/npm/@macintacos"
36
+ # pi's default agent dir only: the supervisor never records PI_CODING_AGENT_DIR.
37
+ # Keep in sync with launcherCandidateDirs (src/commands/install/launcher.ts).
38
+ pi_root="$HOME/.pi/agent/npm/node_modules/@macintacos/caret"
36
39
  # Keep in sync with ownedRootsDir() (src/config/paths.ts).
37
40
  owned_roots="$state/roots"
38
41
 
@@ -107,13 +110,14 @@ live_generation() {
107
110
  if [ -n "$newest" ]; then printf '%s' "$1/$newest"; fi
108
111
  }
109
112
 
110
- # Every dir either agent's cache or caret's own copies offer, runnable caret or
111
- # not — the difference between a broken install (terminal) and an empty cache
112
- # (eviction). OpenCode's cache dir is named exactly `caret` or `caret@<spec>`: v1's
113
- # holds a two-line npm project with the root one level down; v2's holds numbered
114
- # generations, of which only the live one is offered. `*` skips dot-prefixed dirs,
115
- # so a half-copied owned root is never a candidate. Keep in sync with
116
- # launcherCandidateDirs (src/commands/install/launcher.ts) and opencodeRootPaths
113
+ # Every dir an agent's cache, pi's npm folder, or caret's own copies offer,
114
+ # runnable caret or not — the difference between a broken install (terminal)
115
+ # and an empty cache (eviction). OpenCode's cache dir is named exactly `caret`
116
+ # or `caret@<spec>`: v1's holds a two-line npm project with the root one level
117
+ # down; v2's holds numbered generations, of which only the live one is offered.
118
+ # `*` skips dot-prefixed dirs, so a half-copied owned root is never a
119
+ # candidate. Keep in sync with launcherCandidateDirs
120
+ # (src/commands/install/launcher.ts) and opencodeRootPaths
117
121
  # (src/adapters/opencode/paths.ts); both suites pin it through
118
122
  # test/core/commands/install/fixtures/launcher-caches.txt.
119
123
  candidate_dirs() {
@@ -129,6 +133,7 @@ candidate_dirs() {
129
133
  dir="$live/node_modules/@macintacos/caret"
130
134
  if [ -n "$live" ] && [ -d "$dir" ]; then printf '%s\n' "$dir"; fi
131
135
  done
136
+ if [ -d "$pi_root" ]; then printf '%s\n' "$pi_root"; fi
132
137
  for dir in "$owned_roots"/*; do
133
138
  if [ -d "$dir" ]; then printf '%s\n' "$dir"; fi
134
139
  done
@@ -1,16 +1,121 @@
1
1
  // The bridge from an agent's plan-review tool to `caret review`, shared by the OpenCode
2
- // plugin and `caret mcp`: build caret's review envelope, spawn the review, and turn the
3
- // flat decision it prints into the tool's result text. It sits in opencode/ because that
4
- // directory ships as unbundled source the plugin can reach only by relative import, and
5
- // it imports node builtins only, so bundling it into the CLI pulls in no plugin SDK. It
6
- // also holds PLAN_TITLE_INSTRUCTION, since this is the one module the plugin, `caret mcp`
7
- // and `caret steer` can all import.
2
+ // plugin, `caret mcp` and pi's extension: resolve the plan and the caret binary, build
3
+ // caret's review envelope, warm the daemon, spawn the review, and turn the flat decision
4
+ // it prints into the tool's result text; it also holds the detached opener both TUIs use
5
+ // for `band-link --open`. It ships as unbundled source the OpenCode plugin and pi's
6
+ // extension reach by relative import, and the CLI bundles it, so it imports node builtins
7
+ // only: nothing from src/, and no plugin SDK. It also holds PLAN_TITLE_INSTRUCTION, since
8
+ // this is the one module the hosts, `caret mcp` and `caret steer` can all import.
8
9
 
9
10
  import { spawn } from "node:child_process";
11
+ import { readFileSync, statSync } from "node:fs";
12
+ import { constants } from "node:os";
13
+ import { resolve } from "node:path";
14
+ import { fileURLToPath } from "node:url";
10
15
 
16
+ /** `failsafe` is set only on a deny caret made itself because the review could not run,
17
+ * never on a reviewer's. */
11
18
  export type CaretDecision =
12
19
  | { behavior: "allow"; feedback?: string; acceptMode?: string }
13
- | { behavior: "deny"; feedback: string };
20
+ | { behavior: "deny"; feedback: string; failsafe?: true };
21
+
22
+ /** The plan-review tool the agent calls. */
23
+ export const REVIEW_TOOL = "caret_review_plan";
24
+
25
+ // The review tool's model-facing text, shared so the hosts' registrations stay
26
+ // word-for-word identical (plugin-v2.test.ts and test/pi/tool.test.ts pin it per host).
27
+ export const REVIEW_TOOL_DESCRIPTION =
28
+ "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.";
29
+
30
+ export const PLAN_ARG_DESCRIPTION =
31
+ "The complete plan, as markdown, to present for human review. The inline alternative to `path`.";
32
+
33
+ /** The caret binary a host spawns. The `overrideVar` env override wins; else
34
+ * `../bin/caret` beside the calling module's `importMetaUrl`. */
35
+ export function resolveCaretBin(opts: {
36
+ env: Record<string, string | undefined>;
37
+ importMetaUrl: string;
38
+ overrideVar: string;
39
+ }): string {
40
+ const override = opts.env[opts.overrideVar]?.trim();
41
+ if (override) return override;
42
+ return fileURLToPath(new URL("../bin/caret", opts.importMetaUrl));
43
+ }
44
+
45
+ /** `err`'s message, or `err` stringified when it is not an Error. A twin of
46
+ * `errorMessage` in src/lib/types.ts, which this module cannot import. */
47
+ export function errorMessage(err: unknown): string {
48
+ return err instanceof Error ? err.message : String(err);
49
+ }
50
+
51
+ /** Semver triple `[major, minor, patch]`, or null when `v` is not `X.Y.Z` (an
52
+ * optional leading `v` is stripped; trailing prerelease/build metadata ignored). */
53
+ export function parseVersionTriple(v: string): [number, number, number] | null {
54
+ const m = v
55
+ .trim()
56
+ .replace(/^v/, "")
57
+ .match(/^(\d+)\.(\d+)\.(\d+)/);
58
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
59
+ }
60
+
61
+ /** True when `latest` is a strictly higher semver than `current`. Inline (no semver
62
+ * dep) so the deployed plugin stays self-contained; an unparseable version on
63
+ * either side compares false, so the check never nags on something it can't read. */
64
+ export function isNewer(latest: string, current: string): boolean {
65
+ const a = parseVersionTriple(latest);
66
+ const b = parseVersionTriple(current);
67
+ if (!a || !b) return false;
68
+ const [a0, a1, a2] = a;
69
+ const [b0, b1, b2] = b;
70
+ if (a0 !== b0) return a0 > b0;
71
+ if (a1 !== b1) return a1 > b1;
72
+ return a2 > b2;
73
+ }
74
+
75
+ type PlanSource = { plan: string; planFilePath?: string };
76
+
77
+ /** The plan the review tool submits, from exactly one of its `plan` / `path` args (an
78
+ * empty string counts as absent). A `path` resolves against the session directory and
79
+ * must be a readable `.md` file — mirroring the core's `isPlanFile`
80
+ * (src/plan/canonical-file.ts), repeated here because this module cannot import src/.
81
+ * `readFile` returns the text, or undefined when absPath is not a readable regular
82
+ * file. */
83
+ export function resolvePlanSource(
84
+ args: { plan?: string; path?: string },
85
+ directory: string,
86
+ readFile: (absPath: string) => string | undefined,
87
+ ): PlanSource | { error: string } {
88
+ const plan = args.plan || undefined;
89
+ const path = args.path || undefined;
90
+ const notExactlyOne = {
91
+ error: `caret: ${REVIEW_TOOL} takes exactly one of \`plan\` or \`path\`. Pass the plan file as \`path\`, or the plan inline as \`plan\`.`,
92
+ };
93
+ if (path === undefined) return plan === undefined ? notExactlyOne : { plan };
94
+ if (plan !== undefined) return notExactlyOne;
95
+ const planFilePath = resolve(directory, path);
96
+ if (!planFilePath.endsWith(".md")) {
97
+ return {
98
+ 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\`.`,
99
+ };
100
+ }
101
+ const text = readFile(planFilePath);
102
+ if (text === undefined) {
103
+ return {
104
+ error: `caret: ${REVIEW_TOOL} could not read ${planFilePath} as a regular file. Write the plan there first, or pass it inline as \`plan\`.`,
105
+ };
106
+ }
107
+ return { plan: text, planFilePath };
108
+ }
109
+
110
+ /** The file's text, or undefined when `absPath` is not a readable regular file; never
111
+ * throws. */
112
+ export function readRegularFile(absPath: string): string | undefined {
113
+ try {
114
+ return statSync(absPath).isFile() ? readFileSync(absPath, "utf-8") : undefined;
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ }
14
119
 
15
120
  /** Asks the model to open its plan on a `#` heading, which deriveTitle
16
121
  * (src/review/threading.ts) prefers as the review title — keep the two in sync. Sent by
@@ -54,6 +159,7 @@ export function parseDecision(stdout: string): CaretDecision {
54
159
  behavior?: unknown;
55
160
  feedback?: unknown;
56
161
  acceptMode?: unknown;
162
+ failsafe?: unknown;
57
163
  };
58
164
  if (parsed.behavior === "allow") {
59
165
  // Reviewer notes (EXC-791) ride the allow; surface them to the agent below.
@@ -69,6 +175,7 @@ export function parseDecision(stdout: string): CaretDecision {
69
175
  return {
70
176
  behavior: "deny",
71
177
  feedback: typeof parsed.feedback === "string" ? parsed.feedback : "Plan changes requested.",
178
+ ...(parsed.failsafe === true && { failsafe: true }),
72
179
  };
73
180
  }
74
181
  return failsafeDeny("caret: unrecognized review decision — denying to fail safe.");
@@ -78,7 +185,7 @@ export function parseDecision(stdout: string): CaretDecision {
78
185
  }
79
186
 
80
187
  function failsafeDeny(feedback: string): CaretDecision {
81
- return { behavior: "deny", feedback };
188
+ return { behavior: "deny", feedback, failsafe: true };
82
189
  }
83
190
 
84
191
  /** Tool result returned to the agent on approval. Optional reviewer notes
@@ -166,7 +273,8 @@ export function parseReviewUrl(stderr: string): string | undefined {
166
273
  * the child's stderr chunks as they arrive, so the caller can surface the review URL
167
274
  * the core prints there while the review is still pending. An aborted `signal` must
168
275
  * terminate the child and settle (reject) promptly: `caret mcp` refuses a new call until
169
- * the aborted one settles. */
276
+ * the aborted one settles. A child killed by a signal exits 128 + the signal number, as
277
+ * in a shell, so it never reads as success. */
170
278
  export type SpawnRunner = (
171
279
  command: string[],
172
280
  env: Record<string, string | undefined>,
@@ -223,8 +331,7 @@ export async function runReviewViaCaret(
223
331
  );
224
332
  return parseDecision(stdout);
225
333
  } catch (e) {
226
- const message = e instanceof Error ? e.message : String(e);
227
- return failsafeDeny(`caret: review failed to run (${message}) — denying to fail safe.`);
334
+ return failsafeDeny(`caret: review failed to run (${errorMessage(e)}) — denying to fail safe.`);
228
335
  }
229
336
  }
230
337
 
@@ -248,10 +355,44 @@ export const nodeSpawnRunner: SpawnRunner = (command, env, stdin, onStderr, sign
248
355
  });
249
356
  child.stderr.on("data", (chunk) => onStderr?.(chunk.toString()));
250
357
  child.on("error", reject);
251
- child.on("close", (code) => resolve({ stdout, exitCode: code ?? 0 }));
358
+ child.on("close", (code, killedBy) =>
359
+ resolve({ stdout, exitCode: code ?? 128 + (killedBy ? constants.signals[killedBy] : 0) }),
360
+ );
252
361
  // A child killed before reading stdin EPIPEs the write; unhandled, that crashes the
253
362
  // host. The 'error'/'close' handlers above already settle.
254
363
  child.stdin.on("error", () => {});
255
364
  child.stdin.write(stdin);
256
365
  child.stdin.end();
257
366
  });
367
+
368
+ /** Spawns `bin args` detached and unwaited; `onError` hears a child that fails to start
369
+ * or exits non-zero. */
370
+ export type DetachedSpawn = (bin: string, args: string[], onError: (err: Error) => void) => void;
371
+
372
+ /** A DetachedSpawn over node's `spawn` whose children get `env` laid over the inherited
373
+ * environment. */
374
+ export const detachedSpawnWith =
375
+ (env?: Record<string, string>): DetachedSpawn =>
376
+ (bin, args, onError) => {
377
+ // spawn reports ENOENT asynchronously; unhandled, that 'error' takes the host down.
378
+ const child = spawn(bin, args, {
379
+ stdio: "ignore",
380
+ detached: true,
381
+ env: env && { ...process.env, ...env },
382
+ });
383
+ child.on("error", onError);
384
+ // band-link exits 0 on its own failures, so non-zero means caret never ran.
385
+ child.on("exit", (code) => {
386
+ if (code) onError(new Error(`exited with status ${code}`));
387
+ });
388
+ child.unref();
389
+ };
390
+
391
+ export const nodeDetachedSpawn = detachedSpawnWith();
392
+
393
+ /** Warms the caret daemon: `caret prewarm`, detached and best-effort, so it never holds
394
+ * up the turn. CARET_AGENT rides along because this spawn is what stands the daemon up
395
+ * and the daemon picks its adapter from that env. */
396
+ export function warmCaret(bin: string, agent: string): void {
397
+ detachedSpawnWith({ CARET_AGENT: agent })(bin, ["prewarm"], () => {});
398
+ }
@@ -44,7 +44,11 @@ one. What each id means:
44
44
  | `daemon-lock` | the lock names a dead pid, or a port caret is not configured to bind | the check's remedy verbatim |
45
45
  | `agent-install` | the agent the check names does not have caret enabled | `caret install` |
46
46
  | `log-errors` | a live log wrote an NDJSON error record in the last 24h; the detail names which log and when it last erred | step 4 |
47
+ | `opencode-host` | an `opencode` on PATH is below its major's floor (1.3.4 for v1, 2.0.29 for v2), or caret sits in a key or config file that OpenCode doesn't load | the check's remedy verbatim |
47
48
  | `opencode-caret-version` | OpenCode would load a caret behind the published one | the check's remedy verbatim |
49
+ | `pi-host` | `pi` can't run, is older than 1.5.0, or is the pre-rename `@mariozechner/pi-coding-agent` | the check's remedy verbatim |
50
+ | `pi-caret-entry` | pi lists caret more than once, filters out its extension, or lists it without its package installed | the check's remedy verbatim |
51
+ | `pi-caret-version` | pi would load a caret behind the published one | the check's remedy verbatim |
48
52
 
49
53
  An `unknown` check is not a failure: it names its own `reason` — no network, an unreadable
50
54
  config, or a report section that never collected, which also shows as an `error` line