@intentic/sandbox-contract 1.176.3 → 1.209.1

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 (334) hide show
  1. package/README.md +65 -0
  2. package/dist/agent-catalog.d.ts +15 -2
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +50 -1
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/agent-run-model.d.ts +3 -0
  7. package/dist/agent-run-model.d.ts.map +1 -0
  8. package/dist/agent-run-model.js +10 -0
  9. package/dist/agent-run-model.js.map +1 -0
  10. package/dist/capability-env.d.ts +2 -0
  11. package/dist/capability-env.d.ts.map +1 -0
  12. package/dist/capability-env.js +2 -0
  13. package/dist/capability-env.js.map +1 -0
  14. package/dist/chores/chores.d.ts +9 -0
  15. package/dist/chores/chores.d.ts.map +1 -1
  16. package/dist/chores/chores.js +257 -18
  17. package/dist/chores/chores.js.map +1 -1
  18. package/dist/chores/digest.js +0 -0
  19. package/dist/chores/digest.js.map +1 -1
  20. package/dist/chores/extension-update.d.ts +9 -0
  21. package/dist/chores/extension-update.d.ts.map +1 -0
  22. package/dist/chores/extension-update.js +15 -0
  23. package/dist/chores/extension-update.js.map +1 -0
  24. package/dist/chores/fix-deps.d.ts +10 -0
  25. package/dist/chores/fix-deps.d.ts.map +1 -0
  26. package/dist/chores/fix-deps.js +18 -0
  27. package/dist/chores/fix-deps.js.map +1 -0
  28. package/dist/chores/index.d.ts +7 -2
  29. package/dist/chores/index.d.ts.map +1 -1
  30. package/dist/chores/index.js +4 -1
  31. package/dist/chores/index.js.map +1 -1
  32. package/dist/chores/probes.d.ts.map +1 -1
  33. package/dist/chores/probes.js +127 -3
  34. package/dist/chores/probes.js.map +1 -1
  35. package/dist/chores/stack.d.ts +27 -0
  36. package/dist/chores/stack.d.ts.map +1 -0
  37. package/dist/chores/stack.js +121 -0
  38. package/dist/chores/stack.js.map +1 -0
  39. package/dist/chores/verdict.d.ts +2 -1
  40. package/dist/chores/verdict.d.ts.map +1 -1
  41. package/dist/chores/verdict.js +53 -6
  42. package/dist/chores/verdict.js.map +1 -1
  43. package/dist/contract-lock.d.ts +3 -0
  44. package/dist/contract-lock.d.ts.map +1 -0
  45. package/dist/contract-lock.js +35 -0
  46. package/dist/contract-lock.js.map +1 -0
  47. package/dist/contracts/activity.contract.d.ts +10 -0
  48. package/dist/contracts/activity.contract.d.ts.map +1 -1
  49. package/dist/contracts/agent.contract.d.ts +230 -20
  50. package/dist/contracts/agent.contract.d.ts.map +1 -1
  51. package/dist/contracts/agent.contract.js +2 -1
  52. package/dist/contracts/agent.contract.js.map +1 -1
  53. package/dist/contracts/agents.contract.d.ts +847 -1
  54. package/dist/contracts/agents.contract.d.ts.map +1 -1
  55. package/dist/contracts/agents.contract.js +3 -1
  56. package/dist/contracts/agents.contract.js.map +1 -1
  57. package/dist/contracts/automations.contract.d.ts +89 -0
  58. package/dist/contracts/automations.contract.d.ts.map +1 -1
  59. package/dist/contracts/automations.contract.js +3 -1
  60. package/dist/contracts/automations.contract.js.map +1 -1
  61. package/dist/contracts/capabilities.contract.d.ts +108 -32
  62. package/dist/contracts/capabilities.contract.d.ts.map +1 -1
  63. package/dist/contracts/capabilities.contract.js +5 -1
  64. package/dist/contracts/capabilities.contract.js.map +1 -1
  65. package/dist/contracts/chores.contract.d.ts +43 -0
  66. package/dist/contracts/chores.contract.d.ts.map +1 -1
  67. package/dist/contracts/ci.contract.d.ts +5 -0
  68. package/dist/contracts/ci.contract.d.ts.map +1 -1
  69. package/dist/contracts/ci.contract.js +2 -2
  70. package/dist/contracts/ci.contract.js.map +1 -1
  71. package/dist/contracts/claude.contract.d.ts +3 -14
  72. package/dist/contracts/claude.contract.d.ts.map +1 -1
  73. package/dist/contracts/claude.contract.js +2 -3
  74. package/dist/contracts/claude.contract.js.map +1 -1
  75. package/dist/contracts/drafts.contract.d.ts +4 -0
  76. package/dist/contracts/drafts.contract.d.ts.map +1 -1
  77. package/dist/contracts/endpoints.contract.d.ts +28 -12
  78. package/dist/contracts/endpoints.contract.d.ts.map +1 -1
  79. package/dist/contracts/endpoints.contract.js +9 -0
  80. package/dist/contracts/endpoints.contract.js.map +1 -1
  81. package/dist/contracts/extensions.contract.d.ts +399 -69
  82. package/dist/contracts/extensions.contract.d.ts.map +1 -1
  83. package/dist/contracts/extensions.contract.js +15 -1
  84. package/dist/contracts/extensions.contract.js.map +1 -1
  85. package/dist/contracts/git.contract.d.ts +209 -11
  86. package/dist/contracts/git.contract.d.ts.map +1 -1
  87. package/dist/contracts/git.contract.js +12 -2
  88. package/dist/contracts/git.contract.js.map +1 -1
  89. package/dist/contracts/grok.contract.d.ts +0 -13
  90. package/dist/contracts/grok.contract.d.ts.map +1 -1
  91. package/dist/contracts/grok.contract.js +1 -2
  92. package/dist/contracts/grok.contract.js.map +1 -1
  93. package/dist/contracts/host.contract.d.ts +40 -0
  94. package/dist/contracts/host.contract.d.ts.map +1 -1
  95. package/dist/contracts/host.contract.js +3 -2
  96. package/dist/contracts/host.contract.js.map +1 -1
  97. package/dist/contracts/loops.contract.d.ts +343 -0
  98. package/dist/contracts/loops.contract.d.ts.map +1 -0
  99. package/dist/contracts/loops.contract.js +11 -0
  100. package/dist/contracts/loops.contract.js.map +1 -0
  101. package/dist/contracts/panels.contract.d.ts +6 -0
  102. package/dist/contracts/panels.contract.d.ts.map +1 -1
  103. package/dist/contracts/personas.contract.d.ts +112 -0
  104. package/dist/contracts/personas.contract.d.ts.map +1 -0
  105. package/dist/contracts/personas.contract.js +13 -0
  106. package/dist/contracts/personas.contract.js.map +1 -0
  107. package/dist/contracts/ports.contract.d.ts +1 -0
  108. package/dist/contracts/ports.contract.d.ts.map +1 -1
  109. package/dist/contracts/prepush.contract.d.ts +1 -0
  110. package/dist/contracts/prepush.contract.d.ts.map +1 -1
  111. package/dist/contracts/prepush.contract.js.map +1 -1
  112. package/dist/contracts/{codex.contract.d.ts → providers.contract.d.ts} +11 -3
  113. package/dist/contracts/providers.contract.d.ts.map +1 -0
  114. package/dist/contracts/providers.contract.js +6 -0
  115. package/dist/contracts/providers.contract.js.map +1 -0
  116. package/dist/contracts/public.contract.d.ts +24 -0
  117. package/dist/contracts/public.contract.d.ts.map +1 -0
  118. package/dist/contracts/public.contract.js +8 -0
  119. package/dist/contracts/public.contract.js.map +1 -0
  120. package/dist/contracts/push.contract.d.ts +3 -3
  121. package/dist/contracts/secrets.contract.d.ts +9 -0
  122. package/dist/contracts/secrets.contract.d.ts.map +1 -1
  123. package/dist/contracts/sessions.contract.d.ts +16 -1
  124. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  125. package/dist/contracts/sessions.contract.js +1 -1
  126. package/dist/contracts/sessions.contract.js.map +1 -1
  127. package/dist/contracts/settings.contract.d.ts +352 -58
  128. package/dist/contracts/settings.contract.d.ts.map +1 -1
  129. package/dist/contracts/settings.contract.js +2 -1
  130. package/dist/contracts/settings.contract.js.map +1 -1
  131. package/dist/contracts/share.contract.d.ts +55 -0
  132. package/dist/contracts/share.contract.d.ts.map +1 -0
  133. package/dist/contracts/share.contract.js +9 -0
  134. package/dist/contracts/share.contract.js.map +1 -0
  135. package/dist/contracts/skills.contract.d.ts +41 -0
  136. package/dist/contracts/skills.contract.d.ts.map +1 -0
  137. package/dist/contracts/skills.contract.js +9 -0
  138. package/dist/contracts/skills.contract.js.map +1 -0
  139. package/dist/contracts/system.contract.d.ts +205 -8
  140. package/dist/contracts/system.contract.d.ts.map +1 -1
  141. package/dist/contracts/system.contract.js +10 -2
  142. package/dist/contracts/system.contract.js.map +1 -1
  143. package/dist/contracts/workflows.contract.d.ts +575 -0
  144. package/dist/contracts/workflows.contract.d.ts.map +1 -0
  145. package/dist/contracts/workflows.contract.js +13 -0
  146. package/dist/contracts/workflows.contract.js.map +1 -0
  147. package/dist/contracts/workspace.contract.d.ts +18 -4
  148. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  149. package/dist/contracts/workspace.contract.js +6 -3
  150. package/dist/contracts/workspace.contract.js.map +1 -1
  151. package/dist/conversation-ids.d.ts +2 -0
  152. package/dist/conversation-ids.d.ts.map +1 -0
  153. package/dist/conversation-ids.js +123 -0
  154. package/dist/conversation-ids.js.map +1 -0
  155. package/dist/events.d.ts +523 -17
  156. package/dist/events.d.ts.map +1 -1
  157. package/dist/events.js +106 -13
  158. package/dist/events.js.map +1 -1
  159. package/dist/history-state.d.ts.map +1 -1
  160. package/dist/history-state.js +10 -0
  161. package/dist/history-state.js.map +1 -1
  162. package/dist/hostnames.d.ts +4 -0
  163. package/dist/hostnames.d.ts.map +1 -1
  164. package/dist/hostnames.js +7 -2
  165. package/dist/hostnames.js.map +1 -1
  166. package/dist/index.d.ts +3792 -478
  167. package/dist/index.d.ts.map +1 -1
  168. package/dist/index.js +39 -17
  169. package/dist/index.js.map +1 -1
  170. package/dist/listener-protocol.d.ts +77 -0
  171. package/dist/listener-protocol.d.ts.map +1 -0
  172. package/dist/listener-protocol.js +33 -0
  173. package/dist/listener-protocol.js.map +1 -0
  174. package/dist/model-order.d.ts +1 -0
  175. package/dist/model-order.d.ts.map +1 -1
  176. package/dist/model-order.js +16 -0
  177. package/dist/model-order.js.map +1 -1
  178. package/dist/output-fields.d.ts +30 -0
  179. package/dist/output-fields.d.ts.map +1 -0
  180. package/dist/output-fields.js +57 -0
  181. package/dist/output-fields.js.map +1 -0
  182. package/dist/path-refs.d.ts.map +1 -1
  183. package/dist/path-refs.js.map +1 -1
  184. package/dist/publish-drafts.d.ts +7 -0
  185. package/dist/publish-drafts.d.ts.map +1 -0
  186. package/dist/publish-drafts.js +20 -0
  187. package/dist/publish-drafts.js.map +1 -0
  188. package/dist/quick-model.d.ts +2 -1
  189. package/dist/quick-model.d.ts.map +1 -1
  190. package/dist/quick-model.js +18 -14
  191. package/dist/quick-model.js.map +1 -1
  192. package/dist/routes.d.ts +3 -0
  193. package/dist/routes.d.ts.map +1 -1
  194. package/dist/routes.js +63 -0
  195. package/dist/routes.js.map +1 -1
  196. package/dist/runtime-state.d.ts +33 -0
  197. package/dist/runtime-state.d.ts.map +1 -0
  198. package/dist/runtime-state.js +14 -0
  199. package/dist/runtime-state.js.map +1 -0
  200. package/dist/schemas.d.ts +4889 -851
  201. package/dist/schemas.d.ts.map +1 -1
  202. package/dist/schemas.js +993 -192
  203. package/dist/schemas.js.map +1 -1
  204. package/dist/search-globs.d.ts +6 -0
  205. package/dist/search-globs.d.ts.map +1 -0
  206. package/dist/search-globs.js +39 -0
  207. package/dist/search-globs.js.map +1 -0
  208. package/dist/share-paths.d.ts +8 -0
  209. package/dist/share-paths.d.ts.map +1 -0
  210. package/dist/share-paths.js +21 -0
  211. package/dist/share-paths.js.map +1 -0
  212. package/dist/title.d.ts.map +1 -1
  213. package/dist/title.js +1 -1
  214. package/dist/title.js.map +1 -1
  215. package/dist/tunnel-ids.d.ts +1 -0
  216. package/dist/tunnel-ids.d.ts.map +1 -1
  217. package/dist/tunnel-ids.js +1 -0
  218. package/dist/tunnel-ids.js.map +1 -1
  219. package/dist/versions.d.ts +4 -0
  220. package/dist/versions.d.ts.map +1 -0
  221. package/dist/versions.js +15 -0
  222. package/dist/versions.js.map +1 -0
  223. package/dist/workflow-faults.d.ts +4 -0
  224. package/dist/workflow-faults.d.ts.map +1 -0
  225. package/dist/workflow-faults.js +115 -0
  226. package/dist/workflow-faults.js.map +1 -0
  227. package/dist/workspace-state.d.ts +280 -1
  228. package/dist/workspace-state.d.ts.map +1 -1
  229. package/dist/workspace-state.js +163 -21
  230. package/dist/workspace-state.js.map +1 -1
  231. package/package.json +30 -6
  232. package/src/agent-catalog.test.ts +147 -7
  233. package/src/agent-catalog.ts +233 -21
  234. package/src/agent-run-model.test.ts +60 -0
  235. package/src/agent-run-model.ts +51 -0
  236. package/src/capability-env.ts +10 -0
  237. package/src/capability-ledger.test.ts +110 -0
  238. package/src/chores/chores.test.ts +32 -0
  239. package/src/chores/chores.ts +456 -32
  240. package/src/chores/digest.ts +0 -0
  241. package/src/chores/extension-update.ts +37 -0
  242. package/src/chores/fix-deps.ts +40 -0
  243. package/src/chores/index.ts +7 -2
  244. package/src/chores/probes.test.ts +155 -3
  245. package/src/chores/probes.ts +200 -4
  246. package/src/chores/stack.test.ts +126 -0
  247. package/src/chores/stack.ts +244 -0
  248. package/src/chores/verdict.test.ts +357 -19
  249. package/src/chores/verdict.ts +95 -21
  250. package/src/contract-lock.test.ts +17 -0
  251. package/src/contract-lock.ts +66 -0
  252. package/src/contracts/agent.contract.ts +6 -0
  253. package/src/contracts/agents.contract.ts +12 -0
  254. package/src/contracts/automations.contract.ts +17 -1
  255. package/src/contracts/capabilities.contract.ts +22 -0
  256. package/src/contracts/ci.contract.ts +10 -2
  257. package/src/contracts/claude.contract.ts +8 -4
  258. package/src/contracts/endpoints.contract.ts +21 -0
  259. package/src/contracts/extensions.contract.ts +42 -0
  260. package/src/contracts/git.contract.ts +33 -6
  261. package/src/contracts/grok.contract.ts +4 -3
  262. package/src/contracts/host.contract.ts +13 -2
  263. package/src/contracts/loops.contract.ts +62 -0
  264. package/src/contracts/personas.contract.ts +56 -0
  265. package/src/contracts/prepush.contract.ts +4 -3
  266. package/src/contracts/providers.contract.ts +23 -0
  267. package/src/contracts/public.contract.ts +14 -0
  268. package/src/contracts/sessions.contract.ts +5 -1
  269. package/src/contracts/settings.contract.ts +5 -0
  270. package/src/contracts/share.contract.ts +20 -0
  271. package/src/contracts/skills.contract.ts +24 -0
  272. package/src/contracts/system.contract.ts +27 -3
  273. package/src/contracts/workflows.contract.ts +78 -0
  274. package/src/contracts/workspace.contract.ts +10 -2
  275. package/src/conversation-ids.test.ts +61 -0
  276. package/src/conversation-ids.ts +173 -0
  277. package/src/events.test.ts +20 -1
  278. package/src/events.ts +404 -43
  279. package/src/history-state.ts +30 -1
  280. package/src/hostnames.ts +20 -6
  281. package/src/index.ts +58 -17
  282. package/src/listener-protocol.ts +96 -0
  283. package/src/model-order.test.ts +32 -1
  284. package/src/model-order.ts +56 -3
  285. package/src/output-fields.ts +111 -0
  286. package/src/path-refs.test.ts +4 -4
  287. package/src/path-refs.ts +5 -5
  288. package/src/publish-drafts.ts +59 -0
  289. package/src/quick-model.test.ts +70 -27
  290. package/src/quick-model.ts +76 -40
  291. package/src/routes.test.ts +91 -3
  292. package/src/routes.ts +135 -0
  293. package/src/runtime-state.test.ts +45 -0
  294. package/src/runtime-state.ts +124 -0
  295. package/src/schemas.test.ts +16 -29
  296. package/src/schemas.ts +3480 -552
  297. package/src/search-globs.test.ts +46 -0
  298. package/src/search-globs.ts +76 -0
  299. package/src/share-paths.test.ts +48 -0
  300. package/src/share-paths.ts +68 -0
  301. package/src/state-portability.ts +8 -8
  302. package/src/title.test.ts +20 -1
  303. package/src/title.ts +12 -3
  304. package/src/tunnel-ids.ts +15 -1
  305. package/src/versions.test.ts +39 -0
  306. package/src/versions.ts +48 -0
  307. package/src/workflow-faults.test.ts +127 -0
  308. package/src/workflow-faults.ts +188 -0
  309. package/src/workspace-state.test.ts +169 -15
  310. package/src/workspace-state.ts +427 -35
  311. package/dist/contracts/codex.contract.d.ts.map +0 -1
  312. package/dist/contracts/codex.contract.js +0 -6
  313. package/dist/contracts/codex.contract.js.map +0 -1
  314. package/dist/contracts/gemini.contract.d.ts +0 -16
  315. package/dist/contracts/gemini.contract.d.ts.map +0 -1
  316. package/dist/contracts/gemini.contract.js +0 -6
  317. package/dist/contracts/gemini.contract.js.map +0 -1
  318. package/dist/contracts/kimi.contract.d.ts +0 -16
  319. package/dist/contracts/kimi.contract.d.ts.map +0 -1
  320. package/dist/contracts/kimi.contract.js +0 -6
  321. package/dist/contracts/kimi.contract.js.map +0 -1
  322. package/dist/contracts/komodo.contract.d.ts +0 -125
  323. package/dist/contracts/komodo.contract.d.ts.map +0 -1
  324. package/dist/contracts/komodo.contract.js +0 -14
  325. package/dist/contracts/komodo.contract.js.map +0 -1
  326. package/dist/contracts/memory.contract.d.ts +0 -34
  327. package/dist/contracts/memory.contract.d.ts.map +0 -1
  328. package/dist/contracts/memory.contract.js +0 -9
  329. package/dist/contracts/memory.contract.js.map +0 -1
  330. package/src/contracts/codex.contract.ts +0 -10
  331. package/src/contracts/gemini.contract.ts +0 -11
  332. package/src/contracts/kimi.contract.ts +0 -9
  333. package/src/contracts/komodo.contract.ts +0 -36
  334. package/src/contracts/memory.contract.ts +0 -13
