@intentic/sandbox-contract 1.317.0 → 1.318.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 (333) hide show
  1. package/README.md +12 -4
  2. package/dist/chores/chores.js +1 -1
  3. package/dist/chores/chores.js.map +1 -1
  4. package/dist/chores/probes.js +1 -1
  5. package/dist/chores/probes.js.map +1 -1
  6. package/dist/contracts/accounts.contract.d.ts +75 -0
  7. package/dist/contracts/accounts.contract.d.ts.map +1 -1
  8. package/dist/contracts/accounts.contract.js +10 -1
  9. package/dist/contracts/accounts.contract.js.map +1 -1
  10. package/dist/contracts/agent.contract.d.ts +55 -1
  11. package/dist/contracts/agent.contract.d.ts.map +1 -1
  12. package/dist/contracts/agents.contract.d.ts +796 -93
  13. package/dist/contracts/agents.contract.d.ts.map +1 -1
  14. package/dist/contracts/agents.contract.js +12 -12
  15. package/dist/contracts/agents.contract.js.map +1 -1
  16. package/dist/contracts/ci.contract.d.ts +22 -4
  17. package/dist/contracts/ci.contract.d.ts.map +1 -1
  18. package/dist/contracts/device.contract.d.ts +38 -0
  19. package/dist/contracts/device.contract.d.ts.map +1 -1
  20. package/dist/contracts/device.contract.js +2 -0
  21. package/dist/contracts/device.contract.js.map +1 -1
  22. package/dist/contracts/endpoints.contract.d.ts +23 -0
  23. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  24. package/dist/contracts/endpoints.contract.js +6 -1
  25. package/dist/contracts/endpoints.contract.js.map +1 -1
  26. package/dist/contracts/extensions.contract.d.ts +12 -0
  27. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  28. package/dist/contracts/needs.contract.d.ts +2 -0
  29. package/dist/contracts/needs.contract.d.ts.map +1 -1
  30. package/dist/contracts/providers.contract.d.ts +4 -0
  31. package/dist/contracts/providers.contract.d.ts.map +1 -1
  32. package/dist/contracts/runner.contract.d.ts +143 -124
  33. package/dist/contracts/runner.contract.d.ts.map +1 -1
  34. package/dist/contracts/secrets.contract.d.ts +37 -0
  35. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  36. package/dist/contracts/secrets.contract.js +20 -1
  37. package/dist/contracts/secrets.contract.js.map +1 -1
  38. package/dist/contracts/sessions.contract.d.ts +4 -0
  39. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  40. package/dist/contracts/settings.contract.d.ts +31 -2
  41. package/dist/contracts/settings.contract.d.ts.map +1 -1
  42. package/dist/contracts/settings.contract.js +20 -1
  43. package/dist/contracts/settings.contract.js.map +1 -1
  44. package/dist/contracts/system.contract.d.ts +96 -435
  45. package/dist/contracts/system.contract.d.ts.map +1 -1
  46. package/dist/contracts/system.contract.js +2 -12
  47. package/dist/contracts/system.contract.js.map +1 -1
  48. package/dist/contracts/translator.contract.d.ts +7 -0
  49. package/dist/contracts/translator.contract.d.ts.map +1 -1
  50. package/dist/contracts/translator.contract.js.map +1 -1
  51. package/dist/contracts/webext.contract.d.ts +38 -0
  52. package/dist/contracts/webext.contract.d.ts.map +1 -1
  53. package/dist/contracts/webext.contract.js +3 -0
  54. package/dist/contracts/webext.contract.js.map +1 -1
  55. package/dist/contracts/workspace.contract.d.ts +1 -80
  56. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  57. package/dist/contracts/workspace.contract.js +0 -27
  58. package/dist/contracts/workspace.contract.js.map +1 -1
  59. package/dist/events/agent-events.d.ts +21 -0
  60. package/dist/events/agent-events.d.ts.map +1 -1
  61. package/dist/events/agent-events.js +11 -0
  62. package/dist/events/agent-events.js.map +1 -1
  63. package/dist/events/agent-words.d.ts +1 -0
  64. package/dist/events/agent-words.d.ts.map +1 -1
  65. package/dist/events/agent-words.js +1 -1
  66. package/dist/events/agent-words.js.map +1 -1
  67. package/dist/events/land-breakage.d.ts.map +1 -1
  68. package/dist/events/land-breakage.js.map +1 -1
  69. package/dist/events/resume.d.ts +6 -1
  70. package/dist/events/resume.d.ts.map +1 -1
  71. package/dist/events/resume.js +17 -10
  72. package/dist/events/resume.js.map +1 -1
  73. package/dist/events/sandbox-notice.d.ts +183 -0
  74. package/dist/events/sandbox-notice.d.ts.map +1 -0
  75. package/dist/events/sandbox-notice.js +56 -0
  76. package/dist/events/sandbox-notice.js.map +1 -0
  77. package/dist/events/system-events.d.ts +46 -0
  78. package/dist/events/system-events.d.ts.map +1 -1
  79. package/dist/events/system-events.js +2 -0
  80. package/dist/events/system-events.js.map +1 -1
  81. package/dist/events/transcript.d.ts +24 -0
  82. package/dist/events/transcript.d.ts.map +1 -1
  83. package/dist/events/transcript.js +3 -1
  84. package/dist/events/transcript.js.map +1 -1
  85. package/dist/ids/conversation-ids.d.ts +1 -3
  86. package/dist/ids/conversation-ids.d.ts.map +1 -1
  87. package/dist/ids/conversation-ids.js +1 -3
  88. package/dist/ids/conversation-ids.js.map +1 -1
  89. package/dist/ids/project-dir.d.ts +6 -0
  90. package/dist/ids/project-dir.d.ts.map +1 -0
  91. package/dist/ids/project-dir.js +43 -0
  92. package/dist/ids/project-dir.js.map +1 -0
  93. package/dist/index.d.ts +11570 -11030
  94. package/dist/index.d.ts.map +1 -1
  95. package/dist/index.js +7 -4
  96. package/dist/index.js.map +1 -1
  97. package/dist/models/agent-runtimes.d.ts.map +1 -1
  98. package/dist/models/agent-runtimes.js +2 -2
  99. package/dist/models/agent-runtimes.js.map +1 -1
  100. package/dist/models/model-roles.d.ts +1 -9
  101. package/dist/models/model-roles.d.ts.map +1 -1
  102. package/dist/models/model-roles.js +1 -9
  103. package/dist/models/model-roles.js.map +1 -1
  104. package/dist/policy/command-classes.d.ts.map +1 -1
  105. package/dist/policy/command-classes.js +52 -3
  106. package/dist/policy/command-classes.js.map +1 -1
  107. package/dist/policy/safety-policy.d.ts +7 -1
  108. package/dist/policy/safety-policy.d.ts.map +1 -1
  109. package/dist/policy/safety-policy.js +2 -0
  110. package/dist/policy/safety-policy.js.map +1 -1
  111. package/dist/policy/secret-hosts.d.ts +13 -0
  112. package/dist/policy/secret-hosts.d.ts.map +1 -0
  113. package/dist/policy/secret-hosts.js +41 -0
  114. package/dist/policy/secret-hosts.js.map +1 -0
  115. package/dist/policy/turned-away.d.ts.map +1 -1
  116. package/dist/policy/turned-away.js +1 -0
  117. package/dist/policy/turned-away.js.map +1 -1
  118. package/dist/protocol/raw-routes.d.ts +7 -0
  119. package/dist/protocol/raw-routes.d.ts.map +1 -1
  120. package/dist/protocol/raw-routes.js +4 -0
  121. package/dist/protocol/raw-routes.js.map +1 -1
  122. package/dist/protocol/routes.d.ts +2 -6
  123. package/dist/protocol/routes.d.ts.map +1 -1
  124. package/dist/protocol/routes.js +2 -3
  125. package/dist/protocol/routes.js.map +1 -1
  126. package/dist/protocol/stream-of.d.ts +6 -0
  127. package/dist/protocol/stream-of.d.ts.map +1 -0
  128. package/dist/protocol/stream-of.js +4 -0
  129. package/dist/protocol/stream-of.js.map +1 -0
  130. package/dist/schemas/activity.js +1 -1
  131. package/dist/schemas/activity.js.map +1 -1
  132. package/dist/schemas/agent.d.ts +19 -2
  133. package/dist/schemas/agent.d.ts.map +1 -1
  134. package/dist/schemas/agent.js +2 -1
  135. package/dist/schemas/agent.js.map +1 -1
  136. package/dist/schemas/agents.d.ts +61 -0
  137. package/dist/schemas/agents.d.ts.map +1 -1
  138. package/dist/schemas/agents.js +36 -0
  139. package/dist/schemas/agents.js.map +1 -1
  140. package/dist/schemas/automations.d.ts +18 -0
  141. package/dist/schemas/automations.d.ts.map +1 -1
  142. package/dist/schemas/capabilities.d.ts +2 -0
  143. package/dist/schemas/capabilities.d.ts.map +1 -1
  144. package/dist/schemas/capabilities.js +34 -4
  145. package/dist/schemas/capabilities.js.map +1 -1
  146. package/dist/schemas/ci.d.ts +69 -9
  147. package/dist/schemas/ci.d.ts.map +1 -1
  148. package/dist/schemas/ci.js +29 -8
  149. package/dist/schemas/ci.js.map +1 -1
  150. package/dist/schemas/context-trim.d.ts +5 -1
  151. package/dist/schemas/context-trim.d.ts.map +1 -1
  152. package/dist/schemas/context-trim.js +8 -4
  153. package/dist/schemas/context-trim.js.map +1 -1
  154. package/dist/schemas/devices.d.ts +23 -0
  155. package/dist/schemas/devices.d.ts.map +1 -1
  156. package/dist/schemas/devices.js +16 -1
  157. package/dist/schemas/devices.js.map +1 -1
  158. package/dist/schemas/environment.d.ts +48 -0
  159. package/dist/schemas/environment.d.ts.map +1 -1
  160. package/dist/schemas/environment.js +15 -0
  161. package/dist/schemas/environment.js.map +1 -1
  162. package/dist/schemas/extension-updates.d.ts +24 -0
  163. package/dist/schemas/extension-updates.d.ts.map +1 -1
  164. package/dist/schemas/git/git.d.ts +13 -0
  165. package/dist/schemas/git/git.d.ts.map +1 -1
  166. package/dist/schemas/git/git.js +11 -1
  167. package/dist/schemas/git/git.js.map +1 -1
  168. package/dist/schemas/hosts.d.ts +2 -0
  169. package/dist/schemas/hosts.d.ts.map +1 -1
  170. package/dist/schemas/hosts.js +2 -1
  171. package/dist/schemas/hosts.js.map +1 -1
  172. package/dist/schemas/keep-warm.d.ts +4 -0
  173. package/dist/schemas/keep-warm.d.ts.map +1 -1
  174. package/dist/schemas/keep-warm.js +15 -5
  175. package/dist/schemas/keep-warm.js.map +1 -1
  176. package/dist/schemas/loopback-catch.d.ts +33 -0
  177. package/dist/schemas/loopback-catch.d.ts.map +1 -0
  178. package/dist/schemas/loopback-catch.js +20 -0
  179. package/dist/schemas/loopback-catch.js.map +1 -0
  180. package/dist/schemas/maintenance.js +1 -1
  181. package/dist/schemas/maintenance.js.map +1 -1
  182. package/dist/schemas/needs.d.ts +3 -0
  183. package/dist/schemas/needs.d.ts.map +1 -1
  184. package/dist/schemas/needs.js +10 -3
  185. package/dist/schemas/needs.js.map +1 -1
  186. package/dist/schemas/personas.d.ts.map +1 -1
  187. package/dist/schemas/personas.js +6 -3
  188. package/dist/schemas/personas.js.map +1 -1
  189. package/dist/schemas/providers/provider-oauth.d.ts +87 -0
  190. package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
  191. package/dist/schemas/providers/provider-oauth.js +20 -0
  192. package/dist/schemas/providers/provider-oauth.js.map +1 -1
  193. package/dist/schemas/secrets.d.ts +66 -0
  194. package/dist/schemas/secrets.d.ts.map +1 -1
  195. package/dist/schemas/secrets.js +50 -0
  196. package/dist/schemas/secrets.js.map +1 -1
  197. package/dist/schemas/settings-history.d.ts +1 -1
  198. package/dist/schemas/settings-history.d.ts.map +1 -1
  199. package/dist/schemas/settings-history.js +1 -0
  200. package/dist/schemas/settings-history.js.map +1 -1
  201. package/dist/schemas/settings.d.ts +42 -2
  202. package/dist/schemas/settings.d.ts.map +1 -1
  203. package/dist/schemas/settings.js +26 -4
  204. package/dist/schemas/settings.js.map +1 -1
  205. package/dist/schemas/terminal.d.ts +2 -3
  206. package/dist/schemas/terminal.d.ts.map +1 -1
  207. package/dist/schemas/terminal.js +12 -4
  208. package/dist/schemas/terminal.js.map +1 -1
  209. package/dist/schemas/webext.d.ts +3 -0
  210. package/dist/schemas/webext.d.ts.map +1 -1
  211. package/dist/schemas/webext.js +1 -0
  212. package/dist/schemas/webext.js.map +1 -1
  213. package/dist/schemas/workflows.js +3 -3
  214. package/dist/schemas/workflows.js.map +1 -1
  215. package/dist/schemas/workspace/workspace-tree.d.ts +2 -0
  216. package/dist/schemas/workspace/workspace-tree.d.ts.map +1 -1
  217. package/dist/schemas/workspace/workspace-tree.js +4 -0
  218. package/dist/schemas/workspace/workspace-tree.js.map +1 -1
  219. package/dist/state/definition.d.ts +12 -4
  220. package/dist/state/definition.d.ts.map +1 -1
  221. package/dist/state/history-state.d.ts.map +1 -1
  222. package/dist/state/history-state.js +7 -0
  223. package/dist/state/history-state.js.map +1 -1
  224. package/dist/state/runtime-state.d.ts +0 -3
  225. package/dist/state/runtime-state.d.ts.map +1 -1
  226. package/dist/state/runtime-state.js +0 -1
  227. package/dist/state/runtime-state.js.map +1 -1
  228. package/dist/state/workspace-state.d.ts +0 -5
  229. package/dist/state/workspace-state.d.ts.map +1 -1
  230. package/dist/state/workspace-state.js +0 -6
  231. package/dist/state/workspace-state.js.map +1 -1
  232. package/dist/text/documents.d.ts +3 -0
  233. package/dist/text/documents.d.ts.map +1 -1
  234. package/dist/text/documents.js +14 -0
  235. package/dist/text/documents.js.map +1 -1
  236. package/dist/text/transcript-fold.d.ts +4 -3
  237. package/dist/text/transcript-fold.d.ts.map +1 -1
  238. package/dist/text/transcript-fold.js +137 -31
  239. package/dist/text/transcript-fold.js.map +1 -1
  240. package/dist/webext/index.d.ts +1 -0
  241. package/dist/webext/index.d.ts.map +1 -1
  242. package/dist/webext/index.js +1 -0
  243. package/dist/webext/index.js.map +1 -1
  244. package/package.json +5 -5
  245. package/src/chores/chores.ts +2 -2
  246. package/src/chores/probes.ts +1 -1
  247. package/src/contracts/accounts.contract.ts +12 -0
  248. package/src/contracts/agents.contract.ts +13 -13
  249. package/src/contracts/device.contract.ts +5 -0
  250. package/src/contracts/endpoints.contract.ts +20 -4
  251. package/src/contracts/secrets.contract.ts +28 -0
  252. package/src/contracts/settings.contract.ts +29 -0
  253. package/src/contracts/system.contract.ts +5 -14
  254. package/src/contracts/translator.contract.ts +2 -1
  255. package/src/contracts/webext.contract.ts +7 -1
  256. package/src/contracts/workspace.contract.ts +0 -37
  257. package/src/events/agent-events.ts +18 -1
  258. package/src/events/agent-words.ts +4 -1
  259. package/src/events/land-breakage.ts +3 -4
  260. package/src/events/resume.test.ts +13 -2
  261. package/src/events/resume.ts +25 -12
  262. package/src/events/sandbox-notice.ts +92 -0
  263. package/src/events/system-events.ts +7 -0
  264. package/src/events/transcript.ts +7 -1
  265. package/src/events/verify-nudge.ts +3 -3
  266. package/src/ids/conversation-ids.test.ts +2 -26
  267. package/src/ids/conversation-ids.ts +6 -12
  268. package/src/ids/project-dir.fixture.json +65 -0
  269. package/src/ids/project-dir.test.ts +60 -0
  270. package/src/ids/project-dir.ts +70 -0
  271. package/src/index.ts +12 -4
  272. package/src/models/agent-catalog.test.ts +7 -1
  273. package/src/models/agent-runtimes.ts +7 -5
  274. package/src/models/model-roles.ts +1 -9
  275. package/src/policy/command-classes.test.ts +39 -0
  276. package/src/policy/command-classes.ts +70 -3
  277. package/src/policy/safety-policy.ts +10 -0
  278. package/src/policy/secret-hosts.test.ts +89 -0
  279. package/src/policy/secret-hosts.ts +73 -0
  280. package/src/policy/turned-away.ts +1 -0
  281. package/src/protocol/raw-routes.ts +5 -1
  282. package/src/protocol/routes.test.ts +6 -6
  283. package/src/protocol/routes.ts +2 -9
  284. package/src/protocol/stream-of.ts +13 -0
  285. package/src/schemas/activity.ts +1 -1
  286. package/src/schemas/agent.ts +4 -1
  287. package/src/schemas/agents.ts +58 -4
  288. package/src/schemas/automations.ts +2 -2
  289. package/src/schemas/capabilities.ts +44 -7
  290. package/src/schemas/ci.ts +56 -11
  291. package/src/schemas/context-trim.ts +11 -6
  292. package/src/schemas/devices.ts +38 -2
  293. package/src/schemas/environment.ts +39 -0
  294. package/src/schemas/git/git.ts +20 -2
  295. package/src/schemas/hosts.ts +4 -1
  296. package/src/schemas/keep-warm.ts +19 -6
  297. package/src/schemas/loopback-catch.ts +45 -0
  298. package/src/schemas/maintenance.ts +1 -1
  299. package/src/schemas/needs.ts +12 -3
  300. package/src/schemas/personas.ts +8 -3
  301. package/src/schemas/providers/provider-oauth.ts +35 -0
  302. package/src/schemas/secrets.ts +70 -0
  303. package/src/schemas/settings-history.ts +2 -0
  304. package/src/schemas/settings.test.ts +19 -1
  305. package/src/schemas/settings.ts +58 -9
  306. package/src/schemas/speaker.ts +5 -5
  307. package/src/schemas/terminal.ts +19 -6
  308. package/src/schemas/webext.ts +3 -0
  309. package/src/schemas/workflows.ts +5 -5
  310. package/src/schemas/workspace/workspace-tree.ts +7 -0
  311. package/src/state/fix-attempt-plan.test.ts +1 -1
  312. package/src/state/fix-stance.ts +1 -1
  313. package/src/state/history-state.ts +14 -0
  314. package/src/state/runtime-state.ts +0 -3
  315. package/src/state/workspace-state.ts +0 -6
  316. package/src/text/documents.test.ts +25 -1
  317. package/src/text/documents.ts +24 -0
  318. package/src/text/title.ts +1 -1
  319. package/src/text/transcript-fold.test.ts +218 -25
  320. package/src/text/transcript-fold.ts +166 -45
  321. package/src/webext/index.ts +1 -0
  322. package/dist/schemas/workspace/push-checks.d.ts +0 -222
  323. package/dist/schemas/workspace/push-checks.d.ts.map +0 -1
  324. package/dist/schemas/workspace/push-checks.js +0 -97
  325. package/dist/schemas/workspace/push-checks.js.map +0 -1
  326. package/dist/state/red-streak.d.ts +0 -7
  327. package/dist/state/red-streak.d.ts.map +0 -1
  328. package/dist/state/red-streak.js +0 -11
  329. package/dist/state/red-streak.js.map +0 -1
  330. package/src/schemas/workspace/push-checks.test.ts +0 -28
  331. package/src/schemas/workspace/push-checks.ts +0 -155
  332. package/src/state/red-streak.test.ts +0 -28
  333. package/src/state/red-streak.ts +0 -28
