one-code-extension 0.1.3 → 0.3.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 (300) hide show
  1. package/LICENSE +20 -0
  2. package/README.md +200 -172
  3. package/agents/explore.md +9 -6
  4. package/agents/general-purpose.md +1 -1
  5. package/agents/plan.md +6 -4
  6. package/docs/guide/README.md +52 -0
  7. package/docs/guide/bring-your-claude-code-setup.md +126 -0
  8. package/docs/guide/configuration.md +171 -0
  9. package/docs/guide/differences-from-claude-code.md +150 -0
  10. package/docs/guide/doctor.md +86 -0
  11. package/docs/guide/hooks.md +109 -0
  12. package/docs/guide/installation.md +124 -0
  13. package/docs/guide/permissions-modes-and-auto-mode.md +450 -0
  14. package/docs/guide/providers-and-models.md +194 -0
  15. package/docs/guide/reference.md +224 -0
  16. package/docs/guide/sessions-and-context.md +190 -0
  17. package/docs/guide/skills-plugins-and-mcp.md +176 -0
  18. package/docs/guide/subagents-and-workflows.md +193 -0
  19. package/docs/guide/tasks-and-background-work.md +111 -0
  20. package/docs/guide/terminal-interface.md +155 -0
  21. package/docs/guide/tools.md +175 -0
  22. package/docs/guide/troubleshooting.md +175 -0
  23. package/extensions/ask-user/index.ts +65 -71
  24. package/extensions/ask-user/questions.ts +15 -28
  25. package/extensions/ask-user/widget.ts +533 -0
  26. package/extensions/auto-mode/actions.ts +9 -26
  27. package/extensions/auto-mode/classifier-prompt.ts +127 -0
  28. package/extensions/auto-mode/classifier.ts +305 -227
  29. package/extensions/auto-mode/config.ts +279 -77
  30. package/extensions/auto-mode/defaults.ts +17 -60
  31. package/extensions/auto-mode/instructions.ts +2 -1
  32. package/extensions/auto-mode/model-picker.ts +53 -31
  33. package/extensions/auto-mode/model-select.ts +153 -82
  34. package/extensions/auto-mode/paths.ts +13 -1
  35. package/extensions/auto-mode/pause.ts +8 -0
  36. package/extensions/auto-mode/prompt.ts +205 -238
  37. package/extensions/auto-mode/recoverability.ts +229 -0
  38. package/extensions/auto-mode/rules.ts +92 -75
  39. package/extensions/auto-mode/safety-floor.ts +43 -12
  40. package/extensions/auto-mode/sensitive.ts +11 -0
  41. package/extensions/auto-mode/setup-run.ts +245 -0
  42. package/extensions/auto-mode/setup.ts +235 -0
  43. package/extensions/auto-mode/shell-analysis.ts +332 -26
  44. package/extensions/auto-mode/transcript.ts +102 -0
  45. package/extensions/background/index.ts +256 -43
  46. package/extensions/background/monitor-batch.ts +58 -0
  47. package/extensions/background/registry.ts +9 -0
  48. package/extensions/background/wakeup.ts +45 -0
  49. package/extensions/bash/background.ts +47 -22
  50. package/extensions/bash/guards.ts +229 -0
  51. package/extensions/bash/index.ts +81 -19
  52. package/extensions/branding/index.ts +44 -18
  53. package/extensions/branding/startup.ts +23 -8
  54. package/extensions/claude-compat/index.ts +17 -2
  55. package/extensions/claude-context/index.ts +41 -12
  56. package/extensions/clear/index.ts +27 -0
  57. package/extensions/compaction/fit.ts +121 -0
  58. package/extensions/compaction/index.ts +176 -71
  59. package/extensions/compaction/prompt.ts +21 -1
  60. package/extensions/context-budget/budget.ts +61 -0
  61. package/extensions/context-budget/index.ts +66 -0
  62. package/extensions/context-management/index.ts +40 -16
  63. package/extensions/doctor/build.ts +226 -0
  64. package/extensions/doctor/cli.ts +99 -0
  65. package/extensions/doctor/compat.ts +532 -0
  66. package/extensions/doctor/dependencies.ts +186 -0
  67. package/extensions/doctor/fix-prompt.ts +125 -0
  68. package/extensions/doctor/index.ts +308 -0
  69. package/extensions/doctor/models.ts +231 -0
  70. package/extensions/doctor/presets.ts +208 -0
  71. package/extensions/doctor/report.ts +189 -0
  72. package/extensions/doctor/update-lookup.ts +46 -0
  73. package/extensions/doctor/viewer.ts +124 -0
  74. package/extensions/effort/index.ts +41 -4
  75. package/extensions/effort/slider.ts +33 -2
  76. package/extensions/file-tracker/index.ts +143 -20
  77. package/extensions/file-tracker/replay.ts +46 -0
  78. package/extensions/file-tracker/tracker.ts +51 -4
  79. package/extensions/footer/footer-line.ts +207 -0
  80. package/extensions/footer/index.ts +152 -0
  81. package/extensions/footer/pr.ts +53 -0
  82. package/extensions/hooks/executor.ts +57 -20
  83. package/extensions/hooks/formatter-notice.ts +76 -0
  84. package/extensions/hooks/index.ts +314 -73
  85. package/extensions/hooks/matcher.ts +35 -1
  86. package/extensions/hooks/plugin-hooks.ts +3 -3
  87. package/extensions/hooks/protocol.ts +58 -0
  88. package/extensions/hooks/subagent-bridge.ts +77 -0
  89. package/extensions/hooks/trust.ts +15 -4
  90. package/extensions/init/index.ts +26 -0
  91. package/extensions/init/prompt.ts +40 -0
  92. package/extensions/interrupted/index.ts +35 -0
  93. package/extensions/interrupted/line.ts +9 -0
  94. package/extensions/lib/abort.ts +15 -0
  95. package/extensions/lib/agent-loader.ts +183 -0
  96. package/extensions/lib/anthropic-payload.ts +15 -0
  97. package/extensions/lib/atomic-write.ts +56 -0
  98. package/extensions/lib/bridge-watch.ts +20 -0
  99. package/extensions/lib/cache-retention.ts +60 -0
  100. package/extensions/lib/capability-index.ts +466 -0
  101. package/extensions/lib/claude-context.ts +318 -27
  102. package/extensions/lib/claude-settings.ts +64 -0
  103. package/extensions/lib/consent-preview.ts +30 -0
  104. package/extensions/lib/deferred.ts +144 -13
  105. package/extensions/lib/favorites.ts +43 -0
  106. package/extensions/lib/fetch-timeout.ts +15 -0
  107. package/extensions/lib/frontmatter.ts +31 -0
  108. package/extensions/lib/git.ts +44 -2
  109. package/extensions/lib/hook-gate.ts +90 -0
  110. package/extensions/lib/interrupt.ts +94 -0
  111. package/extensions/lib/mcp-overrides.ts +61 -0
  112. package/extensions/lib/mcp-share.ts +25 -0
  113. package/extensions/lib/mcp-status.ts +34 -0
  114. package/extensions/lib/memory.ts +41 -6
  115. package/extensions/lib/model-facts.generated.json +2480 -0
  116. package/extensions/lib/model-facts.ts +162 -0
  117. package/extensions/lib/model-policy.ts +145 -189
  118. package/extensions/lib/model-tier.ts +480 -64
  119. package/extensions/lib/model-unusable.ts +37 -0
  120. package/extensions/lib/notifications.ts +320 -1
  121. package/extensions/lib/one-code-settings.ts +63 -0
  122. package/extensions/lib/original-command.ts +71 -0
  123. package/extensions/lib/package-version.ts +31 -0
  124. package/extensions/lib/paths.ts +39 -4
  125. package/extensions/lib/permission-gate.ts +192 -0
  126. package/extensions/lib/persisted-output.ts +18 -0
  127. package/extensions/lib/pi-ai-estimate.ts +160 -0
  128. package/extensions/lib/pi-version.ts +1 -1
  129. package/extensions/lib/plan-mode-channels.ts +11 -0
  130. package/extensions/lib/plugin-overrides.ts +24 -0
  131. package/extensions/lib/plugin-root.ts +44 -0
  132. package/extensions/lib/plugins.ts +233 -54
  133. package/extensions/lib/prefix-warm-gate.ts +157 -0
  134. package/extensions/lib/process-tree.ts +56 -0
  135. package/extensions/lib/reminders.ts +321 -68
  136. package/extensions/lib/scratchpad.ts +11 -8
  137. package/extensions/lib/session-lifecycle.ts +28 -0
  138. package/extensions/lib/settings-channels.ts +14 -0
  139. package/extensions/lib/shell-tasks.ts +54 -0
  140. package/extensions/lib/skill-overrides.ts +86 -0
  141. package/extensions/lib/skill-scan.ts +110 -0
  142. package/extensions/lib/text-width.ts +267 -0
  143. package/extensions/lib/tui-render.ts +281 -54
  144. package/extensions/lib/usage-bus.ts +75 -0
  145. package/extensions/lib/usage-tracker.ts +67 -0
  146. package/extensions/lib/worktree-isolation.ts +170 -0
  147. package/extensions/lsp/client.ts +69 -12
  148. package/extensions/lsp/format.ts +12 -2
  149. package/extensions/lsp/index.ts +273 -84
  150. package/extensions/lsp/install-hints.ts +43 -0
  151. package/extensions/lsp/keep-alive.ts +19 -0
  152. package/extensions/lsp/plugin-servers.ts +331 -0
  153. package/extensions/lsp/watcher.ts +157 -0
  154. package/extensions/mcp/announce.ts +99 -0
  155. package/extensions/mcp/client.ts +181 -22
  156. package/extensions/mcp/config.ts +55 -24
  157. package/extensions/mcp/index.ts +586 -86
  158. package/extensions/mcp/oauth/browser.ts +26 -0
  159. package/extensions/mcp/oauth/callback.ts +104 -0
  160. package/extensions/mcp/oauth/flow.ts +68 -0
  161. package/extensions/mcp/oauth/provider.ts +147 -0
  162. package/extensions/mcp/oauth/store.ts +86 -0
  163. package/extensions/mcp/panel/keys.ts +34 -0
  164. package/extensions/mcp/panel/model.ts +62 -0
  165. package/extensions/mcp/panel/render.ts +141 -0
  166. package/extensions/mcp/panel/state.ts +108 -0
  167. package/extensions/mcp/schema.ts +83 -6
  168. package/extensions/mcp/trust.ts +340 -0
  169. package/extensions/memory/entries.ts +163 -0
  170. package/extensions/memory/index.ts +119 -2
  171. package/extensions/memory/open-external.ts +97 -0
  172. package/extensions/memory/panel.ts +124 -0
  173. package/extensions/model-default/index.ts +74 -0
  174. package/extensions/notebook/index.ts +36 -18
  175. package/extensions/notebook/notebook.ts +110 -17
  176. package/extensions/permissions/index.ts +1012 -247
  177. package/extensions/permissions/matcher.ts +488 -57
  178. package/extensions/permissions/modes.ts +55 -7
  179. package/extensions/permissions/project-trust.ts +81 -0
  180. package/extensions/permissions/protected-paths.ts +16 -3
  181. package/extensions/permissions/rule-prose.ts +79 -0
  182. package/extensions/permissions/session-grant.ts +91 -0
  183. package/extensions/permissions/settings.ts +112 -33
  184. package/extensions/permissions/subagent-gate.ts +57 -0
  185. package/extensions/plan-mode/index.ts +143 -33
  186. package/extensions/plan-mode/reminder.ts +16 -19
  187. package/extensions/plan-mode/viewer.ts +40 -31
  188. package/extensions/plugins/counts.ts +68 -0
  189. package/extensions/plugins/index.ts +496 -32
  190. package/extensions/plugins/install/install.ts +167 -0
  191. package/extensions/plugins/install/paths.ts +23 -0
  192. package/extensions/plugins/install/registry.ts +72 -0
  193. package/extensions/plugins/marketplace/git.ts +103 -0
  194. package/extensions/plugins/marketplace/official.ts +36 -0
  195. package/extensions/plugins/marketplace/parse.ts +94 -0
  196. package/extensions/plugins/marketplace/registry.ts +60 -0
  197. package/extensions/plugins/marketplace/sync.ts +146 -0
  198. package/extensions/plugins/marketplace/types.ts +128 -0
  199. package/extensions/plugins/panel/keys.ts +52 -0
  200. package/extensions/plugins/panel/render.ts +315 -0
  201. package/extensions/plugins/panel/rows.ts +230 -0
  202. package/extensions/plugins/panel/state.ts +333 -0
  203. package/extensions/recap/index.ts +215 -0
  204. package/extensions/recap/prompt.ts +44 -0
  205. package/extensions/recap/scheduler.ts +87 -0
  206. package/extensions/search-tools/index.ts +0 -0
  207. package/extensions/search-tools/tools.ts +7 -5
  208. package/extensions/skill/index.ts +360 -34
  209. package/extensions/skill/invoke.ts +185 -0
  210. package/extensions/skill/listing.ts +21 -0
  211. package/extensions/skill/panel/keys.ts +41 -0
  212. package/extensions/skill/panel/render.ts +91 -0
  213. package/extensions/skill/panel/state.ts +115 -0
  214. package/extensions/spinner/index.ts +110 -0
  215. package/extensions/spinner/line.ts +51 -0
  216. package/extensions/spinner/verbs.ts +201 -0
  217. package/extensions/subagents/agents.ts +88 -44
  218. package/extensions/subagents/default-model.ts +62 -32
  219. package/extensions/subagents/delegation-steer.ts +23 -0
  220. package/extensions/subagents/hand-back-review.ts +67 -0
  221. package/extensions/subagents/index.ts +1545 -346
  222. package/extensions/subagents/live-runs.ts +293 -0
  223. package/extensions/subagents/model-select.ts +252 -82
  224. package/extensions/subagents/outcome.ts +90 -0
  225. package/extensions/subagents/panel-keys.ts +41 -0
  226. package/extensions/subagents/panel-render.ts +357 -0
  227. package/extensions/subagents/panel-widget.ts +330 -0
  228. package/extensions/subagents/pending-claim.ts +45 -0
  229. package/extensions/subagents/prose.ts +105 -0
  230. package/extensions/subagents/runner.ts +674 -0
  231. package/extensions/subagents/runs.ts +68 -1
  232. package/extensions/subagents/send-to-main-tool.ts +55 -0
  233. package/extensions/subagents/session-turns.ts +138 -0
  234. package/extensions/subagents/shell-panel.ts +198 -0
  235. package/extensions/subagents/usage.ts +16 -4
  236. package/extensions/subagents/worktree.ts +13 -0
  237. package/extensions/system-prompt/environment.ts +5 -3
  238. package/extensions/system-prompt/git-status.ts +93 -0
  239. package/extensions/system-prompt/index.ts +23 -1
  240. package/extensions/system-prompt/template.ts +34 -4
  241. package/extensions/system-prompt/tiers/common.ts +31 -6
  242. package/extensions/system-prompt/tiers/low.ts +13 -3
  243. package/extensions/system-prompt/tiers/mid.ts +21 -8
  244. package/extensions/system-reminder/index.ts +53 -9
  245. package/extensions/tasks/index.ts +65 -16
  246. package/extensions/tasks/store.ts +76 -3
  247. package/extensions/tool-search/announce.ts +42 -0
  248. package/extensions/tool-search/index.ts +136 -13
  249. package/extensions/turn-duration/index.ts +60 -0
  250. package/extensions/turn-duration/line.ts +22 -0
  251. package/extensions/turn-duration/span.ts +41 -0
  252. package/extensions/turn-duration/verbs.ts +24 -0
  253. package/extensions/web/backends.ts +421 -0
  254. package/extensions/web/index.ts +178 -21
  255. package/extensions/web-fetch/index.ts +74 -18
  256. package/extensions/web-fetch/summarize.ts +1 -1
  257. package/extensions/workflow/agent-session.ts +182 -112
  258. package/extensions/workflow/globals.ts +129 -106
  259. package/extensions/workflow/index.ts +343 -28
  260. package/extensions/workflow/records.ts +118 -0
  261. package/extensions/workflow/run-manager.ts +80 -31
  262. package/extensions/workflow/saved-workflows.ts +2 -1
  263. package/extensions/workflow/script-worker.mjs +110 -0
  264. package/extensions/workflow/types.ts +74 -8
  265. package/extensions/workflow/viewer.ts +562 -0
  266. package/extensions/workflow/vm-runtime.ts +118 -22
  267. package/extensions/workflow/widget.ts +126 -18
  268. package/extensions/worktree/guards.ts +240 -0
  269. package/extensions/worktree/index.ts +30 -5
  270. package/extensions/worktree/rewrite.ts +32 -16
  271. package/package.json +20 -10
  272. package/skills/code-review/SKILL.md +165 -0
  273. package/skills/fewer-permission-prompts/SKILL.md +61 -0
  274. package/skills/security-review/SKILL.md +178 -0
  275. package/skills/simplify/SKILL.md +70 -0
  276. package/themes/{one-code-light.json → onecode-light.json} +6 -1
  277. package/themes/{one-code.json → onecode.json} +6 -1
  278. package/types/app.d.ts +8 -1
  279. package/types/community-packages.d.ts +45 -2
  280. package/docs/decisions/auto-mode.md +0 -526
  281. package/docs/decisions/branding.md +0 -127
  282. package/docs/decisions/code-review-remediation.md +0 -106
  283. package/docs/decisions/compaction.md +0 -140
  284. package/docs/decisions/distribution.md +0 -168
  285. package/docs/decisions/hooks.md +0 -64
  286. package/docs/decisions/lsp.md +0 -58
  287. package/docs/decisions/mcp.md +0 -104
  288. package/docs/decisions/memory-state.md +0 -170
  289. package/docs/decisions/model-policy.md +0 -126
  290. package/docs/decisions/modes.md +0 -178
  291. package/docs/decisions/skills-plugins.md +0 -56
  292. package/docs/decisions/subagents-workflows.md +0 -225
  293. package/docs/decisions/system-prompt.md +0 -98
  294. package/docs/decisions/tools.md +0 -407
  295. package/docs/decisions/tui.md +0 -202
  296. package/docs/decisions.md +0 -148
  297. package/extensions/subagents/child.ts +0 -414
  298. package/extensions/subagents/rpc-turns.ts +0 -123
  299. package/extensions/todo/index.ts +0 -133
  300. package/extensions/workflow/permission-gate.ts +0 -71