@@ -0,0 +1,46 @@
1
+ import { expect, test } from "vitest";
2
+ import { includeGlobs } from "./search-globs.js";
3
+
4
+ test("an empty field scopes nothing", () => {
5
+ expect(includeGlobs(undefined)).toEqual({ globs: [], notGlobs: [] });
6
+ expect(includeGlobs(" , ")).toEqual({ globs: [], notGlobs: [] });
7
+ });
8
+
9
+ // The reported bug: a file name read as a folder name found nothing. Both forms come out, as VSCode's
10
+ // expandGlobalGlob emits them.
11
+ test("a bare name is a file as well as a folder, at any depth", () => {
12
+ expect(includeGlobs(`package.json`).globs).toEqual([`**/package.json`, `**/package.json/**`]);
13
+ expect(includeGlobs(`docs`).globs).toEqual([`**/docs`, `**/docs/**`]);
14
+ // A trailing slash is noise — the folder form is generated either way.
15
+ expect(includeGlobs(`docs/`).globs).toEqual([`**/docs`, `**/docs/**`]);
16
+ });
17
+
18
+ test("a path is matched at any depth unless ./ anchors it to the root", () => {
19
+ expect(includeGlobs(`src/db`).globs).toEqual([`**/src/db`, `**/src/db/**`]);
20
+ expect(includeGlobs(`./src/db`).globs).toEqual([`./src/db`, `./src/db/**`]);
21
+ expect(includeGlobs(`/src/db`).globs).toEqual([`./src/db`, `./src/db/**`]);
22
+ });
23
+
24
+ test("a leading dot is the extension shorthand", () => {
25
+ expect(includeGlobs(`.ts`).globs).toEqual([`**/*.ts`, `**/*.ts/**`]);
26
+ });
27
+
28
+ test("wildcards are passed through as typed", () => {
29
+ expect(includeGlobs(`*.test.ts`).globs).toEqual([`**/*.test.ts`, `**/*.test.ts/**`]);
30
+ expect(includeGlobs(`src/**/*.vue`).globs).toEqual([`**/src/**/*.vue`, `**/src/**/*.vue/**`]);
31
+ });
32
+
33
+ test("commas separate patterns except inside a brace group or a character class", () => {
34
+ expect(includeGlobs(`*.{ts,py}`).globs).toEqual([`**/*.{ts,py}`, `**/*.{ts,py}/**`]);
35
+ expect(includeGlobs(`f[a,b].ts`).globs).toEqual([`**/f[a,b].ts`, `**/f[a,b].ts/**`]);
36
+ expect(includeGlobs(`docs, *.md`).globs).toEqual([`**/docs`, `**/docs/**`, `**/*.md`, `**/*.md/**`]);
37
+ });
38
+
39
+ test("a leading ! excludes instead", () => {
40
+ expect(includeGlobs(`src, !*.test.ts`)).toEqual({
41
+ globs: [`**/src`, `**/src/**`],
42
+ notGlobs: [`**/*.test.ts`, `**/*.test.ts/**`],
43
+ });
44
+ // A lone "!" excludes nothing — expanded, it would have matched everything.
45
+ expect(includeGlobs(`!`)).toEqual({ globs: [], notGlobs: [] });
46
+ });
@@ -0,0 +1,76 @@
1
+ /* What the search box's second field MEANS — VSCode's "files to include" grammar, read the way VSCode reads it
2
+ * (its queryBuilder's parseSearchPaths + expandGlobalGlob), and answered as the two path-glob lists the search
3
+ * engine takes.
4
+ *
5
+ * The rules that make it feel like the editor's field, each one load-bearing:
6
+ *
7
+ * `package.json` a bare name is a FILE as well as a folder — every segment expands to BOTH `**\/p` and
8
+ * `**\/p/**`. Reading it as a folder alone is what made a file name find nothing.
9
+ * `src/db` a path is still matched at any depth, not anchored — `**\/src/db`. VSCode only anchors
10
+ * `./src/db` when the segment starts with `./` (or `/`), which is how you say "the one at the root".
11
+ * `.ts` a leading dot is shorthand for the extension: it becomes `*.ts`.
12
+ * `docs/` a trailing slash is noise; the folder form is generated either way.
13
+ * `*.{ts,vue}` commas separate patterns EXCEPT inside `{…}` or `[…]`, which are one pattern's own syntax.
14
+ * `!**\/*.spec.ts` a leading `!` excludes. This is ours, not VSCode's — the editor spends a second box on
15
+ * exclusions and the explorer's sidebar has room for one field.
16
+ *
17
+ * It lives in the contract package because both ends run it: the daemon turns it into engine scope, and the
18
+ * recorded demo answers /workspace/search itself. Two readings of one field would make the same text mean
19
+ * different things depending on which one answered. */
20
+
21
+ // VSCode's splitGlobAware: the split character is inert inside a brace group or a character class, so
22
+ // `*.{ts,py}` and `f[a,b].ts` survive as single patterns.
23
+ const splitPatterns = (include: string, splitChar: string): string[] => {
24
+ const segments: string[] = [];
25
+ let current = ``;
26
+ let inBraces = false;
27
+ let inBrackets = false;
28
+ for (const char of include) {
29
+ if (char === splitChar && !inBraces && !inBrackets) {
30
+ segments.push(current);
31
+ current = ``;
32
+ continue;
33
+ }
34
+ inBraces = char === `{` ? true : char === `}` ? false : inBraces;
35
+ inBrackets = char === `[` ? true : char === `]` ? false : inBrackets;
36
+ current += char;
37
+ }
38
+ segments.push(current);
39
+ return segments.map((segment) => segment.trim()).filter((segment) => segment !== ``);
40
+ };
41
+
42
+ /* One typed segment → the globs that answer it. The pair is the whole trick: `p` matches the file, `p/**`
43
+ * matches everything under a folder of that name, and either may be what the reader meant.
44
+ *
45
+ * `./p` and `/p` anchor at the workspace root; the `./` is kept on the way out because that is exactly how the
46
+ * engine's glob distinguishes an anchored pattern from a name it should look for at any depth. */
47
+ const expand = (segment: string): string[] => {
48
+ const trimmed = segment.replace(/\/+$/, ``);
49
+ if (trimmed === ``) {
50
+ return [];
51
+ }
52
+ const anchored = /^\.?\//.test(trimmed);
53
+ if (anchored) {
54
+ const path = trimmed.replace(/^\.?\//, ``).replace(/^\/+/, ``);
55
+ return path === `` ? [] : [`./${path}`, `./${path}/**`];
56
+ }
57
+ // ".ts" is how people write an extension filter; VSCode reads it as "*.ts" rather than as a hidden file.
58
+ const pattern = trimmed.startsWith(`.`) ? `*${trimmed}` : trimmed;
59
+ return [`**/${pattern}`, `**/${pattern}/**`];
60
+ };
61
+
62
+ export interface IncludeGlobs {
63
+ // Files the search is limited to — empty means the whole workspace. OR'ed: any pattern matching admits.
64
+ readonly globs: readonly string[];
65
+ // Files kept out of it, whatever the includes said.
66
+ readonly notGlobs: readonly string[];
67
+ }
68
+
69
+ export const includeGlobs = (include: string | undefined): IncludeGlobs => {
70
+ const segments = splitPatterns(include ?? ``, `,`);
71
+ return {
72
+ globs: segments.filter((segment) => !segment.startsWith(`!`)).flatMap(expand),
73
+ // A bare "!" excludes nothing; without this it would expand into a glob that matches everything.
74
+ notGlobs: segments.filter((segment) => segment.startsWith(`!`)).flatMap((segment) => expand(segment.slice(1))),
75
+ };
76
+ };
@@ -0,0 +1,48 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SHARE_ID, shareId, shareStem } from "./share-paths.js";
3
+
4
+ /* The id a shared conversation is filed under is two things at once: the readable half of a link somebody
5
+ * pastes, and the half of the address that has to be safe to join onto a directory path. Both halves are
6
+ * checked here, because the id is minted from a TITLE the user typed. */
7
+
8
+ describe("the readable half", () => {
9
+ it("makes a link that says what it points at", () => {
10
+ expect(shareId("Fix the login redirect loop", "3f9c")).toBe("fix-the-login-redirect-loop-3f9c");
11
+ });
12
+
13
+ it("keeps a long title from running away with the address", () => {
14
+ const id = shareId("a".repeat(200), "3f9c");
15
+ expect(id.length).toBeLessThanOrEqual(64);
16
+ expect(SHARE_ID.test(id)).toBe(true);
17
+ });
18
+
19
+ // A title in a writing system this alphabet cannot carry leaves the random half standing alone, which is
20
+ // still a perfectly good id — and a better outcome than an address full of percent-encoding.
21
+ it("falls back to the random half when a title has no letters to give", () => {
22
+ expect(shareStem("日本語のタイトル")).toBe("");
23
+ expect(shareId("日本語のタイトル", "3f9c")).toBe("3f9c");
24
+ });
25
+ });
26
+
27
+ describe("the safe half", () => {
28
+ /* The id is joined onto a path, so a title is the one attacker-shaped input in it. These are the shapes
29
+ * that would matter if the alphabet were not closed. */
30
+ it.each([
31
+ ["../../etc/passwd", "etc-passwd"],
32
+ ["a/b", "a-b"],
33
+ [".hidden", "hidden"],
34
+ [" spaces everywhere ", "spaces-everywhere"],
35
+ ["Emoji 🎉 title", "emoji-title"],
36
+ ["UPPER Case", "upper-case"],
37
+ ])("reduces %j to a name that can only be a name", (title, stem) => {
38
+ expect(shareStem(title)).toBe(stem);
39
+ expect(SHARE_ID.test(shareId(title, "3f9c"))).toBe(true);
40
+ });
41
+
42
+ // The guard the daemon applies before joining an id onto a directory, held to the whole minted space: no
43
+ // title can produce something it rejects, which is what makes a rejection there a bug rather than input.
44
+ it("mints nothing its own guard would refuse", () => {
45
+ const titles = ["Fix login", "../..", "🎉", "a".repeat(300), "-leading-dash", "trailing-dash-"];
46
+ expect(titles.every((title) => SHARE_ID.test(shareId(title, "00112233445566ff")))).toBe(true);
47
+ });
48
+ });
@@ -0,0 +1,68 @@
1
+ /* WHERE A SHARED CONVERSATION LIVES INSIDE THE OUTBOX — one layout, stated once, because three separate
2
+ * things have to agree on it and only one of them is code that runs at the same time as the others.
3
+ *
4
+ * public/
5
+ * conversations/
6
+ * _viewer/ the page's built assets, copied in once and shared by every share
7
+ * <id>/
8
+ * index.html the page, with its conversation baked in
9
+ * files/ the pictures that conversation showed, copied out of the workspace
10
+ *
11
+ * The VIEWER's build has to know its own address before it is ever published (its asset URLs are absolute —
12
+ * `/conversations/_viewer/assets/…` — so that one copy of the assets serves every share and a recipient's
13
+ * browser caches them across links). The DAEMON has to write the tree. And the outbox's own rules have to
14
+ * leave all of it alone: nothing here begins with a dot, which is the one shape that is never served.
15
+ *
16
+ * A directory per share rather than a bare `<id>.html`, so a share owns its pictures — "stop sharing" is then
17
+ * one directory removed, and cannot half-succeed by leaving a folder of someone's screenshots behind. */
18
+
19
+ // Top-level folder inside `public/`. Reads as what it is in the address bar, which matters: this is a string
20
+ // people paste to each other.
21
+ export const SHARE_DIR = "conversations";
22
+
23
+ // The one copy of the page's assets. Underscored to sort away from the shares themselves and to read as
24
+ // machinery rather than as somebody's conversation.
25
+ export const SHARE_VIEWER_DIR = "_viewer";
26
+
27
+ // Where a share's pictures sit, relative to its own page — and therefore also the prefix every rewritten
28
+ // image path in the payload carries.
29
+ export const SHARE_FILES_DIR = "files";
30
+
31
+ // The absolute path the built assets are served from, and the `base` the page is built with.
32
+ export const SHARE_VIEWER_BASE = `/${SHARE_DIR}/${SHARE_VIEWER_DIR}/`;
33
+
34
+ /* The id a share is filed under: a readable stem from the title, plus randomness that is the ONLY thing
35
+ * standing between a stranger and the conversation.
36
+ *
37
+ * Both halves earn their place. The stem is what makes a pasted link say what it points at ("…/conversations/
38
+ * login-redirect-fix-3f9c…"), which is most of why anyone trusts clicking one. The tail is the security: the
39
+ * outbox answers on an unguessable hostname AND requires the exact path (public-files.ts rule 4 — there is no
40
+ * listing), so an address is protected by the sum of the two, and the half this module controls must not be
41
+ * derivable from a title anyone could guess. */
42
+ const STEM_MAX = 48;
43
+
44
+ export const shareStem = (title: string): string => {
45
+ const stem = title
46
+ .toLowerCase()
47
+ .normalize("NFKD")
48
+ // Anything that is not a plain letter or digit becomes a separator, which retires accents, punctuation,
49
+ // emoji and every writing system that would otherwise arrive percent-encoded in the address bar.
50
+ .replace(/[^a-z0-9]+/g, "-")
51
+ .replace(/^-+|-+$/g, "")
52
+ .slice(0, STEM_MAX)
53
+ .replace(/-+$/, "");
54
+ // A title made entirely of characters the rule above drops (a CJK title, an emoji) leaves nothing to read;
55
+ // the id is then the random half alone, which is still a perfectly good id.
56
+ return stem;
57
+ };
58
+
59
+ // `<stem>-<random>`, or the random half alone when the title had no letters to give.
60
+ export const shareId = (title: string, random: string): string => {
61
+ const stem = shareStem(title);
62
+ return stem === "" ? random : `${stem}-${random}`;
63
+ };
64
+
65
+ // What a share's own id must look like before it is ever joined onto a path — the same shape the daemon's
66
+ // other id guards take (transcript-record.ts FILE_ID), and the reason a share id from the wire can be trusted
67
+ // into a directory name.
68
+ export const SHARE_ID = /^[a-z0-9][a-z0-9-]{0,95}$/;
@@ -40,15 +40,15 @@ export interface StateFile {
40
40
 
41
41
  /* The entry that owns a path, LONGEST PREFIX FIRST.
42
42
  *
43
- * Manifests deliberately nest: `.intentic/claude/` is a provider credential store that also contains
44
- * `.intentic/claude/projects/`, the agent's memory notes and transcripts, which are the single most valuable
45
- * thing in a bundle. First-match resolution would classify the notes by whichever entry happened to be listed
46
- * first, so the specific entry has to win over the general one by construction rather than by list order —
47
- * the same reason a .gitignore consults its deepest matcher first.
43
+ * Manifests may deliberately nest when one subtree has a different lifecycle. First-match resolution would
44
+ * classify the child by whichever entry happened to be listed first, so the specific entry has to win over the
45
+ * general one by construction rather than by list order — the same reason a .gitignore consults its deepest
46
+ * matcher first.
48
47
  *
49
- * Undefined for a path no entry claims. Callers decide what that means: the bundler treats an unclaimed
50
- * `.intentic` path as a bug (the coverage test is what makes that safe to assume) and unclaimed ordinary
51
- * workspace files as `carry`, which is what they are.
48
+ * Undefined for a path no entry claims. Callers decide what that means: workspace content (including an
49
+ * extension's undeclared output) defaults to `carry`, while unclaimed history machinery defaults to `derived`.
50
+ * The daemon's state-path coverage guard is what prevents one of its own credential stores from relying on the
51
+ * workspace default.
52
52
  */
53
53
  export const stateFileFor = <T extends StateFile>(path: string, files: readonly T[]): T | undefined =>
54
54
  files
package/src/title.test.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { expect, test } from "vitest";
2
+ import { AgentTurnSchema } from "./schemas.js";
2
3
  import { deriveTitle } from "./title.js";
3
4
 
4
5
  /* The name a conversation opens under. Every case here is a shape the naive rule (collapse whitespace, cut at
@@ -58,6 +59,24 @@ test("cuts on a word boundary instead of mid-syllable", () => {
58
59
  expect(prompt[kept.length]).toBe(" ");
59
60
  });
60
61
 
62
+ test("keeps a cut it cannot put on a word boundary inside the budget anyway", () => {
63
+ /* The sibling of the case above: a long unbroken token straddling the cut leaves no space late enough to
64
+ * back off to, so the length clamp alone ends the title — and it used to end it one character OVER. That
65
+ * character is not a cosmetic overflow, it is a 400 on the turn carrying the name, and it wedged the
66
+ * conversation for good: the browser stores the derived title before it sends, so every retry re-sent the
67
+ * same rejected one. */
68
+ const prompt = `In one of the sandboxes I have experienced "CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS" limit of 20. Make it configurable in sandbox settings somewhere at /sandbox/agent`;
69
+ const title = deriveTitle(prompt);
70
+
71
+ expect(title.endsWith("…")).toBe(true);
72
+ // Asserted THROUGH the contract rather than against a repeated literal: the clamp and the cap drifting
73
+ // apart is the entire bug, and a second copy of the number is how they drift.
74
+ expect(AgentTurnSchema.safeParse({ prompt, title }).success).toBe(true);
75
+ // Not a property of that one sentence — any token wide enough to swallow the window does it.
76
+ const wide = `Investigate ${"X".repeat(120)} please`;
77
+ expect(AgentTurnSchema.safeParse({ prompt: wide, title: deriveTitle(wide) }).success).toBe(true);
78
+ });
79
+
61
80
  test("keeps a link's last meaningful segment rather than its host and scaffolding", () => {
62
81
  const title = deriveTitle("Look at https://gitlab.com/radarsu/intentic/-/merge_requests/42 and tell me what broke");
63
82
 
@@ -67,7 +86,7 @@ test("keeps a link's last meaningful segment rather than its host and scaffoldin
67
86
  });
68
87
 
69
88
  test("collapses a deep path to its basename and leaves a shallow one alone", () => {
70
- expect(deriveTitle("Refactor _apps/web/src/composables/chat/conversation.ts")).toBe("Refactor conversation.ts");
89
+ expect(deriveTitle("Refactor _editor/web/src/composables/chat/conversation.ts")).toBe("Refactor conversation.ts");
71
90
  // Two segments already read as a place; collapsing them would lose the only context the reference carries.
72
91
  expect(deriveTitle("Refactor src/foo.ts")).toBe("Refactor src/foo.ts");
73
92
  });
package/src/title.ts CHANGED
@@ -142,14 +142,14 @@ const shortUrl = (token: string): string => {
142
142
  };
143
143
 
144
144
  // A path is worth its basename once it is deep enough that the lead is scaffolding — `src/foo.ts` already
145
- // reads as a place, `_apps/web/src/composables/chat/conversation.ts` reads as a wall.
145
+ // reads as a place, `_editor/web/src/composables/chat/conversation.ts` reads as a wall.
146
146
  const shortPath = (token: string): string => {
147
147
  const segments = token.replace(/^@/, "").split("/");
148
148
  return segments.length > 2 ? (segments.findLast((segment) => segment !== "") ?? token) : token;
149
149
  };
150
150
 
151
151
  // Trailing punctuation belongs to the sentence, not to the reference inside it, so it is set aside across the
152
- // shortening and put back — `see _apps/web/src/foo.ts,` collapses to `see foo.ts,`.
152
+ // shortening and put back — `see _editor/web/src/foo.ts,` collapses to `see foo.ts,`.
153
153
  const shortReference = (token: string): string => {
154
154
  const match = /^(.*?)([\s,.;:!?)\]]*)$/s.exec(token);