@@ -7,8 +7,6 @@ import {
7
7
  latestFixAttempt,
8
8
  newConversationId,
9
9
  nextFixAttemptId,
10
- PUSH_FIX_PREFIX,
11
- pushFixConversationId,
12
10
  } from "./conversation-ids.js";
13
11
  import { ConversationIdSchema } from "../schemas/agent.js";
14
12
 
@@ -90,28 +88,6 @@ test("the prefix is carried by every fix id", () => {
90
88
  expect(ciFixConversationId(`ci-fix`, 7)).toBe(`ci-fix-ci-fix-7`);
91
89
  });
92
90
 
93
- test("a push fix id is the same string every time the same failure derives it", () => {
94
- expect(pushFixConversationId(`intentic`, `checkout gates,lint`)).toBe(pushFixConversationId(`intentic`, `checkout gates,lint`));
95
- expect(pushFixConversationId(`intentic`, `checkout gates,lint`)).toMatch(/^push-fix-intentic-[0-9a-z]{7}$/);
96
- });
97
-
98
- test("a push fix id separates failures and scopes", () => {
99
- expect(pushFixConversationId(`intentic`, `lint`)).not.toBe(pushFixConversationId(`intentic`, `checkout gates`));
100
- expect(pushFixConversationId(`web`, `lint`)).not.toBe(pushFixConversationId(`api`, `lint`));
101
- });
102
-
103
- // Signatures cover raw gate output: control characters, unicode, path-like strings, and pathological lengths.
104
- test("every derived push fix id passes the conversation-id guard", () => {
105
- const signatures = [``, `checkout gates`, `✗ lint · pnpm lint`, `a`.repeat(4_000), `../../etc/passwd`, `a\nb\tc`, `résumé`];
106
- for (const scope of [`root`, `apps/web`, `my repo`, `___`, `x`.repeat(80)]) {
107
- for (const signature of signatures) {
108
- const id = pushFixConversationId(scope, signature);
109
- expect(ConversationIdSchema.safeParse(id).success).toBe(true);
110
- expect(id.startsWith(PUSH_FIX_PREFIX)).toBe(true);
111
- }
112
- }
113
- });
114
-
115
91
  // Attempt 1 is the bare derived id, so every fix conversation minted before attempts existed reads as attempt 1.
