@intentic/sandbox-contract 1.240.0 → 1.243.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 (310) hide show
  1. package/README.md +52 -16
  2. package/dist/agent-catalog.d.ts +5 -28
  3. package/dist/agent-catalog.d.ts.map +1 -1
  4. package/dist/agent-catalog.js +21 -157
  5. package/dist/agent-catalog.js.map +1 -1
  6. package/dist/agent-run-model.d.ts +3 -2
  7. package/dist/agent-run-model.d.ts.map +1 -1
  8. package/dist/agent-run-model.js +9 -6
  9. package/dist/agent-run-model.js.map +1 -1
  10. package/dist/agent-runtimes.d.ts +27 -0
  11. package/dist/agent-runtimes.d.ts.map +1 -0
  12. package/dist/agent-runtimes.js +113 -0
  13. package/dist/agent-runtimes.js.map +1 -0
  14. package/dist/approvals-execution.d.ts +11 -0
  15. package/dist/approvals-execution.d.ts.map +1 -0
  16. package/dist/approvals-execution.js +44 -0
  17. package/dist/approvals-execution.js.map +1 -0
  18. package/dist/arrival.d.ts +151 -0
  19. package/dist/arrival.d.ts.map +1 -0
  20. package/dist/arrival.js +56 -0
  21. package/dist/arrival.js.map +1 -0
  22. package/dist/card-status.d.ts +5 -0
  23. package/dist/card-status.d.ts.map +1 -0
  24. package/dist/card-status.js +74 -0
  25. package/dist/card-status.js.map +1 -0
  26. package/dist/chores/chores.d.ts.map +1 -1
  27. package/dist/chores/chores.js +1 -1
  28. package/dist/chores/chores.js.map +1 -1
  29. package/dist/command-classes.d.ts +0 -1
  30. package/dist/command-classes.d.ts.map +1 -1
  31. package/dist/command-classes.js +9 -2
  32. package/dist/command-classes.js.map +1 -1
  33. package/dist/command-run.d.ts +10 -0
  34. package/dist/command-run.d.ts.map +1 -0
  35. package/dist/command-run.js +46 -0
  36. package/dist/command-run.js.map +1 -0
  37. package/dist/contracts/agent.contract.d.ts +991 -771
  38. package/dist/contracts/agent.contract.d.ts.map +1 -1
  39. package/dist/contracts/agents.contract.d.ts +345 -337
  40. package/dist/contracts/agents.contract.d.ts.map +1 -1
  41. package/dist/contracts/agents.contract.js +20 -2
  42. package/dist/contracts/agents.contract.js.map +1 -1
  43. package/dist/contracts/approvals.contract.d.ts +99 -0
  44. package/dist/contracts/approvals.contract.d.ts.map +1 -0
  45. package/dist/contracts/approvals.contract.js +32 -0
  46. package/dist/contracts/approvals.contract.js.map +1 -0
  47. package/dist/contracts/ci.contract.d.ts +1 -4
  48. package/dist/contracts/ci.contract.d.ts.map +1 -1
  49. package/dist/contracts/ci.contract.js +1 -9
  50. package/dist/contracts/ci.contract.js.map +1 -1
  51. package/dist/contracts/claude.contract.d.ts +9 -0
  52. package/dist/contracts/claude.contract.d.ts.map +1 -1
  53. package/dist/contracts/cursor.contract.d.ts +6 -0
  54. package/dist/contracts/cursor.contract.d.ts.map +1 -1
  55. package/dist/contracts/git.contract.d.ts +63 -1
  56. package/dist/contracts/git.contract.d.ts.map +1 -1
  57. package/dist/contracts/git.contract.js +22 -4
  58. package/dist/contracts/git.contract.js.map +1 -1
  59. package/dist/contracts/grok.contract.d.ts +3 -0
  60. package/dist/contracts/grok.contract.d.ts.map +1 -1
  61. package/dist/contracts/keys.contract.d.ts +81 -0
  62. package/dist/contracts/keys.contract.d.ts.map +1 -0
  63. package/dist/contracts/keys.contract.js +51 -0
  64. package/dist/contracts/keys.contract.js.map +1 -0
  65. package/dist/contracts/panels.contract.d.ts +7 -0
  66. package/dist/contracts/panels.contract.d.ts.map +1 -1
  67. package/dist/contracts/prepush.contract.js +2 -2
  68. package/dist/contracts/providers.contract.d.ts +2 -0
  69. package/dist/contracts/providers.contract.d.ts.map +1 -1
  70. package/dist/contracts/runner.contract.d.ts +84 -86
  71. package/dist/contracts/runner.contract.d.ts.map +1 -1
  72. package/dist/contracts/safety.contract.d.ts +35 -0
  73. package/dist/contracts/safety.contract.d.ts.map +1 -0
  74. package/dist/contracts/safety.contract.js +32 -0
  75. package/dist/contracts/safety.contract.js.map +1 -0
  76. package/dist/contracts/sessions.contract.d.ts +73 -337
  77. package/dist/contracts/sessions.contract.d.ts.map +1 -1
  78. package/dist/contracts/settings.contract.d.ts +30 -32
  79. package/dist/contracts/settings.contract.d.ts.map +1 -1
  80. package/dist/contracts/system.contract.d.ts +139 -337
  81. package/dist/contracts/system.contract.d.ts.map +1 -1
  82. package/dist/contracts/translator.contract.d.ts +31 -3
  83. package/dist/contracts/translator.contract.d.ts.map +1 -1
  84. package/dist/contracts/usage.contract.d.ts +29 -20
  85. package/dist/contracts/usage.contract.d.ts.map +1 -1
  86. package/dist/contracts/usage.contract.js +13 -0
  87. package/dist/contracts/usage.contract.js.map +1 -1
  88. package/dist/contracts/workspace.contract.d.ts +8 -0
  89. package/dist/contracts/workspace.contract.d.ts.map +1 -1
  90. package/dist/conversation-ids.d.ts +2 -0
  91. package/dist/conversation-ids.d.ts.map +1 -1
  92. package/dist/conversation-ids.js +8 -0
  93. package/dist/conversation-ids.js.map +1 -1
  94. package/dist/credential-material.d.ts +1 -0
  95. package/dist/credential-material.d.ts.map +1 -1
  96. package/dist/credential-material.js +24 -2
  97. package/dist/credential-material.js.map +1 -1
  98. package/dist/definition.d.ts +51 -93
  99. package/dist/definition.d.ts.map +1 -1
  100. package/dist/definition.js +6 -28
  101. package/dist/definition.js.map +1 -1
  102. package/dist/events.d.ts +1831 -2285
  103. package/dist/events.d.ts.map +1 -1
  104. package/dist/events.js +155 -55
  105. package/dist/events.js.map +1 -1
  106. package/dist/history-state.d.ts.map +1 -1
  107. package/dist/history-state.js +1 -0
  108. package/dist/history-state.js.map +1 -1
  109. package/dist/index.d.ts +1959 -1903
  110. package/dist/index.d.ts.map +1 -1
  111. package/dist/index.js +20 -5
  112. package/dist/index.js.map +1 -1
  113. package/dist/ingress-protocol.d.ts +13 -1
  114. package/dist/ingress-protocol.d.ts.map +1 -1
  115. package/dist/ingress-protocol.js +19 -0
  116. package/dist/ingress-protocol.js.map +1 -1
  117. package/dist/mentions.d.ts +3 -0
  118. package/dist/mentions.d.ts.map +1 -0
  119. package/dist/mentions.js +13 -0
  120. package/dist/mentions.js.map +1 -0
  121. package/dist/needs-action.d.ts +7 -0
  122. package/dist/needs-action.d.ts.map +1 -0
  123. package/dist/needs-action.js +3 -0
  124. package/dist/needs-action.js.map +1 -0
  125. package/dist/overlay-lint.d.ts +7 -0
  126. package/dist/overlay-lint.d.ts.map +1 -0
  127. package/dist/overlay-lint.js +68 -0
  128. package/dist/overlay-lint.js.map +1 -0
  129. package/dist/owner-ticket.d.ts +18 -0
  130. package/dist/owner-ticket.d.ts.map +1 -0
  131. package/dist/owner-ticket.js +51 -0
  132. package/dist/owner-ticket.js.map +1 -0
  133. package/dist/plan-pools.d.ts +13 -0
  134. package/dist/plan-pools.d.ts.map +1 -0
  135. package/dist/plan-pools.js +31 -0
  136. package/dist/plan-pools.js.map +1 -0
  137. package/dist/provider-specs.d.ts +228 -0
  138. package/dist/provider-specs.d.ts.map +1 -0
  139. package/dist/provider-specs.js +124 -0
  140. package/dist/provider-specs.js.map +1 -0
  141. package/dist/quick-model.d.ts.map +1 -1
  142. package/dist/quick-model.js +2 -1
  143. package/dist/quick-model.js.map +1 -1
  144. package/dist/runner-protocol.js +2 -2
  145. package/dist/runner-protocol.js.map +1 -1
  146. package/dist/runtime-state.d.ts +2 -2
  147. package/dist/runtime-state.d.ts.map +1 -1
  148. package/dist/runtime-state.js +1 -1
  149. package/dist/runtime-state.js.map +1 -1
  150. package/dist/safety-policy.d.ts +49 -0
  151. package/dist/safety-policy.d.ts.map +1 -0
  152. package/dist/safety-policy.js +52 -0
  153. package/dist/safety-policy.js.map +1 -0
  154. package/dist/schemas/agent.d.ts +21 -3
  155. package/dist/schemas/agent.d.ts.map +1 -1
  156. package/dist/schemas/agent.js +18 -8
  157. package/dist/schemas/agent.js.map +1 -1
  158. package/dist/schemas/agents.d.ts +19 -0
  159. package/dist/schemas/agents.d.ts.map +1 -1
  160. package/dist/schemas/agents.js +15 -0
  161. package/dist/schemas/agents.js.map +1 -1
  162. package/dist/schemas/approvals.d.ts +241 -0
  163. package/dist/schemas/approvals.d.ts.map +1 -0
  164. package/dist/schemas/approvals.js +63 -0
  165. package/dist/schemas/approvals.js.map +1 -0
  166. package/dist/schemas/automations.d.ts +5 -0
  167. package/dist/schemas/automations.d.ts.map +1 -1
  168. package/dist/schemas/ci.d.ts +5 -9
  169. package/dist/schemas/ci.d.ts.map +1 -1
  170. package/dist/schemas/ci.js +3 -10
  171. package/dist/schemas/ci.js.map +1 -1
  172. package/dist/schemas/computers.d.ts +40 -5
  173. package/dist/schemas/computers.d.ts.map +1 -1
  174. package/dist/schemas/computers.js +16 -5
  175. package/dist/schemas/computers.js.map +1 -1
  176. package/dist/schemas/environment.d.ts +11 -131
  177. package/dist/schemas/environment.d.ts.map +1 -1
  178. package/dist/schemas/environment.js +5 -42
  179. package/dist/schemas/environment.js.map +1 -1
  180. package/dist/schemas/git-history.d.ts +4 -0
  181. package/dist/schemas/git-history.d.ts.map +1 -1
  182. package/dist/schemas/git.d.ts +179 -0
  183. package/dist/schemas/git.d.ts.map +1 -1
  184. package/dist/schemas/git.js +39 -1
  185. package/dist/schemas/git.js.map +1 -1
  186. package/dist/schemas/panels.d.ts +21 -0
  187. package/dist/schemas/panels.d.ts.map +1 -1
  188. package/dist/schemas/panels.js +5 -0
  189. package/dist/schemas/panels.js.map +1 -1
  190. package/dist/schemas/plan-limits.d.ts +50 -3
  191. package/dist/schemas/plan-limits.d.ts.map +1 -1
  192. package/dist/schemas/plan-limits.js +11 -6
  193. package/dist/schemas/plan-limits.js.map +1 -1
  194. package/dist/schemas/provider-oauth.d.ts +6 -0
  195. package/dist/schemas/provider-oauth.d.ts.map +1 -1
  196. package/dist/schemas/provider-subscriptions.d.ts +2 -1
  197. package/dist/schemas/provider-subscriptions.d.ts.map +1 -1
  198. package/dist/schemas/provider-subscriptions.js +2 -1
  199. package/dist/schemas/provider-subscriptions.js.map +1 -1
  200. package/dist/schemas/settings.d.ts +29 -16
  201. package/dist/schemas/settings.d.ts.map +1 -1
  202. package/dist/schemas/settings.js +13 -20
  203. package/dist/schemas/settings.js.map +1 -1
  204. package/dist/schemas/system.d.ts +3 -0
  205. package/dist/schemas/system.d.ts.map +1 -1
  206. package/dist/schemas/system.js +5 -1
  207. package/dist/schemas/system.js.map +1 -1
  208. package/dist/schemas/workspace-repos.d.ts +14 -0
  209. package/dist/schemas/workspace-repos.d.ts.map +1 -1
  210. package/dist/schemas/workspace-repos.js +5 -0
  211. package/dist/schemas/workspace-repos.js.map +1 -1
  212. package/dist/schemas/workspace-tree.d.ts +1 -0
  213. package/dist/schemas/workspace-tree.d.ts.map +1 -1
  214. package/dist/schemas/workspace-tree.js +3 -0
  215. package/dist/schemas/workspace-tree.js.map +1 -1
  216. package/dist/transcript-fold.d.ts +31 -0
  217. package/dist/transcript-fold.d.ts.map +1 -0
  218. package/dist/transcript-fold.js +457 -0
  219. package/dist/transcript-fold.js.map +1 -0
  220. package/dist/workspace-state.d.ts +20 -3
  221. package/dist/workspace-state.d.ts.map +1 -1
  222. package/dist/workspace-state.js +22 -2
  223. package/dist/workspace-state.js.map +1 -1
  224. package/package.json +27 -5
  225. package/src/agent-catalog.test.ts +1 -1
  226. package/src/agent-catalog.ts +76 -555
  227. package/src/agent-run-model.test.ts +31 -15
  228. package/src/agent-run-model.ts +26 -12
  229. package/src/agent-runtimes.ts +419 -0
  230. package/src/approvals-execution.ts +96 -0
  231. package/src/arrival.ts +160 -0
  232. package/src/capability-ledger.test.ts +6 -4
  233. package/src/card-status.ts +93 -0
  234. package/src/chores/chores.ts +6 -3
  235. package/src/command-classes.test.ts +25 -0
  236. package/src/command-classes.ts +47 -5
  237. package/src/command-run.ts +78 -0
  238. package/src/contract-lock.test.ts +1 -1
  239. package/src/contract-lock.ts +2 -2
  240. package/src/contracts/agents.contract.ts +35 -2
  241. package/src/contracts/approvals.contract.ts +37 -0
  242. package/src/contracts/ci.contract.ts +0 -13
  243. package/src/contracts/git.contract.ts +28 -3
  244. package/src/contracts/keys.contract.ts +79 -0
  245. package/src/contracts/prepush.contract.ts +3 -3
  246. package/src/contracts/safety.contract.ts +48 -0
  247. package/src/contracts/usage.contract.ts +22 -0
  248. package/src/conversation-ids.test.ts +31 -1
  249. package/src/conversation-ids.ts +37 -3
  250. package/src/credential-material.test.ts +69 -1
  251. package/src/credential-material.ts +86 -5
  252. package/src/definition.ts +25 -53
  253. package/src/events.ts +374 -173
  254. package/src/history-state.ts +6 -0
  255. package/src/index.ts +24 -5
  256. package/src/ingress-protocol.ts +52 -1
  257. package/src/mentions.test.ts +22 -0
  258. package/src/mentions.ts +25 -0
  259. package/src/needs-action.ts +14 -0
  260. package/src/overlay-lint.test.ts +89 -0
  261. package/src/overlay-lint.ts +116 -0
  262. package/src/owner-ticket.test.ts +54 -0
  263. package/src/owner-ticket.ts +102 -0
  264. package/src/plan-pools.test.ts +71 -0
  265. package/src/plan-pools.ts +92 -0
  266. package/src/provider-specs.test.ts +130 -0
  267. package/src/provider-specs.ts +352 -0
  268. package/src/quick-model.ts +9 -5
  269. package/src/runner-protocol.ts +2 -2
  270. package/src/runtime-state.ts +6 -6
  271. package/src/safety-policy.ts +165 -0
  272. package/src/schemas/agent.ts +62 -17
  273. package/src/schemas/agents.ts +71 -0
  274. package/src/schemas/approvals.ts +174 -0
  275. package/src/schemas/ci.ts +19 -26
  276. package/src/schemas/computers.ts +91 -15
  277. package/src/schemas/environment.ts +21 -100
  278. package/src/schemas/git.ts +121 -6
  279. package/src/schemas/issues.ts +1 -1
  280. package/src/schemas/panels.ts +20 -0
  281. package/src/schemas/plan-limits.ts +58 -11
  282. package/src/schemas/provider-subscriptions.ts +7 -2
  283. package/src/schemas/settings.ts +67 -93
  284. package/src/schemas/system.ts +12 -1
  285. package/src/schemas/workspace-repos.ts +9 -0
  286. package/src/schemas/workspace-tree.ts +11 -0
  287. package/src/transcript-fold.test.ts +493 -0
  288. package/src/transcript-fold.ts +655 -0
  289. package/src/workspace-state.test.ts +77 -29
  290. package/src/workspace-state.ts +99 -31
  291. package/dist/contracts/drafts.contract.d.ts +0 -57
  292. package/dist/contracts/drafts.contract.d.ts.map +0 -1
  293. package/dist/contracts/drafts.contract.js +0 -32
  294. package/dist/contracts/drafts.contract.js.map +0 -1
  295. package/dist/definition-action.d.ts +0 -7
  296. package/dist/definition-action.d.ts.map +0 -1
  297. package/dist/definition-action.js +0 -3
  298. package/dist/definition-action.js.map +0 -1
  299. package/dist/publish-drafts.d.ts +0 -7
  300. package/dist/publish-drafts.d.ts.map +0 -1
  301. package/dist/publish-drafts.js +0 -20
  302. package/dist/publish-drafts.js.map +0 -1
  303. package/dist/schemas/drafts.d.ts +0 -84
  304. package/dist/schemas/drafts.d.ts.map +0 -1
  305. package/dist/schemas/drafts.js +0 -44
  306. package/dist/schemas/drafts.js.map +0 -1
  307. package/src/contracts/drafts.contract.ts +0 -35
  308. package/src/definition-action.ts +0 -8
  309. package/src/publish-drafts.ts +0 -59
  310. package/src/schemas/drafts.ts +0 -108