155
155
  const core = match?.[1] ?? token;
@@ -193,11 +193,20 @@ const sentencesOf = (text: string): string[] => {
193
193
  return parts;
194
194
  };
195
195
 
196
+ /* THE ELLIPSIS IS PART OF THE BUDGET, not an addition to it. MAX_LENGTH is a STORAGE cap the wire contract
197
+ * enforces (AgentTurnSchema.title), so a title one character over it is not a wide tab — it is a 400 on the
198
+ * turn that carried it. Cutting to MAX_LENGTH and appending afterwards was exactly that off-by-one, and it
199
+ * hid for as long as it did because the word-boundary backoff almost always absorbed it: an ordinary sentence
200
+ * has a space late in the window, so `kept` came back short and the ellipsis fit. What has no such space is a
201
+ * long unbroken token straddling the cut — a SCREAMING_SNAKE env var, a hash, a base64 blob — and there the
202
+ * fallback kept the full-width slice and made it one character too long. The conversation then wedged: the
203
+ * browser writes the derived title into its state before it sends, so every later turn re-sent the same
204
+ * rejected name. */
196
205
  const clamped = (text: string): string => {
197
206
  if (text.length <= MAX_LENGTH) {
198
207
  return text;
199
208
  }
200
- const cut = text.slice(0, MAX_LENGTH);
209
+ const cut = text.slice(0, MAX_LENGTH - 1);
201
210
  const boundary = cut.lastIndexOf(" ");
202
211
  const kept = boundary >= MIN_WORD_CUT ? cut.slice(0, boundary) : cut;
203
212
  return `${kept.replace(/[\s,;:—–-]+$/, "")}…`;
package/src/tunnel-ids.ts CHANGED
@@ -7,7 +7,7 @@ export const sha256Hex = (value: string): string => createHash("sha256").update(
7
7
 
8
8
  // The sandbox's stable 12-hex id, digested from the connect token. Used by:
9
9
  // • the CLI's sandbox-tunnel bootstrap (sandbox-tunnel.ts) to name the tunnel + DNS
10
- // • the sandbox daemon's preview hostname builder (preview-hostname.ts)
10
+ // • the preview hostname builder beside it (hostnames.ts)
11
11
  // • the sandbox daemon's sync SSH hostname derivation (sync.ts)
12
12
  // All three MUST agree on the digest, so it lives in the contract they share.
13
13
  export const sandboxIdFromToken = (connectToken: string): string | undefined =>
@@ -41,3 +41,17 @@ export const PORT_SLOT_COUNT = 8;
41
41
  * the digest it shares with sandboxIdFromToken. */
42
42
  export const portSlotsFromToken = (connectToken: string): readonly string[] =>
43
43
  Array.from({ length: PORT_SLOT_COUNT }, (_, index) => sha256Hex(`${connectToken}:port:${index}`).slice(0, 12));
44
+
45
+ /* THE OUTBOX SLOT — the `public-<slot>` half of `public-<slot>-<sandboxId>.<zone>`, where the daemon serves the
46
+ * workspace's `public/` directory (PUBLIC_DIR in @intentic/workspace-ignore).
47
+ *
48
+ * Salted for the reason above, and it matters more here than it does for ports: a forwarded port is a live
49
+ * server the owner started minutes ago, whereas a published file sits there. One record per sandbox, stable
50
+ * across restarts so a link stays good for as long as the file does, and derivable only by parties holding the
51
+ * token — the daemon that serves and the platform that mints. Not the browser: it reads the URL off the /public
52
+ * response, exactly as it reads previewUrl off /ports.
53
+ *
54
+ * The unguessable hostname is also what carries the security story, because the files under it have no auth in
55
+ * front of them: with no directory listing (public-files.ts refuses one) an outsider must guess a 12-hex label
56
+ * AND a filename to reach anything at all. */
57
+ export const publicSlotFromToken = (connectToken: string): string => sha256Hex(`${connectToken}:public`).slice(0, 12);
@@ -0,0 +1,39 @@
1
+ import { expect, test } from "vitest";
2
+ import { DEV_VERSION, isBehind, isNewer } from "./versions.js";
3
+
4
+ test("isNewer compares dotted numeric versions", () => {
5
+ expect(isNewer("1.3.0", "1.2.9")).toBe(true); // newer minor
6
+ expect(isNewer("1.2.10", "1.2.9")).toBe(true); // numeric, not lexical
7
+ expect(isNewer("2.0.0", "1.9.9")).toBe(true); // newer major
8
+ expect(isNewer("1.2.3", "1.2.3")).toBe(false); // equal
9
+ expect(isNewer("1.2.2", "1.2.3")).toBe(false); // older
10
+ expect(isNewer("1.2", "1.2.0")).toBe(false); // missing segment treated as 0
11
+ });
12
+
13
+ test("isBehind reports a released build that a newer release has passed", () => {
14
+ expect(isBehind("1.182.0", "1.183.0")).toBe(true);
15
+ expect(isBehind("1.183.0", "1.183.0")).toBe(false);
16
+ expect(isBehind("1.184.0", "1.183.0")).toBe(false); // ahead of what this sandbox knows about
17
+ });
18
+
19
+ // Every way of not knowing resolves to silence. Each of these once had a plausible argument for nagging, and
20
+ // each would have nagged somebody who could do nothing about it.
21
+ test("isBehind stays quiet whenever it cannot be sure", () => {
22
+ expect(isBehind(undefined, "1.183.0")).toBe(false); // the agent reports no version
23
+ expect(isBehind("1.182.0", undefined)).toBe(false); // this sandbox has no latest to compare against
24
+ expect(isBehind(DEV_VERSION, "1.183.0")).toBe(false); // a build made from a working tree, not a release
25
+ });
26
+
27
+ /* A version with a segment that will not parse is read by its numeric prefix, and the failure is one-directional:
28
+ * it can withhold a nag, never invent one. Both halves are pinned because only the second is a safety property —
29
+ * the first is just the prefix doing its job. */
30
+ test("a version that isn't dotted-numeric can only ever withhold the nudge", () => {
31
+ expect(isBehind("1.2.0-rc.1", "1.183.0")).toBe(true); // the prefix already decides it: 2 is behind 183
32
+ expect(isBehind("1.2.0-rc.1", "1.2.0")).toBe(false); // the unparseable segment is where they differ — silence
33
+ });
34
+
35
+ // The version every agent shipped before the release stamp existed. It is genuinely behind — it predates every
36
+ // release that has one — and it must read that way, because those are the installs this whole signal is for.
37
+ test("isBehind flags the hand-written version agents used to carry", () => {
38
+ expect(isBehind("0.1.0", "1.183.0")).toBe(true);
39
+ });
@@ -0,0 +1,48 @@
1
+ /* COMPARING THE VERSIONS THIS SYSTEM STAMPS ON WHAT IT SHIPS — the daemon, the sandbox image, and the two agents
2
+ * that run on a user's own computer. One release stamps all of them to the SAME version, so "is this one behind
3
+ * that one" is one question with one answer, and it lives here because both ends ask it: the daemon compares its
4
+ * own build against the latest published release, and the browser compares a computer's agent against the same.
5
+ *
6
+ * Shared rather than copied because the two copies would not disagree until the day it mattered — 1.9.0 against
7
+ * 1.10.0 is where a hand-rolled comparator goes wrong, and it goes wrong by reporting "up to date". */
8
+
9
+ // Release versions are plain dotted numerics (semantic-release picks them), so there is no semver dependency to
10
+ // take on. A MISSING segment counts as 0, which is what makes "1.2" and "1.2.0" the same version rather than
11
+ // adjacent ones.
12
+ export const isNewer = (a: string, b: string): boolean => {
13
+ const left = a.split(".").map(Number);
14
+ const right = b.split(".").map(Number);
15
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
16
+ const l = left[i] ?? 0;
17
+ const r = right[i] ?? 0;
18
+ if (l !== r) {
19
+ return l > r;
20
+ }
21
+ }
22
+ return false;
23
+ };
24
+
25
+ /* The sentinel every build that is NOT a release carries: the repo keeps 0.0.0 in package.json and only
26
+ * semantic-release stamps a real one, so a working-tree daemon and a locally-compiled agent both answer this.
27
+ *
28
+ * It has to be excluded from the comparison rather than merely lose it. Every published release outranks 0.0.0,
29
+ * so a developer running the agent they just built would be told, permanently, to replace it with something
30
+ * older than what they are running. The daemon already draws exactly this line for itself (isDevBuild). */
31
+ export const DEV_VERSION = `0.0.0`;
32
+
33
+ /* WHETHER TO TELL SOMEBODY THEIR BUILD IS OLD. Deliberately false in every uncertain case, and each one is a
34
+ * different kind of not-knowing:
35
+ *
36
+ * • no installed version — the thing does not report one, so there is nothing to be behind.
37
+ * • no latest version — this sandbox has not reached the registry (or is a dev build and never will).
38
+ * • installed is the dev sentinel — see above; a nag that cannot be satisfied is worse than silence.
39
+ *
40
+ * A version that is not dotted-numeric is compared by its numeric prefix, which is both useful and safe in the
41
+ * only direction that matters: a segment that will not parse compares as neither greater nor less, so it stops
42
+ * the comparison at "not newer". A malformed INSTALLED version can therefore only ever withhold a nag, never
43
+ * invent one — and `latest` comes from the registry, so it is well-formed by construction.
44
+ *
45
+ * The asymmetry is the point. Saying "you are out of date" wrongly sends someone to reinstall a working agent;
46
+ * saying nothing wrongly leaves them where they already were. */
47
+ export const isBehind = (installed: string | undefined, latest: string | undefined): boolean =>
48
+ installed !== undefined && latest !== undefined && installed !== DEV_VERSION && isNewer(latest, installed);
@@ -0,0 +1,127 @@
1
+ import { expect, test } from "vitest";
2
+ import { type Workflow, WorkflowSchema, type WorkflowStep } from "./schemas.js";
3
+ import { workflowFaults, workflowRunFaults } from "./workflow-faults.js";
4
+
5
+ /* The GATE rules. The graph rules beside them are exercised by the scheduler's own integration tests, which
6
+ * have a real run to check them against; these have none to check, which is the point — every fault here is one
7
+ * the workflow has to be refused for BEFORE a run, because the failure it prevents costs a full fan-out of
8
+ * sessions and then reports nothing anybody can act on.
9
+ */
10
+
11
+ const judge = (over: Partial<WorkflowStep> = {}): WorkflowStep => ({
12
+ id: "judge",
13
+ title: "Judge",
14
+ goal: "a release decision exists",
15
+ prompt: "weigh what the steps before you found",
16
+ needs: [],
17
+ handoff: "fresh",
18
+ output: { kind: "json", fields: [{ name: "release", type: "string", description: "pass | fail", required: true }] },
19
+ checks: [],
20
+ context: "fresh",
21
+ ...over,
22
+ });
23
+
24
+ const gated = (over: Partial<Workflow> = {}): Workflow => ({
25
+ id: "release-gate",
26
+ name: "release gate",
27
+ steps: [judge()],
28
+ maxParallel: 1,
29
+ gate: { step: "judge", field: "release", pass: ["pass"] },
30
+ ...over,
31
+ });
32
+
33
+ test("a well-formed gate is no fault at all", () => {
34
+ expect(workflowFaults(gated())).toEqual([]);
35
+ });
36
+
37
+ test("a workflow with no gate is judged on its graph alone", () => {
38
+ const { gate: _gate, ...ungated } = gated();
39
+ expect(workflowFaults(ungated)).toEqual([]);
40
+ });
41
+
42
+ test("duplicate output field names are one fault at both authoring and schema boundaries", () => {
43
+ const duplicate = gated({
44
+ steps: [
45
+ judge({
46
+ output: {
47
+ kind: "json",
48
+ fields: [
49
+ { name: "release", type: "string", description: "pass | fail", required: true },
50
+ { name: "release", type: "boolean", description: "whether to release", required: true },
51
+ ],
52
+ },
53
+ }),
54
+ ],
55
+ });
56
+
57
+ expect(workflowFaults(duplicate)).toContain(`"Judge" declares the output field "release" more than once; field names must be unique.`);
58
+ expect(WorkflowSchema.safeParse(duplicate).success).toBe(false);
59
+ });
60
+
61
+ test("a gate naming a step the workflow does not have is refused", () => {
62
+ const faults = workflowFaults(gated({ gate: { step: "nope", field: "release", pass: ["pass"] } }));
63
+ expect(faults).toEqual([`The gate reads step "nope", which is not a step in this workflow.`]);
64
+ });
65
+
66
+ test("a gate on a step that declares no output fields is refused", () => {
67
+ const faults = workflowFaults(gated({ steps: [judge({ output: { kind: "claim" } })] }));
68
+ expect(faults).toEqual([`The gate reads "Judge", but that step declares no output fields for it to read.`]);
69
+ });
70
+
71
+ test("a gate on a field the step does not declare is refused", () => {
72
+ const faults = workflowFaults(gated({ gate: { step: "judge", field: "shipit", pass: ["pass"] } }));
73
+ expect(faults).toEqual([`The gate reads "shipit", which "Judge" does not declare.`]);
74
+ });
75
+
76
+ // A list has no reading as a release decision, and the one it would fall into (join and compare) is a rule
77
+ // nobody wrote down.
78
+ test("a gate on a list field is refused", () => {
79
+ const steps = [judge({ output: { kind: "json", fields: [{ name: "release", type: "string[]", description: "the verdicts", required: true }] } })];
80
+ const faults = workflowFaults(gated({ steps }));
81
+ expect(faults).toEqual([`The gate reads "release", which is a list — a release decision has to be one value.`]);
82
+ });
83
+
84
+ // The expensive one to discover at run time: it passes every save, then blocks a release on the one commit
85
+ // where the model decided the field was not worth writing.
86
+ test("a gate on an optional field is refused", () => {
87
+ const steps = [judge({ output: { kind: "json", fields: [{ name: "release", type: "string", description: "pass | fail", required: false }] } })];
88
+ const faults = workflowFaults(gated({ steps }));
89
+ expect(faults).toEqual([`The gate reads "release", which "Judge" declares optional — it has to be required.`]);
90
+ });
91
+
92
+ /* THE RUN-TIME RULE, kept apart from every rule above it because it is not about the graph — and the graph is
93
+ * what gets saved. A design whose steps take their goal and instruction from the request is the ordinary shape
94
+ * and must save cleanly; it is only unrunnable on the particular run that brought no request.
95
+ */
96
+
97
+ // The shape the whole feature is for: a saved design that says nothing about the task, pointed at today's job.
98
+ test("a design whose steps inherit is a perfectly good design", () => {
99
+ const inheriting = gated({ steps: [judge({ goal: undefined, prompt: undefined })] });
100
+ expect(workflowFaults(inheriting)).toEqual([]);
101
+ expect(workflowRunFaults(inheriting, "make the importer handle empty files")).toEqual([]);
102
+ });
103
+
104
+ /* Refused at the door rather than discovered by the first step, because this is the one combination with
105
+ * nothing to tell the model at all — and by the time a step found out, the run has already opened a session per
106
+ * root and started paying for them.
107
+ */
108
+ test("running an inheriting design with no request is refused", () => {
109
+ const inheriting = gated({ steps: [judge({ goal: undefined, prompt: undefined })] });
110
+ expect(workflowRunFaults(inheriting, undefined)).toHaveLength(1);
111
+ expect(workflowRunFaults(inheriting, undefined)[0]).toContain(`"Judge"`);
112
+ // Whitespace is not a request. The composer trims before sending, but the gate's webhook body does not.
113
+ expect(workflowRunFaults(inheriting, " \n ")).toHaveLength(1);
114
+ });
115
+
116
+ // Only half-inheriting is still inheriting: a step with its own instruction but no goal is measured against
117
+ // the request, so it needs one just as much.
118
+ test("a step that declares only one of the two still needs a request", () => {
119
+ expect(workflowRunFaults(gated({ steps: [judge({ goal: undefined })] }), undefined)).toHaveLength(1);
120
+ expect(workflowRunFaults(gated({ steps: [judge({ prompt: undefined })] }), undefined)).toHaveLength(1);
121
+ });
122
+
123
+ // A design that says everything itself is startable from anywhere, with no composer behind it — which is what
124
+ // keeps the gate's webhook and the workflows page working for the designs written that way.
125
+ test("a design that declares everything runs with no request at all", () => {
126
+ expect(workflowRunFaults(gated(), undefined)).toEqual([]);
127
+ });