116
92
  test("the first attempt wears the failure's own id, later ones carry their number", () => {
117
93
  const base = ciFixConversationId(`web`, 41);
@@ -136,8 +112,8 @@ test("another failure's id is not an attempt at this one", () => {
136
112
  });
137
113
 
138
114
  test("attempts are read off the roster, earliest first, and the newest is the live answer", () => {
139
- const base = pushFixConversationId(`intentic`, `lint`);
140
- const roster = [{ id: `swift-otter-k9m2` }, { id: fixAttemptId(base, 3) }, { id: base }, { id: pushFixConversationId(`intentic`, `typecheck`) }];
115
+ const base = ciFixConversationId(`intentic`, 7);
116
+ const roster = [{ id: `swift-otter-k9m2` }, { id: fixAttemptId(base, 3) }, { id: base }, { id: ciFixConversationId(`intentic`, 8) }];
141
117
  expect(fixAttemptsOf(base, roster).map((entry) => entry.attempt)).toEqual([1, 3]);
142
118
  expect(latestFixAttempt(base, roster)?.agent.id).toBe(fixAttemptId(base, 3));
143
119
  expect(latestFixAttempt(base, [])).toBeUndefined();
@@ -160,13 +160,9 @@ const repoSlug = (repo: string): string =>
160
160
  .replace(/^-+|-+$/g, "") || "repo";
161
161
  export const ciFixConversationId = (repo: string, runId: number): string => `${CI_FIX_PREFIX}${repoSlug(repo)}-${runId}`;
162
162
 
163
- // Keyed by when the project's push red began (pushFixBase, `red:<since>`), not a run id (a push has none): a refused push
164
- // is owed by that red too (push-checks-store.ts). Hashed rather than spelled into the branch name; the fix agent's first
165
- // message names what it is for.
166
- export const PUSH_FIX_PREFIX = "push-fix-";
167
-
168
163
  // FNV-1a, not a cryptographic hash: nothing here is secret, the only requirement is the same failure yields the same
169
- // seven characters in every browser and in node. The one spelling: a push finding's id (push-checks-store.ts) is this too.
164
+ // seven characters in every browser and in node. The one spelling: what a failing main owes is named by it too (a
165
+ // failed job's id, ci/main-fixer.ts).
170
166
  const FNV_OFFSET = 0x811c_9dc5;
171
167
  const FNV_PRIME = 0x0100_0193;
172
168
  export const fnvDigest = (text: string): string => {
@@ -177,14 +173,12 @@ export const fnvDigest = (text: string): string => {
177
173
  return (hash >>> 0).toString(36).padStart(7, "0");
178
174
  };
179
175
 
180
- export const pushFixConversationId = (scope: string, signature: string): string => `${PUSH_FIX_PREFIX}${repoSlug(scope)}-${fnvDigest(signature)}`;
181
-
182
- // A red main-line check that no conversation still holding the work could take: keyed by the project and the moment its
183
- // red streak began, so every attempt at one streak shares a base and a later streak in the same project starts over.
176
+ // A failing main-line check that no conversation still holding the work could take: keyed by the project and the moment
177
+ // it began failing, so every attempt at one streak shares a base and a later streak in the same project starts over.
184
178
  // Seconds in base36 keep it short; the workspace root is spelled `workspace`, since its project folder is empty.
185
179
  export const LAND_FIX_PREFIX = "land-fix-";
186
- export const landFixConversationId = (project: string, redSince: number): string =>
187
- `${LAND_FIX_PREFIX}${repoSlug(project === "" ? "workspace" : project)}-${Math.floor(redSince / 1000).toString(36)}`;
180
+ export const landFixConversationId = (project: string, failingSince: number): string =>
181
+ `${LAND_FIX_PREFIX}${repoSlug(project === "" ? "workspace" : project)}-${Math.floor(failingSince / 1000).toString(36)}`;
188
182
 
189
183
  // ONE FAILURE, MANY ATTEMPTS, ONE LIVE ANSWER. The derived id above names the FAILURE; an attempt at it is a
190
184
  // conversation of its own, so starting over (another model, a clean worktree) never rewrites a record some other window
@@ -0,0 +1,65 @@
1
+ {
2
+ "names": [
3
+ { "name": "my-app", "valid": true },
4
+ { "name": "My_App.v2", "valid": true },
5
+ { "name": "a", "valid": true },
6
+ { "name": "0", "valid": true },
7
+ { "name": "Public", "valid": true },
8
+ { "name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "valid": true },
9
+ { "name": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "valid": false },
10
+ { "name": "public", "valid": false },
11
+ { "name": "refs", "valid": false },
12
+ { "name": "site", "valid": false },
13
+ { "name": "root", "valid": false },
14
+ { "name": "intent", "valid": false },
15
+ { "name": "desired-state", "valid": false },
16
+ { "name": "app", "valid": false },
17
+ { "name": "AGENTS.md", "valid": false },
18
+ { "name": "node_modules", "valid": false },
19
+ { "name": "dist", "valid": false },
20
+ { "name": "venv", "valid": false },
21
+ { "name": "claude.json", "valid": false },
22
+ { "name": "__pycache__", "valid": false },
23
+ { "name": ".intentic", "valid": false },
24
+ { "name": ".hidden", "valid": false },
25
+ { "name": "-flag", "valid": false },
26
+ { "name": "_under", "valid": false },
27
+ { "name": "", "valid": false },
28
+ { "name": "a/b", "valid": false },
29
+ { "name": "..", "valid": false },
30
+ { "name": "has space", "valid": false },
31
+ { "name": "café", "valid": false },
32
+ { "name": "my-app/", "valid": false }
33
+ ],
34
+ "folders": [
35
+ { "folder": "my-app", "dirName": "my-app" },
36
+ { "folder": "My App", "dirName": "My-App" },
37
+ { "folder": "My App!!", "dirName": "My-App" },
38
+ { "folder": "Café Menu", "dirName": "Cafe-Menu" },
39
+ { "folder": "日本語", "dirName": "project" },
40
+ { "folder": "", "dirName": "project" },
41
+ { "folder": "...", "dirName": "project" },
42
+ { "folder": ".config", "dirName": "config" },
43
+ { "folder": "--flag--", "dirName": "flag" },
44
+ { "folder": "_private", "dirName": "private" },
45
+ { "folder": "public", "dirName": "public-project" },
46
+ { "folder": "site", "dirName": "site-project" },
47
+ { "folder": "app", "dirName": "app-project" },
48
+ { "folder": "AGENTS.md", "dirName": "AGENTS.md-project" },
49
+ { "folder": "dist", "dirName": "dist-project" },
50
+ { "folder": "node_modules", "dirName": "node_modules-project" },
51
+ { "folder": "__pycache__", "dirName": "pycache__" },
52
+ { "folder": "a--b", "dirName": "a-b" },
53
+ { "folder": "report (final) v2", "dirName": "report-final-v2" },
54
+ {
55
+ "folder": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
56
+ "dirName": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
57
+ },
58
+ {
59
+ "folder": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa b",
60
+ "dirName": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
61
+ },
62
+ { "folder": "C++ Project", "dirName": "C-Project" },
63
+ { "folder": "release.2024.", "dirName": "release.2024" }
64
+ ]
65
+ }
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { MEMORY_FILE, PUBLIC_DIR, REFERENCE_DIR, WORKSPACE_ROOT } from "@intentic/constants";
3
+ import { STARTER_REPO } from "../state/starter.js";
4
+ import { isProjectDirName, projectDirNameFor, projectDirNameOf, projectRemoteDir, RESERVED_PROJECT_DIR_NAMES } from "./project-dir.js";
5
+
6
+ // The cases `ic` is held to as well (_sandbox/ic/src/sandbox/project_dir.rs), read from the one file both run.
7
+ interface ProjectDirCases {
8
+ readonly names: readonly { readonly name: string; readonly valid: boolean }[];
9
+ readonly folders: readonly { readonly folder: string; readonly dirName: string }[];
10
+ }
11
+ // SAFETY: the fixture is this repository's own file, written to this shape; one that drifted fails every case below.
12
+ const CASES = JSON.parse(readFileSync(new URL("./project-dir.fixture.json", import.meta.url), "utf8")) as ProjectDirCases;
13
+
14
+ test.each([...CASES.names])("the shared name case $name is valid: $valid", ({ name, valid }) => {
15
+ expect(isProjectDirName(name)).toBe(valid);
16
+ });
17
+
18
+ test.each([...CASES.folders])("the folder $folder syncs into $dirName", ({ folder, dirName }) => {
19
+ expect(projectDirNameFor(folder)).toBe(dirName);
20
+ });
21
+
22
+ test("every folder name the slug produces is one the validation accepts, and slugging it again changes nothing", () => {
23
+ for (const { folder } of CASES.folders) {
24
+ const name = projectDirNameFor(folder);
25
+ expect(isProjectDirName(name)).toBe(true);
26
+ expect(projectDirNameFor(name)).toBe(name);
27
+ }
28
+ });
29
+
30
+ test("the reserved names are the ones the daemon keeps at the workspace root", () => {
31
+ expect([...RESERVED_PROJECT_DIR_NAMES].toSorted()).toEqual(
32
+ [
33
+ PUBLIC_DIR,
34
+ REFERENCE_DIR,
35
+ STARTER_REPO,
36
+ MEMORY_FILE,
37
+ "root",
38
+ "intent",
39
+ "desired-state",
40
+ "app",
41
+ "node_modules",
42
+ "dist",
43
+ "venv",
44
+ "claude.json",
45
+ ].toSorted(),
46
+ );
47
+ });
48
+
49
+ test("a project dir is exactly one valid name under the workspace root", () => {
50
+ expect(projectRemoteDir("my-app")).toBe(`${WORKSPACE_ROOT}/my-app`);
51
+ expect(projectDirNameOf(`${WORKSPACE_ROOT}/my-app`)).toBe("my-app");
52
+ expect(projectDirNameOf(WORKSPACE_ROOT)).toBeUndefined();
53
+ expect(projectDirNameOf(`${WORKSPACE_ROOT}/`)).toBeUndefined();
54
+ expect(projectDirNameOf(`${WORKSPACE_ROOT}/my-app/`)).toBeUndefined();
55
+ expect(projectDirNameOf(`${WORKSPACE_ROOT}/my-app/src`)).toBeUndefined();
56
+ expect(projectDirNameOf(`${WORKSPACE_ROOT}/public`)).toBeUndefined();
57
+ expect(projectDirNameOf(`${WORKSPACE_ROOT}/../etc`)).toBeUndefined();
58
+ expect(projectDirNameOf("/workspace/my-app")).toBeUndefined();
59
+ expect(projectDirNameOf("/srv/work/my-app", "/srv/work")).toBe("my-app");
60
+ });
@@ -0,0 +1,70 @@
1
+ import { MEMORY_FILE, PUBLIC_DIR, REFERENCE_DIR, WORKSPACE_ROOT } from "@intentic/constants";
2
+ import { STARTER_REPO } from "../state/starter.js";
3
+
4
+ // A PROJECT SANDBOX is made for one folder on the owner's computer, synced live into `/work/<name>` rather than into
5
+ // /work itself: the workspace root is the daemon's own, where it keeps its state, seeds its starter repo and serves
6
+ // `public/` to anyone with a link. `<name>` is derived from the folder's name once, on the owner's side, and every
7
+ // later reader (the sync agent, `ic`, the daemon) only validates it. `_sandbox/ic/src/sandbox/project_dir.rs` holds the
8
+ // validation again for the host side; `project-dir.fixture.json` is the one set of cases both run.
9
+
10
+ // Top-level names the daemon treats as its own, so a project can never land on one: the public outbox, the reference
11
+ // shelf, the starter repo seeded on first boot, the root repo's id and the three repo roles repo discovery refuses
12
+ // (`_sandbox/sandbox/src/workspace/layout/repo-discovery.ts`), and the standing-instructions file it writes. Then the
13
+ // names every walk of the workspace skips at any depth (@intentic/workspace-ignore IGNORED_DIRS, and the private file
14
+ // the git scopes leave out), where a project would sync and never be seen; the daemon's tests hold this list to that
15
+ // one. Everything else of either kind starts with a dot (`.intentic`, `.venv`), which the name rule below refuses.
16
+ export const RESERVED_PROJECT_DIR_NAMES: readonly string[] = [
17
+ PUBLIC_DIR,
18
+ REFERENCE_DIR,
19
+ STARTER_REPO,
20
+ "root",
21
+ "intent",
22
+ "desired-state",
23
+ "app",
24
+ MEMORY_FILE,
25
+ "node_modules",
26
+ "dist",
27
+ "venv",
28
+ "claude.json",
29
+ ];
30
+
31
+ // Starts alphanumeric, so no dot-file and no leading dash an argv could read as a flag; no separator, so it is always
32
+ // one path segment; bounded, since it also names a git dir and a folder in every view of the workspace.
33
+ const PROJECT_DIR_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
34
+ const PROJECT_DIR_NAME_MAX = 64;
35
+
36
+ // Used when nothing of the folder's name survives the slug (a name in another script, or only punctuation).
37
+ const FALLBACK_NAME = "project";
38
+
39
+ // Combining marks, which NFKD splits off an accented letter.
40
+ const COMBINING_MARKS = /[\u0300-\u036f]/g;
41
+
42
+ export const isProjectDirName = (name: string): boolean => PROJECT_DIR_NAME.test(name) && !RESERVED_PROJECT_DIR_NAMES.includes(name);
43
+
44
+ // The workspace-relative name for a folder the owner picked, always one `isProjectDirName` accepts. Accents are folded
45
+ // to their base letter first, so `Café` keeps its `e` rather than losing it to a dash.
46
+ export const projectDirNameFor = (folderBasename: string): string => {
47
+ const slug = folderBasename
48
+ .normalize("NFKD")
49
+ .replace(COMBINING_MARKS, "")
50
+ .replace(/[^A-Za-z0-9._-]+/g, "-")
51
+ .replace(/-{2,}/g, "-")
52
+ .replace(/^[._-]+/, "")
53
+ .slice(0, PROJECT_DIR_NAME_MAX)
54
+ .replace(/[.-]+$/, "");
55
+ const name = slug === "" ? FALLBACK_NAME : slug;
56
+ return RESERVED_PROJECT_DIR_NAMES.includes(name) ? `${name}-project` : name;
57
+ };
58
+
59
+ // Where a project folder lands in the sandbox, and the name back out of such a path: exactly `<root>/<name>`, anything
60
+ // else (a deeper path, a trailing slash, a reserved name) is not a project dir.
61
+ export const projectRemoteDir = (name: string): string => `${WORKSPACE_ROOT}/${name}`;
62
+
63
+ export const projectDirNameOf = (remoteDir: string, root: string = WORKSPACE_ROOT): string | undefined => {
64
+ const prefix = `${root}/`;
65
+ if (!remoteDir.startsWith(prefix)) {
66
+ return undefined;
67
+ }
68
+ const name = remoteDir.slice(prefix.length);
69
+ return isProjectDirName(name) ? name : undefined;
70
+ };
package/src/index.ts CHANGED
@@ -58,6 +58,8 @@ export { choresContract } from "./contracts/chores.contract.js";
58
58
  export { ciContract } from "./contracts/ci.contract.js";
59
59
  export {
60
60
  endpointsContract,
61
+ type LocalModelDevice,
62
+ LocalModelDeviceSchema,
61
63
  LocalModelFitSchema,
62
64
  type LocalModelFitResponse,
63
65
  type LocalModelGpu,
@@ -111,6 +113,7 @@ export * from "./events/agent-events.js";
111
113
  export * from "./events/agent-words.js";
112
114
  export * from "./events/requests.js";
113
115
  export * from "./events/resume.js";
116
+ export * from "./events/sandbox-notice.js";
114
117
  export * from "./events/system-events.js";
115
118
  export * from "./events/transcript.js";
116
119
  export * from "./events/land-breakage.js";
@@ -139,7 +142,6 @@ export * from "./state/history-state.js";
139
142
  export * from "./state/fix-stance.js";
140
143
  export * from "./state/landed-commit.js";
141
144
  export * from "./state/fix-attempt-plan.js";
142
- export * from "./state/red-streak.js";
143
145
  // Provider vocabulary bottom-up: agent-runtimes, provider-specs, agent-catalog; three modules since the dependency
144
146
  // points one way.
145
147
  export * from "./models/agent-runtimes.js";
@@ -152,6 +154,7 @@ export * from "./policy/safety-policy.js";
152
154
  export * from "./policy/reserved-servers.js";
153
155
  export * from "./policy/credential-material.js";
154
156
  export * from "./policy/capability-secrets.js";
157
+ export * from "./policy/secret-hosts.js";
155
158
  export * from "./ids/conversation-ids.js";
156
159
  export * from "./text/documents.js";
157
160
  export * from "./protocol/host-protocol.js";
@@ -161,6 +164,7 @@ export * from "./protocol/runner-protocol.js";
161
164
  export * from "./protocol/listener-protocol.js";
162
165
  export * from "./protocol/container-requirements.js";
163
166
  export * from "./ids/hostnames.js";
167
+ export * from "./ids/project-dir.js";
164
168
  export * from "./policy/overlay-lint.js";
165
169
  export * from "./models/model-label.js";
166
170
  export * from "./models/model-order.js";
@@ -200,6 +204,7 @@ export * from "./schemas/git/git.js";
200
204
  export * from "./schemas/git/git-history.js";
201
205
  export * from "./schemas/history.js";
202
206
  export * from "./schemas/hosts.js";
207
+ export * from "./schemas/loopback-catch.js";
203
208
  export * from "./schemas/intentic.js";
204
209
  export * from "./schemas/inventory.js";
205
210
  export * from "./schemas/issues.js";
@@ -239,7 +244,6 @@ export * from "./schemas/webext.js";
239
244
  export * from "./schemas/workflows.js";
240
245
  export * from "./schemas/workspace/workspace-repos.js";
241
246
  export * from "./schemas/workspace/workspace-search.js";
242
- export * from "./schemas/workspace/push-checks.js";
243
247
  export * from "./schemas/workspace/workspace-setup.js";
244
248
  export * from "./schemas/workspace/workspace-tree.js";
245
249
  export * from "./state/arrival.js";
@@ -308,8 +312,12 @@ export const SANDBOX_ROUTES: readonly ContractRoute[] = contractRoutes(sandboxCo
308
312
  export const SANDBOX_ROUTE_NAMES: readonly string[] = SANDBOX_ROUTES.map((route) => route.name);
309
313
 
310
314
  // Each route's shape, for the failure names alone can't describe: a route both builds have, but whose payload only one
311
- // expects. Computed once at load, since it's too expensive to repeat per connection.
312
- export const SANDBOX_ROUTE_SHAPES: Readonly<Record<string, string>> = routeShapes(sandboxContract);
315
+ // expects. Computed on the first ask and kept, since it's too expensive to repeat per connection — and too expensive to
316
+ // pay at load: turning every route's schemas into JSON Schema was a third of a second of a phone's startup at 4× CPU
317
+ // throttle, for a comparison a browser only makes when it has a disagreement to explain (the version card). The daemon
318
+ // asks on its first hello.
319
+ let fingerprints: Readonly<Record<string, string>> | undefined;
320
+ export const sandboxRouteFingerprints = (): Readonly<Record<string, string>> => (fingerprints ??= routeShapes(sandboxContract));
313
321
 
314
322
  // The contract route a concrete browser request belongs to, bound to this build's route table.
315
323
  export const sandboxRouteName = (method: string, pathWithQuery: string): string | undefined =>
@@ -126,6 +126,9 @@ test("the ceiling has nothing to disclose; a floor names what it lacks", () => {
126
126
  expect(grokCaps.steering).toBe(false);
127
127
  expect(grokCaps.isolation).toBe("cwd");
128
128
  expect(limitationsOf(grokCaps).length).toBeGreaterThan(0);
129
+ // The turn's remote servers, as Codex takes them: mounted on the shared OpenCode server per conversation.
130
+ expect(grokCaps.mcp).toBe("http");
131
+ expect(limitationsOf(grokCaps)).toContain("MCP servers only, none of the daemon's own tools, no plugins");
129
132
 
130
133
  const acpCaps = capabilitiesOf("some-installed-agent", "native");
131
134
  expect(acpCaps.mcp).toBe("http");
@@ -178,7 +181,10 @@ test("the safety axes disclose the middle answer differently from the floor", ()
178
181
 
179
182
  expect(codexCaps.rulebook).toBe("approval");
180
183
  expect(piCaps.rulebook).toBe("none");
181
- expect(grokCaps.rulebook).toBe("refuse-only");
184
+ expect(grokCaps.rulebook).toBe("approval");
185
+ // A hold parks on a card now that the turn's watchdog is held while it waits, so nothing says it cannot hold.
186
+ expect(limitationsOf(grokCaps)).toContain("command rules apply only to calls this agent raises");
187
+ expect(limitationsOf(grokCaps)).not.toContain("command rules can refuse but not hold");
182
188
 
183
189
  expect(limitationsOf(claudeCaps).join(" ")).not.toContain("command rules");
184
190
  expect(limitationsOf(claudeCaps).join(" ")).not.toContain("stored secrets");
@@ -102,14 +102,15 @@ export const CODEX: AgentCapabilities = {
102
102
  secrets: "none",
103
103
  };
104
104
 
105
- // OpenCode (the Grok runtime): its own agentic loop, its own tools, allow-all permissions. Takes a model id, a prompt
106
- // and one system message of ours; no effort scale, no tools of ours, no command list.
105
+ // OpenCode (the Grok runtime): its own agentic loop and tools, with the turn's remote MCP servers mounted on the shared
106
+ // server per conversation. Takes a model id, a prompt and one system message of ours; no effort scale, no command list.
107
107
  export const OPENCODE: AgentCapabilities = {
108
108
  runtime: "opencode",
109
109
  steering: false,
110
110
  permissions: "plan",
111
111
  questions: false,
112
- mcp: "none",
112
+ // Added at runtime under the conversation's own names, each prompt showing its session only its own (opencode-mcp.ts).
113
+ mcp: "http",
113
114
  execution: ["shell"],
114
115
  effort: false,
115
116
  fastMode: false,
@@ -121,8 +122,9 @@ export const OPENCODE: AgentCapabilities = {
121
122
  // `system` on the prompt body, per message; adds to OpenCode's own prompt, with no seam to replace it.
122
123
  instructions: "append",
123
124
  skillDiscovery: "prompt",
124
- // refuse-only for its inactivity watchdog, not its protocol: a paused approval reads as a stalled turn.
125
- rulebook: "refuse-only",
125
+ // OpenCode raises its permission asks for the commands its config names; the daemon answers from the rulebook, a
126
+ // hold parked on a card while the turn's inactivity watchdog is held.
127
+ rulebook: "approval",
126
128
  secrets: "none",
127
129
  };
128
130
 
@@ -71,7 +71,7 @@ export const MODEL_ROLES = [
71
71
  {
72
72
  id: "pipeline-fix",
73
73
  label: "Pipeline fixes",
74
- blurb: "The agent started by Fix on a red pipeline.",
74
+ blurb: "The agent started by Fix on a failed pipeline.",
75
75
  kind: "run",
76
76
  trigger: "pressed",
77
77
  icon: "wave-pulse",
@@ -108,14 +108,6 @@ export const MODEL_ROLES = [
108
108
  trigger: "pressed",
109
109
  icon: "list-check",
110
110
  },
111
- {
112
- id: "pre-push-fix",
113
- label: "Pre-push fixes",
114
- blurb: "The fix proposed when a check fails on the way to a push.",
115
- kind: "run",
116
- trigger: "pressed",
117
- icon: "cloud-upload",
118
- },
119
111
  {
120
112
  // Pressed trigger: the owner's approval click starts this turn; the queue between is just machinery.
121
113
  id: "approval-queue",
@@ -40,6 +40,22 @@ describe("git.destructive", () => {
40
40
  test("a -f belonging to the next command in a pipeline is not a force-push", () => {
41
41
  expect(classify("git push origin main | grep -f patterns.txt")).not.toContain("git.destructive");
42
42
  });
43
+
44
+ test("deleting a checkout's .git, or re-initialising the folder it stands in, cuts it off from its history", () => {
45
+ for (const command of ["rm .git", "rm -f .git", "rm -rf ./.git", "rm -rf tabularium/.git", "rm .git && git init -b main"]) {
46
+ expect(classify(command), command).toContain("git.destructive");
47
+ }
48
+ for (const command of ["git init", "git init .", "git init -b main", "git init --initial-branch main -q ."]) {
49
+ expect(classify(command), command).toContain("git.destructive");
50
+ }
51
+ expect(marked("cd /work && rm .git && git init -b main", "git.destructive")).toEqual(["rm .git", "git init -b main"]);
52
+ });
53
+
54
+ test("a new project's git init, and files that merely start with .git, are left alone", () => {
55
+ for (const command of ["git init new-project", "git init -b main tools/cli", "rm .gitignore", "rm -f .github/old.yml", "rm .gitmodules"]) {
56
+ expect(classify(command), command).not.toContain("git.destructive");
57
+ }
58
+ });
43
59
  });
44
60
 
45
61
  describe("files.destructive", () => {
@@ -72,6 +88,27 @@ describe("files.destructive", () => {
72
88
  }
73
89
  });
74
90
 
91
+ test("find deleting what it walks, or handing it to rm, is the same delete as rm -rf", () => {
92
+ for (const command of [
93
+ "find /home/x/PROJECT/cerberus-worktrees/m1-a -depth -delete",
94
+ "find build -name '*.o' -delete",
95
+ "find . -type d -name node_modules -exec rm -rf {} +",
96
+ "find dist -exec /bin/rm -f {} \\;",
97
+ "find tmp -execdir rm {} \\;",
98
+ "find . -name '*.log' -print0 | xargs -0 rm -f",
99
+ "ls old | xargs rm",
100
+ ]) {
101
+ expect(classify(command), command).toContain("files.destructive");
102
+ }
103
+ expect(marked("find build -name '*.o' -delete; echo done", "files.destructive")).toEqual(["find build -name '*.o' -delete"]);
104
+ });
105
+
106
+ test("a find that only lists, and an xargs that runs something else, pass", () => {
107
+ for (const command of ["find . -name '*.ts'", "find . -newer deleted.txt -print", "find src -exec grep -l rm {} +", "ls | xargs wc -l"]) {
108
+ expect(classify(command), command).not.toContain("files.destructive");
109
+ }
110
+ });
111
+
75
112
  test("a script that deletes one file, or nothing, is not recursive deletion", () => {
76
113
  for (const code of ['fs.unlinkSync("tmp.txt")', 'await fs.promises.rm("tmp.txt")', "const rmq = queue.rm(job)"]) {
77
114
  expect(classify(code), code).not.toContain("files.destructive");
@@ -113,6 +150,8 @@ describe("system.destructive", () => {
113
150
  "rm -rf C:\\",
114
151
  'fs.rmSync("/", { recursive: true, force: true })',
115
152
  'rimraf("/work")',
153
+ "find / -delete",
154
+ "find /home -type f -delete",
116
155
  ]) {
117
156
  expect(classify(command), command).toContain("system.destructive");
118
157
  }
@@ -375,6 +375,66 @@ const recursiveForceRms = (command: string): CommandSpan[] =>
375
375
  .filter((invocation) => invocation.recursive && invocation.force)
376
376
  .map((invocation) => invocation.span);
377
377
 
378
+ // The same tree delete spelled without `rm -rf`: `find` deleting what it walks, or handing each hit to `rm`, and a list
379
+ // piped into `rm` through xargs. An agent told `rm -rf` needs a card reaches for exactly these.
380
+ const FIND_INVOCATION = /\bfind\s+([^|;&\n]*)/g;
381
+ const FIND_DELETES = /\s(?:-delete\b|-exec(?:dir)?\s+(?:[^\s|;&]*\/)?rm\b)/;
382
+ const XARGS_RM = /\bxargs\b[^|;&\n]*?\s(?:[^\s|;&]*\/)?rm\b/g;
383
+
384
+ interface FindDelete {
385
+ // The paths find starts walking from: the words before its first expression (`-name`, `(`, `!`).
386
+ readonly starts: readonly string[];
387
+ readonly span: CommandSpan;
388
+ }
389
+
390
+ const parseFindDeletes = (command: string): FindDelete[] =>
391
+ [...command.matchAll(FIND_INVOCATION)].flatMap((invocation) => {
392
+ const args = invocation[1] as string;
393
+ if (!FIND_DELETES.test(` ${args}`)) {
394
+ return [];
395
+ }
396
+ const words = args.trim().split(/\s+/);
397
+ const expression = words.findIndex((word) => /^[-(!]/.test(word));
398
+ const starts = (expression === -1 ? words : words.slice(0, expression)).filter((word) => word !== "").map(unquote);
399
+ return [{ starts, span: { start: invocation.index, end: invocation.index + invocation[0].trimEnd().length } }];
400
+ });
401
+
402
+ const findAndXargsDeletes = (command: string): CommandSpan[] => [
403
+ ...parseFindDeletes(command).map((found) => found.span),
404
+ ...spansOf([XARGS_RM], command),
405
+ ];
406
+
407
+ // What cuts a checkout off from its repository's history: deleting its `.git` (for a worktree, a one-line pointer
408
+ // whose loss strands every commit the checkout was reading), or `git init` over the folder it stands in, which makes
409
+ // a standalone repository sharing nothing with the one it came from. A `git init <new-dir>` for a new project is not
410
+ // this, so only an init of the current folder counts.
411
+ const GIT_POINTER = /(?:^|[/\\])\.git[/\\]?$/;
412
+ const GIT_INIT = /\bgit\s+init\b([^|;&\n]*)/g;
413
+ const GIT_INIT_VALUED = /^(?:-b|--initial-branch|--template|--separate-git-dir|--object-format|--ref-format)$/;
414
+
415
+ const initsHere = (args: string): boolean => {
416
+ const words = args.trim().split(/\s+/).filter((word) => word !== "");
417
+ const operands: string[] = [];
418
+ for (let index = 0; index < words.length; index += 1) {
419
+ const word = words[index] as string;
420
+ if (GIT_INIT_VALUED.test(word)) {
421
+ index += 1;
422
+ } else if (!word.startsWith("-")) {
423
+ operands.push(unquote(word));
424
+ }
425
+ }
426
+ return operands.every((operand) => operand === "." || operand === "./");
427
+ };
428
+
429
+ const checkoutUnlinks = (command: string): CommandSpan[] => [
430
+ ...parseRm(command)
431
+ .filter((invocation) => invocation.operands.some((operand) => GIT_POINTER.test(operand)))
432
+ .map((invocation) => invocation.span),
433
+ ...[...command.matchAll(GIT_INIT)]
434
+ .filter((invocation) => initsHere(invocation[1] as string))
435
+ .map((invocation) => ({ start: invocation.index, end: invocation.index + invocation[0].trimEnd().length })),
436
+ ];
437
+
378
438
  // A recursive delete aimed at a root, in either spelling the gate is handed (shell or script). Which targets count as
379
439
  // roots is the locus's answer.
380
440
  const rootDeletes = (program: string, locus: CommandLocus): CommandSpan[] => [
@@ -384,6 +444,9 @@ const rootDeletes = (program: string, locus: CommandLocus): CommandSpan[] => [
384
444
  ...nodeDeleteTargets(program)
385
445
  .filter((delete_) => isRootTarget(delete_.target, locus))
386
446
  .map((delete_) => delete_.span),
447
+ ...parseFindDeletes(program)
448
+ .filter((found) => found.starts.some((start) => isRootTarget(start, locus)))
449
+ .map((found) => found.span),
387
450
  ];
388
451
 
389
452
  // The g twins, built once at load: a card is minted per command held, classify runs per command typed.
@@ -434,10 +497,10 @@ const credentialReads = (command: string, context: CommandContext): CommandSpan[
434
497
 
435
498
  // One function per class; empty means the command is not in it, so membership and evidence are the same walk.
436
499
  const MATCHES: Readonly<Record<CommandClass, (command: string, context: CommandContext) => CommandSpan[]>> = {
437
- "git.destructive": (command) => spansOf(GIT_DESTRUCTIVE_G, command),
500
+ "git.destructive": (command) => [...spansOf(GIT_DESTRUCTIVE_G, command), ...checkoutUnlinks(command)],
438
501
  // The only class that can answer "not here": in its own copy a conversation's checkout is its own to move.
439
502
  "git.branch-switch": (command, context) => (context.ownCheckout === true ? [] : spansOf(GIT_BRANCH_SWITCH_G, command)),
440
- "files.destructive": (command) => [...recursiveForceRms(command), ...recursiveDeletes(command)],
503
+ "files.destructive": (command) => [...recursiveForceRms(command), ...recursiveDeletes(command), ...findAndXargsDeletes(command)],
441
504
  "system.destructive": (command, context) => [...spansOf(BLOCK_DEVICE_G, command), ...rootDeletes(command, context.locus)],
442
505
  "container.state": (command) => spansOf(CONTAINER_STATE_G, command),
443
506
  "secrets.access": credentialReads,
@@ -489,6 +552,8 @@ export const COMMAND_CLASS_PATTERNS: Readonly<Record<CommandClass, readonly Comm
489
552
  { code: "git clean -f" },
490
553
  { code: "git branch -D" },
491
554
  { code: "git filter-branch" },
555
+ { code: "rm .git", qualifier: "any flags: a checkout's link to its history" },
556
+ { code: "git init", qualifier: "only in the current folder, which may already be a checkout" },
492
557
  ],
493
558
  "git.branch-switch": [
494
559
  { code: "git switch <branch>" },
@@ -498,6 +563,8 @@ export const COMMAND_CLASS_PATTERNS: Readonly<Record<CommandClass, readonly Comm
498
563
  { code: "rm -rf <path>" },
499
564
  { code: "fs.rm(<path>, { recursive: true })", qualifier: "also rmSync, rmdir, rmdirSync" },
500
565
  { code: "rimraf(<path>)" },
566
+ { code: "find <path> -delete", qualifier: "also -exec rm and -execdir rm" },
567
+ { code: "xargs rm" },
501
568
  ],
502
569
  "system.destructive": [
503
570
  { code: "mkfs" },
@@ -507,7 +574,7 @@ export const COMMAND_CLASS_PATTERNS: Readonly<Record<CommandClass, readonly Comm
507
574
  { code: "dd of=/dev/…" },
508
575
  { code: "shred /dev/…" },
509
576
  { code: "> /dev/sda" },
510
- { code: "rm -rf /", qualifier: "only when the target is a root, listed below" },
577
+ { code: "rm -rf /", qualifier: "also find / -delete; only when the target is a root, listed below" },
511
578
  ],
512
579
  "container.state": [
513
580
  { code: "docker volume rm", qualifier: "also remove, prune, and podman for any of these" },
@@ -64,6 +64,15 @@ export const COMMAND_RULE_CATALOG: readonly CommandRule[] = CommandClassSchema.o
64
64
  export const CommandJudgeModeSchema = z.enum(["off", "watch", "on"]);
65
65
  export type CommandJudgeMode = z.infer<typeof CommandJudgeModeSchema>;
66
66
 
67
+ // What an agent's own project-dependency install (`pnpm add`, `npm install`, `uv sync`) does, one answer for every
68
+ // turn. Where the install lands is the sandbox's business, not the owner's: an isolated turn installs into its own copy
69
+ // and a main-tree turn takes the install lane the daemon's own installs take. This only says whether a person is asked:
70
+ // - automatic: it runs
71
+ // - ask: a card in the turn; the turn waits on that one call, and "allow for this conversation" stops the asking
72
+ // - never: refused; the manifest change still lands and the daemon installs it then
73
+ export const ProjectInstallModeSchema = z.enum(["automatic", "ask", "never"]);
74
+ export type ProjectInstallMode = z.infer<typeof ProjectInstallModeSchema>;
75
+
67
76
  // What the judge answers, one instruction per verdict:
68
77
  // - allow: run it, say nothing, the ordinary answer for a triage false positive
69
78
  // - ask: raise the card; the only path to a person, meant to be rare
@@ -122,6 +131,7 @@ Ask me before:
122
131
 
123
132
  - publishing or releasing anything (npm publish, a GitHub release, a container push);
124
133
  - force-pushing, hard-resetting or otherwise discarding commits that are not this turn's own work;
134
+ - deleting a checkout's \`.git\` or running \`git init\` in a folder that already is one: in a worktree that cuts it off from my workspace's history, and its work can no longer come back to me;
125
135
  - moving a checkout onto another branch (\`git checkout <branch>\`, \`git switch\`) — you only ever see this for a tree that is mine as well as yours, never for a copy of your own, so the worktree paragraph above does not cover it;
126
136
  - sending a credential anywhere outside this container.
127
137