@cmetech/otto 1.1.0 → 1.2.4

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 (425) hide show
  1. package/dist/coworker/persona-commands.d.ts +1 -0
  2. package/dist/coworker/persona-commands.js +5 -0
  3. package/dist/coworker/persona-commands.test.d.ts +1 -0
  4. package/dist/coworker/persona-commands.test.js +45 -0
  5. package/dist/resources/.managed-resources-content-hash +1 -1
  6. package/dist/resources/extensions/_coworker-paths.js +8 -0
  7. package/dist/resources/extensions/coworker-artifacts/artifacts-command.js +31 -0
  8. package/dist/resources/extensions/coworker-artifacts/artifacts-singleton.js +17 -0
  9. package/dist/resources/extensions/coworker-artifacts/extension-manifest.json +13 -0
  10. package/dist/resources/extensions/coworker-artifacts/index.js +125 -0
  11. package/dist/resources/extensions/coworker-artifacts/list-tool.js +27 -0
  12. package/dist/resources/extensions/coworker-artifacts/open-tool.js +25 -0
  13. package/dist/resources/extensions/coworker-memory/extension-manifest.json +13 -0
  14. package/dist/resources/extensions/coworker-memory/index.js +219 -0
  15. package/dist/resources/extensions/coworker-memory/memorize-tool.js +10 -0
  16. package/dist/resources/extensions/coworker-memory/memory-command.js +157 -0
  17. package/dist/resources/extensions/coworker-memory/memory-singleton.js +55 -0
  18. package/dist/resources/extensions/coworker-memory/recall-tool.js +18 -0
  19. package/dist/resources/extensions/coworker-memory/session-hooks.js +45 -0
  20. package/dist/resources/extensions/coworker-scratchpad/attach-banners.js +53 -0
  21. package/dist/resources/extensions/coworker-scratchpad/extension-manifest.json +13 -0
  22. package/dist/resources/extensions/coworker-scratchpad/format-age.js +9 -0
  23. package/dist/resources/extensions/coworker-scratchpad/helpers.js +38 -0
  24. package/dist/resources/extensions/coworker-scratchpad/index.js +199 -0
  25. package/dist/resources/extensions/coworker-scratchpad/mime-bundle.js +20 -0
  26. package/dist/resources/extensions/coworker-scratchpad/scratchpad-tool.js +118 -0
  27. package/dist/resources/extensions/coworker-scratchpad/session-sidecar.js +60 -0
  28. package/dist/resources/extensions/coworker-scratchpad/sp-command.js +597 -0
  29. package/dist/resources/extensions/coworker-scratchpad/workspace-pointer.js +41 -0
  30. package/dist/resources/extensions/coworker-scratchpad/workspace-root.js +17 -0
  31. package/dist/resources/extensions/coworker-vault/audit-command.js +35 -0
  32. package/dist/resources/extensions/coworker-vault/connect-command.js +42 -0
  33. package/dist/resources/extensions/coworker-vault/datasource-command.js +50 -0
  34. package/dist/resources/extensions/coworker-vault/extension-manifest.json +12 -0
  35. package/dist/resources/extensions/coworker-vault/index.js +171 -0
  36. package/dist/resources/extensions/coworker-vault/test-helpers.js +86 -0
  37. package/dist/resources/extensions/coworker-vault/vault-singleton.js +24 -0
  38. package/dist/resources/extensions/otto/commands/release-notes/_data.js +82 -0
  39. package/dist/resources/extensions/otto/commands/release-notes/command.js +15 -4
  40. package/dist/resources/extensions/subagent/index.js +8 -1
  41. package/dist/resources/extensions/subagent/launch.js +37 -5
  42. package/dist/resources/extensions/subagent/run-store.js +1 -0
  43. package/dist/resources/extensions/workflow/bootstrap/register-extension.js +2 -0
  44. package/dist/resources/extensions/workflow/bootstrap/register-hooks.js +10 -0
  45. package/dist/resources/extensions/workflow/persona-status.js +87 -0
  46. package/dist/update-cmd.d.ts +19 -0
  47. package/dist/update-cmd.js +177 -6
  48. package/package.json +25 -10
  49. package/packages/contracts/package.json +1 -1
  50. package/packages/coworker-artifacts/dist/artifact-store.d.ts +25 -0
  51. package/packages/coworker-artifacts/dist/artifact-store.js +187 -0
  52. package/packages/coworker-artifacts/dist/dir-snapshot.d.ts +7 -0
  53. package/packages/coworker-artifacts/dist/dir-snapshot.js +54 -0
  54. package/packages/coworker-artifacts/dist/errors.d.ts +18 -0
  55. package/packages/coworker-artifacts/dist/errors.js +37 -0
  56. package/packages/coworker-artifacts/dist/index.d.ts +7 -0
  57. package/packages/coworker-artifacts/dist/index.js +7 -0
  58. package/packages/coworker-artifacts/dist/readme-renderer.d.ts +5 -0
  59. package/packages/coworker-artifacts/dist/readme-renderer.js +47 -0
  60. package/packages/coworker-artifacts/dist/resolve-uri.d.ts +3 -0
  61. package/packages/coworker-artifacts/dist/resolve-uri.js +29 -0
  62. package/packages/coworker-artifacts/dist/slug.d.ts +4 -0
  63. package/packages/coworker-artifacts/dist/slug.js +32 -0
  64. package/packages/coworker-artifacts/dist/types.d.ts +52 -0
  65. package/packages/coworker-artifacts/dist/types.js +1 -0
  66. package/packages/coworker-artifacts/package.json +20 -0
  67. package/packages/coworker-artifacts/src/artifact-store.test.ts +188 -0
  68. package/packages/coworker-artifacts/src/artifact-store.ts +206 -0
  69. package/packages/coworker-artifacts/src/artifacts-integration.test.ts +109 -0
  70. package/packages/coworker-artifacts/src/dir-snapshot.test.ts +71 -0
  71. package/packages/coworker-artifacts/src/dir-snapshot.ts +52 -0
  72. package/packages/coworker-artifacts/src/errors.test.ts +37 -0
  73. package/packages/coworker-artifacts/src/errors.ts +28 -0
  74. package/packages/coworker-artifacts/src/index.test.ts +22 -0
  75. package/packages/coworker-artifacts/src/index.ts +7 -0
  76. package/packages/coworker-artifacts/src/readme-renderer.test.ts +72 -0
  77. package/packages/coworker-artifacts/src/readme-renderer.ts +56 -0
  78. package/packages/coworker-artifacts/src/resolve-uri.test.ts +46 -0
  79. package/packages/coworker-artifacts/src/resolve-uri.ts +29 -0
  80. package/packages/coworker-artifacts/src/slug.test.ts +47 -0
  81. package/packages/coworker-artifacts/src/slug.ts +31 -0
  82. package/packages/coworker-artifacts/src/types.ts +61 -0
  83. package/packages/coworker-artifacts/tsconfig.json +15 -0
  84. package/packages/coworker-artifacts/tsconfig.publish.json +4 -0
  85. package/packages/coworker-memory/dist/context-injection.d.ts +9 -0
  86. package/packages/coworker-memory/dist/context-injection.js +41 -0
  87. package/packages/coworker-memory/dist/errors.d.ts +25 -0
  88. package/packages/coworker-memory/dist/errors.js +51 -0
  89. package/packages/coworker-memory/dist/index.d.ts +12 -0
  90. package/packages/coworker-memory/dist/index.js +12 -0
  91. package/packages/coworker-memory/dist/layer-a-store.d.ts +16 -0
  92. package/packages/coworker-memory/dist/layer-a-store.js +78 -0
  93. package/packages/coworker-memory/dist/local-sqlite-backend.d.ts +28 -0
  94. package/packages/coworker-memory/dist/local-sqlite-backend.js +167 -0
  95. package/packages/coworker-memory/dist/memory-backend.d.ts +14 -0
  96. package/packages/coworker-memory/dist/memory-backend.js +1 -0
  97. package/packages/coworker-memory/dist/memory-recorder.d.ts +50 -0
  98. package/packages/coworker-memory/dist/memory-recorder.js +69 -0
  99. package/packages/coworker-memory/dist/migrations/001-init.sql +38 -0
  100. package/packages/coworker-memory/dist/migrations/002-artifact-kind.sql +50 -0
  101. package/packages/coworker-memory/dist/paste-detector.d.ts +5 -0
  102. package/packages/coworker-memory/dist/paste-detector.js +14 -0
  103. package/packages/coworker-memory/dist/persona-seed.d.ts +10 -0
  104. package/packages/coworker-memory/dist/persona-seed.js +38 -0
  105. package/packages/coworker-memory/dist/recall-formatter.d.ts +2 -0
  106. package/packages/coworker-memory/dist/recall-formatter.js +14 -0
  107. package/packages/coworker-memory/dist/scope-resolver.d.ts +9 -0
  108. package/packages/coworker-memory/dist/scope-resolver.js +10 -0
  109. package/packages/coworker-memory/dist/types.d.ts +51 -0
  110. package/packages/coworker-memory/dist/types.js +2 -0
  111. package/packages/coworker-memory/dist/workspace-id.d.ts +3 -0
  112. package/packages/coworker-memory/dist/workspace-id.js +54 -0
  113. package/packages/coworker-memory/package.json +35 -0
  114. package/packages/coworker-memory/src/activator-integration.test.ts +141 -0
  115. package/packages/coworker-memory/src/context-injection.test.ts +72 -0
  116. package/packages/coworker-memory/src/context-injection.ts +57 -0
  117. package/packages/coworker-memory/src/errors.test.ts +45 -0
  118. package/packages/coworker-memory/src/errors.ts +42 -0
  119. package/packages/coworker-memory/src/index.test.ts +21 -0
  120. package/packages/coworker-memory/src/index.ts +12 -0
  121. package/packages/coworker-memory/src/layer-a-store.test.ts +85 -0
  122. package/packages/coworker-memory/src/layer-a-store.ts +88 -0
  123. package/packages/coworker-memory/src/local-sqlite-backend.test.ts +110 -0
  124. package/packages/coworker-memory/src/local-sqlite-backend.ts +185 -0
  125. package/packages/coworker-memory/src/memory-backend.ts +10 -0
  126. package/packages/coworker-memory/src/memory-integration.test.ts +89 -0
  127. package/packages/coworker-memory/src/memory-recorder.test.ts +101 -0
  128. package/packages/coworker-memory/src/memory-recorder.ts +95 -0
  129. package/packages/coworker-memory/src/migrations/001-init.sql +38 -0
  130. package/packages/coworker-memory/src/migrations/002-artifact-kind.sql +50 -0
  131. package/packages/coworker-memory/src/paste-detector.test.ts +23 -0
  132. package/packages/coworker-memory/src/paste-detector.ts +18 -0
  133. package/packages/coworker-memory/src/persona-seed.test.ts +57 -0
  134. package/packages/coworker-memory/src/persona-seed.ts +46 -0
  135. package/packages/coworker-memory/src/recall-formatter.test.ts +34 -0
  136. package/packages/coworker-memory/src/recall-formatter.ts +15 -0
  137. package/packages/coworker-memory/src/scope-resolver.test.ts +23 -0
  138. package/packages/coworker-memory/src/scope-resolver.ts +18 -0
  139. package/packages/coworker-memory/src/types.ts +61 -0
  140. package/packages/coworker-memory/src/workspace-id.test.ts +48 -0
  141. package/packages/coworker-memory/src/workspace-id.ts +56 -0
  142. package/packages/coworker-memory/tsconfig.json +15 -0
  143. package/packages/coworker-memory/tsconfig.publish.json +4 -0
  144. package/packages/coworker-persona/dist/commands.d.ts +7 -0
  145. package/packages/coworker-persona/dist/commands.js +35 -0
  146. package/packages/coworker-persona/dist/defaults/manifest.yaml +12 -0
  147. package/packages/coworker-persona/dist/defaults/steering/identity.md +3 -0
  148. package/packages/coworker-persona/dist/index.d.ts +3 -0
  149. package/packages/coworker-persona/dist/index.js +3 -0
  150. package/packages/coworker-persona/dist/manifest.d.ts +24 -0
  151. package/packages/coworker-persona/dist/manifest.js +21 -0
  152. package/packages/coworker-persona/dist/registry.d.ts +22 -0
  153. package/packages/coworker-persona/dist/registry.js +142 -0
  154. package/packages/coworker-persona/package.json +28 -0
  155. package/packages/coworker-persona/scripts/copy-defaults.cjs +17 -0
  156. package/packages/coworker-persona/src/commands.ts +47 -0
  157. package/packages/coworker-persona/src/defaults/manifest.yaml +12 -0
  158. package/packages/coworker-persona/src/defaults/steering/identity.md +3 -0
  159. package/packages/coworker-persona/src/index.ts +3 -0
  160. package/packages/coworker-persona/src/manifest.test.ts +67 -0
  161. package/packages/coworker-persona/src/manifest.ts +49 -0
  162. package/packages/coworker-persona/src/registry.test.ts +89 -0
  163. package/packages/coworker-persona/src/registry.ts +147 -0
  164. package/packages/coworker-persona/tsconfig.json +15 -0
  165. package/packages/coworker-persona/tsconfig.publish.json +4 -0
  166. package/packages/coworker-scratchpad/dist/cell-archive.d.ts +39 -0
  167. package/packages/coworker-scratchpad/dist/cell-archive.js +77 -0
  168. package/packages/coworker-scratchpad/dist/cell-tree.d.ts +14 -0
  169. package/packages/coworker-scratchpad/dist/cell-tree.js +72 -0
  170. package/packages/coworker-scratchpad/dist/child-process-runtime.d.ts +129 -0
  171. package/packages/coworker-scratchpad/dist/child-process-runtime.js +427 -0
  172. package/packages/coworker-scratchpad/dist/collector-registry.d.ts +12 -0
  173. package/packages/coworker-scratchpad/dist/collector-registry.js +29 -0
  174. package/packages/coworker-scratchpad/dist/detect-kind.d.ts +3 -0
  175. package/packages/coworker-scratchpad/dist/detect-kind.js +19 -0
  176. package/packages/coworker-scratchpad/dist/file-collector.d.ts +15 -0
  177. package/packages/coworker-scratchpad/dist/file-collector.js +99 -0
  178. package/packages/coworker-scratchpad/dist/index.d.ts +13 -0
  179. package/packages/coworker-scratchpad/dist/index.js +13 -0
  180. package/packages/coworker-scratchpad/dist/kernel-bindings.d.ts +49 -0
  181. package/packages/coworker-scratchpad/dist/kernel-bindings.js +220 -0
  182. package/packages/coworker-scratchpad/dist/kernel-entry.d.ts +1 -0
  183. package/packages/coworker-scratchpad/dist/kernel-entry.js +355 -0
  184. package/packages/coworker-scratchpad/dist/kernel-protocol.d.ts +171 -0
  185. package/packages/coworker-scratchpad/dist/kernel-protocol.js +48 -0
  186. package/packages/coworker-scratchpad/dist/kernel-spawn.d.ts +3 -0
  187. package/packages/coworker-scratchpad/dist/kernel-spawn.js +54 -0
  188. package/packages/coworker-scratchpad/dist/namespace-codec.d.ts +22 -0
  189. package/packages/coworker-scratchpad/dist/namespace-codec.js +61 -0
  190. package/packages/coworker-scratchpad/dist/scratchpad-lock.d.ts +24 -0
  191. package/packages/coworker-scratchpad/dist/scratchpad-lock.js +86 -0
  192. package/packages/coworker-scratchpad/dist/scratchpad-manager.d.ts +193 -0
  193. package/packages/coworker-scratchpad/dist/scratchpad-manager.js +866 -0
  194. package/packages/coworker-scratchpad/dist/staleness-banner.d.ts +12 -0
  195. package/packages/coworker-scratchpad/dist/staleness-banner.js +27 -0
  196. package/packages/coworker-scratchpad/package.json +31 -0
  197. package/packages/coworker-scratchpad/src/cell-archive.test.ts +150 -0
  198. package/packages/coworker-scratchpad/src/cell-archive.ts +97 -0
  199. package/packages/coworker-scratchpad/src/cell-tree.test.ts +105 -0
  200. package/packages/coworker-scratchpad/src/cell-tree.ts +90 -0
  201. package/packages/coworker-scratchpad/src/child-process-runtime.test.ts +413 -0
  202. package/packages/coworker-scratchpad/src/child-process-runtime.ts +493 -0
  203. package/packages/coworker-scratchpad/src/collector-registry.test.ts +69 -0
  204. package/packages/coworker-scratchpad/src/collector-registry.ts +33 -0
  205. package/packages/coworker-scratchpad/src/detect-kind.test.ts +33 -0
  206. package/packages/coworker-scratchpad/src/detect-kind.ts +22 -0
  207. package/packages/coworker-scratchpad/src/file-collector.test.ts +109 -0
  208. package/packages/coworker-scratchpad/src/file-collector.ts +114 -0
  209. package/packages/coworker-scratchpad/src/index.ts +74 -0
  210. package/packages/coworker-scratchpad/src/kernel-bindings.test.ts +188 -0
  211. package/packages/coworker-scratchpad/src/kernel-bindings.ts +279 -0
  212. package/packages/coworker-scratchpad/src/kernel-entry.test.ts +123 -0
  213. package/packages/coworker-scratchpad/src/kernel-entry.ts +390 -0
  214. package/packages/coworker-scratchpad/src/kernel-protocol.test.ts +105 -0
  215. package/packages/coworker-scratchpad/src/kernel-protocol.ts +230 -0
  216. package/packages/coworker-scratchpad/src/kernel-spawn.test.ts +60 -0
  217. package/packages/coworker-scratchpad/src/kernel-spawn.ts +54 -0
  218. package/packages/coworker-scratchpad/src/namespace-codec.test.ts +102 -0
  219. package/packages/coworker-scratchpad/src/namespace-codec.ts +90 -0
  220. package/packages/coworker-scratchpad/src/scratchpad-lock.test.ts +98 -0
  221. package/packages/coworker-scratchpad/src/scratchpad-lock.ts +102 -0
  222. package/packages/coworker-scratchpad/src/scratchpad-manager.test.ts +1343 -0
  223. package/packages/coworker-scratchpad/src/scratchpad-manager.ts +891 -0
  224. package/packages/coworker-scratchpad/src/staleness-banner.test.ts +53 -0
  225. package/packages/coworker-scratchpad/src/staleness-banner.ts +33 -0
  226. package/packages/coworker-scratchpad/src/vault-integration.test.ts +221 -0
  227. package/packages/coworker-scratchpad/tsconfig.json +15 -0
  228. package/packages/coworker-scratchpad/tsconfig.publish.json +4 -0
  229. package/packages/coworker-types/dist/artifacts.d.ts +31 -0
  230. package/packages/coworker-types/dist/artifacts.js +2 -0
  231. package/packages/coworker-types/dist/contracts.d.ts +32 -0
  232. package/packages/coworker-types/dist/contracts.js +1 -0
  233. package/packages/coworker-types/dist/index.d.ts +5 -0
  234. package/packages/coworker-types/dist/index.js +5 -0
  235. package/packages/coworker-types/dist/memory.d.ts +61 -0
  236. package/packages/coworker-types/dist/memory.js +3 -0
  237. package/packages/coworker-types/dist/scratchpad.d.ts +43 -0
  238. package/packages/coworker-types/dist/scratchpad.js +2 -0
  239. package/packages/coworker-types/dist/vault.d.ts +34 -0
  240. package/packages/coworker-types/dist/vault.js +2 -0
  241. package/packages/coworker-types/package.json +24 -0
  242. package/packages/coworker-types/src/artifacts.test.ts +52 -0
  243. package/packages/coworker-types/src/artifacts.ts +35 -0
  244. package/packages/coworker-types/src/contracts.test.ts +43 -0
  245. package/packages/coworker-types/src/contracts.ts +36 -0
  246. package/packages/coworker-types/src/index.ts +5 -0
  247. package/packages/coworker-types/src/memory.test.ts +50 -0
  248. package/packages/coworker-types/src/memory.ts +79 -0
  249. package/packages/coworker-types/src/scratchpad.test.ts +46 -0
  250. package/packages/coworker-types/src/scratchpad.ts +51 -0
  251. package/packages/coworker-types/src/smoke.test.ts +34 -0
  252. package/packages/coworker-types/src/vault.test.ts +49 -0
  253. package/packages/coworker-types/src/vault.ts +40 -0
  254. package/packages/coworker-types/tsconfig.json +15 -0
  255. package/packages/coworker-types/tsconfig.publish.json +4 -0
  256. package/packages/coworker-utils/dist/audit-log.d.ts +34 -0
  257. package/packages/coworker-utils/dist/audit-log.js +88 -0
  258. package/packages/coworker-utils/dist/index.d.ts +6 -0
  259. package/packages/coworker-utils/dist/index.js +6 -0
  260. package/packages/coworker-utils/dist/lease.d.ts +7 -0
  261. package/packages/coworker-utils/dist/lease.js +67 -0
  262. package/packages/coworker-utils/dist/logger.d.ts +13 -0
  263. package/packages/coworker-utils/dist/logger.js +26 -0
  264. package/packages/coworker-utils/dist/migration-runner.d.ts +7 -0
  265. package/packages/coworker-utils/dist/migration-runner.js +36 -0
  266. package/packages/coworker-utils/dist/ndjson-channel.d.ts +3 -0
  267. package/packages/coworker-utils/dist/ndjson-channel.js +38 -0
  268. package/packages/coworker-utils/dist/secret-scanner.d.ts +10 -0
  269. package/packages/coworker-utils/dist/secret-scanner.js +42 -0
  270. package/packages/coworker-utils/package.json +24 -0
  271. package/packages/coworker-utils/src/audit-log.test.ts +140 -0
  272. package/packages/coworker-utils/src/audit-log.ts +107 -0
  273. package/packages/coworker-utils/src/index.ts +6 -0
  274. package/packages/coworker-utils/src/lease.test.ts +64 -0
  275. package/packages/coworker-utils/src/lease.ts +76 -0
  276. package/packages/coworker-utils/src/logger.test.ts +50 -0
  277. package/packages/coworker-utils/src/logger.ts +45 -0
  278. package/packages/coworker-utils/src/migration-runner.test.ts +65 -0
  279. package/packages/coworker-utils/src/migration-runner.ts +50 -0
  280. package/packages/coworker-utils/src/ndjson-channel.test.ts +76 -0
  281. package/packages/coworker-utils/src/ndjson-channel.ts +41 -0
  282. package/packages/coworker-utils/src/secret-scanner.test.ts +61 -0
  283. package/packages/coworker-utils/src/secret-scanner.ts +56 -0
  284. package/packages/coworker-utils/tsconfig.json +15 -0
  285. package/packages/coworker-utils/tsconfig.publish.json +4 -0
  286. package/packages/coworker-vault/dist/data-vault.d.ts +41 -0
  287. package/packages/coworker-vault/dist/data-vault.js +223 -0
  288. package/packages/coworker-vault/dist/engine-registry.d.ts +34 -0
  289. package/packages/coworker-vault/dist/engine-registry.js +90 -0
  290. package/packages/coworker-vault/dist/engines/jira.yaml +17 -0
  291. package/packages/coworker-vault/dist/errors.d.ts +28 -0
  292. package/packages/coworker-vault/dist/errors.js +57 -0
  293. package/packages/coworker-vault/dist/index.d.ts +6 -0
  294. package/packages/coworker-vault/dist/index.js +6 -0
  295. package/packages/coworker-vault/dist/injector.d.ts +19 -0
  296. package/packages/coworker-vault/dist/injector.js +77 -0
  297. package/packages/coworker-vault/dist/types.d.ts +28 -0
  298. package/packages/coworker-vault/dist/types.js +1 -0
  299. package/packages/coworker-vault/dist/vault-keep.d.ts +4 -0
  300. package/packages/coworker-vault/dist/vault-keep.js +21 -0
  301. package/packages/coworker-vault/package.json +29 -0
  302. package/packages/coworker-vault/src/data-vault.test.ts +199 -0
  303. package/packages/coworker-vault/src/data-vault.ts +257 -0
  304. package/packages/coworker-vault/src/engine-registry.test.ts +120 -0
  305. package/packages/coworker-vault/src/engine-registry.ts +107 -0
  306. package/packages/coworker-vault/src/engines/jira.yaml +17 -0
  307. package/packages/coworker-vault/src/errors.test.ts +58 -0
  308. package/packages/coworker-vault/src/errors.ts +50 -0
  309. package/packages/coworker-vault/src/index.test.ts +24 -0
  310. package/packages/coworker-vault/src/index.ts +6 -0
  311. package/packages/coworker-vault/src/injector.test.ts +109 -0
  312. package/packages/coworker-vault/src/injector.ts +98 -0
  313. package/packages/coworker-vault/src/types.ts +33 -0
  314. package/packages/coworker-vault/src/vault-keep.test.ts +49 -0
  315. package/packages/coworker-vault/src/vault-keep.ts +31 -0
  316. package/packages/coworker-vault/tsconfig.json +15 -0
  317. package/packages/coworker-vault/tsconfig.publish.json +4 -0
  318. package/packages/daemon/package.json +3 -3
  319. package/packages/mcp-server/package.json +3 -3
  320. package/packages/mcp-server/tsconfig.tsbuildinfo +1 -1
  321. package/packages/native/package.json +1 -1
  322. package/packages/native/tsconfig.tsbuildinfo +1 -1
  323. package/packages/pi-agent-core/package.json +1 -1
  324. package/packages/pi-agent-core/tsconfig.tsbuildinfo +1 -1
  325. package/packages/pi-ai/package.json +1 -1
  326. package/packages/pi-ai/tsconfig.tsbuildinfo +1 -1
  327. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +6 -1
  328. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  329. package/packages/pi-coding-agent/dist/core/extensions/runner.js +22 -3
  330. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  331. package/packages/pi-coding-agent/dist/core/resolve-config-value.test.js +11 -0
  332. package/packages/pi-coding-agent/dist/core/resolve-config-value.test.js.map +1 -1
  333. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.d.ts +47 -0
  334. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.d.ts.map +1 -0
  335. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.js +107 -0
  336. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.js.map +1 -0
  337. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.d.ts +19 -0
  338. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.d.ts.map +1 -0
  339. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.js +121 -0
  340. package/packages/pi-coding-agent/dist/modes/rpc/raw-stdout.regression.test.js.map +1 -0
  341. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  342. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +17 -1
  343. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  344. package/packages/pi-coding-agent/package.json +2 -2
  345. package/packages/pi-coding-agent/src/core/extensions/runner.ts +22 -3
  346. package/packages/pi-coding-agent/src/core/resolve-config-value.test.ts +11 -0
  347. package/packages/pi-coding-agent/src/modes/rpc/raw-stdout.regression.test.ts +129 -0
  348. package/packages/pi-coding-agent/src/modes/rpc/raw-stdout.ts +117 -0
  349. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +18 -1
  350. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  351. package/packages/pi-tui/package.json +1 -1
  352. package/packages/pi-tui/tsconfig.tsbuildinfo +1 -1
  353. package/packages/rpc-client/package.json +2 -2
  354. package/packages/rpc-client/tsconfig.tsbuildinfo +1 -1
  355. package/pkg/package.json +1 -1
  356. package/scripts/install.js +6 -5
  357. package/src/resources/extensions/_coworker-paths.test.ts +40 -0
  358. package/src/resources/extensions/_coworker-paths.ts +10 -0
  359. package/src/resources/extensions/coworker-artifacts/artifacts-command.test.ts +54 -0
  360. package/src/resources/extensions/coworker-artifacts/artifacts-command.ts +43 -0
  361. package/src/resources/extensions/coworker-artifacts/artifacts-singleton.test.ts +25 -0
  362. package/src/resources/extensions/coworker-artifacts/artifacts-singleton.ts +29 -0
  363. package/src/resources/extensions/coworker-artifacts/extension-manifest.json +13 -0
  364. package/src/resources/extensions/coworker-artifacts/index.test.ts +46 -0
  365. package/src/resources/extensions/coworker-artifacts/index.ts +154 -0
  366. package/src/resources/extensions/coworker-artifacts/list-tool.test.ts +29 -0
  367. package/src/resources/extensions/coworker-artifacts/list-tool.ts +53 -0
  368. package/src/resources/extensions/coworker-artifacts/open-tool.test.ts +30 -0
  369. package/src/resources/extensions/coworker-artifacts/open-tool.ts +43 -0
  370. package/src/resources/extensions/coworker-memory/extension-manifest.json +13 -0
  371. package/src/resources/extensions/coworker-memory/index.test.ts +137 -0
  372. package/src/resources/extensions/coworker-memory/index.ts +257 -0
  373. package/src/resources/extensions/coworker-memory/memorize-tool.test.ts +41 -0
  374. package/src/resources/extensions/coworker-memory/memorize-tool.ts +20 -0
  375. package/src/resources/extensions/coworker-memory/memory-command.test.ts +134 -0
  376. package/src/resources/extensions/coworker-memory/memory-command.ts +131 -0
  377. package/src/resources/extensions/coworker-memory/memory-singleton.test.ts +41 -0
  378. package/src/resources/extensions/coworker-memory/memory-singleton.ts +89 -0
  379. package/src/resources/extensions/coworker-memory/recall-tool.test.ts +50 -0
  380. package/src/resources/extensions/coworker-memory/recall-tool.ts +35 -0
  381. package/src/resources/extensions/coworker-memory/session-hooks.test.ts +77 -0
  382. package/src/resources/extensions/coworker-memory/session-hooks.ts +61 -0
  383. package/src/resources/extensions/coworker-scratchpad/attach-banners.test.ts +124 -0
  384. package/src/resources/extensions/coworker-scratchpad/attach-banners.ts +67 -0
  385. package/src/resources/extensions/coworker-scratchpad/extension-manifest.json +13 -0
  386. package/src/resources/extensions/coworker-scratchpad/format-age.test.ts +30 -0
  387. package/src/resources/extensions/coworker-scratchpad/format-age.ts +6 -0
  388. package/src/resources/extensions/coworker-scratchpad/helpers.test.ts +93 -0
  389. package/src/resources/extensions/coworker-scratchpad/helpers.ts +42 -0
  390. package/src/resources/extensions/coworker-scratchpad/index.test.ts +514 -0
  391. package/src/resources/extensions/coworker-scratchpad/index.ts +207 -0
  392. package/src/resources/extensions/coworker-scratchpad/mime-bundle.test.ts +61 -0
  393. package/src/resources/extensions/coworker-scratchpad/mime-bundle.ts +23 -0
  394. package/src/resources/extensions/coworker-scratchpad/scratchpad-tool.test.ts +137 -0
  395. package/src/resources/extensions/coworker-scratchpad/scratchpad-tool.ts +165 -0
  396. package/src/resources/extensions/coworker-scratchpad/session-sidecar.test.ts +133 -0
  397. package/src/resources/extensions/coworker-scratchpad/session-sidecar.ts +68 -0
  398. package/src/resources/extensions/coworker-scratchpad/sp-command.test.ts +836 -0
  399. package/src/resources/extensions/coworker-scratchpad/sp-command.ts +602 -0
  400. package/src/resources/extensions/coworker-scratchpad/workspace-pointer.test.ts +74 -0
  401. package/src/resources/extensions/coworker-scratchpad/workspace-pointer.ts +55 -0
  402. package/src/resources/extensions/coworker-scratchpad/workspace-root.test.ts +51 -0
  403. package/src/resources/extensions/coworker-scratchpad/workspace-root.ts +16 -0
  404. package/src/resources/extensions/coworker-vault/audit-command.test.ts +109 -0
  405. package/src/resources/extensions/coworker-vault/audit-command.ts +56 -0
  406. package/src/resources/extensions/coworker-vault/connect-command.test.ts +103 -0
  407. package/src/resources/extensions/coworker-vault/connect-command.ts +69 -0
  408. package/src/resources/extensions/coworker-vault/datasource-command.test.ts +80 -0
  409. package/src/resources/extensions/coworker-vault/datasource-command.ts +81 -0
  410. package/src/resources/extensions/coworker-vault/extension-manifest.json +12 -0
  411. package/src/resources/extensions/coworker-vault/index.test.ts +82 -0
  412. package/src/resources/extensions/coworker-vault/index.ts +181 -0
  413. package/src/resources/extensions/coworker-vault/test-helpers.ts +120 -0
  414. package/src/resources/extensions/coworker-vault/vault-singleton.test.ts +27 -0
  415. package/src/resources/extensions/coworker-vault/vault-singleton.ts +40 -0
  416. package/src/resources/extensions/otto/commands/release-notes/_data.ts +96 -0
  417. package/src/resources/extensions/otto/commands/release-notes/command.ts +16 -3
  418. package/src/resources/extensions/subagent/index.ts +9 -0
  419. package/src/resources/extensions/subagent/launch.test.ts +97 -0
  420. package/src/resources/extensions/subagent/launch.ts +42 -5
  421. package/src/resources/extensions/subagent/run-store.ts +3 -1
  422. package/src/resources/extensions/workflow/bootstrap/register-extension.ts +2 -0
  423. package/src/resources/extensions/workflow/bootstrap/register-hooks.ts +10 -0
  424. package/src/resources/extensions/workflow/persona-status.ts +109 -0
  425. package/src/resources/extensions/workflow/tests/auto-recovery.test.ts +34 -0