@@ -0,0 +1,96 @@
1
+ /* HOW AN APPROVED THING GETS DONE, the shared half, written once because the daemon acts on it, the app draws
2
+ * it, and the two have to agree on the same seconds.
3
+ *
4
+ * THERE IS NO EXECUTOR AUTOMATION, and its absence is the design. Publishing used to be a scheduled automation:
5
+ * a cron waking every few minutes, running a shell guard over the queue directory, almost always finding
6
+ * nothing, a job whose entire job was to ask "yet?" forever. It also made the approve button conditional on a
7
+ * row in a list nobody had asked for: delete the automation and approvals silently went nowhere, with the
8
+ * button still there and still saying yes. The daemon owns execution (approvals-executor.ts). It knows the
9
+ * moment an item comes due because it is the process that wrote it, so it sleeps until exactly then and costs
10
+ * nothing in between.
11
+ *
12
+ * APPROVAL IS NOT "DO IT NOW", IT IS "DO IT UNLESS I STOP YOU". A post is public and permanent the instant it
13
+ * lands, a booking is charged, and the gap between realising and clicking is about two seconds, so an approved
14
+ * item carrying no date of its own is dated HOLD into the future, and the queue counts it down in the open. The
15
+ * hold is stored as an ordinary scheduledAt rather than as a new state, which is what keeps it honest: it
16
+ * survives a restart, it reads as one number in the same place a scheduled item's date is already read, and
17
+ * calling it off is the "put it back in review" click that was there before any of this.
18
+ *
19
+ * A MINUTE, because that is the whole width of the decision: long enough to catch the wrong word you see the
20
+ * moment the row stops being a form, short enough that approving something still feels like doing it. */
21
+ export const APPROVAL_HOLD_MS = 60_000;
22
+
23
+ /* WHO CAN BE SENT BY CODE ALONE. A connector reached through a real API with a stored credential is a request
24
+ * the daemon can make itself, no model, no browser, no turn, and it either got a 200 or it did not. A
25
+ * connector that IS a logged-in browser session (reddit, x) has no such door: posting there means driving a
26
+ * page whose markup moves under you, past dialogs and rate screens nobody can enumerate in advance, which is
27
+ * precisely the work an agent turn exists to absorb.
28
+ *
29
+ * So the split follows what the platform actually offers rather than what would be cheaper, and it is stated
30
+ * here rather than guessed at a call site, because being wrong in the optimistic direction means a post that
31
+ * silently never goes out. A platform absent from this set is published by an agent turn, which always works
32
+ * and merely costs more. */
33
+ export const DIRECT_PUBLISH_PLATFORMS: ReadonlySet<string> = new Set(["discord"]);
34
+
35
+ // The workspace-relative directory both prompts below name, spelled once.
36
+ const APPROVALS_DIR = ".intentic/config/approvals";
37
+
38
+ // The status bookkeeping every executing turn is told, identical across kinds so the file the queue reads back
39
+ // is the same shape whoever wrote it.
40
+ const WRITE_BACK = [
41
+ `Before you act on one, set its "status" to "running" and "startedAt" to the epoch ms, so a turn that dies`,
42
+ `here cannot do it twice. When it is done, set "status":"done" plus "finishedAt" (epoch ms) and, where`,
43
+ `there is one, "result": the post's URL, a confirmation number, whatever a person would want to go and`,
44
+ `look at. If it failed, set "status":"failed" plus an "error" saying what went wrong in plain words the`,
45
+ `owner can act on, then move to the next one instead of retrying in a loop.`,
46
+ ];
47
+
48
+ /* WHAT THE PUBLISH TURN IS TOLD, for the posts no API can carry. It NAMES the files rather than saying "go and
49
+ * look": the daemon has already decided what is due, and a turn that re-derives that decision can disagree
50
+ * with it, sending something the owner pulled back a second ago, or skipping something it judged not ready.
51
+ * The turn's job is the part only it can do, which is working the platform's own UI.
52
+ *
53
+ * It still writes the outcome back into the file, because the file is where the queue reads it, and a post
54
+ * that went out without saying so is a post the owner sends twice. */
55
+ export const publishTurnPrompt = (posts: readonly { readonly id: string; readonly platform: string }[]): string =>
56
+ [
57
+ `Publish these approved posts, which are due now. They live in ${APPROVALS_DIR}/, one JSON file each:`,
58
+ ``,
59
+ ...posts.map((post) => `- ${post.id}.json (${post.platform})`),
60
+ ``,
61
+ `Take them ONE AT A TIME, and for each:`,
62
+ `1. Read the file. ${WRITE_BACK[0]}`,
63
+ ` ${WRITE_BACK[1]}`,
64
+ `2. Post exactly its "content": with its "title", "target" and "media", using that platform's skill.`,
65
+ ` A "target" that is a URL means this post is a REPLY to whatever is at it: open that exact URL and`,
66
+ ` reply where it lands. On reddit a comment permalink (.../comments/<post>/<slug>/<comment>/) has to`,
67
+ ` nest under that comment rather than becoming a new top-level comment on the thread.`,
68
+ `3. ${WRITE_BACK[2]} ${WRITE_BACK[3]}`,
69
+ ` ${WRITE_BACK[4]}`,
70
+ ``,
71
+ `Never rewrite the content: the owner approved these exact words. Never touch a file not listed above.`,
72
+ ].join(`\n`);
73
+
74
+ /* WHAT THE ACTION TURN IS TOLD. The same discipline as a publish turn, named files, status written before and
75
+ * after, with the work itself coming from the file: the agent that proposed the action wrote its own
76
+ * instructions for this moment, knowing this turn would arrive with none of the conversation. The owner's yes
77
+ * covered what `summary` and `details` said, so the instructions are carried out as written and nothing beyond
78
+ * them is done; an action that turns out to need more than it said is failed with that sentence, not
79
+ * improvised. */
80
+ export const actionTurnPrompt = (actions: readonly { readonly id: string; readonly summary: string }[]): string =>
81
+ [
82
+ `Carry out these approved actions, which are due now. They live in ${APPROVALS_DIR}/, one JSON file each:`,
83
+ ``,
84
+ ...actions.map((action) => `- ${action.id}.json: ${action.summary}`),
85
+ ``,
86
+ `Take them ONE AT A TIME, and for each:`,
87
+ `1. Read the file. ${WRITE_BACK[0]}`,
88
+ ` ${WRITE_BACK[1]}`,
89
+ `2. Do exactly what its "instructions" say. The owner approved what "summary" and "details" describe and`,
90
+ ` nothing more: if carrying it out would need something they do not say, stop and fail it with that`,
91
+ ` reason rather than improvising.`,
92
+ `3. ${WRITE_BACK[2]} ${WRITE_BACK[3]}`,
93
+ ` ${WRITE_BACK[4]}`,
94
+ ``,
95
+ `Never touch a file not listed above.`,
96
+ ].join(`\n`);
package/src/arrival.ts ADDED
@@ -0,0 +1,160 @@
1
+ import { z } from "zod";
2
+ import { NeedsActionSchema } from "./needs-action.js";
3
+
4
+ /* AN ARRIVAL: something coming INTO this sandbox, whatever it came from and whatever it is made of.
5
+ *
6
+ * There used to be three of these, each with its own schemas, its own routes and its own card, and the split
7
+ * was by ARTIFACT: a `sandbox.toml` had one surface, an environment bundle a second, a foreign assistant's
8
+ * home directory a third. That is the wrong axis. The owner's question is never "which of your three import
9
+ * features is this" — it is "I have a thing, take what is safe from it" — and the three artifacts answer it
10
+ * with the same four moves: read the thing, show what would land, take the ticked rows, say what is left.
11
+ *
12
+ * So there is ONE pipeline, and the artifact is a parser:
13
+ *
14
+ * source → plan (items the owner ticks, refused lines, needsAction said BEFORE anything writes)
15
+ * → apply (the ticked ids, against a plan RE-DERIVED from the held bytes)
16
+ * → report (what landed, what did not and why, what still needs a person)
17
+ *
18
+ * WHAT THE MERGE FIXED, beyond three of everything: the bundle door was the only one that WROTE ON FILE PICK.
19
+ * It is also the most destructive of the three — a bundle lands over a workspace rather than beside it — so
20
+ * the one arrival most deserving of a preview was the one without it. Sharing the pipeline is what gave it
21
+ * one, and what makes "untick the 6 GB monorepo" a thing an owner can say to a bundle at all.
22
+ *
23
+ * THE OUTBOUND HALF IS NOT HERE. Deriving a definition, packing a bundle and publishing the workspace repo
24
+ * live on definition.ts and schemas/environment.ts, because they answer the opposite question and share no
25
+ * shape with this one. */
26
+
27
+ // Which of the four things this is. The two assistants are separate members rather than one "foreign": the
28
+ // checklist badges the source by name, and an owner who packed a Hermes folder should read the word Hermes.
29
+ export const ArrivalSourceSchema = z.enum(["definition", "bundle", "hermes", "openclaw"]);
30
+ export type ArrivalSource = z.infer<typeof ArrivalSourceSchema>;
31
+
32
+ // The subset a foreign assistant's home directory can be. Narrower than ArrivalSource on purpose: a connected
33
+ // computer is scanned for these two and never for a bundle, which is a file, not a setup.
34
+ export const AssistantSourceSchema = z.enum(["hermes", "openclaw"]);
35
+ export type AssistantSource = z.infer<typeof AssistantSourceSchema>;
36
+
37
+ /* WHAT AN ITEM BECOMES HERE, not what it was there. The apply loop dispatches on this and the checklist
38
+ * groups by it, so an owner reads "3 skills, 2 connections" rather than a foreign directory listing or a tar
39
+ * table of contents. One enum across all four sources, which is what makes the union honest: a repository is
40
+ * a `repo` whether it arrived as a remote to clone (definition) or as a git dir in a tar (bundle), and the
41
+ * owner ticking it means the same thing either way. */
42
+ export const ArrivalGroupSchema = z.enum([
43
+ // The /work tree as a whole, cloned from the remote a definition's `[workspace]` names.
44
+ "workspace",
45
+ // One repository: cloned from its remote, or unpacked with its real git dir from a bundle.
46
+ "repo",
47
+ // Loose files landing in /work: a bundle's workspace bytes, an assistant's notes folder.
48
+ "files",
49
+ // Transcripts, checkpoint timelines, ledgers. Bundle-only — nothing else can reference them.
50
+ "history",
51
+ // The overlay Dockerfile.
52
+ "environment",
53
+ // One connection, landing unauthenticated unless its secret travelled.
54
+ "capability",
55
+ // The agent settings that differ from their defaults.
56
+ "settings",
57
+ // A merge into CLAUDE.md / AGENTS.md.
58
+ "memory",
59
+ "skill",
60
+ "automation",
61
+ // One credential VALUE, and the only group gated behind the apply's second consent.
62
+ "secret",
63
+ ]);
64
+ export type ArrivalGroup = z.infer<typeof ArrivalGroupSchema>;
65
+
66
+ /* ONE ROW OF THE CHECKLIST. Two flags rather than one, because the four sources fail an item for two
67
+ * genuinely different reasons and collapsing them would lose the one the owner can act on:
68
+ *
69
+ * `applicable: false` the target already holds this, so it CANNOT be ticked. A definition and a bundle
70
+ * land beside what is there, never over it, so an existing repo directory or
71
+ * capability id greys its row and prints the reason.
72
+ * `recommended: false` it can be ticked and the reader should look first. An adapter's judgment about a
73
+ * server URL pointing at the old machine, or an env key that reads as tuning.
74
+ *
75
+ * A source that has no use for one of them fills it constantly: a definition recommends everything it can
76
+ * apply, an assistant adapter refuses inside its own walk rather than emitting inapplicable rows. */
77
+ export const ArrivalItemSchema = z.object({
78
+ /* Deterministic and derived from the artifact ("repo:intentic", "skill:weather", "bundle:history"), which
79
+ * is the whole reason the apply may re-derive: the ids the owner ticked name the same items in the
80
+ * second derivation as in the one the browser rendered. */
81
+ id: z.string(),
82
+ group: ArrivalGroupSchema,
83
+ // The checklist line, in plain words: "Repository intentic", "Skill, weather", "Workspace files".
84
+ label: z.string(),
85
+ detail: z.string().optional(),
86
+ applicable: z.boolean(),
87
+ // Why it cannot be ticked. Present exactly when `applicable` is false.
88
+ reason: z.string().optional(),
89
+ recommended: z.boolean(),
90
+ /* Names of the secrets this row would store — never values. Non-empty rows only move when the apply
91
+ * carries `includeSecrets`; the report names what stayed behind rather than pretending it landed. */
92
+ secrets: z.array(z.string()),
93
+ });
94
+ export type ArrivalItem = z.infer<typeof ArrivalItemSchema>;
95
+
96
+ export const ArrivalPlanSchema = z.object({
97
+ source: ArrivalSourceSchema,
98
+ /* Names the held artifact for the apply call. Minted per plan; a new plan replaces the held one, because
99
+ * an owner changing their mind is the ordinary case and not a conflict. */
100
+ token: z.string(),
101
+ // What the artifact calls itself: a definition's `name`, a bundle's source sandbox. For the reader only.
102
+ name: z.string().optional(),
103
+ items: z.array(ArrivalItemSchema),
104
+ /* Whether this artifact holds credential VALUES at all, which is what decides whether the apply's second
105
+ * consent is even a question. A definition is false by construction (it carries names, never values); a
106
+ * bundle is whatever its owner chose at export; an assistant's home directory is true whenever any row
107
+ * names a secret. The card asks with a toggle only when this is true, so the ordinary arrival is not made
108
+ * to answer a question about credentials that do not exist. */
109
+ carriesSecrets: z.boolean(),
110
+ // What the reader saw and will not offer at all: sessions, logs, pairing state, a tar entry this daemon
111
+ // refuses to write. Listed rather than silent.
112
+ refused: z.array(z.string()),
113
+ // What is already known not to move mechanically, surfaced at PREVIEW time so the owner ticks with open
114
+ // eyes and again on the report.
115
+ needsAction: z.array(NeedsActionSchema),
116
+ });
117
+ export type ArrivalPlan = z.infer<typeof ArrivalPlanSchema>;
118
+
119
+ export const ArrivalApplySchema = z.object({
120
+ token: z.string(),
121
+ // The ticked ids. Ids the re-derived plan does not contain are ignored rather than erroring: the artifact
122
+ // is the truth, and a stale checklist must not block the items that still exist.
123
+ items: z.array(z.string()),
124
+ /* The owner's explicit consent to move credential VALUES, asked once, on the inbound side, for every
125
+ * source. It used to be asked at EXPORT time for a bundle and at APPLY time for an assistant, which is
126
+ * the same question in two places with two different answers about who is consenting to what. */
127
+ includeSecrets: z.boolean(),
128
+ });
129
+ export type ArrivalApply = z.infer<typeof ArrivalApplySchema>;
130
+
131
+ export const ArrivalReportSchema = z.object({
132
+ applied: z.array(z.object({ id: z.string(), group: ArrivalGroupSchema, label: z.string() })),
133
+ // Ticked and did not land, each with its reason. Distinct from `refused` and from inapplicable rows,
134
+ // which were never attempted.
135
+ failed: z.array(z.object({ id: z.string(), label: z.string(), error: z.string() })),
136
+ refused: z.array(z.string()),
137
+ needsAction: z.array(NeedsActionSchema),
138
+ });
139
+ export type ArrivalReport = z.infer<typeof ArrivalReportSchema>;
140
+
141
+ /* ONE OF THE OWNER'S OWN COMPUTERS, as an arrival source that needs no packing at all: the daemon walks the
142
+ * machine's home folder over the socket it already holds. Read on the card's first render for every enrolled
143
+ * machine, so the offer appears before the owner has read a single instruction.
144
+ *
145
+ * `found` absent means "connected, and nothing to bring in from here", which is a real answer worth rendering
146
+ * quietly rather than an error: the machine may simply not be the one the assistant runs on. */
147
+ export const ArrivalHostSchema = z.object({
148
+ id: z.string(),
149
+ online: z.boolean(),
150
+ found: AssistantSourceSchema.optional(),
151
+ // Why this machine cannot be read right now, when it cannot: asleep, or its own refusal, in its words.
152
+ detail: z.string().optional(),
153
+ });
154
+ export type ArrivalHost = z.infer<typeof ArrivalHostSchema>;
155
+ export const ArrivalHostsSchema = z.object({ hosts: z.array(ArrivalHostSchema) });
156
+
157
+ // Read a setup off a connected computer instead of an upload. Answers with a plan exactly as the upload door
158
+ // does; everything after this point is identical whichever door the arrival came through.
159
+ export const ArrivalScanSchema = z.object({ host: z.string().min(1) });
160
+ export type ArrivalScan = z.infer<typeof ArrivalScanSchema>;
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { type AgentCapabilities, capabilitiesOf, limitationsOf } from "./agent-catalog.js";
2
+ import { capabilitiesOf, limitationsOf } from "./agent-catalog.js";
3
+ import type { AgentCapabilities } from "./agent-runtimes.js";
3
4
 
