one-code-extension 0.1.4 → 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 -217
  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
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Git-recoverability check for the containment pre-gate (pure core + thin git
3
+ * runner).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Claude Code's auto mode trusts the whole project directory as the agent's
8
+ * sandbox: an in-project `rm` or `git reset --hard` is auto-approved with no
9
+ * classifier call, even when it destroys uncommitted or untracked work git can
10
+ * never restore (confirmed live — see docs/decisions/auto-mode.md). One Code
11
+ * keeps the cheap in-project fast path but refuses that specific risk: an
12
+ * in-project destructive action is auto-approved **only when git can put the
13
+ * bytes back** — the target is tracked and clean (whole-tree ops: the entire
14
+ * tree is clean). Anything else — untracked, dirty, not a git repo, or a git
15
+ * query we could not run — is `unknown`/`unrecoverable`, and the pre-gate
16
+ * escalates it to the classifier rather than destroying it silently.
17
+ *
18
+ * ## Contract
19
+ *
20
+ * Like the shell pre-gate, this may only ever *widen* what is provably safe.
21
+ * `recoverable` is returned only on positive proof (git says tracked+clean);
22
+ * every ambiguity — an error, a path git does not know, a non-repo — resolves to
23
+ * `unknown`, which the caller treats exactly like `unrecoverable`: classify.
24
+ * Being wrong toward `unknown` costs a classifier call; being wrong toward
25
+ * `recoverable` destroys the user's work, so uncertainty never lands there.
26
+ */
27
+
28
+ import { execFileSync } from "node:child_process";
29
+ import { existsSync, lstatSync, readdirSync } from "node:fs";
30
+ import { basename, join, sep } from "node:path";
31
+
32
+ export type Recoverability = "recoverable" | "unrecoverable" | "unknown";
33
+
34
+ export interface RecoverabilityResult {
35
+ verdict: Recoverability;
36
+ /** One line naming why, for the decision log — never echoes file contents. */
37
+ reason: string;
38
+ }
39
+
40
+ /** What git reports about one destruction target. */
41
+ export interface TargetState {
42
+ /** Original token, for the reason string (never an expanded value). */
43
+ token: string;
44
+ /** `git ls-files` matched it — git has a committed/indexed copy to restore from. */
45
+ tracked: boolean;
46
+ /** `git status --porcelain --ignored` reported anything for it — uncommitted, untracked, or ignored content that a restore would not bring back. */
47
+ dirty: boolean;
48
+ /** True when the target is a directory (its reason names the content git would not restore). */
49
+ directory?: boolean;
50
+ }
51
+
52
+ /**
53
+ * Judge a set of explicit destruction targets (`rm a b`, `truncate f`). Every
54
+ * target must be independently recoverable; the first that is not decides the
55
+ * whole action, since one command destroys them all.
56
+ */
57
+ export function judgeTargets(targets: TargetState[]): RecoverabilityResult {
58
+ if (targets.length === 0) return { verdict: "unknown", reason: "no destruction target could be identified" };
59
+ for (const target of targets) {
60
+ if (!target.tracked) {
61
+ return { verdict: "unrecoverable", reason: `${target.token} is not tracked by git, so deleting it is not reversible` };
62
+ }
63
+ if (target.dirty) {
64
+ return {
65
+ verdict: "unrecoverable",
66
+ reason: target.directory
67
+ ? `${target.token} is a directory holding uncommitted, untracked, or ignored files that a git restore would not bring back`
68
+ : `${target.token} has uncommitted changes that a git restore would not bring back`,
69
+ };
70
+ }
71
+ }
72
+ return { verdict: "recoverable", reason: "every target is tracked by git and clean, so git can restore it" };
73
+ }
74
+
75
+ /**
76
+ * Judge a whole-tree destructive op. Recoverable only when the entire working
77
+ * tree is clean: a clean tree's state is committed (reachable again via
78
+ * reflog/checkout), while any modified, staged, or untracked entry is content the
79
+ * op would erase for good. This judge is generic over any tree-wide reset, but at
80
+ * present only `git reset --hard` is routed here — `git clean -fd` / `git checkout
81
+ * .` are escalated directly by shell-analysis (isWholeTreeGitReset), so they reach
82
+ * the classifier rather than this fast path.
83
+ */
84
+ export function judgeWholeTree(isRepo: boolean, statusPorcelain: string): RecoverabilityResult {
85
+ if (!isRepo) return { verdict: "unrecoverable", reason: "not a git repository, so a tree-wide reset cannot be undone" };
86
+ return statusPorcelain.trim().length === 0
87
+ ? { verdict: "recoverable", reason: "the working tree is clean, so git can restore it" }
88
+ : { verdict: "unrecoverable", reason: "the working tree has uncommitted or untracked changes a reset would destroy" };
89
+ }
90
+
91
+ /** Combine several sub-verdicts: the whole action is only as recoverable as its least-recoverable part. */
92
+ export function combine(results: RecoverabilityResult[]): RecoverabilityResult {
93
+ if (results.length === 0) return { verdict: "unknown", reason: "nothing to check" };
94
+ const worst = results.find((r) => r.verdict === "unrecoverable") ?? results.find((r) => r.verdict === "unknown");
95
+ return worst ?? results[0];
96
+ }
97
+
98
+ /** The path is `.git`, sits inside a `.git`, or is a directory with a `.git` entry (repo root, nested repo, worktree). */
99
+ function isOrHoldsGitDir(path: string): boolean {
100
+ if (basename(path) === ".git") return true;
101
+ if (path.split(sep).includes(".git")) return true;
102
+ try {
103
+ return lstatSync(path).isDirectory() && existsSync(join(path, ".git"));
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Whether any entry named `.git` exists anywhere under `dir`. Bounded by
111
+ * `budget` directory entries; returns undefined when the budget runs out (the
112
+ * caller escalates). Symlinks are not followed.
113
+ */
114
+ function holdsNestedGitDir(dir: string, budget: number): boolean | undefined {
115
+ const stack = [dir];
116
+ let seen = 0;
117
+ while (stack.length > 0) {
118
+ const current = stack.pop() as string;
119
+ let entries;
120
+ try {
121
+ entries = readdirSync(current, { withFileTypes: true });
122
+ } catch {
123
+ continue;
124
+ }
125
+ for (const entry of entries) {
126
+ if (++seen > budget) return undefined;
127
+ if (entry.name === ".git") return true;
128
+ if (entry.isDirectory()) stack.push(join(current, entry.name));
129
+ }
130
+ }
131
+ return false;
132
+ }
133
+
134
+ /** A thin, defensive git call: any failure (not a repo, git missing, timeout) becomes `undefined`. */
135
+ function git(cwd: string, args: string[]): { ok: boolean; stdout: string } | undefined {
136
+ try {
137
+ const stdout = execFileSync("git", args, { cwd, encoding: "utf8", timeout: 5_000, stdio: ["ignore", "pipe", "ignore"] });
138
+ return { ok: true, stdout };
139
+ } catch (error) {
140
+ // A non-zero exit still carries stdout (e.g. ls-files --error-unmatch), which
141
+ // the caller may want; distinguish "ran and said no" from "could not run".
142
+ const status = (error as { status?: number }).status;
143
+ if (typeof status === "number") return { ok: false, stdout: String((error as { stdout?: unknown }).stdout ?? "") };
144
+ return undefined; // git not found / timed out / killed — genuinely unknown
145
+ }
146
+ }
147
+
148
+ export interface Destruction {
149
+ /** Resolved absolute paths the command destroys (already proven in-project by the pre-gate). */
150
+ targets: string[];
151
+ /** True for reset --hard / clean / checkout . — the target is the whole working tree, not named paths. */
152
+ wholeTree: boolean;
153
+ }
154
+
155
+ /**
156
+ * Run the git queries and judge. `cwd` is the command's effective directory.
157
+ * Returns `unknown` on any git failure so the caller escalates rather than
158
+ * trusting an answer we could not compute.
159
+ */
160
+ export function checkRecoverability(cwd: string, destruction: Destruction): RecoverabilityResult {
161
+ const repoProbe = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
162
+ const isRepo = repoProbe?.ok === true && repoProbe.stdout.trim() === "true";
163
+
164
+ if (destruction.wholeTree) {
165
+ if (!isRepo) return judgeWholeTree(false, "");
166
+ const status = git(cwd, ["status", "--porcelain"]);
167
+ if (!status) return { verdict: "unknown", reason: "could not read git status to judge a tree-wide reset" };
168
+ return judgeWholeTree(true, status.stdout);
169
+ }
170
+
171
+ if (destruction.targets.length === 0) return { verdict: "unknown", reason: "no destruction target could be identified" };
172
+ if (!isRepo) return { verdict: "unrecoverable", reason: "not a git repository, so deletion is not reversible" };
173
+
174
+ const states: TargetState[] = [];
175
+ for (const path of destruction.targets) {
176
+ // A target that does not exist has nothing to lose: deleting it is a no-op,
177
+ // truncating it creates a fresh empty file. Either way there is no
178
+ // pre-existing content a restore would need to bring back. Use lstat, not
179
+ // existsSync: existsSync follows a symlink to its target, so a tracked-but-
180
+ // dirty *broken* symlink (its target missing) would read as "nothing to
181
+ // lose" and be wrongly cleared as recoverable — the link entry itself is
182
+ // real, uncommitted content, so it must be queried against git.
183
+ let entryExists = true;
184
+ try {
185
+ lstatSync(path);
186
+ } catch {
187
+ entryExists = false;
188
+ }
189
+ if (!entryExists) continue;
190
+ // The repository itself is never recoverable from itself: `rm -rf .` at the
191
+ // root passes every tracked/clean test (git ls-files matches any dir with
192
+ // tracked files) yet deletes `.git`. Same for a path inside `.git` and for a
193
+ // directory holding a nested repository or worktree link.
194
+ if (isOrHoldsGitDir(path)) {
195
+ return { verdict: "unrecoverable", reason: `${path} is or contains a .git directory, so deleting it destroys the history that would restore it` };
196
+ }
197
+ const isDirectory = (() => {
198
+ try {
199
+ return lstatSync(path).isDirectory();
200
+ } catch {
201
+ return false;
202
+ }
203
+ })();
204
+ // `--ignored`: porcelain omits ignored files by default, so a directory of
205
+ // build output or a `.env` read as "clean" while `rm -rf` erased it for good.
206
+ const tracked = git(cwd, ["ls-files", "--error-unmatch", "--", path]);
207
+ const status = git(cwd, ["status", "--porcelain", "--ignored", "--", path]);
208
+ if (tracked === undefined || status === undefined) {
209
+ return { verdict: "unknown", reason: "could not query git for a deletion target" };
210
+ }
211
+ const dirty = status.stdout.trim().length > 0;
212
+ // A clean, tracked directory can still hold a nested repository deeper down
213
+ // (git status never lists a `.git`, so it reads as clean). Only scanned once
214
+ // the cheap checks passed; a huge tree exhausts the budget and stays unknown.
215
+ if (isDirectory && tracked.ok && !dirty) {
216
+ const nested = holdsNestedGitDir(path, 5_000);
217
+ if (nested === undefined) {
218
+ return { verdict: "unknown", reason: `${path} is too large to scan for nested repositories` };
219
+ }
220
+ if (nested) {
221
+ return { verdict: "unrecoverable", reason: `${path} contains a nested .git directory whose history git cannot restore` };
222
+ }
223
+ }
224
+ states.push({ token: path, tracked: tracked.ok, dirty, directory: isDirectory });
225
+ }
226
+ // Every target was a non-existent path (pure creation / no-op) — nothing to lose.
227
+ if (states.length === 0) return { verdict: "recoverable", reason: "no existing content would be destroyed" };
228
+ return judgeTargets(states);
229
+ }
@@ -1,96 +1,113 @@
1
1
  /**
2
- * Rule indexing and verdict grounding (pure).
2
+ * Grounding the stage-2 verdict against CC's rule names (pure).
3
3
  *
4
- * The classifier used to return a free-text reason, and a model required to
5
- * justify a decision it has no grounds for produces the most plausible-sounding
6
- * justification instead of a true one: a `.claude/notes.md` write came back
7
- * blocked "because it is outside the working directory", which was false. That
8
- * reason is what the user reads in `/permissions`, what the model is told to act
9
- * on, and — via the tier — what decides whether the user even gets a prompt.
4
+ * Claude Code's stage-2 classifier emits `<category>Exact BLOCK Rule Name</category>`
5
+ * when an action matches a BLOCK rule. CC itself discards that and shows a fixed
6
+ * "Blocked by classifier"; we instead validate the category against an index of
7
+ * the rule names actually present in the embedded ruleset, and derive the tier
8
+ * (hard vs soft) from which section the name was found in — never from a field
9
+ * the model chose freely.
10
10
  *
11
- * So rules are numbered here, the classifier cites an id, and the id is
12
- * validated against this index. What the user sees is the *rule's own text*, not
13
- * the model's paraphrase of it, and the tier comes from the cited id's prefix
14
- * rather than from a field the model was free to invent. The model's own wording
15
- * is kept, but as commentary that is never mistaken for fact.
16
- *
17
- * Claude Code went further and stopped showing a reason at all (v2.1.208 shows a
18
- * fixed "Blocked by classifier"), having shipped written explanations first and
19
- * moved off them. Keeping the explanation is worth it — it is how a user knows
20
- * which rule or environment entry to change — but only if it cannot assert
21
- * things that are not so.
11
+ * A block citing a category that does not exist is still a block, but tagged
12
+ * `unmatched`, so a hallucinated name cannot borrow a real HARD rule's authority.
13
+ * The rule's own name is what the user sees; the model's wording is kept as
14
+ * commentary that is never mistaken for fact. This is decision 1 in
15
+ * docs/decisions/auto-mode.md, re-expressed against CC's category scheme in
16
+ * place of our old H1/S4/A2 ids.
22
17
  */
23
18
 
24
- import type { AutoModeConfig } from "./config.ts";
25
-
26
- export type RuleTier = "hard_deny" | "soft_deny" | "allow";
19
+ export type BlockTier = "hard_deny" | "soft_deny";
27
20
 
28
21
  export interface IndexedRule {
29
- /** `H1`, `S4`, `A2` — what the classifier cites. */
30
- id: string;
31
- tier: RuleTier;
32
- /** The rule's full text, as configured. */
33
- text: string;
34
- /** Short human-readable name, for a one-line denial listing. */
35
- label: string;
22
+ /** The rule's exact name as it appears in the ruleset (e.g. "Git Destructive"). */
23
+ name: string;
24
+ tier: BlockTier;
36
25
  }
37
26
 
38
- const TIER_PREFIX: Record<RuleTier, string> = { hard_deny: "H", soft_deny: "S", allow: "A" };
27
+ export interface RuleIndex {
28
+ rules: IndexedRule[];
29
+ /** Keyed by normalized name, for case/whitespace-insensitive lookup. */
30
+ byName: Map<string, IndexedRule>;
31
+ }
39
32
 
40
- /**
41
- * Grounds for a block that are real but are not numbered rules. Without these
42
- * the classifier would have to force such a decision onto whichever rule looked
43
- * closest, which is the misattribution this module exists to stop.
44
- */
45
- export const RESERVED_BLOCK_IDS: Record<string, { reason: string; tier: "soft_deny" }> = {
46
- boundary: {
47
- reason: "The user stated a boundary in this conversation that this action would cross.",
48
- tier: "soft_deny",
49
- },
50
- instructions: {
51
- reason: "The project's own instruction file forbids this.",
52
- tier: "soft_deny",
53
- },
54
- unclear: {
55
- reason: "What this action would do could not be determined from the call, so it was not allowed.",
56
- tier: "soft_deny",
57
- },
58
- };
33
+ /** Section headings in CC's ruleset that bound the block-rule lists. */
34
+ const HARD_HEADING = "## HARD BLOCK";
35
+ const SOFT_HEADING = "## SOFT BLOCK";
36
+ const ALLOW_HEADING = "## ALLOW (exceptions)";
37
+
38
+ /** Compare names ignoring case, surrounding punctuation, and whitespace runs. */
39
+ export function normalizeName(value: string): string {
40
+ return value
41
+ .trim()
42
+ .toLowerCase()
43
+ .replace(/\s+/g, " ")
44
+ .replace(/[.,:;]+$/, "");
45
+ }
59
46
 
60
47
  /**
61
- * A rule's display name: the part before the first colon, with any
62
- * `[must name …]` qualifier stripped. Falls back to a clipped prefix for a
63
- * user-written rule with no such structure.
48
+ * The rule name at the head of a top-level bullet line, or undefined if the line
49
+ * is not a rule entry. A rule line looks like `- Name [named+specifics …]: text`
50
+ * or `- Name: text`; the name is what precedes the first ` [` qualifier or `:`.
51
+ * Indented lines (sub-bullets) and non-bullet prose are ignored.
64
52
  */
65
- export function ruleLabel(text: string): string {
66
- const head = text.split(":")[0] ?? text;
67
- const stripped = head.replace(/\[[^\]]*\]/g, "").trim();
68
- const candidate = stripped || head.trim();
69
- if (candidate.length > 0 && candidate.length <= 60 && candidate.length < text.length) return candidate;
70
- return `${text.slice(0, 57).trim()}…`;
53
+ function ruleNameFromLine(line: string): string | undefined {
54
+ if (!line.startsWith("- ")) return undefined; // top-level bullets only
55
+ const body = line.slice(2);
56
+ // Cut at the first " [" (the [named+specifics …] qualifier) or the first ":".
57
+ const bracket = body.indexOf(" [");
58
+ const colon = body.indexOf(":");
59
+ let end = body.length;
60
+ if (bracket >= 0) end = Math.min(end, bracket);
61
+ if (colon >= 0) end = Math.min(end, colon);
62
+ const name = body.slice(0, end).replace(/\*/g, "").trim();
63
+ return name.length > 0 ? name : undefined;
71
64
  }
72
65
 
73
- export interface RuleIndex {
74
- rules: IndexedRule[];
75
- byId: Map<string, IndexedRule>;
66
+ /** The text between two markers (exclusive), or "" if the bounds are missing. */
67
+ function sliceSection(text: string, from: string, to: string): string {
68
+ const start = text.indexOf(from);
69
+ if (start < 0) return "";
70
+ const end = text.indexOf(to, start + from.length);
71
+ return text.slice(start + from.length, end < 0 ? text.length : end);
76
72
  }
77
73
 
78
- /** Number every rule so a verdict can point at one. */
79
- export function indexRules(config: AutoModeConfig): RuleIndex {
80
- const rules: IndexedRule[] = [];
81
- const tiers: RuleTier[] = ["hard_deny", "soft_deny", "allow"];
82
- for (const tier of tiers) {
83
- const entries = config[tier];
84
- for (const [position, text] of entries.entries()) {
85
- rules.push({ id: `${TIER_PREFIX[tier]}${position + 1}`, tier, text, label: ruleLabel(text) });
86
- }
74
+ function collectRules(section: string, tier: BlockTier, out: IndexedRule[], byName: Map<string, IndexedRule>): void {
75
+ for (const line of section.split("\n")) {
76
+ const name = ruleNameFromLine(line);
77
+ if (!name) continue;
78
+ const nameKey = normalizeName(name);
79
+ if (byName.has(nameKey)) continue; // a name appearing in two sections keeps its first (HARD) tier
80
+ const rule: IndexedRule = { name, tier };
81
+ out.push(rule);
82
+ byName.set(nameKey, rule);
83
+ // Also key the FULL line text (when it differs), so a verdict quoting the
84
+ // whole rule line still grounds. Built-in and user-appended lines get this
85
+ // uniformly — user extras especially need it, since they have no short
86
+ // `Name:` head and may contain `*`s that the derived name strips.
87
+ const fullKey = normalizeName(line.slice(2));
88
+ if (fullKey && fullKey !== nameKey && !byName.has(fullKey)) byName.set(fullKey, rule);
87
89
  }
88
- return { rules, byId: new Map(rules.map((rule) => [rule.id, rule])) };
89
90
  }
90
91
 
91
- /** Render one tier for the prompt, with ids the classifier can cite. */
92
- export function renderTier(index: RuleIndex, tier: RuleTier): string {
93
- const entries = index.rules.filter((rule) => rule.tier === tier);
94
- if (entries.length === 0) return "";
95
- return `<${tier}>\n${entries.map((rule) => `${rule.id}. ${rule.text}`).join("\n")}\n</${tier}>`;
92
+ /**
93
+ * Parse the block-rule names out of the embedded ruleset (user rule extras
94
+ * included — buildRuleset has already appended them into these sections, so
95
+ * they are indexed by the same scan as the built-ins). HARD BLOCK names win a
96
+ * tie over SOFT (a HARD rule must never be downgraded by a same-named soft
97
+ * one). ALLOW names are not block categories; they are intentionally not
98
+ * indexed, so a block citing an allow name lands as `unmatched` rather than a
99
+ * real block.
100
+ */
101
+ export function buildCategoryIndex(ruleset: string): RuleIndex {
102
+ const rules: IndexedRule[] = [];
103
+ const byName = new Map<string, IndexedRule>();
104
+ collectRules(sliceSection(ruleset, HARD_HEADING, SOFT_HEADING), "hard_deny", rules, byName);
105
+ collectRules(sliceSection(ruleset, SOFT_HEADING, ALLOW_HEADING), "soft_deny", rules, byName);
106
+ return { rules, byName };
107
+ }
108
+
109
+ /** Look up a category the classifier emitted. undefined ⇒ not a known BLOCK rule. */
110
+ export function groundCategory(index: RuleIndex, category: string | undefined): IndexedRule | undefined {
111
+ if (!category) return undefined;
112
+ return index.byName.get(normalizeName(category));
96
113
  }
@@ -22,9 +22,10 @@
22
22
  * fires on routine work teaches the user to approve without reading.
23
23
  */
24
24
 
25
- import { join } from "node:path";
26
25
  import { analyzeShellCommand } from "./shell-analysis.ts";
27
26
  import { autoModeSettingsPaths } from "./config.ts";
27
+ import { oneCodeProjectSettingsPath } from "../lib/one-code-settings.ts";
28
+ import { claudeJsonPath } from "../lib/paths.ts";
28
29
  import { isWritingTool, resolveForContainment, toAbsolute } from "./paths.ts";
29
30
 
30
31
  /** Case-fold the same way resolveForContainment's output is folded. */
@@ -41,22 +42,43 @@ function fold(path: string): string {
41
42
  */
42
43
  const SETTINGS_TAIL = /\/\.claude\/settings(\.local)?\.json$/;
43
44
 
44
- function safetyControlFiles(home: string): string[] {
45
+ /**
46
+ * One Code's own settings files — the global `~/.onecode/settings.json` and the
47
+ * per-repo `~/.onecode/projects/<slug>/settings.json`, wherever `.onecode`
48
+ * lives. Both now carry `permissions.allow` rules that `loadPermissionSettings`
49
+ * reads and that bypass the classifier, so a write to either is a gate-control
50
+ * write and must hit this floor rather than the auto-mode classifier (the global
51
+ * file is also reached via `autoModeSettingsPaths`; this covers the per-repo one,
52
+ * which that list never includes, and both under any checkout).
53
+ */
54
+ const ONECODE_SETTINGS_TAIL = /\/\.onecode\/(projects\/[^/]+\/)?settings\.json$/;
55
+
56
+ function safetyControlFiles(home: string, oneCodeProjectSettings?: string): string[] {
45
57
  return [
46
- // autoMode + user permission rules, and the managed-settings paths.
58
+ // autoMode + user permission rules, and the managed-settings paths (the
59
+ // global ~/.onecode/settings.json here honours ONECODE_STATE_DIR).
47
60
  ...autoModeSettingsPaths(home),
48
- // Claude Code's global state file also carries permission configuration.
49
- join(home, ".claude.json"),
61
+ // Claude Code's global state file also carries permission configuration;
62
+ // claudeJsonPath honours CLAUDE_CONFIG_DIR so a relocated file is still caught.
63
+ claudeJsonPath(home),
64
+ // The per-repo One Code settings file also carries permission rules. The
65
+ // tail regex catches it under a literal `.onecode`; this catches it when
66
+ // ONECODE_STATE_DIR relocated the state root (review L2).
67
+ ...(oneCodeProjectSettings ? [oneCodeProjectSettings] : []),
50
68
  ];
51
69
  }
52
70
 
53
- /** Whether a *resolved* path (resolveForContainment output) is a gate control. */
54
- export function isSafetyControlTarget(resolved: string, home: string): boolean {
71
+ /**
72
+ * Whether a *resolved* path (resolveForContainment output) is a gate control.
73
+ * `oneCodeProjectSettings` is the current repo's One Code settings file, resolved
74
+ * by the caller (once per session) so this need not re-walk for the project root.
75
+ */
76
+ export function isSafetyControlTarget(resolved: string, home: string, oneCodeProjectSettings?: string): boolean {
55
77
  const target = fold(resolved);
56
- if (SETTINGS_TAIL.test(target)) return true;
78
+ if (SETTINGS_TAIL.test(target) || ONECODE_SETTINGS_TAIL.test(target)) return true;
57
79
  // The control files go through the same resolution as the write target, or
58
80
  // the two sides can disagree about the same file (macOS /var → /private/var).
59
- return safetyControlFiles(home).some((file) => {
81
+ return safetyControlFiles(home, oneCodeProjectSettings).some((file) => {
60
82
  const control = resolveForContainment(file) ?? fold(file);
61
83
  return control === target || fold(file) === target;
62
84
  });
@@ -68,6 +90,13 @@ export interface FloorInput {
68
90
  input: Record<string, unknown>;
69
91
  cwd: string;
70
92
  home: string;
93
+ /**
94
+ * The current repo's One Code settings file, resolved once per session by the
95
+ * caller. When omitted it is derived from `cwd` (a filesystem walk) — fine for
96
+ * tests, but the per-tool-call permission path passes it to avoid the walk
97
+ * (distribution review 2026-09-09, L2 / efficiency pass).
98
+ */
99
+ oneCodeProjectSettings?: string;
71
100
  }
72
101
 
73
102
  const REASON = (token: string) =>
@@ -79,12 +108,14 @@ const REASON = (token: string) =>
79
108
  * files included), so linking a settings file elsewhere and writing the link
80
109
  * does not slip past.
81
110
  */
82
- export function safetyControlWrite({ toolName, input, cwd, home }: FloorInput): string | undefined {
111
+ export function safetyControlWrite({ toolName, input, cwd, home, oneCodeProjectSettings }: FloorInput): string | undefined {
112
+ const perRepoSettings = oneCodeProjectSettings ?? oneCodeProjectSettingsPath(cwd, home);
113
+
83
114
  if (isWritingTool(toolName)) {
84
115
  const raw = input.path ?? input.file_path ?? input.notebook_path;
85
116
  if (typeof raw !== "string" || raw.length === 0) return undefined;
86
117
  const resolved = resolveForContainment(toAbsolute(cwd, raw, home));
87
- return resolved && isSafetyControlTarget(resolved, home) ? REASON(raw) : undefined;
118
+ return resolved && isSafetyControlTarget(resolved, home, perRepoSettings) ? REASON(raw) : undefined;
88
119
  }
89
120
 
90
121
  if (toolName === "bash") {
@@ -92,7 +123,7 @@ export function safetyControlWrite({ toolName, input, cwd, home }: FloorInput):
92
123
  if (!command) return undefined;
93
124
  const evidence = analyzeShellCommand({ command, cwd, home });
94
125
  for (const write of evidence.writes) {
95
- if (write.resolved && isSafetyControlTarget(write.resolved, home)) return REASON(write.token);
126
+ if (write.resolved && isSafetyControlTarget(write.resolved, home, perRepoSettings)) return REASON(write.token);
96
127
  }
97
128
  }
98
129
 
@@ -36,9 +36,20 @@ const SENSITIVE_DIR_PAIRS = [
36
36
  [".config", "gcloud"],
37
37
  [".config", "gh"],
38
38
  [".local", "share"],
39
+ [".huggingface", "token"],
40
+ ["library", "keychains"], // macOS keychain databases
39
41
  ] as const;
40
42
 
41
43
  const SENSITIVE_BASENAMES = new Set([
44
+ // The harness's own credential stores: pi's / One Code's `agent/auth.json`
45
+ // and Claude Code's `~/.claude/.credentials.json` (PERMISSIONS-REVIEW-2026-09-05
46
+ // H2 — the classifier's own API key was readable unclassified).
47
+ "auth.json",
48
+ ".credentials.json",
49
+ "credentials.json",
50
+ "credentials.toml", // cargo
51
+ ".vault-token",
52
+ ".gitconfig", // credential helpers, embedded tokens
42
53
  ".bash_history",
43
54
  ".bash_profile",
44
55
  ".bashrc",