@@ -30,6 +30,7 @@
30
30
  * denylist — see `sensitive.ts`), F1 (git global flags).
31
31
  */
32
32
 
33
+ import { isProtectedPath } from "../permissions/protected-paths.ts";
33
34
  import { isExecutionPrimitivePath, isSensitivePath } from "./sensitive.ts";
34
35
  import { isWithin, resolveForContainment, toAbsolute } from "./paths.ts";
35
36
 
@@ -47,8 +48,46 @@ export interface ShellEvidence {
47
48
  sensitivePaths: string[];
48
49
  /** In-project paths whose contents execute later (`.git/hooks/*`, `.vscode/*.json`). */
49
50
  executionPrimitives: string[];
51
+ /**
52
+ * Tooling/agent-configuration paths (`permissions/protected-paths.ts`: `.cargo/`,
53
+ * `.claude/`, hook configs, shell rc files, …) this command writes. The
54
+ * `write`/`edit` tools route these to the classifier; a shell redirect or
55
+ * `rm`/`mv` onto the same path must not be "safe" merely for being in-project.
56
+ */
57
+ protectedPaths: string[];
50
58
  /** Network-capable commands present (curl, ssh, …) — an egress signal for the classifier. */
51
59
  network: string[];
60
+ /**
61
+ * Paths a read-only command reads from OUTSIDE the working directory
62
+ * (original tokens). Claude Code asks for every read outside it; here the
63
+ * read escalates so the classifier at least sees it — until 2026-09-05
64
+ * `cat ~/.onecode/agent/auth.json` fast-pathed as "safe"
65
+ * (PERMISSIONS-REVIEW-2026-09-05 H2).
66
+ */
67
+ outsideReads: string[];
68
+ /**
69
+ * True when the ONLY reason this escalated is one or more outside-cwd reads:
70
+ * no write, mutation, network, unknown command or unmodelled syntax. Plan
71
+ * mode uses it to put such a command to the user as a read instead of
72
+ * refusing it as a mutation. Never true for a "safe" verdict.
73
+ */
74
+ readOnlyOutside: boolean;
75
+ /**
76
+ * True when the ONLY reason this escalated is an in-project filesystem
77
+ * mutation/deletion — every path is inside the working directory and resolved,
78
+ * nothing touches the network, no credential/execution-primitive path, no
79
+ * unknown command, interpreter, glob, xargs, `cd`, or unmodelled syntax. The
80
+ * containment gate uses this to decide whether a git-recoverability check may
81
+ * clear the command (see auto-mode/recoverability.ts). Never true when the
82
+ * verdict is "safe" (nothing escalated) — it only qualifies an escalation.
83
+ */
84
+ containedNonNetwork: boolean;
85
+ /**
86
+ * True for a whole-working-tree destructive git op (`git reset --hard`), whose
87
+ * recoverability is judged against the whole tree's cleanliness rather than
88
+ * named paths.
89
+ */
90
+ wholeTree: boolean;
52
91
  }