4
5
  /* THE CAPABILITY LEDGER, which claims in the record are backed by machinery, and which by prose.
5
6
  *
@@ -118,9 +119,10 @@ describe("a descriptive claim reaches the user", () => {
118
119
  // A boolean's floor is `false` and needs no entry; every other descriptive field has to name its own.
119
120
  // Asserted against the KEYS of DIMINISHED so the failure prints the floors that do exist, which is what
120
121
  // tells "this field was never added" apart from "this field was renamed and its entry left behind".
121
- expect(boolean ? [field] : Object.keys(DIMINISHED), `${field} is descriptive and not a boolean: add its weakest value to DIMINISHED above`).toContain(
122
- field,
123
- );
122
+ expect(
123
+ boolean ? [field] : Object.keys(DIMINISHED),
124
+ `${field} is descriptive and not a boolean: add its weakest value to DIMINISHED above`,
125
+ ).toContain(field);
124
126
  const floor = boolean ? false : DIMINISHED[field];
125
127
 
126
128
  const lacking: AgentCapabilities = { ...CEILING, [field]: floor };
@@ -0,0 +1,93 @@
1
+ import type { CardField, TranscriptCards, TranscriptRow } from "./events.js";
2
+ import type { AgentReply } from "./schemas/plan-limits.js";
3
+
4
+ /* HOW A REPLY SETTLES THE CARD IT ANSWERS, the one derivation, used by the fold when the `resolved` frame lands
5
+ * (transcript-fold.ts) and by a chat freezing its own card the instant its answer is accepted, before that
6
+ * frame comes back. Two callers, one rule, which is what keeps "answered" looking the same on the window that
7
+ * clicked and on every window that only watched.
8
+ *
9
+ * No reply is nobody answering: the turn was stopped, or died under the card, which is not a decision, so every
10
+ * card reads `cancelled`. A reply of the wrong kind cannot reach a card of another, the requestId is what
11
+ * matched it, and reads as unanswered rather than inventing a decision. */
12
+ type Cards = { -readonly [K in CardField]?: TranscriptRow[K] };
13
+
14
+ export const settledCards = (cards: TranscriptCards, reply: AgentReply | undefined): TranscriptCards => {
15
+ const out: Cards = {};
16
+ const { plan, question, permission, browserHelp, terminalHelp, serviceOffer, capabilityOffer, paymentOffer } = cards;
17
+ if (plan !== undefined) {
18
+ out.plan = { ...plan, status: reply?.kind !== "plan" ? "cancelled" : reply.approve ? "approved" : "rejected" };
19
+ }
20
+ if (question !== undefined) {
21
+ const answers = reply?.kind === "question" ? reply.answers : undefined;
22
+ out.question = {
23
+ ...question,
24
+ status: reply?.kind === "question" && reply.cancelled !== true ? "answered" : "cancelled",
25
+ ...(answers === undefined ? {} : { answers }),
26
+ };
27
+ }
28
+ if (permission !== undefined) {
29
+ out.permission = {
30
+ ...permission,
31
+ status:
32
+ reply?.kind !== "permission"
33
+ ? "cancelled"
34
+ : reply.decision === "deny"
35
+ ? "denied"
36
+ : reply.decision === "always"
37
+ ? "always"
38
+ : "allowed",
39
+ };
40
+ }
41
+ if (browserHelp !== undefined) {
42
+ out.browserHelp = { ...browserHelp, status: reply?.kind !== "browser_help" ? "cancelled" : reply.helped ? "helped" : "declined" };
43
+ }
44
+ if (terminalHelp !== undefined) {
45
+ out.terminalHelp = { ...terminalHelp, status: reply?.kind !== "terminal_help" ? "cancelled" : reply.helped ? "helped" : "declined" };
46
+ }
47
+ if (serviceOffer !== undefined) {
48
+ out.serviceOffer = { ...serviceOffer, status: reply?.kind !== "service_offer" ? "cancelled" : reply.approve ? "approved" : "skipped" };
49
+ }
50
+ // A yes settles the DECISION, not the ask: the owner is now setting the capability up, so the card moves to
51
+ // `connecting` and stays there until the capability_outcome frame says how the setup ended.
52
+ if (capabilityOffer !== undefined) {
53
+ out.capabilityOffer = {
54
+ ...capabilityOffer,
55
+ status: reply?.kind !== "capability_offer" ? "cancelled" : reply.connect ? "connecting" : "skipped",
56
+ };
57
+ }
58
+ // A yes settles the decision; whether the money actually moved is the payment_receipt frame's to say.
59
+ if (paymentOffer !== undefined) {
60
+ out.paymentOffer = { ...paymentOffer, status: reply?.kind !== "payment_offer" ? "cancelled" : reply.approve ? "approved" : "skipped" };
61
+ }
62
+ return out;
63
+ };
64
+
65
+ /* The same cards with every one still `pending` frozen as `cancelled`: the turn ended out from under the ask
66
+ * (a Stop, a death, a failure) and nobody decided anything. Returns the SAME object when nothing was pending,
67
+ * so a caller can tell a row that changed from one that did not. */
68
+ export const cancelledCards = (cards: TranscriptCards): TranscriptCards => {
69
+ const out: Cards = {};
70
+ let changed = false;
71
+ for (const field of [
72
+ "plan",
73
+ "question",
74
+ "permission",
75
+ "browserHelp",
76
+ "terminalHelp",
77
+ "serviceOffer",
78
+ "capabilityOffer",
79
+ "paymentOffer",
80
+ ] as const) {
81
+ const card = cards[field];
82
+ if (card === undefined) {
83
+ continue;
84
+ }
85
+ if (card.status === "pending") {
86
+ changed = true;
87
+ (out as Record<CardField, unknown>)[field] = { ...card, status: "cancelled" };
88
+ } else {
89
+ (out as Record<CardField, unknown>)[field] = card;
90
+ }
91
+ }
92
+ return changed ? out : cards;
93
+ };
@@ -460,9 +460,12 @@ const testStrength: Chore = {
460
460
  criterion: `Stryker's mutation score for the repo is under ${MUTATION_FLOOR}%.`,
461
461
  stance: `act`,
462
462
  needs: [`mutation`],
463
- // Quarterly rather than monthly: a mutation score moves when tests are rewritten, which is not a weekly event,
464
- // and the probe behind it is the most expensive one here.
465
- cadenceMs: 90 * DAY_MS,
463
+ /* Weekly. This was quarterly on the reasoning that a mutation score moves when tests are rewritten and that
464
+ * is not a weekly event; on 2026-08-31 agents rewrote about 180 test files in an afternoon, and the score is
465
+ * exactly the number that should have said so. The probe is still the most expensive one here, and
466
+ * `--incremental` is what makes a weekly cadence affordable: the first run costs a full run, every one after it
467
+ * costs the mutants whose code or tests changed. */
468
+ cadenceMs: 7 * DAY_MS,
466
469
  assess: (context) => {
467
470
  const facts = factsOf(context, `mutation`);
468
471
  if (facts === undefined || facts.mutation.score >= MUTATION_FLOOR) {
@@ -198,6 +198,31 @@ describe("secrets.access", () => {
198
198
  }
199
199
  });
200
200
 
201
+ /* A SEARCH PATTERN IS NOT A PATH, and the escaped dot is why this needed saying: `process.env` is excluded
202
+ * by the dotenv pattern's own lookbehind, and `process\.env` — the same thing typed into a grep — walked
203
+ * straight past it, because the character before `.env` is then a backslash rather than the `s`. Grepping
204
+ * this workspace for its own env reads was one of the commonest cards the class raised. */
205
+ test("a credential-shaped name inside a regex is a pattern, not a file", () => {
206
+ for (const command of [
207
+ String.raw`rg -n 'process\.env\.(INTENTIC_[A-Z]+|GITHUB_[A-Z]+)\b' --type ts .`,
208
+ String.raw`rg -o 'process\.env\.\w+' . | sort -u`,
209
+ String.raw`grep -rn '\.npmrc' .`,
210
+ String.raw`rg '\.ssh/id_ed25519' -l`,
211
+ String.raw`rg -n '\.env\b' -g '!*.md' .`,
212
+ ]) {
213
+ expect(classifyCommand(command), command).not.toContain("secrets.access");
214
+ }
215
+ });
216
+
217
+ /* THE ONE PATH THAT SPELLS `\.` AND IS A PATH. The machine agent's shell runs on somebody's Windows laptop,
218
+ * where the backslash is the separator, so the rule above must not read a real credential read as a regex:
219
+ * a separator is followed by a path segment, an escape by the character it escapes. */
220
+ test("a windows path keeps the class", () => {
221
+ for (const command of [String.raw`type C:\Users\me\.env`, String.raw`copy %USERPROFILE%\.ssh\id_rsa \tmp`]) {
222
+ expect(classifyCommand(command), command).toContain("secrets.access");
223
+ }
224
+ });
225
+
201
226
  /* Carrying a reference IS reading the credential: it becomes the value on the way into the process, so a
202
227
  * command holding one belongs in this class however it is spelled. Otherwise the outside-content floor in
203
228
  * actions.ts is bypassed by the shorter route to the same place, writing `{{secret:X}}` into a curl rather
@@ -20,6 +20,14 @@ import { type CommandClass, CommandClassSchema } from "./schemas/agent.js";
20
20
  * boundary, the boundaries are structural and elsewhere: the container, the isolated worktree, the land gate,
21
21
  * an automation's tool allowlist, and, on somebody's own computer, the scope switches enforced there.
22
22
  *
23
+ * WHICH IS WHY THIS NO LONGER DECIDES ANYTHING. A match used to BE the verdict: whatever fired here became the
24
+ * permission card, so `echo "rm -rf /"`, `rg 'rm -rf'` and an actual delete were one question with one answer.
25
+ * Now a match only means A JUDGE SHOULD LOOK (safety-policy.ts argues the move at length, guard/command-gate.ts
26
+ * implements it), and the judge reads the owner's written policy plus what the daemon knows about the turn.
27
+ * That changes what these patterns should optimise for: being OVER-inclusive is close to free, because a false
28
+ * positive now costs one model call rather than one interruption, and a miss still costs everything. Anyone
29
+ * tuning a pattern below should widen rather than narrow it.
30
+ *
23
31
  * Matching is deliberately UNANCHORED, substrings, not line starts. Another PreToolUse hook may have rewrapped
24
32
  * the command by the time this reads it (agent-terminals.ts wraps every Bash call in bin/tmux-run), and the
25
33
  * agent's own line survives verbatim inside that wrapper. Nothing the wrapper adds is in any class below.
@@ -414,6 +422,36 @@ const enclosingPath = (command: string, span: CommandSpan): string => {
414
422
  .replace(/^[@<>=]+/, "");
415
423
  };
416
424
 
425
+ /* A WORD THAT IS A PATTERN RATHER THAN A PATH, dropped before the table's guess about a FILE is believed at all.
426
+ *
427
+ * The table reads shell text looking for filenames, and a search command carries something that looks exactly
428
+ * like one and is not: `rg 'process\.env\.(INTENTIC_[A-Z]+)' --type ts .` names no file and opens nothing, and
429
+ * it earned a card reading "this command would read credential material" over a grep of this workspace's own
430
+ * source. The `.env` in it survives the dotenv pattern's `process.env` exclusion for one reason: the lookbehind
431
+ * sees the REGEX'S BACKSLASH rather than the `s` of `process`, and a backslash is neither a word character nor a
432
+ * dot. Every credential-shaped name has the same hole — `rg '\.npmrc'`, `rg '\.ssh/id_ed25519'` — so it is fixed
433
+ * once here rather than seven times in the table.
434
+ *
435
+ * THE ESCAPED DOT IS THE TELL. `\.` is how a regex spells a literal dot, and a POSIX path never needs it. The
436
+ * one thing that spells `\.` and IS a path is Windows (`type C:\Users\me\.env`), which the machine agent's shell
437
+ * really does see — and there the other backslashes are SEPARATORS, each followed by a path segment rather than
438
+ * by the character it escapes. That is the whole discrimination.
439
+ *
440
+ * A CHARACTER CLASS and a CLASS ESCAPE are the other two tells, and both are nearly free: `[…]` is legal in a
441
+ * filename and never in one anybody writes, and `\w`, `\d`, `\b` mean nothing to a shell. The class escapes are
442
+ * matched only where a word character does NOT follow, which is what keeps `\dev` and `\swap` (Windows
443
+ * directories) out of them. The word edges (WORD_EDGE) already cut a word at the `(`, `|` and quotes carrying
444
+ * the rest of a regex's syntax, so these are what is left of it by the time a word reaches here.
445
+ *
446
+ * Judged on the ENCLOSING WORD, the same word the fact-check would have asked the filesystem about, so a
447
+ * pattern and a path are told apart once and both consults see the same answer. */
448
+ const CHARACTER_CLASS = /\[[^\]]*\]/;
449
+ const CLASS_ESCAPE = /\\[wdsbWDSB](?!\w)/;
450
+ const ESCAPED_DOT = /\\\./;
451
+ const PATH_SEPARATOR = /\\\w/;
452
+ const namesAPattern = (word: string): boolean =>
453
+ CHARACTER_CLASS.test(word) || CLASS_ESCAPE.test(word) || (ESCAPED_DOT.test(word) && !PATH_SEPARATOR.test(word));
454
+
417
455
  /* WHERE A COMMAND READS CREDENTIAL MATERIAL: every secret reference in it, plus every credential-shaped path the
418
456
  * context did not positively clear.
419
457
  *
@@ -422,7 +460,10 @@ const enclosingPath = (command: string, span: CommandSpan): string => {
422
460
  * class would evaporate on every caller without a filesystem. */
423
461
  const credentialReads = (command: string, context: CommandContext | undefined): CommandSpan[] => [
424
462
  ...spansOf(SECRET_REFERENCES_G, command),
425
- ...spansOf(CREDENTIAL_PATHS_G, command).filter((span) => context?.holdsSecret?.(enclosingPath(command, span)) !== false),
463
+ ...spansOf(CREDENTIAL_PATHS_G, command).filter((span) => {
464
+ const word = enclosingPath(command, span);
465
+ return !namesAPattern(word) && context?.holdsSecret?.(word) !== false;
466
+ }),
426
467
  ];
427
468
 
428
469
  // WHERE each class fires, one entry per class. Empty ⇒ the command is not in it, so membership and evidence are
@@ -462,7 +503,8 @@ export const COMMAND_CLASS_LABELS: Readonly<Record<CommandClass, string>> = {
462
503
  "network.outbound": "send a request out to the internet",
463
504
  };
464
505
 
465
- /* THE CLASSES THE DAEMON HOLDS WHERE THE OWNER WROTE NO RULE, and the machine agent refuses without its own
466
- * switch. Named here rather than at either consult, so "which commands are dangerous enough to stop by
467
- * default" has one answer that both enforcement points read. */
468
- export const FLOOR_CLASSES: ReadonlySet<CommandClass> = new Set<CommandClass>(["system.destructive"]);
506
+ /* No verdict set lives here any more. Which classes are worth stopping for is a POLICY question now, and it is
507
+ * answered in two places that are honest about being different: safety-policy.ts's HARD_RULE_CLASSES for the
508
+ * one thing nothing recovers, and the owner's own written policy for everything else. The machine agent keeps
509
+ * its own set beside its scope switches (machine/src/computer/tools/shell.ts), because "which commands need
510
+ * the destructive switch" is a question about that capability card rather than about this catalog. */
@@ -0,0 +1,78 @@
1
+ import type { CommandRun } from "./schemas/ci.js";
2
+
3
+ /* THE HEADING OVER A SETTLED RUN, three or four words, because it is read at a glance and from across a view
4
+ * the user may have walked back into, and because it is read in TWO places that must agree: the card the app
5
+ * floats over the workspace, and the notification the daemon sends to a phone when the user is not there to
6
+ * see the card. Two copies of these words drifted once ("Checks failed" on the card, "Check failed" on the
7
+ * phone), which is the whole reason the sentence lives in the package both sides already share.
8
+ *
9
+ * `subject` is what ran, in the words the button used: "Checks" for the pre-push check, "Push" for the push,
10
+ * and the verb the user actually clicked ("Publish", "Sync") where the flow knows it. `passed` is included
11
+ * for totality; no card is raised for it, a pass sends the push and says so where the click was. */
12
+ export interface FollowRunOptions<R extends CommandRun> {
13
+ // The wait between "is it done yet" questions.
14
+ readonly intervalMs: number;
15
+ // Stop following: the caller has moved on. Resolves undefined rather than throwing, the run itself is
16
+ // still going wherever it was, and nobody here has anything to report about it.
17
+ readonly signal?: AbortSignal | undefined;
18
+ // Every state read, settled or not, for a surface that draws progress (the terminal to open, the command).
19
+ readonly onState?: ((run: R) => void) | undefined;
20
+ // A poll that could not be read. NOT the run failing: it is still going on the daemon, and the next tick
21
+ // usually reconnects, so following continues; the caller decides what to show meanwhile.
22
+ readonly onError?: ((cause: unknown) => void) | undefined;
23
+ }
24
+
25
+ const sleep = (ms: number, signal: AbortSignal | undefined): Promise<void> =>
26
+ new Promise((resolve) => {
27
+ const timer = setTimeout(done, ms);
28
+ function done(): void {
29
+ clearTimeout(timer);
30
+ signal?.removeEventListener("abort", done);
31
+ resolve();
32
+ }
33
+ signal?.addEventListener("abort", done, { once: true });
34
+ });
35
+
36
+ /* FOLLOW A RUN TO ITS VERDICT: read its state until it is no longer running, and answer with the settled run.
37
+ * The one loop under every surface that starts a command and waits for the answer, the web's run watcher and
38
+ * an extension's push pill alike, because a daemon that answers "started" at once (prepush.contract.ts,
39
+ * git.contract.ts push) leaves every caller to write this loop, and each one that did wrote the dropped-poll
40
+ * case differently. Reads once immediately, so a run that has already settled is answered without a wait. */
41
+ export const followCommandRun = async <R extends CommandRun>(read: () => Promise<R>, options: FollowRunOptions<R>): Promise<R | undefined> => {
42
+ const { intervalMs, signal, onState, onError } = options;
43
+ for (;;) {
44
+ if (signal?.aborted === true) {
45
+ return undefined;
46
+ }
47
+ try {
48
+ const run = await read();
49
+ onState?.(run);
50
+ if (run.status !== "running") {
51
+ return run;
52
+ }
53
+ } catch (cause) {
54
+ onError?.(cause);
55
+ }
56
+ await sleep(intervalMs, signal);
57
+ }
58
+ };
59
+
60
+ export const commandRunOutcome = (run: Pick<CommandRun, "status" | "timedOut">, subject: string): string => {
61
+ if (run.timedOut === true) {
62
+ return `${subject} timed out`;
63
+ }
64
+ switch (run.status) {
65
+ case "error":
66
+ return `${subject} couldn't run`;
67
+ case "cancelled":
68
+ return `${subject} stopped`;
69
+ case "failed":
70
+ return `${subject} failed`;
71
+ case "passed":
72
+ return `${subject} passed`;
73
+ default:
74
+ // `idle` reaches a card only when the command was cleared between the click and the request, there
75
+ // is no run any more, so it says so rather than implying one ran and said nothing.
76
+ return `${subject} didn't run`;
77
+ }
78
+ };
@@ -5,7 +5,7 @@ import { currentLock } from "./contract-lock.js";
5
5
  /* The committed lock and the code must say the same thing: see contract-lock.ts for what the pair buys.
6
6
  *
7
7
  * This is the HALF that runs everywhere the tests run; the other half (a shrunk lock needs a declared break)
8
- * lives in prepass.mjs, which has git and this suite does not.
8
+ * lives in _tools/checks/contract-shrink.mjs, which has git and this suite does not.
9
9
  *
10
10
  * ITS OWN BUDGET, because the default one is a HANG DETECTOR and this test does real work: it serializes every
11
11
  * schema this package exports, ~500 of them, to JSON Schema. That is ~100ms with the machine to itself and it
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Two readers depend on that answer. contract-lock.test.ts fails whenever the committed file is out of step
6
6
  * with the code, so a contract change always lands WITH its lock diff, visible in review, attributable to a
7
- * commit. And prepass.mjs (invariant 6) compares the committed lock against its merge-base to insist that a
7
+ * commit. And the contract-shrink check (_tools/checks/) compares the committed lock against its merge-base to insist that a
8
8
  * SHRUNK surface, a schema or field removed, a type changed, arrives as a `!` commit carrying a
9
9
  * `Breaking-Note:` trailer, which is what feeds the release's "Breaking changes" section and the update card's
10
10
  * warning. Additions pass freely; every persisted-manifest reader parses loosely, so growth breaks nobody.
@@ -58,7 +58,7 @@ export const currentLock = (): Record<string, unknown> => {
58
58
  /* ONE LINE PER SCHEMA, on purpose, not JSON.stringify(lock, null, 4). Pretty-printed, the lock is a 35k-line
59
59
  * wall nobody scrolls; a line per export keeps it ~500 lines and makes `git diff` read as the list of WHICH
60
60
  * surfaces moved, which is the level a reviewer reviews at. The path-level detail lives in the tooling: the
61
- * lock test diffs parsed objects and prepass invariant 6 names the exact removed paths, so nothing is lost by
61
+ * lock test diffs parsed objects and the contract-shrink check names the exact removed paths, so nothing is lost by
62
62
  * not laying the structure out vertically. */
63
63
  export const serializeLock = (lock: Record<string, unknown>): string =>
64
64
  `{\n${Object.entries(lock)