@@ -0,0 +1,836 @@
1
+ import { describe, it, beforeEach, afterEach } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
4
+ import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ import { registerSpCommand, createCurrentScratchpadProvider, type SpDeps } from './sp-command.js';
8
+ import { ScratchpadBusyError } from '@otto/coworker-scratchpad';
9
+
10
+ interface StubEntry {
11
+ live: boolean;
12
+ lastUsedAt: number;
13
+ hasActiveCell: boolean;
14
+ }
15
+
16
+ interface StubMgr {
17
+ list(): Array<{ name: string; live: boolean; lastUsedAt: number; hasActiveCell: boolean }>;
18
+ create(name: string, opts?: { bindings?: string[] }): Promise<unknown>;
19
+ getOrAttach(name: string, opts?: { forceTakeover?: boolean; takeoverReason?: string }): Promise<unknown>;
20
+ remove(name: string): Promise<void>;
21
+ save(name: string): Promise<void>;
22
+ detach(name: string, sessionId: string): Promise<void>;
23
+ clearHistory(name: string): Promise<void>;
24
+ markRecoveryNotesSeen(name: string): Promise<void>;
25
+ evict(name: string, opts?: { force?: boolean }): Promise<{ interrupted: boolean }>;
26
+ // Phase 2 Task 16: bindings operations. Backed by actual meta.json writes
27
+ // in the test root so /sp list and post-create assertions can verify state.
28
+ addBinding(name: string, ref: string): Promise<{ added: boolean }>;
29
+ removeBinding(name: string, ref: string): Promise<{ removed: boolean }>;
30
+ readBindings(name: string): string[];
31
+ rootDir(): string;
32
+ calls: Array<[string, ...unknown[]]>;
33
+ /** Test-only: override per-entry state for /sp list rendering tests. */
34
+ setEntry(name: string, partial: Partial<StubEntry>): void;
35
+ }
36
+
37
+ // Phase 2 Task 16 test helper: read/write meta.json on disk in the test root.
38
+ // The stub manager persists meta.json so sp-command's readBindingsFromMeta()
39
+ // (which inspects disk directly) observes the same state the manager set.
40
+ function writeMetaToDisk(root: string, name: string, patch: Record<string, unknown>): void {
41
+ const dir = join(root, name);
42
+ mkdirSync(dir, { recursive: true });
43
+ const path = join(dir, 'meta.json');
44
+ let cur: Record<string, unknown> = {};
45
+ if (existsSync(path)) {
46
+ try { cur = JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { /* drop */ }
47
+ }
48
+ writeFileSync(path, JSON.stringify({ ...cur, ...patch }, null, 2));
49
+ }
50
+
51
+ function readMetaFromDisk(root: string, name: string): Record<string, unknown> {
52
+ const path = join(root, name, 'meta.json');
53
+ if (!existsSync(path)) return {};
54
+ try { return JSON.parse(readFileSync(path, 'utf8')) as Record<string, unknown>; } catch { return {}; }
55
+ }
56
+
57
+ function makeStub(root: string, existing: string[] = [], busyOnAttach: boolean = false): StubMgr {
58
+ const calls: StubMgr['calls'] = [];
59
+ let busy = busyOnAttach;
60
+ const entries = new Map<string, StubEntry>();
61
+ for (const n of existing) entries.set(n, { live: false, lastUsedAt: 0, hasActiveCell: false });
62
+ return {
63
+ calls,
64
+ rootDir: () => root,
65
+ list() {
66
+ calls.push(['list']);
67
+ return existing.map((n) => {
68
+ const e = entries.get(n) ?? { live: false, lastUsedAt: 0, hasActiveCell: false };
69
+ return { name: n, live: e.live, lastUsedAt: e.lastUsedAt, hasActiveCell: e.hasActiveCell };
70
+ });
71
+ },
72
+ async create(name, opts) {
73
+ calls.push(opts && Object.keys(opts).length > 0 ? ['create', name, opts] : ['create', name]);
74
+ if (existing.includes(name)) throw new Error(`scratchpad ${name} already exists`);
75
+ existing.push(name);
76
+ // Newly created scratchpad behaves like a fresh warm entry.
77
+ entries.set(name, { live: true, lastUsedAt: Date.now(), hasActiveCell: false });
78
+ // Phase 2 Task 16: persist meta.json so the stub mirrors the real
79
+ // manager's writeMeta(name, opts.bindings) call. bindings default to []
80
+ // matching the v4 schema invariant.
81
+ writeMetaToDisk(root, name, {
82
+ name,
83
+ bindings: Array.isArray(opts?.bindings) ? opts!.bindings : [],
84
+ schema_version: 4,
85
+ });
86
+ return null;
87
+ },
88
+ async getOrAttach(name, opts) {
89
+ calls.push(['getOrAttach', name, opts ?? {}]);
90
+ if (busy && !opts?.forceTakeover) {
91
+ throw new ScratchpadBusyError(name, { pid: 9999, host: 'host-x', acquired_at: '2026-05-31T10:00:00.000Z' });
92
+ }
93
+ busy = false; // takeover succeeded; subsequent attaches are normal
94
+ if (!existing.includes(name)) existing.push(name);
95
+ entries.set(name, { live: true, lastUsedAt: Date.now(), hasActiveCell: false });
96
+ return null;
97
+ },
98
+ async remove(name) {
99
+ calls.push(['remove', name]);
100
+ const i = existing.indexOf(name);
101
+ if (i >= 0) existing.splice(i, 1);
102
+ entries.delete(name);
103
+ // Phase 2 Task 16: also clean up the on-disk meta.json the stub created
104
+ // so subsequent /sp list calls don't see the dead scratchpad.
105
+ try { rmSync(join(root, name), { recursive: true, force: true }); } catch { /* drop */ }
106
+ },
107
+ async save(name) { calls.push(['save', name]); if (!existing.includes(name)) throw new Error(`scratchpad ${name} is not warm — nothing to save`); },
108
+ async detach(name, sid) { calls.push(['detach', name, sid]); },
109
+ async clearHistory(name) { calls.push(['clearHistory', name]); },
110
+ async markRecoveryNotesSeen(name) { calls.push(['markRecoveryNotesSeen', name]); },
111
+ async evict(name, opts) {
112
+ calls.push(['evict', name, opts ?? {}]);
113
+ const e = entries.get(name);
114
+ if (!e || !e.live) throw new Error(`scratchpad ${name} is not warm (already cold)`);
115
+ if (e.hasActiveCell && !opts?.force) {
116
+ throw new Error(`cannot evict ${name}: cell is running (use --force to interrupt)`);
117
+ }
118
+ const interrupted = e.hasActiveCell === true;
119
+ entries.set(name, { live: false, lastUsedAt: e.lastUsedAt, hasActiveCell: false });
120
+ return { interrupted };
121
+ },
122
+ setEntry(name, partial) {
123
+ const cur = entries.get(name) ?? { live: false, lastUsedAt: 0, hasActiveCell: false };
124
+ entries.set(name, { ...cur, ...partial });
125
+ },
126
+ async addBinding(name, ref) {
127
+ calls.push(['addBinding', name, ref]);
128
+ if (!existing.includes(name)) throw new Error(`scratchpad not found: ${name}`);
129
+ const meta = readMetaFromDisk(root, name);
130
+ const bindings = Array.isArray(meta.bindings) ? [...(meta.bindings as string[])] : [];
131
+ if (bindings.includes(ref)) return { added: false };
132
+ bindings.push(ref);
133
+ writeMetaToDisk(root, name, { bindings });
134
+ return { added: true };
135
+ },
136
+ async removeBinding(name, ref) {
137
+ calls.push(['removeBinding', name, ref]);
138
+ if (!existing.includes(name)) throw new Error(`scratchpad not found: ${name}`);
139
+ const meta = readMetaFromDisk(root, name);
140
+ const bindings = Array.isArray(meta.bindings) ? [...(meta.bindings as string[])] : [];
141
+ const idx = bindings.indexOf(ref);
142
+ if (idx < 0) return { removed: false };
143
+ bindings.splice(idx, 1);
144
+ writeMetaToDisk(root, name, { bindings });
145
+ return { removed: true };
146
+ },
147
+ readBindings(name) {
148
+ const meta = readMetaFromDisk(root, name);
149
+ return Array.isArray(meta.bindings) ? (meta.bindings as string[]) : [];
150
+ },
151
+ };
152
+ }
153
+
154
+ interface FakeCtx {
155
+ notifications: Array<[string, string]>;
156
+ hasUI: boolean;
157
+ cwd: string;
158
+ ui: {
159
+ notify: (msg: string, level: string) => void;
160
+ confirm: (title: string, msg: string) => Promise<boolean>;
161
+ input: (title: string, placeholder?: string) => Promise<string | undefined>;
162
+ };
163
+ }
164
+ function makeCtx(confirmAnswer: boolean = true, ...rest: Array<string | undefined>): FakeCtx {
165
+ const inputAnswer: string | undefined = rest.length === 0 ? 'because reason' : rest[0];
166
+ const notifications: FakeCtx['notifications'] = [];
167
+ return {
168
+ notifications,
169
+ hasUI: false,
170
+ cwd: process.cwd(),
171
+ ui: {
172
+ notify: (m, l) => notifications.push([l, m]),
173
+ confirm: async (_title: string, _msg: string) => confirmAnswer,
174
+ input: async (_title: string, _placeholder?: string) => inputAnswer,
175
+ },
176
+ };
177
+ }
178
+
179
+ interface FakePi {
180
+ commands: Map<string, { description: string; handler: (args: string, ctx: FakeCtx) => Promise<void>; getArgumentCompletions?: (prefix: string) => Array<{ value: string; label: string }> }>;
181
+ registerCommand(name: string, opts: { description: string; handler: (args: string, ctx: FakeCtx) => Promise<void>; getArgumentCompletions?: (prefix: string) => Array<{ value: string; label: string }> }): void;
182
+ }
183
+ function makePi(): FakePi {
184
+ const commands = new Map();
185
+ return { commands, registerCommand(name, opts) { commands.set(name, opts); } };
186
+ }
187
+
188
+ let root: string;
189
+
190
+ describe('sp-command dispatch (stubbed manager)', () => {
191
+ beforeEach(async () => {
192
+ root = await mkdtemp(join(tmpdir(), 'sp-root-'));
193
+ });
194
+ afterEach(async () => {
195
+ await rm(root, { recursive: true, force: true });
196
+ });
197
+
198
+ async function seedExistingOnDisk(names: string[]): Promise<void> {
199
+ for (const n of names) {
200
+ await mkdir(join(root, n), { recursive: true });
201
+ await writeFile(join(root, n, 'meta.json'), JSON.stringify({ name: n }));
202
+ }
203
+ }
204
+
205
+ function wire(existing: string[] = []): { pi: FakePi; ctx: FakeCtx; mgr: StubMgr; current: { name: string | null } } {
206
+ const pi = makePi();
207
+ const ctx = makeCtx();
208
+ const mgr = makeStub(root, existing);
209
+ const current = { name: null as string | null };
210
+ const deps: SpDeps = {
211
+ getManager: () => mgr as unknown as SpDeps['getManager'] extends () => infer T ? T : never,
212
+ getCurrentName: () => current.name,
213
+ setCurrentName: (n) => { current.name = n; },
214
+ rootDir: () => root,
215
+ getSessionId: () => 'sess-1',
216
+ getWorkspaceCwd: () => '/tmp/test-cwd',
217
+ } as SpDeps;
218
+ registerSpCommand(pi as unknown as Parameters<typeof registerSpCommand>[0], deps);
219
+ return { pi, ctx, mgr, current };
220
+ }
221
+
222
+ function wireWithConfirm(confirm: boolean, existing: string[] = []): { pi: FakePi; ctx: FakeCtx; mgr: StubMgr; current: { name: string | null } } {
223
+ const pi = makePi();
224
+ const ctx = makeCtx(confirm);
225
+ const mgr = makeStub(root, existing);
226
+ const current = { name: null as string | null };
227
+ const deps: SpDeps = {
228
+ getManager: () => mgr as unknown as SpDeps['getManager'] extends () => infer T ? T : never,
229
+ getCurrentName: () => current.name,
230
+ setCurrentName: (n) => { current.name = n; },
231
+ rootDir: () => root,
232
+ getSessionId: () => 'sess-1',
233
+ getWorkspaceCwd: () => '/tmp/test-cwd',
234
+ } as SpDeps;
235
+ registerSpCommand(pi as unknown as Parameters<typeof registerSpCommand>[0], deps);
236
+ return { pi, ctx, mgr, current };
237
+ }
238
+
239
+ function wireWithBusy(confirm: boolean, inputAnswer: string | undefined, existing: string[] = []): { pi: FakePi; ctx: FakeCtx; mgr: StubMgr; current: { name: string | null } } {
240
+ const pi = makePi();
241
+ const ctx = makeCtx(confirm, inputAnswer);
242
+ const mgr = makeStub(root, existing, /* busyOnAttach */ true);
243
+ const current = { name: null as string | null };
244
+ const deps: SpDeps = {
245
+ getManager: () => mgr as unknown as SpDeps['getManager'] extends () => infer T ? T : never,
246
+ getCurrentName: () => current.name,
247
+ setCurrentName: (n) => { current.name = n; },
248
+ rootDir: () => root,
249
+ getSessionId: () => 'sess-1',
250
+ getWorkspaceCwd: () => '/tmp/test-cwd',
251
+ } as SpDeps;
252
+ registerSpCommand(pi as unknown as Parameters<typeof registerSpCommand>[0], deps);
253
+ return { pi, ctx, mgr, current };
254
+ }
255
+
256
+ it('/sp with no verb dispatches to list', async () => {
257
+ const { pi, ctx, mgr } = wire(['default']);
258
+ await pi.commands.get('sp')!.handler('', ctx);
259
+ assert.equal(mgr.calls[0][0], 'list');
260
+ assert.ok(ctx.notifications.some(([_l, m]) => m.includes('default')));
261
+ });
262
+
263
+ it('/sp new <name> creates and sets currentName', async () => {
264
+ const { pi, ctx, mgr, current } = wire();
265
+ await pi.commands.get('sp')!.handler('new p1', ctx);
266
+ assert.deepEqual(mgr.calls, [['create', 'p1']]);
267
+ assert.equal(current.name, 'p1');
268
+ });
269
+
270
+ it('/sp new with invalid name errors before touching manager', async () => {
271
+ const { pi, ctx, mgr, current } = wire();
272
+ await pi.commands.get('sp')!.handler('new 1bad', ctx);
273
+ assert.deepEqual(mgr.calls, []);
274
+ assert.equal(current.name, null);
275
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /invalid scratchpad name/.test(m)));
276
+ });
277
+
278
+ it('/sp attach <name> warms and sets currentName', async () => {
279
+ const { pi, ctx, mgr, current } = wire(['p1']);
280
+ await seedExistingOnDisk(['p1']);
281
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
282
+ assert.deepEqual(mgr.calls, [['getOrAttach', 'p1', {}]]);
283
+ assert.equal(current.name, 'p1');
284
+ });
285
+
286
+ it('/sp reset <name> calls remove then create; preserves currentName when matched', async () => {
287
+ const { pi, ctx, mgr, current } = wire(['p1']);
288
+ current.name = 'p1';
289
+ await pi.commands.get('sp')!.handler('reset p1', ctx);
290
+ assert.deepEqual(mgr.calls, [['remove', 'p1'], ['create', 'p1']]);
291
+ assert.equal(current.name, 'p1');
292
+ });
293
+
294
+ it('/sp view <name> reads cells.jsonl and emits a summary', async () => {
295
+ const { pi, ctx } = wire(['p1']);
296
+ await mkdir(join(root, 'p1'), { recursive: true });
297
+ await writeFile(join(root, 'p1', 'cells.jsonl'), [
298
+ JSON.stringify({ type: 'header', version: 1 }),
299
+ JSON.stringify({ id: 1, parentId: null, code: 'return 1;', ok: true, value: 1, stdout: '', ts: 't1' }),
300
+ ].join('\n') + '\n');
301
+ await pi.commands.get('sp')!.handler('view p1', ctx);
302
+ assert.ok(ctx.notifications.some(([_l, m]) => m.includes('cell 1') || m.includes('return 1;')));
303
+ });
304
+
305
+ it('/sp remove <name> deletes and clears currentName if matched', async () => {
306
+ const { pi, ctx, mgr, current } = wire(['p1']);
307
+ current.name = 'p1';
308
+ await pi.commands.get('sp')!.handler('remove p1', ctx);
309
+ assert.deepEqual(mgr.calls, [['remove', 'p1']]);
310
+ assert.equal(current.name, null);
311
+ });
312
+
313
+ it('/sp view (no name, no current) auto-attaches to default', async () => {
314
+ const { pi, ctx, mgr, current } = wire();
315
+ await pi.commands.get('sp')!.handler('view', ctx);
316
+ // ensureCurrent returned 'default'; view tries to read cells.jsonl which is missing -> empty result
317
+ assert.equal(current.name, 'default');
318
+ assert.ok(ctx.notifications.some(([_l, m]) => /no cells yet/i.test(m) || /total_cells.*0/.test(m)));
319
+ });
320
+
321
+ it('getArgumentCompletions returns existing scratchpad names for verb-2nd-arg', async () => {
322
+ const { pi } = wire();
323
+ await mkdir(join(root, 'investigation-1'), { recursive: true });
324
+ await writeFile(join(root, 'investigation-1', 'meta.json'), JSON.stringify({ name: 'investigation-1' }));
325
+ await mkdir(join(root, 'p1-1234'), { recursive: true });
326
+ await writeFile(join(root, 'p1-1234', 'meta.json'), JSON.stringify({ name: 'p1-1234' }));
327
+ const completions = pi.commands.get('sp')!.getArgumentCompletions!('attach ');
328
+ const values = completions.map((c) => c.value).sort();
329
+ assert.deepEqual(values, ['attach investigation-1', 'attach p1-1234']);
330
+ });
331
+
332
+ it('/sp tree prints the formatted tree of the current scratchpad', async () => {
333
+ const { pi, ctx } = wire(['p1']);
334
+ await mkdir(join(root, 'p1'), { recursive: true });
335
+ await writeFile(join(root, 'p1', 'cells.jsonl'), [
336
+ JSON.stringify({ type: 'header', version: 1 }),
337
+ JSON.stringify({ id: 1, parentId: null, code: 'return 1;', ok: true, value: 1, stdout: '', ts: 't1' }),
338
+ JSON.stringify({ id: 2, parentId: 1, code: 'return 2;', ok: true, value: 2, stdout: '', ts: 't2' }),
339
+ ].join('\n') + '\n');
340
+ await writeFile(join(root, 'p1', 'meta.json'), JSON.stringify({ name: 'p1', cell_leaf_id: 2 }));
341
+ await pi.commands.get('sp')!.handler('tree p1', ctx);
342
+ const text = ctx.notifications.map(([_, m]) => m).join('\n');
343
+ assert.match(text, /#1/);
344
+ assert.match(text, /#2/);
345
+ assert.match(text, /\*/); // current-leaf marker
346
+ });
347
+
348
+ it('/sp tree --to <id> calls manager.setLeaf', async () => {
349
+ const setLeafCalls: Array<[string, number]> = [];
350
+ const { pi, ctx, mgr } = wire(['p1']);
351
+ (mgr as unknown as { setLeaf: (n: string, i: number) => Promise<void> }).setLeaf = async (n, i) => { setLeafCalls.push([n, i]); };
352
+ await pi.commands.get('sp')!.handler('tree p1 --to 1', ctx);
353
+ assert.deepEqual(setLeafCalls, [['p1', 1]]);
354
+ assert.ok(ctx.notifications.some(([_, m]) => /set leaf of p1 to cell 1/.test(m)));
355
+ });
356
+
357
+ it('/sp tree --to with non-numeric id reports a usage error', async () => {
358
+ const { pi, ctx } = wire(['p1']);
359
+ await pi.commands.get('sp')!.handler('tree p1 --to bogus', ctx);
360
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /Usage: \/sp tree/.test(m)));
361
+ });
362
+
363
+ it('/sp tree on a scratchpad with no cells notifies "no cells yet"', async () => {
364
+ const { pi, ctx } = wire(['p1']);
365
+ await mkdir(join(root, 'p1'), { recursive: true });
366
+ await pi.commands.get('sp')!.handler('tree p1', ctx);
367
+ assert.ok(ctx.notifications.some(([_, m]) => /no cells yet/.test(m)));
368
+ });
369
+
370
+ it('/sp fork <src> <dst> calls manager.fork', async () => {
371
+ const forkCalls: Array<[string, string]> = [];
372
+ const { pi, ctx, mgr } = wire(['p1']);
373
+ (mgr as unknown as { fork: (s: string, d: string) => Promise<void> }).fork = async (s, d) => { forkCalls.push([s, d]); };
374
+ await pi.commands.get('sp')!.handler('fork p1 p2', ctx);
375
+ assert.deepEqual(forkCalls, [['p1', 'p2']]);
376
+ assert.ok(ctx.notifications.some(([_, m]) => /forked p1 → p2/.test(m)));
377
+ });
378
+
379
+ it('/sp fork without two args reports a usage error', async () => {
380
+ const { pi, ctx } = wire();
381
+ await pi.commands.get('sp')!.handler('fork onlyone', ctx);
382
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /Usage: \/sp fork/.test(m)));
383
+ });
384
+
385
+ it('/sp save calls manager.save on current scratchpad', async () => {
386
+ const { pi, ctx, mgr, current } = wire(['p1']);
387
+ current.name = 'p1';
388
+ await pi.commands.get('sp')!.handler('save', ctx);
389
+ assert.deepEqual(mgr.calls, [['save', 'p1']]);
390
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /saved p1/.test(m)));
391
+ });
392
+
393
+ it('/sp save errors when no current and no arg', async () => {
394
+ const { pi, ctx, mgr } = wire();
395
+ await pi.commands.get('sp')!.handler('save', ctx);
396
+ assert.deepEqual(mgr.calls, []);
397
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /no current scratchpad/.test(m)));
398
+ });
399
+
400
+ it('/sp detach removes current and clears currentName', async () => {
401
+ const { pi, ctx, mgr, current } = wire(['p1']);
402
+ current.name = 'p1';
403
+ await pi.commands.get('sp')!.handler('detach', ctx);
404
+ assert.deepEqual(mgr.calls, [['detach', 'p1', 'sess-1']]);
405
+ assert.equal(current.name, null);
406
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /detached from p1/.test(m)));
407
+ });
408
+
409
+ it('/sp detach errors when not attached', async () => {
410
+ const { pi, ctx, mgr } = wire();
411
+ await pi.commands.get('sp')!.handler('detach', ctx);
412
+ assert.deepEqual(mgr.calls, []);
413
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /not attached/.test(m)));
414
+ });
415
+
416
+ it('/sp clear-history confirms then calls manager.clearHistory', async () => {
417
+ const { pi, ctx, mgr, current } = wireWithConfirm(true, ['p1']);
418
+ current.name = 'p1';
419
+ await pi.commands.get('sp')!.handler('clear-history', ctx);
420
+ assert.deepEqual(mgr.calls, [['clearHistory', 'p1']]);
421
+ });
422
+
423
+ it('/sp clear-history cancels when confirm returns false', async () => {
424
+ const { pi, ctx, mgr, current } = wireWithConfirm(false, ['p1']);
425
+ current.name = 'p1';
426
+ await pi.commands.get('sp')!.handler('clear-history', ctx);
427
+ assert.deepEqual(mgr.calls, []);
428
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /cancelled/.test(m)));
429
+ });
430
+
431
+ it('/sp remove on current scratchpad confirms; --yes skips confirm', async () => {
432
+ // confirm=false => without --yes, remove is blocked
433
+ const { pi: pi1, ctx: ctx1, mgr: mgr1, current: cur1 } = wireWithConfirm(false, ['p1']);
434
+ cur1.name = 'p1';
435
+ await pi1.commands.get('sp')!.handler('remove p1', ctx1);
436
+ assert.deepEqual(mgr1.calls, []);
437
+ assert.ok(ctx1.notifications.some(([l, m]) => l === 'info' && /cancelled/.test(m)));
438
+
439
+ // --yes flag bypasses the prompt even with confirm=false
440
+ const { pi: pi2, ctx: ctx2, mgr: mgr2, current: cur2 } = wireWithConfirm(false, ['p1']);
441
+ cur2.name = 'p1';
442
+ await pi2.commands.get('sp')!.handler('remove p1 --yes', ctx2);
443
+ assert.deepEqual(mgr2.calls, [['remove', 'p1']]);
444
+ assert.equal(cur2.name, null);
445
+ });
446
+
447
+ it('/sp remove of non-current scratchpad does NOT confirm', async () => {
448
+ const { pi, ctx, mgr, current } = wireWithConfirm(false, ['p1', 'p2']);
449
+ current.name = 'p1';
450
+ await pi.commands.get('sp')!.handler('remove p2', ctx);
451
+ // confirm=false should not block because p2 != current; remove proceeds.
452
+ assert.deepEqual(mgr.calls, [['remove', 'p2']]);
453
+ assert.equal(current.name, 'p1', 'currentName preserved');
454
+ });
455
+
456
+ it('/sp attach happy path attaches normally (no busy)', async () => {
457
+ const { pi, ctx, mgr, current } = wire(['p1']);
458
+ await seedExistingOnDisk(['p1']);
459
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
460
+ assert.deepEqual(mgr.calls[0], ['getOrAttach', 'p1', {}]);
461
+ assert.equal(current.name, 'p1');
462
+ });
463
+
464
+ it('/sp attach on busy without flag: confirm accepted, reason from input → retry with forceTakeover', async () => {
465
+ const { pi, ctx, mgr, current } = wireWithBusy(true, 'debugging stuck cell');
466
+ await seedExistingOnDisk(['p1']);
467
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
468
+ // First call throws busy; second call has forceTakeover.
469
+ assert.equal(mgr.calls[0][0], 'getOrAttach');
470
+ assert.equal(mgr.calls[1][0], 'getOrAttach');
471
+ const secondOpts = mgr.calls[1][2] as { forceTakeover?: boolean; takeoverReason?: string };
472
+ assert.equal(secondOpts.forceTakeover, true);
473
+ assert.equal(secondOpts.takeoverReason, 'debugging stuck cell');
474
+ assert.equal(current.name, 'p1');
475
+ });
476
+
477
+ it('/sp attach on busy with confirm declined: cancelled; no retry', async () => {
478
+ const { pi, ctx, mgr, current } = wireWithBusy(false, 'unused');
479
+ await seedExistingOnDisk(['p1']);
480
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
481
+ // Only the initial busy call; no retry.
482
+ assert.equal(mgr.calls.filter((c) => c[0] === 'getOrAttach').length, 1);
483
+ assert.equal(current.name, null);
484
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /cancelled/.test(m)));
485
+ });
486
+
487
+ it('/sp attach on busy with input undefined (user escaped): cancelled; no retry', async () => {
488
+ const { pi, ctx, mgr, current } = wireWithBusy(true, undefined);
489
+ await seedExistingOnDisk(['p1']);
490
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
491
+ assert.equal(mgr.calls.filter((c) => c[0] === 'getOrAttach').length, 1);
492
+ assert.equal(current.name, null);
493
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /cancelled/.test(m)));
494
+ });
495
+
496
+ it('/sp attach --force-takeover skips confirm but still prompts for reason via input', async () => {
497
+ const { pi, ctx, mgr, current } = wireWithBusy(/* confirm */ false, 'because flag');
498
+ await seedExistingOnDisk(['p1']);
499
+ await pi.commands.get('sp')!.handler('attach p1 --force-takeover', ctx);
500
+ // confirm=false but the flag bypasses it
501
+ assert.equal(mgr.calls.length, 2);
502
+ const secondOpts = mgr.calls[1][2] as { forceTakeover?: boolean; takeoverReason?: string };
503
+ assert.equal(secondOpts.forceTakeover, true);
504
+ assert.equal(secondOpts.takeoverReason, 'because flag');
505
+ assert.equal(current.name, 'p1');
506
+ });
507
+
508
+ it('/sp attach --force-takeover --reason "..." is fully non-interactive', async () => {
509
+ // Both confirm and input stubs would return non-cancel values, but neither should be invoked.
510
+ const { pi, ctx, mgr, current } = wireWithBusy(false, undefined);
511
+ await seedExistingOnDisk(['p1']);
512
+ await pi.commands.get('sp')!.handler('attach p1 --force-takeover --reason "explicit reason"', ctx);
513
+ assert.equal(mgr.calls.length, 2);
514
+ const secondOpts = mgr.calls[1][2] as { forceTakeover?: boolean; takeoverReason?: string };
515
+ assert.equal(secondOpts.forceTakeover, true);
516
+ assert.equal(secondOpts.takeoverReason, 'explicit reason');
517
+ assert.equal(current.name, 'p1');
518
+ });
519
+
520
+ it('/sp notes [<name>] reads meta.recovery_notes and prints all', async () => {
521
+ const { pi, ctx } = wire(['p1']);
522
+ await mkdir(join(root, 'p1'), { recursive: true });
523
+ await writeFile(join(root, 'p1', 'meta.json'), JSON.stringify({
524
+ recovery_notes: [
525
+ { kind: 'snapshot-failed', message: 'boom', at: '2026-05-31T10:00:00.000Z' },
526
+ { kind: 'cells-since-snapshot', n: 2, at: '2026-05-31T11:00:00.000Z' },
527
+ ],
528
+ }));
529
+ await pi.commands.get('sp')!.handler('notes p1', ctx);
530
+ const banner = ctx.notifications.find(([l]) => l === 'info');
531
+ assert.ok(banner, 'info notify present');
532
+ assert.match(banner![1], /p1 recovery notes \(2\)/);
533
+ assert.match(banner![1], /snapshot-failed: boom/);
534
+ assert.match(banner![1], /2 cells since last snapshot/);
535
+ });
536
+
537
+ it('/sp notes on empty notes emits "no recovery notes"', async () => {
538
+ const { pi, ctx } = wire(['p1']);
539
+ await mkdir(join(root, 'p1'), { recursive: true });
540
+ await writeFile(join(root, 'p1', 'meta.json'), JSON.stringify({}));
541
+ await pi.commands.get('sp')!.handler('notes p1', ctx);
542
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /no recovery notes for p1/.test(m)));
543
+ });
544
+
545
+ describe('/sp list idle-age + /sp evict (Task D)', () => {
546
+ it('list shows "active" for an entry whose lastUsedAt is now', async () => {
547
+ const { pi, ctx } = wire();
548
+ await pi.commands.get('sp')!.handler('new t', ctx);
549
+ // /sp new creates a fresh warm entry (lastUsedAt=now); /sp list should label it 'active'.
550
+ await pi.commands.get('sp')!.handler('list', ctx);
551
+ const listMsg = ctx.notifications.filter(([l]) => l === 'info').map(([, m]) => m).join('\n');
552
+ assert.match(listMsg, /● live\s+t\s+active/, 'list should show "● live t active" for fresh entry');
553
+ });
554
+
555
+ it('list shows "idle Xm" when entry is idle (backdate lastUsedAt by 4 min)', async () => {
556
+ const { pi, ctx, mgr } = wire();
557
+ await pi.commands.get('sp')!.handler('new t', ctx);
558
+ // Backdate lastUsedAt by 4 minutes so the formatter renders 'idle 4m'.
559
+ mgr.setEntry('t', { lastUsedAt: Date.now() - 4 * 60_000 });
560
+ await pi.commands.get('sp')!.handler('list', ctx);
561
+ const listMsg = ctx.notifications.filter(([l]) => l === 'info').map(([, m]) => m).join('\n');
562
+ assert.match(listMsg, /● live\s+t\s+idle 4m/, 'list should show "idle 4m" for backdated entry');
563
+ });
564
+
565
+ it('/sp evict t notifies and flips entry to cold', async () => {
566
+ const { pi, ctx, mgr } = wire();
567
+ await pi.commands.get('sp')!.handler('new t', ctx);
568
+ await pi.commands.get('sp')!.handler('evict t', ctx);
569
+ assert.ok(
570
+ ctx.notifications.some(([l, m]) => l === 'info' && /evicted t \(still on disk; \/sp attach t to re-warm\)/.test(m)),
571
+ 'evict notification missing',
572
+ );
573
+ // Manager.evict was called with no force flag.
574
+ assert.ok(mgr.calls.some((c) => c[0] === 'evict' && c[1] === 't'), 'manager.evict was not called');
575
+ // Subsequent /sp list should show t as cold.
576
+ await pi.commands.get('sp')!.handler('list', ctx);
577
+ const listMsg = ctx.notifications.filter(([l]) => l === 'info').map(([, m]) => m).join('\n');
578
+ assert.match(listMsg, /○ cold\s+t/, 'list should show t as cold after evict');
579
+ });
580
+ });
581
+
582
+ describe('/sp attach existence guard (Task C)', () => {
583
+ it('errors with a helpful suggestion when scratchpad does not exist on disk', async () => {
584
+ const { pi, ctx, mgr, current } = wire();
585
+ await pi.commands.get('sp')!.handler('attach not-a-real-name', ctx);
586
+
587
+ const errors = ctx.notifications.filter(([l]) => l === 'error');
588
+ assert.equal(errors.length, 1);
589
+ assert.match(errors[0]![1], /scratchpad not found: not-a-real-name/);
590
+ assert.match(errors[0]![1], /Use \/sp new not-a-real-name to create it/);
591
+ // No phantom scratchpad created on disk and manager never invoked.
592
+ assert.equal(existsSync(join(root, 'not-a-real-name')), false, 'no phantom dir created');
593
+ assert.equal(mgr.calls.filter((c) => c[0] === 'getOrAttach').length, 0, 'getOrAttach not called');
594
+ assert.equal(current.name, null);
595
+ });
596
+
597
+ it('still attaches normally when scratchpad exists', async () => {
598
+ const { pi, ctx, mgr, current } = wire(['real']);
599
+ await seedExistingOnDisk(['real']);
600
+ await pi.commands.get('sp')!.handler('attach real', ctx);
601
+
602
+ assert.equal(ctx.notifications.filter(([l]) => l === 'error').length, 0);
603
+ assert.ok(ctx.notifications.some(([_l, m]) => /attached to scratchpad: real/.test(m)));
604
+ assert.deepEqual(mgr.calls[0], ['getOrAttach', 'real', {}]);
605
+ assert.equal(current.name, 'real');
606
+ });
607
+ });
608
+
609
+ describe('/sp — vault bindings (Phase 2 Task 16)', () => {
610
+ it('/sp new <name> --use jira:prod records bindings in meta.json', async () => {
611
+ const { pi, ctx, mgr, current } = wire();
612
+ await pi.commands.get('sp')!.handler('new p1 --use jira:prod', ctx);
613
+ // Manager.create was invoked with bindings option.
614
+ assert.deepEqual(mgr.calls, [['create', 'p1', { bindings: ['jira:prod'] }]]);
615
+ const meta = readMetaFromDisk(root, 'p1');
616
+ assert.deepEqual(meta.bindings, ['jira:prod']);
617
+ assert.equal(current.name, 'p1');
618
+ // Notification mentions the binding.
619
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /bindings: jira:prod/.test(m)));
620
+ });
621
+
622
+ it('/sp new with multiple --use flags records all bindings', async () => {
623
+ const { pi, ctx, mgr } = wire();
624
+ await pi.commands.get('sp')!.handler('new p1 --use jira:prod --use foo:bar', ctx);
625
+ assert.deepEqual(mgr.calls, [['create', 'p1', { bindings: ['jira:prod', 'foo:bar'] }]]);
626
+ const meta = readMetaFromDisk(root, 'p1');
627
+ assert.deepEqual(meta.bindings, ['jira:prod', 'foo:bar']);
628
+ });
629
+
630
+ it('/sp new --use with malformed ref errors before touching manager', async () => {
631
+ const { pi, ctx, mgr, current } = wire();
632
+ await pi.commands.get('sp')!.handler('new p1 --use not-a-valid-ref', ctx);
633
+ // create() must NOT have been called.
634
+ assert.deepEqual(mgr.calls, []);
635
+ assert.equal(current.name, null);
636
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /BindingRef|not-a-valid-ref/.test(m)));
637
+ });
638
+
639
+ it('/sp use <name> <ref> appends to bindings (idempotent)', async () => {
640
+ const { pi, ctx, mgr } = wire();
641
+ await pi.commands.get('sp')!.handler('new p1', ctx);
642
+ await pi.commands.get('sp')!.handler('use p1 jira:prod', ctx);
643
+ assert.deepEqual(readMetaFromDisk(root, 'p1').bindings, ['jira:prod']);
644
+ assert.ok(mgr.calls.some((c) => c[0] === 'addBinding' && c[1] === 'p1' && c[2] === 'jira:prod'));
645
+ // Second call is idempotent — same ref produces info, not error.
646
+ ctx.notifications.length = 0;
647
+ await pi.commands.get('sp')!.handler('use p1 jira:prod', ctx);
648
+ assert.deepEqual(readMetaFromDisk(root, 'p1').bindings, ['jira:prod']);
649
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /already present/.test(m)));
650
+ });
651
+
652
+ it('/sp use emits hint about /sp reset', async () => {
653
+ const { pi, ctx } = wire();
654
+ await pi.commands.get('sp')!.handler('new p1', ctx);
655
+ await pi.commands.get('sp')!.handler('use p1 jira:prod', ctx);
656
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /\/sp reset to inject into the live kernel/.test(m)));
657
+ });
658
+
659
+ it('/sp use with malformed ref errors', async () => {
660
+ const { pi, ctx } = wire();
661
+ await pi.commands.get('sp')!.handler('new p1', ctx);
662
+ await pi.commands.get('sp')!.handler('use p1 not-a-valid-ref', ctx);
663
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /BindingRef|not-a-valid-ref/.test(m)));
664
+ assert.deepEqual(readMetaFromDisk(root, 'p1').bindings, []);
665
+ });
666
+
667
+ it('/sp use with missing args reports usage error', async () => {
668
+ const { pi, ctx } = wire();
669
+ await pi.commands.get('sp')!.handler('use p1', ctx);
670
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /Usage: \/sp use/.test(m)));
671
+ });
672
+
673
+ it('/sp unuse <name> <ref> removes from bindings', async () => {
674
+ const { pi, ctx, mgr } = wire();
675
+ await pi.commands.get('sp')!.handler('new p1 --use jira:prod', ctx);
676
+ await pi.commands.get('sp')!.handler('unuse p1 jira:prod', ctx);
677
+ assert.deepEqual(readMetaFromDisk(root, 'p1').bindings, []);
678
+ assert.ok(mgr.calls.some((c) => c[0] === 'removeBinding' && c[1] === 'p1' && c[2] === 'jira:prod'));
679
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /binding removed: jira:prod from p1/.test(m)));
680
+ });
681
+
682
+ it('/sp unuse of absent binding emits "not present" info', async () => {
683
+ const { pi, ctx } = wire();
684
+ await pi.commands.get('sp')!.handler('new p1', ctx);
685
+ await pi.commands.get('sp')!.handler('unuse p1 jira:prod', ctx);
686
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'info' && /not present/.test(m)));
687
+ });
688
+
689
+ it('/sp unuse with missing args reports usage error', async () => {
690
+ const { pi, ctx } = wire();
691
+ await pi.commands.get('sp')!.handler('unuse p1', ctx);
692
+ assert.ok(ctx.notifications.some(([l, m]) => l === 'error' && /Usage: \/sp unuse/.test(m)));
693
+ });
694
+
695
+ it('/sp list output includes binding count column for bound scratchpads', async () => {
696
+ const { pi, ctx } = wire();
697
+ await pi.commands.get('sp')!.handler('new p1 --use jira:prod', ctx);
698
+ ctx.notifications.length = 0;
699
+ await pi.commands.get('sp')!.handler('list', ctx);
700
+ const listMsg = ctx.notifications.filter(([l]) => l === 'info').map(([, m]) => m).join('\n');
701
+ assert.match(listMsg, /uses:1/, 'list should show "uses:1" for scratchpad with one binding');
702
+ });
703
+
704
+ it('/sp list omits binding column for unbound scratchpads', async () => {
705
+ const { pi, ctx } = wire();
706
+ await pi.commands.get('sp')!.handler('new p1', ctx);
707
+ ctx.notifications.length = 0;
708
+ await pi.commands.get('sp')!.handler('list', ctx);
709
+ const listMsg = ctx.notifications.filter(([l]) => l === 'info').map(([, m]) => m).join('\n');
710
+ assert.doesNotMatch(listMsg, /uses:/, 'list should not show "uses:" for unbound scratchpad');
711
+ });
712
+
713
+ it('/sp list shows correct binding count after /sp use', async () => {
714
+ const { pi, ctx } = wire();
715
+ await pi.commands.get('sp')!.handler('new p1', ctx);
716
+ await pi.commands.get('sp')!.handler('use p1 jira:prod', ctx);
717
+ await pi.commands.get('sp')!.handler('use p1 foo:bar', ctx);
718
+ ctx.notifications.length = 0;
719
+ await pi.commands.get('sp')!.handler('list', ctx);
720
+ const listMsg = ctx.notifications.filter(([l]) => l === 'info').map(([, m]) => m).join('\n');
721
+ assert.match(listMsg, /uses:2/);
722
+ });
723
+
724
+ it('/sp reset preserves bindings across the respawn', async () => {
725
+ const { pi, ctx } = wire();
726
+ await pi.commands.get('sp')!.handler('new p1 --use jira:prod', ctx);
727
+ assert.deepEqual(readMetaFromDisk(root, 'p1').bindings, ['jira:prod']);
728
+ await pi.commands.get('sp')!.handler('reset p1', ctx);
729
+ // remove() then create() — bindings should survive via the preserved list.
730
+ assert.deepEqual(readMetaFromDisk(root, 'p1').bindings, ['jira:prod']);
731
+ });
732
+ });
733
+
734
+ describe('/sp attach staleness banner (Phase 2 Task 16)', () => {
735
+ function wireWithStaleness(lookup: (ref: string) => Promise<string | null>): { pi: FakePi; ctx: FakeCtx; mgr: StubMgr; current: { name: string | null } } {
736
+ const pi = makePi();
737
+ const ctx = makeCtx();
738
+ const mgr = makeStub(root, ['p1']);
739
+ const current = { name: null as string | null };
740
+ const deps: SpDeps = {
741
+ getManager: () => mgr as unknown as SpDeps['getManager'] extends () => infer T ? T : never,
742
+ getCurrentName: () => current.name,
743
+ setCurrentName: (n) => { current.name = n; },
744
+ rootDir: () => root,
745
+ getSessionId: () => 'sess-1',
746
+ getWorkspaceCwd: () => '/tmp/test-cwd',
747
+ getStalenessVault: () => ({ lookupLastModified: lookup }),
748
+ } as SpDeps;
749
+ registerSpCommand(pi as unknown as Parameters<typeof registerSpCommand>[0], deps);
750
+ return { pi, ctx, mgr, current };
751
+ }
752
+
753
+ it('emits a warning banner when a binding was modified after attach mtime', async () => {
754
+ // Seed p1 with a binding then bump meta.json mtime to a known past time.
755
+ await mkdir(join(root, 'p1'), { recursive: true });
756
+ const metaPath = join(root, 'p1', 'meta.json');
757
+ writeFileSync(metaPath, JSON.stringify({ name: 'p1', bindings: ['jira:prod'], schema_version: 4 }));
758
+ // Backdate the meta mtime by 10 seconds so any "now" lookup is fresher.
759
+ const past = new Date(Date.now() - 10_000);
760
+ const { utimesSync } = await import('node:fs');
761
+ utimesSync(metaPath, past, past);
762
+ // Vault reports the ref was modified just now (after spawn).
763
+ const lookup = async (_ref: string): Promise<string | null> => new Date().toISOString();
764
+ const { pi, ctx } = wireWithStaleness(lookup);
765
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
766
+ const warnings = ctx.notifications.filter(([l]) => l === 'warning');
767
+ assert.equal(warnings.length, 1, 'expected one staleness warning');
768
+ assert.match(warnings[0]![1], /jira:prod was modified after this kernel was spawned/);
769
+ });
770
+
771
+ it('does NOT emit a banner when bindings are empty', async () => {
772
+ await mkdir(join(root, 'p1'), { recursive: true });
773
+ writeFileSync(join(root, 'p1', 'meta.json'), JSON.stringify({ name: 'p1', bindings: [], schema_version: 4 }));
774
+ const lookup = async (_ref: string): Promise<string | null> => new Date().toISOString();
775
+ const { pi, ctx } = wireWithStaleness(lookup);
776
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
777
+ assert.equal(ctx.notifications.filter(([l]) => l === 'warning').length, 0);
778
+ });
779
+
780
+ it('does NOT emit a banner when getStalenessVault is unset', async () => {
781
+ // wire() does not set getStalenessVault.
782
+ await mkdir(join(root, 'p1'), { recursive: true });
783
+ writeFileSync(join(root, 'p1', 'meta.json'), JSON.stringify({ name: 'p1', bindings: ['jira:prod'], schema_version: 4 }));
784
+ const { pi, ctx } = wire(['p1']);
785
+ await pi.commands.get('sp')!.handler('attach p1', ctx);
786
+ assert.equal(ctx.notifications.filter(([l]) => l === 'warning').length, 0);
787
+ });
788
+ });
789
+
790
+ describe('/sp fork bindings copy (Phase 2 Task 16)', () => {
791
+ // The fork-copies-bindings behavior is owned by ScratchpadManager.fork —
792
+ // the sp-command layer just dispatches to manager.fork. The relevant
793
+ // assertion is that manager.fork is invoked (existing test covers that)
794
+ // AND the manager's fork implementation copies meta.bindings (covered by
795
+ // scratchpad-manager.test.ts). Here we add a thin end-to-end check by
796
+ // using a stub fork that mimics the real copy semantics.
797
+ it('/sp fork copies src bindings to dst via manager.fork', async () => {
798
+ const { pi, ctx, mgr } = wire(['p1']);
799
+ // Seed src meta.json with bindings.
800
+ writeMetaToDisk(root, 'p1', { name: 'p1', bindings: ['jira:prod'], schema_version: 4 });
801
+ // Override fork to mimic the real manager: copy src meta.bindings to dst.
802
+ (mgr as unknown as { fork: (s: string, d: string) => Promise<void> }).fork = async (s, d) => {
803
+ const srcMeta = readMetaFromDisk(root, s);
804
+ const srcBindings = Array.isArray(srcMeta.bindings) ? (srcMeta.bindings as string[]) : [];
805
+ writeMetaToDisk(root, d, { name: d, bindings: srcBindings, schema_version: 4 });
806
+ };
807
+ await pi.commands.get('sp')!.handler('fork p1 p2', ctx);
808
+ assert.deepEqual(readMetaFromDisk(root, 'p2').bindings, ['jira:prod']);
809
+ });
810
+ });
811
+
812
+ describe('/sp — currentScratchpadName accessor (Phase 3)', () => {
813
+ // Phase 3 Task 18: memory's MemoryRecorder needs to know which scratchpad
814
+ // is currently attached for the given session to derive the default Room.
815
+ // The accessor reads the Phase 1 per-session sidecar; returns null when
816
+ // nothing is attached or any IO error occurs.
817
+ it('returns name when attached', async () => {
818
+ const { pi, ctx } = wire();
819
+ const getCurrent = createCurrentScratchpadProvider({ scratchpadsRoot: root });
820
+ // /sp new writes the session sidecar (see sp-command.ts:233).
821
+ await pi.commands.get('sp')!.handler('new p1', ctx);
822
+ assert.equal(getCurrent('sess-1'), 'p1');
823
+ });
824
+
825
+ it('returns null when no scratchpad attached', async () => {
826
+ // No /sp new or attach was performed -> sidecar file does not exist.
827
+ const getCurrent = createCurrentScratchpadProvider({ scratchpadsRoot: root });
828
+ assert.equal(getCurrent('sess-1'), null);
829
+ });
830
+
831
+ it('returns null for empty sessionId', async () => {
832
+ const getCurrent = createCurrentScratchpadProvider({ scratchpadsRoot: root });
833
+ assert.equal(getCurrent(''), null);
834
+ });
835
+ });
836
+ });