53
92
 
54
93
  /** Commands that read and do not write, and are safe to fast-path. */
@@ -77,7 +116,9 @@ const READ_ONLY_COMMANDS = new Set([
77
116
  "nl",
78
117
  "od",
79
118
  "paste",
80
- "printenv",
119
+ // `printenv` and a bare `env` are NOT read-only for this purpose: they dump
120
+ // the process environment, provider keys included (Claude Code removed both
121
+ // from its read-only list for the same reason).
81
122
  "printf",
82
123
  "pwd",
83
124
  "readlink",
@@ -101,6 +142,29 @@ const READ_ONLY_COMMANDS = new Set([
101
142
  "yes",
102
143
  ]);
103
144
 
145
+ /**
146
+ * Read-only commands that take no file operands, so a path-looking positional
147
+ * (`echo a/b`, `which node`) is not a read of that path and is not checked for
148
+ * working-directory containment.
149
+ */
150
+ const NO_FILE_OPERANDS = new Set([
151
+ "basename",
152
+ "date",
153
+ "dirname",
154
+ "echo",
155
+ "false",
156
+ "hostname",
157
+ "id",
158
+ "printf",
159
+ "pwd",
160
+ "tr",
161
+ "true",
162
+ "uname",
163
+ "which",
164
+ "whoami",
165
+ "yes",
166
+ ]);
167
+
104
168
  /**
105
169
  * `grep` and friends take a pattern before their paths, so the first positional
106
170
  * is not a path token. Tracked separately to avoid classifying a regex as a file.
@@ -160,6 +224,46 @@ const MUTATION_COMMANDS = new Set([
160
224
  "zip",
161
225
  ]);
162
226
 
227
+ /**
228
+ * The delete/truncate commands whose blast radius is exactly their (in-project,
229
+ * resolved, concrete) path arguments, so an in-project use may be cleared by the
230
+ * containment + git-recoverability gate rather than always reaching the
231
+ * classifier. This is an *allowlist* for the "provably contained" conclusion —
232
+ * omission is safe (the command still escalates to the classifier via the generic
233
+ * mutation path). Overwrite/copy tools (cp, mv, dd, tee) are deliberately left
234
+ * out for now: their destination semantics are subtler, so they keep classifying.
235
+ * Membership here does not clear anything on its own: every target must resolve
236
+ * inside the working directory and the recoverability check must pass.
237
+ */
238
+ const DELETE_COMMANDS = new Set(["rm", "rmdir", "shred", "truncate"]);
239
+
240
+ /** Glob/other shell metacharacters we cannot enumerate, so a target carrying one is not "concrete". */
241
+ function hasGlob(token: string): boolean {
242
+ return /[*?\[\]]/.test(token);
243
+ }
244
+
245
+ /** `git reset --hard [ref]` — a whole-working-tree discard of uncommitted changes. */
246
+ function isWholeTreeGitReset(args: Token[]): boolean {
247
+ const positionals = args.filter((token) => !token.value.startsWith("-"));
248
+ return positionals[0]?.value === "reset" && args.some((token) => token.value === "--hard");
249
+ }
250
+
251
+ /**
252
+ * Whether git is being pointed at another repository or tree (`-C dir`,
253
+ * `--git-dir[=]…`, `--work-tree[=]…`, `--namespace`, `--exec-path`). A
254
+ * whole-tree op carrying one of these acts on THAT tree, so judging it against
255
+ * the working directory would clear a reset of some other checkout.
256
+ */
257
+ function hasGitRetargetFlag(args: Token[]): boolean {
258
+ return args.some((token) => {
259
+ const value = token.value;
260
+ if (!value.startsWith("-")) return false;
261
+ if (GIT_GLOBAL_VALUE_FLAGS.has(value)) return true;
262
+ const eq = value.indexOf("=");
263
+ return eq > 0 && GIT_GLOBAL_VALUE_FLAGS.has(value.slice(0, eq));
264
+ });
265
+ }
266
+
163
267
  /**
164
268
  * Interpreters whose *scripts* can write files the token-level pass never sees
165
269
  * (review finding N8: `awk 'BEGIN{print > "f"}'`). Their programs are not
@@ -236,14 +340,14 @@ const GIT_READ_ONLY_SUBCOMMANDS = new Set([
236
340
  */
237
341
  const GIT_GLOBAL_VALUE_FLAGS = new Set(["-C", "--git-dir", "--work-tree", "--namespace", "--exec-path"]);
238
342
 
239
- interface Token {
343
+ export interface Token {
240
344
  /** The token with quotes removed and `$'…'` decoded. */
241
345
  value: string;
242
346
  /** True when the token was written with any quoting or expansion syntax. */
243
347
  hadExpansion: boolean;
244
348
  }
245
349
 
246
- interface Segment {
350
+ export interface Segment {
247
351
  tokens: Token[];
248
352
  /** Redirection targets found in this segment, in written order. */
249
353
  redirects: string[];
@@ -282,27 +386,40 @@ function decodeAnsiC(body: string): string {
282
386
  });
283
387
  }
284
388
 
389
+ /**
390
+ * Syntax through which a command can run something other than what its
391
+ * visible words say. The permission matcher refuses to let a prefix/wildcard
392
+ * allow rule cover a command carrying any of it (the user approved `npm test
393
+ * …`, not whatever `$(…)` evaluates to), and the pre-gate escalates on it.
394
+ */
395
+ export function hasInjectionSyntax(command: string): string | undefined {
396
+ if (/\$\(/.test(command)) return "uses command substitution $( )";
397
+ if (/(^|[^\\])`/.test(command)) return "uses backtick command substitution";
398
+ if (/[<>]\(/.test(command)) return "uses process substitution";
399
+ if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
400
+ if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
401
+ if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
402
+ return undefined;
403
+ }
404
+
285
405
  /** Syntax we do not model at all; its presence alone forces escalation. */
286
406
  export function hasUnmodelledSyntax(command: string): string | undefined {
287
407
  if (command.includes("\n")) return "spans multiple lines";
288
- if (/\$\(/.test(command)) return "uses command substitution $( )";
289
- if (/(^|[^\\])`/.test(command)) return "uses backtick command substitution";
408
+ const injection = hasInjectionSyntax(command);
409
+ if (injection) return injection;
290
410
  if (/<<</.test(command)) return "uses a here-string";
291
411
  if (/<</.test(command)) return "uses a heredoc";
292
- if (/[<>]\(/.test(command)) return "uses process substitution";
293
412
  // Brace expansion resolves to paths we cannot enumerate (review finding N3).
294
413
  if (/\{[^{}]*,[^{}]*\}/.test(command)) return "uses brace expansion, whose expanded paths cannot be checked";
295
414
  if (/\$\{?[A-Za-z_]/.test(command)) return "references environment variables, whose values are unknown here";
296
- if (/\beval\b|\bexec\b/.test(command)) return "uses eval/exec";
297
- if (/\|\s*(bash|sh|zsh|python|perl|node|ruby)\b/.test(command)) return "pipes into an interpreter";
298
- if (/base64\s+(-d|--decode)/.test(command)) return "decodes base64, which can hide the real command";
299
415
  return undefined;
300
416
  }
301
417
 
302
418
  /**
303
419
  * Split a command into pipeline/list segments and tokenize each. Unlike the
304
420
  * original, `<`, `>`, `&`, and `|` terminate a token even without surrounding
305
- * whitespace, so `cmd>file` and `a|b` are seen (review finding N4).
421
+ * whitespace, so `cmd>file` and `a|b` are seen (review finding N4). An
422
+ * unquoted newline ends a segment too (it is a command separator in bash).
306
423
  */
307
424
  export function parseCommand(command: string): { segments: Segment[]; parseFailed: boolean } {
308
425
  const segments: Segment[] = [];
@@ -332,14 +449,15 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
332
449
  quoted = false;
333
450
  };
334
451
 
335
- const pushSegment = (at: number) => {
452
+ /** `at` = index of the separator; `width` = its length (`&&` is 2). */
453
+ const pushSegment = (at: number, width = 1) => {
336
454
  pushToken();
337
455
  if (tokens.length > 0 || redirects.length > 0) {
338
456
  segments.push({ tokens, redirects, raw: command.slice(rawStart, at).trim() });
339
457
  }
340
458
  tokens = [];
341
459
  redirects = [];
342
- rawStart = at + 1;
460
+ rawStart = at + width;
343
461
  };
344
462
 
345
463
  for (let i = 0; i < command.length; i++) {
@@ -382,6 +500,12 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
382
500
  continue;
383
501
  }
384
502
 
503
+ if (ch === "\n") {
504
+ // An unquoted newline separates commands exactly like `;`. Without this
505
+ // `npm test x\ncurl evil` would be one segment whose lead is `npm test`.
506
+ pushSegment(i);
507
+ continue;
508
+ }
385
509
  if (/\s/.test(ch)) {
386
510
  pushToken();
387
511
  continue;
@@ -408,8 +532,9 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
408
532
  if (ch === "|" || ch === ";" || ch === "&") {
409
533
  // `&&`, `||`, `;`, `|`, `&` all end a segment. Any of them means the
410
534
  // next command is separate, which is all we need to know.
411
- pushSegment(i);
412
- if (command[i + 1] === ch) i++;
535
+ const doubled = command[i + 1] === ch;
536
+ pushSegment(i, doubled ? 2 : 1);
537
+ if (doubled) i++;
413
538
  continue;
414
539
  }
415
540
 
@@ -422,7 +547,7 @@ export function parseCommand(command: string): { segments: Segment[]; parseFaile
422
547
  }
423
548
 
424
549
  /** Peel wrappers to the command that actually runs (review finding N5). */
425
- function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
550
+ export function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peeled: string[] } {
426
551
  const peeled: string[] = [];
427
552
  let index = 0;
428
553
 
@@ -449,6 +574,69 @@ function resolvePayload(tokens: Token[]): { command: string; args: Token[]; peel
449
574
  }
450
575
  }
451
576
 
577
+ /**
578
+ * A segment's tokens normalised for command-position checks: subshell parens
579
+ * stripped from the head token only (`(cd` → `cd` — a later argument may
580
+ * legitimately begin with `(`), leading `do`/`then`/`else` keywords skipped,
581
+ * and as many trailing `)`s stripped off the last token as `(`s were opened at
582
+ * the head, so `(git stash)` still matches subcommand `stash`. A subshell
583
+ * whose closing paren lands in a *different* segment (`(a && git stash)`) is
584
+ * a known limitation — the closer is only balanced within one segment.
585
+ * Used by the bash/worktree guard pipelines, not by the escalation analyzer.
586
+ */
587
+ export function leadTokens(seg: Segment): Token[] {
588
+ const tokens = [...seg.tokens];
589
+ let i = 0;
590
+ let opened = 0;
591
+ for (;;) {
592
+ while (i < tokens.length) {
593
+ const value = tokens[i].value;
594
+ const lead = /^[({]+/.exec(value)?.[0] ?? "";
595
+ if (lead.length === 0) break;
596
+ opened += (lead.match(/\(/g) ?? []).length;
597
+ const stripped = value.slice(lead.length);
598
+ if (stripped.length === 0) {
599
+ i++;
600
+ continue;
601
+ }
602
+ tokens[i] = { ...tokens[i], value: stripped };
603
+ break;
604
+ }
605
+ if (i < tokens.length && ["do", "then", "else"].includes(tokens[i].value)) {
606
+ i++;
607
+ continue;
608
+ }
609
+ break;
610
+ }
611
+ if (opened > 0 && tokens.length > i) {
612
+ const last = tokens.length - 1;
613
+ const trailing = /\)+$/.exec(tokens[last].value)?.[0];
614
+ if (trailing) {
615
+ const strip = Math.min(trailing.length, opened);
616
+ tokens[last] = { ...tokens[last], value: tokens[last].value.slice(0, tokens[last].value.length - strip) };
617
+ }
618
+ }
619
+ return tokens.slice(i);
620
+ }
621
+
622
+ /**
623
+ * Superset of GIT_GLOBAL_VALUE_FLAGS for *locating* the subcommand, not for
624
+ * judging safety: `-c`/`--config-env` are included here because they do take a
625
+ * value, while the escalation analyzer above deliberately refuses to skip them.
626
+ */
627
+ const GIT_LOCATOR_VALUE_FLAGS = new Set([...GIT_GLOBAL_VALUE_FLAGS, "-c", "--config-env"]);
628
+
629
+ /** The git subcommand and the tokens after it, global flags skipped. */
630
+ export function gitSubcommand(args: Token[]): { sub?: string; rest: Token[] } {
631
+ let i = 0;
632
+ while (i < args.length) {
633
+ const value = args[i].value;
634
+ if (!value.startsWith("-")) return { sub: value, rest: args.slice(i + 1) };
635
+ i += GIT_LOCATOR_VALUE_FLAGS.has(value) ? 2 : 1;
636
+ }
637
+ return { rest: [] };
638
+ }
639
+
452
640
  /** Basename of a command token, `.exe` stripped, lowercased. */
453
641
  function commandName(token: string): string {
454
642
  const withForwardSlashes = token.trim().toLowerCase().replace(/\\/g, "/");
@@ -501,16 +689,19 @@ function gitEscalationReason(args: Token[], isDirOutsideCwd: (dir: string) => bo
501
689
  if (token === "-c" || token.startsWith("-c=") || token === "--config-env") {
502
690
  return "passes git -c/--config-env, which can turn a read into code execution";
503
691
  }
504
- if (GIT_GLOBAL_VALUE_FLAGS.has(token)) {
505
- const value = args[index + 1]?.value;
692
+ // `--git-dir=/x` spells the same flag as `--git-dir /x`; normalise both.
693
+ const eq = token.indexOf("=");
694
+ const flag = eq > 0 ? token.slice(0, eq) : token;
695
+ if (GIT_GLOBAL_VALUE_FLAGS.has(flag)) {
696
+ const value = eq > 0 ? token.slice(eq + 1) : args[index + 1]?.value;
506
697
  // `-C`/`--git-dir`/`--work-tree` retarget git at another directory. If that
507
698
  // directory escapes the working directory the operation is no longer
508
699
  // provably in-project, so escalate rather than skipping the flag blindly
509
700
  // (was review gap: `git -C /etc status` classified safe).
510
- if ((token === "-C" || token === "--git-dir" || token === "--work-tree") && value && isDirOutsideCwd(value)) {
511
- return `runs git ${token} ${value}, which points outside the working directory`;
701
+ if ((flag === "-C" || flag === "--git-dir" || flag === "--work-tree") && value && isDirOutsideCwd(value)) {
702
+ return `runs git ${flag} ${value}, which points outside the working directory`;
512
703
  }
513
- index += 2;
704
+ index += eq > 0 ? 1 : 2;
514
705
  continue;
515
706
  }
516
707
  index++;
@@ -525,12 +716,18 @@ export interface AnalyzeInput {
525
716
  command: string;
526
717
  cwd: string;
527
718
  home: string;
719
+ /**
720
+ * Directories protected at runtime beyond protected-paths.ts's static list
721
+ * (pi's own agent dir — permissions/matcher.ts `DecideInput.protectedDirs`).
722
+ * A redirect landing inside one escalates like a write to `.git/hooks`.
723
+ */
724
+ protectedDirs?: string[];
528
725
  }
529
726
 
530
727
  /**
531
728
  * Classify a shell command. Never denies — see the module contract above.
532
729
  */
533
- export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): ShellEvidence {
730
+ export function analyzeShellCommand({ command, cwd, home, protectedDirs = [] }: AnalyzeInput): ShellEvidence {
534
731
  const evidence: ShellEvidence = {
535
732
  verdict: "safe",
536
733
  notes: [],
@@ -538,10 +735,26 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
538
735
  writes: [],
539
736
  sensitivePaths: [],
540
737
  executionPrimitives: [],
738
+ protectedPaths: [],
541
739
  network: [],
740
+ outsideReads: [],
741
+ readOnlyOutside: false,
742
+ containedNonNetwork: false,
743
+ wholeTree: false,
542
744
  };
543
- const escalate = (note: string) => {
745
+ /**
746
+ * Set by any escalation reason that is NOT a bare in-project mutation — i.e.
747
+ * anything meaning the command reaches outside the project or cannot be fully
748
+ * accounted for. When it stays false through an escalation, the only blocker
749
+ * was in-project mutation, and the containment gate may consult recoverability.
750
+ */
751
+ let uncontained = false;
752
+ /** Set by any escalation that is not an outside-cwd read (see readOnlyOutside). */
753
+ let escalatedBeyondReads = false;
754
+ const escalate = (note: string, opts?: { contained?: boolean; outsideRead?: boolean }) => {
544
755
  evidence.verdict = "escalate";
756
+ if (!opts?.contained) uncontained = true;
757
+ if (!opts?.outsideRead) escalatedBeyondReads = true;
545
758
  if (!evidence.notes.includes(note)) evidence.notes.push(note);
546
759
  };
547
760
 
@@ -594,6 +807,18 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
594
807
  evidence.executionPrimitives.push(token);
595
808
  escalate(`writes to ${token}, whose contents execute later without further approval`);
596
809
  }
810
+ // Same list the write/edit tools are gated on: an in-project redirect onto
811
+ // `.cargo/config.toml`, `.claude/agents/x.md`, or `lefthook.yml` reconfigures
812
+ // the toolchain or the agent itself, so containment says nothing about it.
813
+ const protectedTarget = [absolute, resolved].some(
814
+ (candidate) =>
815
+ candidate !== undefined &&
816
+ (isProtectedPath(candidate, effectiveCwd) || protectedDirs.some((dir) => isWithin(dir, candidate))),
817
+ );
818
+ if (protectedTarget && !evidence.protectedPaths.includes(token)) {
819
+ evidence.protectedPaths.push(token);
820
+ escalate(`writes to ${token}, a protected tooling or agent configuration path`);
821
+ }
597
822
  };
598
823
 
599
824
  for (const segment of segments) {
@@ -605,10 +830,27 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
605
830
  for (const token of segment.redirects) checkWriteTarget(token);
606
831
 
607
832
  const { command: name, args, peeled } = resolvePayload(segment.tokens);
608
- if (!name) continue;
833
+ if (!name) {
834
+ // A wrapper with nothing to wrap is a command of its own: a bare `env`
835
+ // (or `env -i`, `nice`) prints the whole process environment / state.
836
+ // Until 2026-09-05 this fell through as "safe" (H2).
837
+ if (peeled.length > 0) {
838
+ escalate(
839
+ peeled.includes("env")
840
+ ? "runs env with no command, which prints the whole process environment"
841
+ : `runs ${peeled.join(" → ")} with no command to wrap`,
842
+ );
843
+ }
844
+ continue;
845
+ }
609
846
  evidence.commands.push(name);
610
847
  if (peeled.length > 0) {
611
- escalate(`wraps the real command in ${peeled.join(" → ")}, so ${name} is what actually runs`);
848
+ // A transparent wrapper (timeout, env, nice) leaves the payload's own
849
+ // arguments visible, so an in-project payload stays contained; xargs is
850
+ // the exception — its targets come from stdin, unknown to this check.
851
+ escalate(`wraps the real command in ${peeled.join(" → ")}, so ${name} is what actually runs`, {
852
+ contained: !peeled.includes("xargs"),
853
+ });
612
854
  }
613
855
 
614
856
  if (NETWORK_COMMANDS.has(name)) {
@@ -643,6 +885,22 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
643
885
  }
644
886
 
645
887
  if (name === "git") {
888
+ // `git reset --hard` is an in-project whole-tree discard: escalate, but
889
+ // mark it contained so the recoverability gate can clear it when the tree
890
+ // is clean. Any other non-read-only git subcommand is uncontained.
891
+ if (isWholeTreeGitReset(args)) {
892
+ if (hasGitRetargetFlag(args)) {
893
+ // The reset acts on whatever -C/--git-dir/--work-tree names, not on
894
+ // the working directory the recoverability judge would inspect.
895
+ escalate("runs git reset --hard against another tree (-C/--git-dir/--work-tree), which cannot be judged here");
896
+ continue;
897
+ }
898
+ evidence.wholeTree = true;
899
+ escalate("runs git reset --hard, which discards uncommitted changes in the working tree", {
900
+ contained: true,
901
+ });
902
+ continue;
903
+ }
646
904
  const reason = gitEscalationReason(args, (dir) => {
647
905
  const resolved = resolveForContainment(toAbsolute(effectiveCwd, dir, home));
648
906
  return resolved === undefined || !isWithin(containmentRoot, resolved);
@@ -660,16 +918,58 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
660
918
  }
661
919
 
662
920
  const isMutation = MUTATION_COMMANDS.has(name);
663
- if (isMutation) escalate(`runs ${name}, which modifies the filesystem`);
921
+ const isDelete = DELETE_COMMANDS.has(name);
922
+ // A delete confined to in-project paths is what the containment gate exists
923
+ // to clear (subject to recoverability); any other mutation (cp/mv/tar/…)
924
+ // stays uncontained and reaches the classifier as before.
925
+ if (isMutation) escalate(`runs ${name}, which modifies the filesystem`, { contained: isDelete });
664
926
 
665
927
  if (!isMutation && !READ_ONLY_COMMANDS.has(name) && !PATTERN_FIRST_COMMANDS.has(name) && name !== "find") {
666
928
  escalate(`runs ${name}, which is not on the read-only allowlist`);
667
929
  }
668
930
 
931
+ // A read-only command's path operands are read: one that resolves outside
932
+ // the working directory escalates (Claude Code asks for every read outside
933
+ // it — filesystem.ts — and removed env/printenv from its read-only list).
934
+ // Bare names (`cat notes.txt`) resolve inside the cwd and stay fast-pathed;
935
+ // a pattern-first command's first positional is its pattern, not a path.
936
+ if (!isMutation && !NO_FILE_OPERANDS.has(name)) {
937
+ const positionals = args.filter((token) => !token.value.startsWith("-")).map((token) => token.value);
938
+ // The first positional is the pattern only when no flag supplies it:
939
+ // with `-e PAT` / `-f FILE` (`--regexp`, `--file`) every positional is a
940
+ // path, and `-f`'s value is itself a file read.
941
+ const patternByFlag = args.some(({ value }) => /^(-[a-zA-Z]*[ef][a-zA-Z]*|--regexp(=.*)?|--file(=.*)?)$/.test(value));
942
+ if (PATTERN_FIRST_COMMANDS.has(name) && !patternByFlag) positionals.shift();
943
+ // `--file=FILE` carries its read inside the flag token.
944
+ for (const { value } of args) {
945
+ if (value.startsWith("--file=")) positionals.push(value.slice("--file=".length));
946
+ }
947
+ for (const value of positionals) {
948
+ if (!looksLikePath(value)) continue;
949
+ const resolved = resolveForContainment(toAbsolute(effectiveCwd, value, home));
950
+ if (resolved !== undefined && isWithin(containmentRoot, resolved)) continue;
951
+ if (!evidence.outsideReads.includes(value)) evidence.outsideReads.push(value);
952
+ escalate(`reads ${value}, which is outside the working directory`, { outsideRead: true });
953
+ }
954
+ }
955
+
669
956
  // The positional destinations of writing commands are the paths that get
670
957
  // written. (Redirections were already handled at the top of the loop.)
671
958
  const writeTokens: string[] = [];
672
- if (isMutation) {
959
+ if (isDelete) {
960
+ // A delete's targets are every non-flag positional, bare names included
961
+ // (`rm notes.txt` has no slash but is still a real target the
962
+ // recoverability gate must see). A glob target cannot be enumerated, so
963
+ // it drops out of containment — the command then reaches the classifier.
964
+ for (const token of args) {
965
+ if (token.value.startsWith("-")) continue;
966
+ if (hasGlob(token.value)) {
967
+ escalate(`targets ${token.value}, a glob whose expansion cannot be checked`);
968
+ continue;
969
+ }
970
+ writeTokens.push(token.value);
971
+ }
972
+ } else if (isMutation) {
673
973
  const positionals = args.filter((token) => !token.value.startsWith("-") && looksLikePath(token.value));
674
974
  // For cp/mv/rsync/ln the destination is last; for the rest every
675
975
  // positional is a candidate target.
@@ -698,5 +998,11 @@ export function analyzeShellCommand({ command, cwd, home }: AnalyzeInput): Shell
698
998
  for (const token of writeTokens) checkWriteTarget(token);
699
999
  }
700
1000
 
1001
+ // The command escalated, but every reason was an in-project delete/whole-tree
1002
+ // reset — nothing reached outside the project, the network, or the unknown. The
1003
+ // containment gate may now consult git-recoverability (auto-mode/recoverability).
1004
+ evidence.containedNonNetwork = evidence.verdict === "escalate" && !uncontained;
1005
+ evidence.readOnlyOutside = evidence.verdict === "escalate" && !escalatedBeyondReads;
1006
+
701
1007
  return evidence;
702
1008
  }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The `<transcript>` block the classifier reads (pure).
3
+ *
4
+ * Claude Code hands its classifier a compact JSONL transcript of the session —
5
+ * user messages and tool-call *inputs only*, with tool RESULTS stripped so
6
+ * hostile content the agent read cannot reach the classifier as if it were
7
+ * context. Tool names are CC's PascalCase (`Bash`, `Edit`, …). We mirror that
8
+ * shape exactly (see docs/decisions/auto-mode.md, P5).
9
+ *
10
+ * The last entry is always the action under review — permissions/index.ts appends
11
+ * the call being judged before rendering. Results are never appended here; only
12
+ * user messages and tool inputs enter, which is the isolation boundary.
13
+ */
14
+
15
+ // Native (snake_case) tool name → Claude Code's PascalCase spelling, reused from
16
+ // the hooks matcher (its `ccToolName` is the correct-direction, mcp-passthrough
17
+ // map; permissions/matcher.ts's is lower-cased and cannot recover casing).
18
+ import { ccToolName } from "../hooks/matcher.ts";
19
+
20
+ export { ccToolName };
21
+
22
+ /**
23
+ * One line of the transcript: a user message, a tool call's input, or a call the
24
+ * permission rules refused. A `denied` line is harness-generated, never tool
25
+ * output, so it does not weaken the results-stripped isolation boundary — and
26
+ * without it the classifier could not see that the rules had already refused
27
+ * this class of action, so it cleared an equivalent-effect retry on the user's
28
+ * original intent (WEAK-MODEL-REVIEW-2026-09-06 H2).
29
+ */
30
+ export type TranscriptEntry =
31
+ | { kind: "user"; text: string }
32
+ | { kind: "tool"; tool: string; input: Record<string, unknown> }
33
+ | { kind: "denied"; tool: string; subject: string; rule: string };
34
+
35
+ /** Truncate one field so a single huge argument cannot dominate the transcript. */
36
+ export function clip(value: string, max: number): string {
37
+ return value.length <= max ? value : `${value.slice(0, max)}… [truncated, ${value.length} chars]`;
38
+ }
39
+
40
+ /** Clip every string leaf of a tool input, leaving structure intact. */
41
+ function clipInput(input: Record<string, unknown>, max: number): Record<string, unknown> {
42
+ const out: Record<string, unknown> = {};
43
+ for (const [key, value] of Object.entries(input)) {
44
+ out[key] = typeof value === "string" ? clip(value, max) : value;
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /**
50
+ * Render one entry as its compact JSON line. Bash renders as `{"Bash":"<command>"}`
51
+ * (the command string, as Claude Code does); every other tool renders as
52
+ * `{"<Tool>":{…input…}}` with string fields clipped.
53
+ */
54
+ function renderEntry(entry: TranscriptEntry, maxField: number): string {
55
+ if (entry.kind === "user") return JSON.stringify({ user: clip(entry.text, maxField) });
56
+ if (entry.kind === "denied") {
57
+ return JSON.stringify({
58
+ denied_by_permission_rule: { tool: ccToolName(entry.tool), attempted: clip(entry.subject, maxField), rule: entry.rule },
59
+ });
60
+ }
61
+ const name = ccToolName(entry.tool);
62
+ const command = entry.input.command;
63
+ if (entry.tool === "bash" && typeof command === "string") {
64
+ return JSON.stringify({ [name]: clip(command, maxField) });
65
+ }
66
+ return JSON.stringify({ [name]: clipInput(entry.input, maxField) });
67
+ }
68
+
69
+ export interface RenderOptions {
70
+ /** Max chars per string field. */
71
+ maxField?: number;
72
+ /** Max chars for the whole rendered transcript; oldest entries drop first. */
73
+ maxChars?: number;
74
+ }
75
+
76
+ /**
77
+ * Render the ordered entries into a `<transcript>…</transcript>` block. When the
78
+ * rendered lines exceed `maxChars`, the oldest are dropped and a marker records
79
+ * it — the action under review (the last entry) is always kept. The full user
80
+ * messages are carried separately for intent verification, so dropping old lines
81
+ * here never weakens that check.
82
+ */
83
+ export function renderTranscript(entries: TranscriptEntry[], options: RenderOptions = {}): string {
84
+ const maxField = options.maxField ?? 2000;
85
+ const maxChars = options.maxChars ?? 60_000;
86
+ const lines = entries.map((entry) => renderEntry(entry, maxField));
87
+
88
+ // Keep the newest lines that fit the budget, walking from the end in one pass
89
+ // (the last line — the action under review — is always kept). +1 per line for
90
+ // the joining "\n".
91
+ let firstKept = lines.length;
92
+ let running = 0;
93
+ for (let i = lines.length - 1; i >= 0; i--) {
94
+ running += lines[i].length + 1;
95
+ if (i < lines.length - 1 && running > maxChars) break;
96
+ firstKept = i;
97
+ }
98
+ const kept = lines.slice(firstKept);
99
+ const dropped = firstKept;
100
+ const body = dropped > 0 ? [`{"note":"${dropped} earlier transcript entr${dropped === 1 ? "y" : "ies"} omitted for length"}`, ...kept] : kept;
101
+ return `<transcript>\n${body.join("\n")}\n</transcript>`;
102
+ }