@cluesmith/codev 2.0.0-rc.4 → 2.0.0-rc.41

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 (316) hide show
  1. package/bin/porch.js +6 -35
  2. package/dashboard/dist/assets/index-BLqoFC1H.js +120 -0
  3. package/dashboard/dist/assets/index-BLqoFC1H.js.map +1 -0
  4. package/dashboard/dist/assets/index-CXwnJkPh.css +32 -0
  5. package/dashboard/dist/index.html +13 -0
  6. package/dist/agent-farm/cli.d.ts.map +1 -1
  7. package/dist/agent-farm/cli.js +93 -64
  8. package/dist/agent-farm/cli.js.map +1 -1
  9. package/dist/agent-farm/commands/architect.d.ts.map +1 -1
  10. package/dist/agent-farm/commands/architect.js +13 -6
  11. package/dist/agent-farm/commands/architect.js.map +1 -1
  12. package/dist/agent-farm/commands/attach.d.ts +13 -0
  13. package/dist/agent-farm/commands/attach.d.ts.map +1 -0
  14. package/dist/agent-farm/commands/attach.js +179 -0
  15. package/dist/agent-farm/commands/attach.js.map +1 -0
  16. package/dist/agent-farm/commands/cleanup.d.ts.map +1 -1
  17. package/dist/agent-farm/commands/cleanup.js +30 -3
  18. package/dist/agent-farm/commands/cleanup.js.map +1 -1
  19. package/dist/agent-farm/commands/consult.js +1 -1
  20. package/dist/agent-farm/commands/consult.js.map +1 -1
  21. package/dist/agent-farm/commands/index.d.ts +2 -2
  22. package/dist/agent-farm/commands/index.d.ts.map +1 -1
  23. package/dist/agent-farm/commands/index.js +2 -2
  24. package/dist/agent-farm/commands/index.js.map +1 -1
  25. package/dist/agent-farm/commands/{util.d.ts → shell.d.ts} +5 -5
  26. package/dist/agent-farm/commands/shell.d.ts.map +1 -0
  27. package/dist/agent-farm/commands/{util.js → shell.js} +23 -36
  28. package/dist/agent-farm/commands/shell.js.map +1 -0
  29. package/dist/agent-farm/commands/spawn.d.ts.map +1 -1
  30. package/dist/agent-farm/commands/spawn.js +455 -217
  31. package/dist/agent-farm/commands/spawn.js.map +1 -1
  32. package/dist/agent-farm/commands/start.d.ts.map +1 -1
  33. package/dist/agent-farm/commands/start.js +27 -79
  34. package/dist/agent-farm/commands/start.js.map +1 -1
  35. package/dist/agent-farm/commands/stop.d.ts.map +1 -1
  36. package/dist/agent-farm/commands/stop.js +79 -10
  37. package/dist/agent-farm/commands/stop.js.map +1 -1
  38. package/dist/agent-farm/commands/tower.d.ts +9 -0
  39. package/dist/agent-farm/commands/tower.d.ts.map +1 -1
  40. package/dist/agent-farm/commands/tower.js +59 -19
  41. package/dist/agent-farm/commands/tower.js.map +1 -1
  42. package/dist/agent-farm/db/index.d.ts.map +1 -1
  43. package/dist/agent-farm/db/index.js +15 -0
  44. package/dist/agent-farm/db/index.js.map +1 -1
  45. package/dist/agent-farm/db/schema.d.ts +1 -1
  46. package/dist/agent-farm/db/schema.d.ts.map +1 -1
  47. package/dist/agent-farm/db/schema.js +6 -3
  48. package/dist/agent-farm/db/schema.js.map +1 -1
  49. package/dist/agent-farm/db/types.d.ts +3 -0
  50. package/dist/agent-farm/db/types.d.ts.map +1 -1
  51. package/dist/agent-farm/db/types.js +3 -0
  52. package/dist/agent-farm/db/types.js.map +1 -1
  53. package/dist/agent-farm/hq-connector.d.ts +2 -2
  54. package/dist/agent-farm/hq-connector.js +2 -2
  55. package/dist/agent-farm/servers/dashboard-server.js +435 -131
  56. package/dist/agent-farm/servers/dashboard-server.js.map +1 -1
  57. package/dist/agent-farm/servers/tower-server.js +430 -17
  58. package/dist/agent-farm/servers/tower-server.js.map +1 -1
  59. package/dist/agent-farm/state.d.ts +4 -10
  60. package/dist/agent-farm/state.d.ts.map +1 -1
  61. package/dist/agent-farm/state.js +30 -31
  62. package/dist/agent-farm/state.js.map +1 -1
  63. package/dist/agent-farm/types.d.ts +48 -0
  64. package/dist/agent-farm/types.d.ts.map +1 -1
  65. package/dist/agent-farm/utils/config.d.ts.map +1 -1
  66. package/dist/agent-farm/utils/config.js +12 -11
  67. package/dist/agent-farm/utils/config.js.map +1 -1
  68. package/dist/agent-farm/utils/deps.d.ts.map +1 -1
  69. package/dist/agent-farm/utils/deps.js +0 -16
  70. package/dist/agent-farm/utils/deps.js.map +1 -1
  71. package/dist/agent-farm/utils/notifications.d.ts +30 -0
  72. package/dist/agent-farm/utils/notifications.d.ts.map +1 -0
  73. package/dist/agent-farm/utils/notifications.js +121 -0
  74. package/dist/agent-farm/utils/notifications.js.map +1 -0
  75. package/dist/agent-farm/utils/server-utils.d.ts +2 -1
  76. package/dist/agent-farm/utils/server-utils.d.ts.map +1 -1
  77. package/dist/agent-farm/utils/server-utils.js +11 -1
  78. package/dist/agent-farm/utils/server-utils.js.map +1 -1
  79. package/dist/agent-farm/utils/shell.d.ts +9 -22
  80. package/dist/agent-farm/utils/shell.d.ts.map +1 -1
  81. package/dist/agent-farm/utils/shell.js +34 -34
  82. package/dist/agent-farm/utils/shell.js.map +1 -1
  83. package/dist/agent-farm/utils/terminal-ports.d.ts +1 -1
  84. package/dist/agent-farm/utils/terminal-ports.js +1 -1
  85. package/dist/cli.d.ts.map +1 -1
  86. package/dist/cli.js +5 -54
  87. package/dist/cli.js.map +1 -1
  88. package/dist/commands/adopt.d.ts.map +1 -1
  89. package/dist/commands/adopt.js +39 -4
  90. package/dist/commands/adopt.js.map +1 -1
  91. package/dist/commands/consult/index.d.ts.map +1 -1
  92. package/dist/commands/consult/index.js +63 -3
  93. package/dist/commands/consult/index.js.map +1 -1
  94. package/dist/commands/doctor.d.ts.map +1 -1
  95. package/dist/commands/doctor.js +0 -15
  96. package/dist/commands/doctor.js.map +1 -1
  97. package/dist/commands/init.d.ts.map +1 -1
  98. package/dist/commands/init.js +31 -2
  99. package/dist/commands/init.js.map +1 -1
  100. package/dist/commands/porch/build-counter.d.ts +5 -0
  101. package/dist/commands/porch/build-counter.d.ts.map +1 -0
  102. package/dist/commands/porch/build-counter.js +5 -0
  103. package/dist/commands/porch/build-counter.js.map +1 -0
  104. package/dist/commands/porch/checks.d.ts +16 -29
  105. package/dist/commands/porch/checks.d.ts.map +1 -1
  106. package/dist/commands/porch/checks.js +90 -144
  107. package/dist/commands/porch/checks.js.map +1 -1
  108. package/dist/commands/porch/claude.d.ts +27 -0
  109. package/dist/commands/porch/claude.d.ts.map +1 -0
  110. package/dist/commands/porch/claude.js +107 -0
  111. package/dist/commands/porch/claude.js.map +1 -0
  112. package/dist/commands/porch/index.d.ts +21 -43
  113. package/dist/commands/porch/index.d.ts.map +1 -1
  114. package/dist/commands/porch/index.js +466 -926
  115. package/dist/commands/porch/index.js.map +1 -1
  116. package/dist/commands/porch/plan.d.ts +70 -0
  117. package/dist/commands/porch/plan.d.ts.map +1 -0
  118. package/dist/commands/porch/plan.js +190 -0
  119. package/dist/commands/porch/plan.js.map +1 -0
  120. package/dist/commands/porch/prompts.d.ts +19 -0
  121. package/dist/commands/porch/prompts.d.ts.map +1 -0
  122. package/dist/commands/porch/prompts.js +250 -0
  123. package/dist/commands/porch/prompts.js.map +1 -0
  124. package/dist/commands/porch/protocol.d.ts +59 -0
  125. package/dist/commands/porch/protocol.d.ts.map +1 -0
  126. package/dist/commands/porch/protocol.js +260 -0
  127. package/dist/commands/porch/protocol.js.map +1 -0
  128. package/dist/commands/porch/run.d.ts +40 -0
  129. package/dist/commands/porch/run.d.ts.map +1 -0
  130. package/dist/commands/porch/run.js +893 -0
  131. package/dist/commands/porch/run.js.map +1 -0
  132. package/dist/commands/porch/state.d.ts +23 -112
  133. package/dist/commands/porch/state.d.ts.map +1 -1
  134. package/dist/commands/porch/state.js +81 -699
  135. package/dist/commands/porch/state.js.map +1 -1
  136. package/dist/commands/porch/types.d.ts +72 -173
  137. package/dist/commands/porch/types.d.ts.map +1 -1
  138. package/dist/commands/porch/types.js +2 -1
  139. package/dist/commands/porch/types.js.map +1 -1
  140. package/dist/commands/update.d.ts.map +1 -1
  141. package/dist/commands/update.js +22 -0
  142. package/dist/commands/update.js.map +1 -1
  143. package/dist/lib/scaffold.d.ts +24 -0
  144. package/dist/lib/scaffold.d.ts.map +1 -1
  145. package/dist/lib/scaffold.js +78 -0
  146. package/dist/lib/scaffold.js.map +1 -1
  147. package/dist/terminal/index.d.ts +8 -0
  148. package/dist/terminal/index.d.ts.map +1 -0
  149. package/dist/terminal/index.js +5 -0
  150. package/dist/terminal/index.js.map +1 -0
  151. package/dist/terminal/pty-manager.d.ts +60 -0
  152. package/dist/terminal/pty-manager.d.ts.map +1 -0
  153. package/dist/terminal/pty-manager.js +334 -0
  154. package/dist/terminal/pty-manager.js.map +1 -0
  155. package/dist/terminal/pty-session.d.ts +79 -0
  156. package/dist/terminal/pty-session.d.ts.map +1 -0
  157. package/dist/terminal/pty-session.js +215 -0
  158. package/dist/terminal/pty-session.js.map +1 -0
  159. package/dist/terminal/ring-buffer.d.ts +27 -0
  160. package/dist/terminal/ring-buffer.d.ts.map +1 -0
  161. package/dist/terminal/ring-buffer.js +74 -0
  162. package/dist/terminal/ring-buffer.js.map +1 -0
  163. package/dist/terminal/ws-protocol.d.ts +27 -0
  164. package/dist/terminal/ws-protocol.d.ts.map +1 -0
  165. package/dist/terminal/ws-protocol.js +44 -0
  166. package/dist/terminal/ws-protocol.js.map +1 -0
  167. package/package.json +18 -3
  168. package/skeleton/DEPENDENCIES.md +3 -29
  169. package/skeleton/builders.md +1 -1
  170. package/skeleton/protocol-schema.json +282 -0
  171. package/skeleton/protocols/bugfix/builder-prompt.md +49 -0
  172. package/skeleton/protocols/bugfix/protocol.json +14 -2
  173. package/skeleton/protocols/experiment/builder-prompt.md +47 -0
  174. package/skeleton/protocols/experiment/protocol.json +101 -0
  175. package/skeleton/protocols/maintain/builder-prompt.md +41 -0
  176. package/skeleton/protocols/maintain/prompts/audit.md +111 -0
  177. package/skeleton/protocols/maintain/prompts/clean.md +91 -0
  178. package/skeleton/protocols/maintain/prompts/sync.md +113 -0
  179. package/skeleton/protocols/maintain/prompts/verify.md +110 -0
  180. package/skeleton/protocols/maintain/protocol.json +141 -0
  181. package/skeleton/protocols/maintain/protocol.md +13 -7
  182. package/skeleton/protocols/protocol-schema.json +53 -0
  183. package/skeleton/protocols/spider/builder-prompt.md +53 -0
  184. package/skeleton/protocols/spider/prompts/implement.md +109 -50
  185. package/skeleton/protocols/spider/prompts/specify.md +29 -4
  186. package/skeleton/protocols/spider/protocol.json +96 -154
  187. package/skeleton/protocols/spider/protocol.md +26 -16
  188. package/skeleton/protocols/spider/templates/plan.md +14 -0
  189. package/skeleton/protocols/tick/builder-prompt.md +51 -0
  190. package/skeleton/protocols/tick/protocol.json +7 -2
  191. package/skeleton/resources/commands/agent-farm.md +25 -43
  192. package/skeleton/resources/commands/overview.md +6 -16
  193. package/skeleton/resources/workflow-reference.md +2 -2
  194. package/skeleton/roles/architect.md +152 -315
  195. package/skeleton/roles/builder.md +109 -218
  196. package/skeleton/templates/AGENTS.md +1 -1
  197. package/skeleton/templates/CLAUDE.md +1 -1
  198. package/skeleton/templates/cheatsheet.md +4 -2
  199. package/templates/dashboard/index.html +17 -43
  200. package/templates/dashboard/js/dialogs.js +7 -7
  201. package/templates/dashboard/js/files.js +2 -2
  202. package/templates/dashboard/js/main.js +3 -3
  203. package/templates/dashboard/js/projects.js +3 -3
  204. package/templates/dashboard/js/tabs.js +1 -1
  205. package/templates/dashboard/js/utils.js +22 -87
  206. package/templates/tower.html +497 -26
  207. package/dist/agent-farm/commands/kickoff.d.ts +0 -19
  208. package/dist/agent-farm/commands/kickoff.d.ts.map +0 -1
  209. package/dist/agent-farm/commands/kickoff.js +0 -331
  210. package/dist/agent-farm/commands/kickoff.js.map +0 -1
  211. package/dist/agent-farm/commands/rename.d.ts +0 -13
  212. package/dist/agent-farm/commands/rename.d.ts.map +0 -1
  213. package/dist/agent-farm/commands/rename.js +0 -33
  214. package/dist/agent-farm/commands/rename.js.map +0 -1
  215. package/dist/agent-farm/commands/tutorial.d.ts +0 -10
  216. package/dist/agent-farm/commands/tutorial.d.ts.map +0 -1
  217. package/dist/agent-farm/commands/tutorial.js +0 -49
  218. package/dist/agent-farm/commands/tutorial.js.map +0 -1
  219. package/dist/agent-farm/commands/util.d.ts.map +0 -1
  220. package/dist/agent-farm/commands/util.js.map +0 -1
  221. package/dist/agent-farm/tutorial/index.d.ts +0 -8
  222. package/dist/agent-farm/tutorial/index.d.ts.map +0 -1
  223. package/dist/agent-farm/tutorial/index.js +0 -8
  224. package/dist/agent-farm/tutorial/index.js.map +0 -1
  225. package/dist/agent-farm/tutorial/prompts.d.ts +0 -57
  226. package/dist/agent-farm/tutorial/prompts.d.ts.map +0 -1
  227. package/dist/agent-farm/tutorial/prompts.js +0 -147
  228. package/dist/agent-farm/tutorial/prompts.js.map +0 -1
  229. package/dist/agent-farm/tutorial/runner.d.ts +0 -52
  230. package/dist/agent-farm/tutorial/runner.d.ts.map +0 -1
  231. package/dist/agent-farm/tutorial/runner.js +0 -204
  232. package/dist/agent-farm/tutorial/runner.js.map +0 -1
  233. package/dist/agent-farm/tutorial/state.d.ts +0 -26
  234. package/dist/agent-farm/tutorial/state.d.ts.map +0 -1
  235. package/dist/agent-farm/tutorial/state.js +0 -89
  236. package/dist/agent-farm/tutorial/state.js.map +0 -1
  237. package/dist/agent-farm/tutorial/steps/first-spec.d.ts +0 -7
  238. package/dist/agent-farm/tutorial/steps/first-spec.d.ts.map +0 -1
  239. package/dist/agent-farm/tutorial/steps/first-spec.js +0 -136
  240. package/dist/agent-farm/tutorial/steps/first-spec.js.map +0 -1
  241. package/dist/agent-farm/tutorial/steps/implementation.d.ts +0 -7
  242. package/dist/agent-farm/tutorial/steps/implementation.d.ts.map +0 -1
  243. package/dist/agent-farm/tutorial/steps/implementation.js +0 -76
  244. package/dist/agent-farm/tutorial/steps/implementation.js.map +0 -1
  245. package/dist/agent-farm/tutorial/steps/index.d.ts +0 -10
  246. package/dist/agent-farm/tutorial/steps/index.d.ts.map +0 -1
  247. package/dist/agent-farm/tutorial/steps/index.js +0 -10
  248. package/dist/agent-farm/tutorial/steps/index.js.map +0 -1
  249. package/dist/agent-farm/tutorial/steps/planning.d.ts +0 -7
  250. package/dist/agent-farm/tutorial/steps/planning.d.ts.map +0 -1
  251. package/dist/agent-farm/tutorial/steps/planning.js +0 -143
  252. package/dist/agent-farm/tutorial/steps/planning.js.map +0 -1
  253. package/dist/agent-farm/tutorial/steps/review.d.ts +0 -7
  254. package/dist/agent-farm/tutorial/steps/review.d.ts.map +0 -1
  255. package/dist/agent-farm/tutorial/steps/review.js +0 -78
  256. package/dist/agent-farm/tutorial/steps/review.js.map +0 -1
  257. package/dist/agent-farm/tutorial/steps/setup.d.ts +0 -7
  258. package/dist/agent-farm/tutorial/steps/setup.d.ts.map +0 -1
  259. package/dist/agent-farm/tutorial/steps/setup.js +0 -126
  260. package/dist/agent-farm/tutorial/steps/setup.js.map +0 -1
  261. package/dist/agent-farm/tutorial/steps/welcome.d.ts +0 -7
  262. package/dist/agent-farm/tutorial/steps/welcome.d.ts.map +0 -1
  263. package/dist/agent-farm/tutorial/steps/welcome.js +0 -50
  264. package/dist/agent-farm/tutorial/steps/welcome.js.map +0 -1
  265. package/dist/commands/pcheck/cache.d.ts +0 -48
  266. package/dist/commands/pcheck/cache.d.ts.map +0 -1
  267. package/dist/commands/pcheck/cache.js +0 -170
  268. package/dist/commands/pcheck/cache.js.map +0 -1
  269. package/dist/commands/pcheck/evaluator.d.ts +0 -15
  270. package/dist/commands/pcheck/evaluator.d.ts.map +0 -1
  271. package/dist/commands/pcheck/evaluator.js +0 -246
  272. package/dist/commands/pcheck/evaluator.js.map +0 -1
  273. package/dist/commands/pcheck/index.d.ts +0 -12
  274. package/dist/commands/pcheck/index.d.ts.map +0 -1
  275. package/dist/commands/pcheck/index.js +0 -249
  276. package/dist/commands/pcheck/index.js.map +0 -1
  277. package/dist/commands/pcheck/parser.d.ts +0 -39
  278. package/dist/commands/pcheck/parser.d.ts.map +0 -1
  279. package/dist/commands/pcheck/parser.js +0 -155
  280. package/dist/commands/pcheck/parser.js.map +0 -1
  281. package/dist/commands/pcheck/types.d.ts +0 -82
  282. package/dist/commands/pcheck/types.d.ts.map +0 -1
  283. package/dist/commands/pcheck/types.js +0 -5
  284. package/dist/commands/pcheck/types.js.map +0 -1
  285. package/dist/commands/porch/consultation.d.ts +0 -56
  286. package/dist/commands/porch/consultation.d.ts.map +0 -1
  287. package/dist/commands/porch/consultation.js +0 -330
  288. package/dist/commands/porch/consultation.js.map +0 -1
  289. package/dist/commands/porch/notifications.d.ts +0 -99
  290. package/dist/commands/porch/notifications.d.ts.map +0 -1
  291. package/dist/commands/porch/notifications.js +0 -223
  292. package/dist/commands/porch/notifications.js.map +0 -1
  293. package/dist/commands/porch/plan-parser.d.ts +0 -38
  294. package/dist/commands/porch/plan-parser.d.ts.map +0 -1
  295. package/dist/commands/porch/plan-parser.js +0 -166
  296. package/dist/commands/porch/plan-parser.js.map +0 -1
  297. package/dist/commands/porch/protocol-loader.d.ts +0 -46
  298. package/dist/commands/porch/protocol-loader.d.ts.map +0 -1
  299. package/dist/commands/porch/protocol-loader.js +0 -249
  300. package/dist/commands/porch/protocol-loader.js.map +0 -1
  301. package/dist/commands/porch/signal-parser.d.ts +0 -88
  302. package/dist/commands/porch/signal-parser.d.ts.map +0 -1
  303. package/dist/commands/porch/signal-parser.js +0 -148
  304. package/dist/commands/porch/signal-parser.js.map +0 -1
  305. package/dist/commands/tower.d.ts +0 -16
  306. package/dist/commands/tower.d.ts.map +0 -1
  307. package/dist/commands/tower.js +0 -21
  308. package/dist/commands/tower.js.map +0 -1
  309. package/skeleton/config.json +0 -7
  310. package/skeleton/porch/protocols/bugfix.json +0 -85
  311. package/skeleton/porch/protocols/spider.json +0 -135
  312. package/skeleton/porch/protocols/tick.json +0 -76
  313. package/skeleton/protocols/spider/prompts/defend.md +0 -215
  314. package/skeleton/protocols/spider/prompts/evaluate.md +0 -241
  315. package/templates/dashboard/css/activity.css +0 -151
  316. package/templates/dashboard/js/activity.js +0 -112
@@ -7,16 +7,119 @@
7
7
  * - protocol: --protocol Spawn to run a protocol (cleanup, experiment, etc.)
8
8
  * - shell: --shell Bare Claude session (no prompt, no worktree)
9
9
  */
10
- import { resolve, basename, join } from 'node:path';
11
- import { existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, symlinkSync, unlinkSync } from 'node:fs';
12
- import { tmpdir } from 'node:os';
13
- import { randomUUID } from 'node:crypto';
10
+ import { resolve, basename } from 'node:path';
11
+ import { existsSync, readFileSync, writeFileSync, chmodSync, readdirSync, symlinkSync } from 'node:fs';
14
12
  import { readdir } from 'node:fs/promises';
15
13
  import { getConfig, ensureDirectories, getResolvedCommands } from '../utils/index.js';
16
14
  import { logger, fatal } from '../utils/logger.js';
17
- import { run, commandExists, findAvailablePort, spawnTtyd } from '../utils/shell.js';
15
+ import { run, commandExists, findAvailablePort } from '../utils/shell.js';
18
16
  import { loadState, upsertBuilder } from '../state.js';
19
17
  import { loadRolePrompt } from '../utils/roles.js';
18
+ /**
19
+ * Simple Handlebars-like template renderer
20
+ * Supports: {{variable}}, {{#if condition}}...{{/if}}, {{object.property}}
21
+ */
22
+ function renderTemplate(template, context) {
23
+ let result = template;
24
+ // Process {{#if condition}}...{{/if}} blocks
25
+ // eslint-disable-next-line no-constant-condition
26
+ while (true) {
27
+ const ifMatch = result.match(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/);
28
+ if (!ifMatch)
29
+ break;
30
+ const [fullMatch, condition, content] = ifMatch;
31
+ const value = getNestedValue(context, condition);
32
+ result = result.replace(fullMatch, value ? content : '');
33
+ }
34
+ // Process {{variable}} and {{object.property}} substitutions
35
+ result = result.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_, path) => {
36
+ const value = getNestedValue(context, path);
37
+ if (value === undefined || value === null)
38
+ return '';
39
+ return String(value);
40
+ });
41
+ // Clean up any double newlines left from removed sections
42
+ result = result.replace(/\n{3,}/g, '\n\n');
43
+ return result.trim();
44
+ }
45
+ /**
46
+ * Get nested value from object using dot notation
47
+ */
48
+ function getNestedValue(obj, path) {
49
+ const parts = path.split('.');
50
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
51
+ let current = obj;
52
+ for (const part of parts) {
53
+ if (current === null || current === undefined)
54
+ return undefined;
55
+ current = current[part];
56
+ }
57
+ return current;
58
+ }
59
+ /**
60
+ * Load builder-prompt.md template for a protocol
61
+ */
62
+ function loadBuilderPromptTemplate(config, protocolName) {
63
+ const templatePath = resolve(config.codevDir, 'protocols', protocolName, 'builder-prompt.md');
64
+ if (existsSync(templatePath)) {
65
+ return readFileSync(templatePath, 'utf-8');
66
+ }
67
+ return null;
68
+ }
69
+ /**
70
+ * Build the prompt using protocol template or fallback to inline prompt
71
+ */
72
+ function buildPromptFromTemplate(config, protocolName, context) {
73
+ const template = loadBuilderPromptTemplate(config, protocolName);
74
+ if (template) {
75
+ logger.info(`Using template: protocols/${protocolName}/builder-prompt.md`);
76
+ return renderTemplate(template, context);
77
+ }
78
+ // Fallback: no template found, return a basic prompt
79
+ logger.debug(`No template found for ${protocolName}, using inline prompt`);
80
+ return buildFallbackPrompt(protocolName, context);
81
+ }
82
+ /**
83
+ * Build a fallback prompt when no template exists
84
+ */
85
+ function buildFallbackPrompt(protocolName, context) {
86
+ const modeInstructions = context.mode === 'strict'
87
+ ? `## Mode: STRICT
88
+ Porch orchestrates your work. Run: \`porch run ${context.project_id || ''}\``
89
+ : `## Mode: SOFT
90
+ You follow the protocol yourself. The architect monitors your work and verifies compliance.`;
91
+ let prompt = `# ${protocolName.toUpperCase()} Builder (${context.mode} mode)
92
+
93
+ You are implementing ${context.input_description}.
94
+
95
+ ${modeInstructions}
96
+
97
+ ## Protocol
98
+ Follow the ${protocolName.toUpperCase()} protocol: \`codev/protocols/${protocolName}/protocol.md\`
99
+ Read and internalize the protocol before starting any work.
100
+ `;
101
+ if (context.spec) {
102
+ prompt += `\n## Spec\nRead the specification at: \`${context.spec.path}\`\n`;
103
+ }
104
+ if (context.plan) {
105
+ prompt += `\n## Plan\nFollow the implementation plan at: \`${context.plan.path}\`\n`;
106
+ }
107
+ if (context.issue) {
108
+ prompt += `\n## Issue #${context.issue.number}
109
+ **Title**: ${context.issue.title}
110
+
111
+ **Description**:
112
+ ${context.issue.body || '(No description provided)'}
113
+ `;
114
+ }
115
+ if (context.task_text) {
116
+ prompt += `\n## Task\n${context.task_text}\n`;
117
+ }
118
+ return prompt;
119
+ }
120
+ // =============================================================================
121
+ // ID and Session Management
122
+ // =============================================================================
20
123
  /**
21
124
  * Generate a short 4-character base64-encoded ID
22
125
  * Uses URL-safe base64 (a-z, A-Z, 0-9, -, _) for filesystem-safe IDs
@@ -45,64 +148,27 @@ function generateShortId() {
45
148
  .substring(0, 4);
46
149
  }
47
150
  /**
48
- * Format current date/time as YYYY-MM-DD HH:MM
49
- */
50
- function formatDateTime() {
51
- const now = new Date();
52
- const year = now.getFullYear();
53
- const month = String(now.getMonth() + 1).padStart(2, '0');
54
- const day = String(now.getDate()).padStart(2, '0');
55
- const hours = String(now.getHours()).padStart(2, '0');
56
- const minutes = String(now.getMinutes()).padStart(2, '0');
57
- return `${year}-${month}-${day} ${hours}:${minutes}`;
58
- }
59
- /**
60
- * Rename a Claude session after it starts
61
- * Uses tmux buffer approach for reliable text input (same as af send)
62
- */
63
- function renameClaudeSession(sessionName, displayName) {
64
- // Wait for Claude to be ready, then send /rename command
65
- setTimeout(async () => {
66
- try {
67
- // Add date/time to the display name
68
- const nameWithTime = `${displayName} (${formatDateTime()})`;
69
- const renameCommand = `/rename ${nameWithTime}`;
70
- // Use buffer approach for reliable input (like af send)
71
- const tempFile = join(tmpdir(), `rename-${randomUUID()}.txt`);
72
- const bufferName = `rename-${sessionName}`;
73
- writeFileSync(tempFile, renameCommand);
74
- await run(`tmux load-buffer -b "${bufferName}" "${tempFile}"`);
75
- await run(`tmux paste-buffer -b "${bufferName}" -t "${sessionName}"`);
76
- await run(`tmux delete-buffer -b "${bufferName}"`).catch(() => { });
77
- await run(`tmux send-keys -t "${sessionName}" Enter`);
78
- // Clean up temp file
79
- try {
80
- unlinkSync(tempFile);
81
- }
82
- catch { }
83
- }
84
- catch {
85
- // Non-fatal - session naming is a nice-to-have
86
- }
87
- }, 5000); // 5 second delay for Claude to initialize
88
- }
89
- /**
90
- * Validate spawn options - ensure exactly one mode is selected
151
+ * Validate spawn options - ensure exactly one input mode is selected
152
+ * Note: --protocol serves dual purpose:
153
+ * 1. As an input mode when used alone (e.g., `af spawn --protocol experiment`)
154
+ * 2. As a protocol override when combined with other input modes (e.g., `af spawn -p 0001 --protocol tick`)
91
155
  */
92
156
  function validateSpawnOptions(options) {
93
- const modes = [
157
+ // Count input modes (excluding --protocol which can be used as override)
158
+ const inputModes = [
94
159
  options.project,
95
160
  options.task,
96
- options.protocol,
97
161
  options.shell,
98
162
  options.worktree,
99
163
  options.issue,
100
164
  ].filter(Boolean);
101
- if (modes.length === 0) {
165
+ // --protocol alone is a valid input mode
166
+ const protocolAlone = options.protocol && inputModes.length === 0;
167
+ if (inputModes.length === 0 && !protocolAlone) {
102
168
  fatal('Must specify one of: --project (-p), --issue (-i), --task, --protocol, --shell, --worktree\n\nRun "af spawn --help" for examples.');
103
169
  }
104
- if (modes.length > 1) {
105
- fatal('Flags --project, --issue, --task, --protocol, --shell, --worktree are mutually exclusive');
170
+ if (inputModes.length > 1) {
171
+ fatal('Flags --project, --issue, --task, --shell, --worktree are mutually exclusive');
106
172
  }
107
173
  if (options.files && !options.task) {
108
174
  fatal('--files requires --task');
@@ -110,23 +176,35 @@ function validateSpawnOptions(options) {
110
176
  if ((options.noComment || options.force) && !options.issue) {
111
177
  fatal('--no-comment and --force require --issue');
112
178
  }
179
+ // --protocol as override cannot be used with --shell or --worktree
180
+ if (options.protocol && inputModes.length > 0 && (options.shell || options.worktree)) {
181
+ fatal('--protocol cannot be used with --shell or --worktree (no protocol applies)');
182
+ }
183
+ // --use-protocol is now deprecated in favor of --protocol as universal override
184
+ // Keep for backwards compatibility but prefer --protocol
185
+ if (options.useProtocol && (options.shell || options.worktree)) {
186
+ fatal('--use-protocol cannot be used with --shell or --worktree (no protocol applies)');
187
+ }
113
188
  }
114
189
  /**
115
190
  * Determine the spawn mode from options
191
+ * Note: --protocol can be used as both an input mode (alone) or an override (with other modes)
116
192
  */
117
193
  function getSpawnMode(options) {
194
+ // Primary input modes take precedence over --protocol as override
118
195
  if (options.project)
119
196
  return 'spec';
120
197
  if (options.issue)
121
198
  return 'bugfix';
122
199
  if (options.task)
123
200
  return 'task';
124
- if (options.protocol)
125
- return 'protocol';
126
201
  if (options.shell)
127
202
  return 'shell';
128
203
  if (options.worktree)
129
204
  return 'worktree';
205
+ // --protocol alone is the protocol input mode
206
+ if (options.protocol)
207
+ return 'protocol';
130
208
  throw new Error('No mode specified');
131
209
  }
132
210
  // loadRolePrompt imported from ../utils/roles.js
@@ -188,6 +266,136 @@ function validateProtocol(config, protocolName) {
188
266
  fatal(`Protocol ${protocolName} exists but has no protocol.md file`);
189
267
  }
190
268
  }
269
+ /**
270
+ * Load and parse a protocol.json file
271
+ */
272
+ function loadProtocol(config, protocolName) {
273
+ const protocolJsonPath = resolve(config.codevDir, 'protocols', protocolName, 'protocol.json');
274
+ if (!existsSync(protocolJsonPath)) {
275
+ return null;
276
+ }
277
+ try {
278
+ const content = readFileSync(protocolJsonPath, 'utf-8');
279
+ return JSON.parse(content);
280
+ }
281
+ catch {
282
+ logger.warn(`Warning: Failed to parse ${protocolJsonPath}`);
283
+ return null;
284
+ }
285
+ }
286
+ /**
287
+ * Resolve which protocol to use based on precedence:
288
+ * 1. Explicit --protocol flag when used as override (with other input modes)
289
+ * 2. Explicit --use-protocol flag (backwards compatibility)
290
+ * 3. Spec file **Protocol**: header (for --project mode)
291
+ * 4. Hardcoded defaults (spider for specs, bugfix for issues)
292
+ */
293
+ async function resolveProtocol(options, config) {
294
+ // Count input modes to determine if --protocol is being used as override
295
+ const inputModes = [
296
+ options.project,
297
+ options.task,
298
+ options.shell,
299
+ options.worktree,
300
+ options.issue,
301
+ ].filter(Boolean);
302
+ const protocolAsOverride = options.protocol && inputModes.length > 0;
303
+ // 1. --protocol as override always wins when combined with other input modes
304
+ if (protocolAsOverride) {
305
+ validateProtocol(config, options.protocol);
306
+ return options.protocol.toLowerCase();
307
+ }
308
+ // 2. Explicit --use-protocol override (backwards compatibility)
309
+ if (options.useProtocol) {
310
+ validateProtocol(config, options.useProtocol);
311
+ return options.useProtocol.toLowerCase();
312
+ }
313
+ // 3. For spec mode, check spec file header (preserves existing behavior)
314
+ if (options.project) {
315
+ const specFile = await findSpecFile(config.codevDir, options.project);
316
+ if (specFile) {
317
+ const specContent = readFileSync(specFile, 'utf-8');
318
+ const match = specContent.match(/\*\*Protocol\*\*:\s*(\w+)/i);
319
+ if (match) {
320
+ const protocolFromSpec = match[1].toLowerCase();
321
+ // Validate the protocol exists
322
+ try {
323
+ validateProtocol(config, protocolFromSpec);
324
+ return protocolFromSpec;
325
+ }
326
+ catch {
327
+ // If protocol from spec doesn't exist, fall through to defaults
328
+ logger.warn(`Warning: Protocol "${match[1]}" from spec not found, using default`);
329
+ }
330
+ }
331
+ }
332
+ }
333
+ // 4. Hardcoded defaults based on input type
334
+ if (options.project)
335
+ return 'spider';
336
+ if (options.issue)
337
+ return 'bugfix';
338
+ // --protocol alone (not as override) uses the protocol name itself
339
+ if (options.protocol)
340
+ return options.protocol.toLowerCase();
341
+ if (options.task)
342
+ return 'spider';
343
+ return 'spider'; // Final fallback
344
+ }
345
+ // Note: GitHubIssue interface is defined later in the file
346
+ /**
347
+ * Resolve the builder mode (strict vs soft)
348
+ * Precedence:
349
+ * 1. Explicit --strict or --soft flags (always win)
350
+ * 2. Protocol defaults from protocol.json
351
+ * 3. Input type defaults (spec = strict, all others = soft)
352
+ */
353
+ function resolveMode(options, protocol) {
354
+ // 1. Explicit flags always win
355
+ if (options.strict && options.soft) {
356
+ fatal('--strict and --soft are mutually exclusive');
357
+ }
358
+ if (options.strict) {
359
+ return 'strict';
360
+ }
361
+ if (options.soft) {
362
+ return 'soft';
363
+ }
364
+ // 2. Protocol defaults from protocol.json
365
+ if (protocol?.defaults?.mode) {
366
+ return protocol.defaults.mode;
367
+ }
368
+ // 3. Input type defaults: only spec mode defaults to strict
369
+ if (options.project) {
370
+ return 'strict';
371
+ }
372
+ // All other modes default to soft
373
+ return 'soft';
374
+ }
375
+ /**
376
+ * Execute pre-spawn hooks defined in protocol.json
377
+ * Hooks are data-driven but reuse existing implementation logic
378
+ */
379
+ async function executePreSpawnHooks(protocol, context) {
380
+ if (!protocol?.hooks?.['pre-spawn'])
381
+ return;
382
+ const hooks = protocol.hooks['pre-spawn'];
383
+ // collision-check: reuses existing checkBugfixCollisions() logic
384
+ if (hooks['collision-check'] && context.issueNumber && context.issue && context.worktreePath) {
385
+ await checkBugfixCollisions(context.issueNumber, context.worktreePath, context.issue, !!context.force);
386
+ }
387
+ // comment-on-issue: posts comment to GitHub issue
388
+ if (hooks['comment-on-issue'] && context.issueNumber && !context.noComment) {
389
+ const message = hooks['comment-on-issue'];
390
+ logger.info('Commenting on issue...');
391
+ try {
392
+ await run(`gh issue comment ${context.issueNumber} --body "${message}"`);
393
+ }
394
+ catch {
395
+ logger.warn('Warning: Failed to comment on issue (continuing anyway)');
396
+ }
397
+ }
398
+ }
191
399
  /**
192
400
  * Check for required dependencies
193
401
  */
@@ -195,9 +403,6 @@ async function checkDependencies() {
195
403
  if (!(await commandExists('git'))) {
196
404
  fatal('git not found');
197
405
  }
198
- if (!(await commandExists('ttyd'))) {
199
- fatal('ttyd not found. Install with: brew install ttyd');
200
- }
201
406
  }
202
407
  /**
203
408
  * Find an available port, avoiding ports already in use by other builders
@@ -248,7 +453,25 @@ async function createWorktree(config, branchName, worktreePath) {
248
453
  }
249
454
  }
250
455
  /**
251
- * Start tmux session and ttyd for a builder
456
+ * Create a terminal session via the node-pty terminal manager REST API.
457
+ * The dashboard server must be running with node-pty backend enabled.
458
+ */
459
+ async function createPtySession(config, command, args, cwd) {
460
+ const dashboardPort = config.dashboardPort;
461
+ const response = await fetch(`http://localhost:${dashboardPort}/api/terminals`, {
462
+ method: 'POST',
463
+ headers: { 'Content-Type': 'application/json' },
464
+ body: JSON.stringify({ command, args, cwd, cols: 200, rows: 50 }),
465
+ });
466
+ if (!response.ok) {
467
+ const text = await response.text();
468
+ throw new Error(`Failed to create PTY session: ${response.status} ${text}`);
469
+ }
470
+ const result = await response.json();
471
+ return { terminalId: result.id };
472
+ }
473
+ /**
474
+ * Start a terminal session for a builder
252
475
  */
253
476
  async function startBuilderSession(config, builderId, worktreePath, baseCmd, prompt, roleContent, roleSource) {
254
477
  const port = await findFreePort(config);
@@ -268,79 +491,45 @@ async function startBuilderSession(config, builderId, worktreePath, baseCmd, pro
268
491
  writeFileSync(roleFile, roleWithPort);
269
492
  logger.info(`Loaded role (${roleSource})`);
270
493
  scriptContent = `#!/bin/bash
271
- exec ${baseCmd} --append-system-prompt "$(cat '${roleFile}')" "$(cat '${promptFile}')"
494
+ cd "${worktreePath}"
495
+ while true; do
496
+ ${baseCmd} --append-system-prompt "$(cat '${roleFile}')" "$(cat '${promptFile}')"
497
+ echo ""
498
+ echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
499
+ sleep 2
500
+ done
272
501
  `;
273
502
  }
274
503
  else {
275
504
  scriptContent = `#!/bin/bash
276
- exec ${baseCmd} "$(cat '${promptFile}')"
505
+ cd "${worktreePath}"
506
+ while true; do
507
+ ${baseCmd} "$(cat '${promptFile}')"
508
+ echo ""
509
+ echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
510
+ sleep 2
511
+ done
277
512
  `;
278
513
  }
279
514
  writeFileSync(scriptPath, scriptContent);
280
515
  chmodSync(scriptPath, '755');
281
- // Create tmux session running the script
282
- await run(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${scriptPath}"`);
283
- await run(`tmux set-option -t "${sessionName}" status off`);
284
- // Enable mouse scrolling in tmux
285
- await run('tmux set -g mouse on');
286
- await run('tmux set -g set-clipboard on');
287
- await run('tmux set -g allow-passthrough on');
288
- // Copy selection to clipboard when mouse is released (pbcopy for macOS)
289
- await run('tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"');
290
- await run('tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"');
291
- // Start ttyd connecting to the tmux session
292
- logger.info('Starting builder terminal...');
293
- const customIndexPath = resolve(config.templatesDir, 'ttyd-index.html');
294
- const hasCustomIndex = existsSync(customIndexPath);
295
- if (hasCustomIndex) {
296
- logger.info('Using custom terminal with file click support');
297
- }
298
- const ttydProcess = spawnTtyd({
299
- port,
300
- sessionName,
301
- cwd: worktreePath,
302
- customIndexPath: hasCustomIndex ? customIndexPath : undefined,
303
- });
304
- if (!ttydProcess?.pid) {
305
- fatal('Failed to start ttyd process for builder');
306
- }
307
- // Rename Claude session for better history tracking
308
- renameClaudeSession(sessionName, `Builder ${builderId}`);
309
- return { port, pid: ttydProcess.pid, sessionName };
516
+ // Create PTY session via REST API (node-pty backend)
517
+ logger.info('Creating PTY terminal session...');
518
+ const { terminalId } = await createPtySession(config, '/bin/bash', [scriptPath], worktreePath);
519
+ logger.info(`Terminal session created: ${terminalId}`);
520
+ return { port: 0, pid: 0, sessionName, terminalId };
310
521
  }
311
522
  /**
312
- * Start a shell session (no worktree, just tmux + ttyd)
523
+ * Start a shell session (no worktree, just node-pty)
313
524
  */
314
525
  async function startShellSession(config, shellId, baseCmd) {
315
526
  const port = await findFreePort(config);
316
527
  const sessionName = `shell-${shellId}`;
317
- logger.info('Creating tmux session...');
318
- // Shell mode: just launch Claude with no prompt
319
- await run(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${config.projectRoot}" "${baseCmd}"`);
320
- await run(`tmux set-option -t "${sessionName}" status off`);
321
- // Enable mouse scrolling in tmux
322
- await run('tmux set -g mouse on');
323
- await run('tmux set -g set-clipboard on');
324
- await run('tmux set -g allow-passthrough on');
325
- // Copy selection to clipboard when mouse is released (pbcopy for macOS)
326
- await run('tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"');
327
- await run('tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"');
328
- // Start ttyd connecting to the tmux session
329
- logger.info('Starting shell terminal...');
330
- const customIndexPath = resolve(config.templatesDir, 'ttyd-index.html');
331
- const hasCustomIndex = existsSync(customIndexPath);
332
- const ttydProcess = spawnTtyd({
333
- port,
334
- sessionName,
335
- cwd: config.projectRoot,
336
- customIndexPath: hasCustomIndex ? customIndexPath : undefined,
337
- });
338
- if (!ttydProcess?.pid) {
339
- fatal('Failed to start ttyd process for shell');
340
- }
341
- // Rename Claude session for better history tracking
342
- renameClaudeSession(sessionName, `Shell ${shellId}`);
343
- return { port, pid: ttydProcess.pid, sessionName };
528
+ // Create PTY session via REST API (node-pty backend)
529
+ logger.info('Creating PTY terminal session for shell...');
530
+ const { terminalId } = await createPtySession(config, '/bin/bash', ['-c', baseCmd], config.projectRoot);
531
+ logger.info(`Shell terminal session created: ${terminalId}`);
532
+ return { port: 0, pid: 0, sessionName, terminalId };
344
533
  }
345
534
  // =============================================================================
346
535
  // Mode-specific spawn implementations
@@ -369,33 +558,38 @@ async function spawnSpec(options, config) {
369
558
  await ensureDirectories(config);
370
559
  await checkDependencies();
371
560
  await createWorktree(config, branchName, worktreePath);
372
- // Parse protocol from spec file metadata
373
- const specContent = readFileSync(specFile, 'utf-8');
374
- const protocolMatch = specContent.match(/\*\*Protocol\*\*:\s*(\w+)/i);
375
- const protocol = protocolMatch ? protocolMatch[1].toUpperCase() : 'SPIDER';
376
- const protocolPath = `codev/protocols/${protocol.toLowerCase()}/protocol.md`;
377
- // Build the prompt
561
+ // Resolve protocol using precedence: --use-protocol > spec header > default
562
+ const protocol = await resolveProtocol(options, config);
563
+ const protocolPath = `codev/protocols/${protocol}/protocol.md`;
564
+ // Load protocol definition for potential hooks/config
565
+ const protocolDef = loadProtocol(config, protocol);
566
+ // Resolve mode: --soft flag > protocol defaults > input type defaults
567
+ const mode = resolveMode(options, protocolDef);
568
+ logger.kv('Protocol', protocol.toUpperCase());
569
+ logger.kv('Mode', mode.toUpperCase());
570
+ // Build the prompt using template
378
571
  const specRelPath = `codev/specs/${specName}.md`;
379
572
  const planRelPath = `codev/plans/${specName}.md`;
380
- let initialPrompt = `## Protocol
381
- Follow the ${protocol} protocol STRICTLY: ${protocolPath}
382
- Read and internalize the protocol before starting any work.
383
-
384
- ## Task
385
- Implement the feature specified in ${specRelPath}.`;
573
+ const templateContext = {
574
+ protocol_name: protocol.toUpperCase(),
575
+ mode,
576
+ mode_soft: mode === 'soft',
577
+ mode_strict: mode === 'strict',
578
+ project_id: projectId,
579
+ input_description: `the feature specified in ${specRelPath}`,
580
+ spec: { path: specRelPath, name: specName },
581
+ };
386
582
  if (hasPlan) {
387
- initialPrompt += ` Follow the implementation plan in ${planRelPath}.`;
583
+ templateContext.plan = { path: planRelPath, name: specName };
388
584
  }
389
- initialPrompt += `
390
-
391
- Start by reading the protocol, spec${hasPlan ? ', and plan' : ''}, then begin implementation.`;
585
+ const initialPrompt = buildPromptFromTemplate(config, protocol, templateContext);
392
586
  const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.
393
587
 
394
588
  ${initialPrompt}`;
395
589
  // Load role
396
590
  const role = options.noRole ? null : loadRolePrompt(config, 'builder');
397
591
  const commands = getResolvedCommands();
398
- const { port, pid, sessionName } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
592
+ const { port, pid, sessionName, terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
399
593
  const builder = {
400
594
  id: builderId,
401
595
  name: specName,
@@ -407,11 +601,18 @@ ${initialPrompt}`;
407
601
  branch: branchName,
408
602
  tmuxSession: sessionName,
409
603
  type: 'spec',
604
+ terminalId,
410
605
  };
411
606
  upsertBuilder(builder);
412
607
  logger.blank();
413
608
  logger.success(`Builder ${builderId} spawned!`);
414
- logger.kv('Terminal', `http://localhost:${port}`);
609
+ logger.kv('Mode', mode === 'strict' ? 'Strict (porch-driven)' : 'Soft (protocol-guided)');
610
+ if (terminalId) {
611
+ logger.kv('Terminal', `ws://localhost:${config.dashboardPort}/ws/terminal/${terminalId}`);
612
+ }
613
+ else {
614
+ logger.kv('Terminal', `http://localhost:${port}`);
615
+ }
415
616
  }
416
617
  /**
417
618
  * Spawn builder for an ad-hoc task
@@ -432,16 +633,30 @@ async function spawnTask(options, config) {
432
633
  await ensureDirectories(config);
433
634
  await checkDependencies();
434
635
  await createWorktree(config, branchName, worktreePath);
435
- // Build the prompt
436
- let prompt = taskText;
636
+ // Resolve protocol (tasks can specify --protocol override, default is 'spider')
637
+ const protocol = await resolveProtocol(options, config);
638
+ const protocolDef = loadProtocol(config, protocol);
639
+ const mode = resolveMode(options, protocolDef);
640
+ // Build the prompt using template
641
+ let taskDescription = taskText;
437
642
  if (options.files && options.files.length > 0) {
438
- prompt += `\n\nRelevant files to consider:\n${options.files.map(f => `- ${f}`).join('\n')}`;
643
+ taskDescription += `\n\nRelevant files to consider:\n${options.files.map(f => `- ${f}`).join('\n')}`;
439
644
  }
440
- const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition. ${prompt}`;
645
+ const templateContext = {
646
+ protocol_name: protocol.toUpperCase(),
647
+ mode,
648
+ mode_soft: mode === 'soft',
649
+ mode_strict: mode === 'strict',
650
+ project_id: builderId,
651
+ input_description: 'an ad-hoc task',
652
+ task_text: taskDescription,
653
+ };
654
+ const prompt = buildPromptFromTemplate(config, protocol, templateContext);
655
+ const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n\n${prompt}`;
441
656
  // Load role
442
657
  const role = options.noRole ? null : loadRolePrompt(config, 'builder');
443
658
  const commands = getResolvedCommands();
444
- const { port, pid, sessionName } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
659
+ const { port, pid, sessionName, terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
445
660
  const builder = {
446
661
  id: builderId,
447
662
  name: `Task: ${taskText.substring(0, 30)}${taskText.length > 30 ? '...' : ''}`,
@@ -454,11 +669,12 @@ async function spawnTask(options, config) {
454
669
  tmuxSession: sessionName,
455
670
  type: 'task',
456
671
  taskText,
672
+ terminalId,
457
673
  };
458
674
  upsertBuilder(builder);
459
675
  logger.blank();
460
676
  logger.success(`Builder ${builderId} spawned!`);
461
- logger.kv('Terminal', `http://localhost:${port}`);
677
+ logger.kv('Terminal', terminalId ? `node-pty:${terminalId}` : `http://localhost:${port}`);
462
678
  }
463
679
  /**
464
680
  * Spawn builder to run a protocol
@@ -477,12 +693,24 @@ async function spawnProtocol(options, config) {
477
693
  await ensureDirectories(config);
478
694
  await checkDependencies();
479
695
  await createWorktree(config, branchName, worktreePath);
480
- // Build the prompt
481
- const prompt = `You are running the ${protocolName} protocol. Start by reading codev/protocols/${protocolName}/protocol.md and follow its instructions.`;
696
+ // Load protocol definition and resolve mode
697
+ const protocolDef = loadProtocol(config, protocolName);
698
+ const mode = resolveMode(options, protocolDef);
699
+ logger.kv('Mode', mode.toUpperCase());
700
+ // Build the prompt using template
701
+ const templateContext = {
702
+ protocol_name: protocolName.toUpperCase(),
703
+ mode,
704
+ mode_soft: mode === 'soft',
705
+ mode_strict: mode === 'strict',
706
+ project_id: builderId,
707
+ input_description: `running the ${protocolName.toUpperCase()} protocol`,
708
+ };
709
+ const prompt = buildPromptFromTemplate(config, protocolName, templateContext);
482
710
  // Load protocol-specific role or fall back to builder role
483
711
  const role = options.noRole ? null : loadProtocolRole(config, protocolName);
484
712
  const commands = getResolvedCommands();
485
- const { port, pid, sessionName } = await startBuilderSession(config, builderId, worktreePath, commands.builder, prompt, role?.content ?? null, role?.source ?? null);
713
+ const { port, pid, sessionName, terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, prompt, role?.content ?? null, role?.source ?? null);
486
714
  const builder = {
487
715
  id: builderId,
488
716
  name: `Protocol: ${protocolName}`,
@@ -495,11 +723,12 @@ async function spawnProtocol(options, config) {
495
723
  tmuxSession: sessionName,
496
724
  type: 'protocol',
497
725
  protocolName,
726
+ terminalId,
498
727
  };
499
728
  upsertBuilder(builder);
500
729
  logger.blank();
501
730
  logger.success(`Builder ${builderId} spawned!`);
502
- logger.kv('Terminal', `http://localhost:${port}`);
731
+ logger.kv('Terminal', terminalId ? `node-pty:${terminalId}` : `http://localhost:${port}`);
503
732
  }
504
733
  /**
505
734
  * Spawn a bare shell session (no worktree, no prompt)
@@ -511,7 +740,7 @@ async function spawnShell(options, config) {
511
740
  await ensureDirectories(config);
512
741
  await checkDependencies();
513
742
  const commands = getResolvedCommands();
514
- const { port, pid, sessionName } = await startShellSession(config, shortId, commands.builder);
743
+ const { port, pid, sessionName, terminalId } = await startShellSession(config, shortId, commands.builder);
515
744
  // Shell sessions are tracked as builders with type 'shell'
516
745
  // They don't have worktrees or branches
517
746
  const builder = {
@@ -525,11 +754,12 @@ async function spawnShell(options, config) {
525
754
  branch: '',
526
755
  tmuxSession: sessionName,
527
756
  type: 'shell',
757
+ terminalId,
528
758
  };
529
759
  upsertBuilder(builder);
530
760
  logger.blank();
531
761
  logger.success(`Shell ${shellId} spawned!`);
532
- logger.kv('Terminal', `http://localhost:${port}`);
762
+ logger.kv('Terminal', terminalId ? `node-pty:${terminalId}` : `http://localhost:${port}`);
533
763
  }
534
764
  /**
535
765
  * Spawn a worktree session (has worktree/branch, but no initial prompt)
@@ -563,57 +793,48 @@ async function spawnWorktree(options, config) {
563
793
  writeFileSync(roleFile, roleWithPort);
564
794
  logger.info(`Loaded role (${role.source})`);
565
795
  scriptContent = `#!/bin/bash
566
- exec ${commands.builder} --append-system-prompt "$(cat '${roleFile}')"
796
+ cd "${worktreePath}"
797
+ while true; do
798
+ ${commands.builder} --append-system-prompt "$(cat '${roleFile}')"
799
+ echo ""
800
+ echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
801
+ sleep 2
802
+ done
567
803
  `;
568
804
  }
569
805
  else {
570
806
  scriptContent = `#!/bin/bash
571
- exec ${commands.builder}
807
+ cd "${worktreePath}"
808
+ while true; do
809
+ ${commands.builder}
810
+ echo ""
811
+ echo "Claude exited. Restarting in 2 seconds... (Ctrl+C to quit)"
812
+ sleep 2
813
+ done
572
814
  `;
573
815
  }
574
816
  writeFileSync(scriptPath, scriptContent, { mode: 0o755 });
575
- // Create tmux session running the launch script
576
- await run(`tmux new-session -d -s "${sessionName}" -x 200 -y 50 -c "${worktreePath}" "${scriptPath}"`);
577
- await run(`tmux set-option -t "${sessionName}" status off`);
578
- // Enable mouse scrolling in tmux
579
- await run('tmux set -g mouse on');
580
- await run('tmux set -g set-clipboard on');
581
- await run('tmux set -g allow-passthrough on');
582
- // Copy selection to clipboard when mouse is released (pbcopy for macOS)
583
- await run('tmux bind-key -T copy-mode MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"');
584
- await run('tmux bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"');
585
- // Start ttyd connecting to the tmux session
586
- logger.info('Starting worktree terminal...');
587
- const customIndexPath = resolve(config.codevDir, 'templates', 'ttyd-index.html');
588
- const hasCustomIndex = existsSync(customIndexPath);
589
- if (hasCustomIndex) {
590
- logger.info('Using custom terminal with file click support');
591
- }
592
- const ttydProcess = spawnTtyd({
593
- port,
594
- sessionName,
595
- cwd: worktreePath,
596
- customIndexPath: hasCustomIndex ? customIndexPath : undefined,
597
- });
598
- if (!ttydProcess?.pid) {
599
- fatal('Failed to start ttyd process for worktree');
600
- }
817
+ // Create PTY session via REST API (node-pty backend)
818
+ logger.info('Creating PTY terminal session for worktree...');
819
+ const { terminalId: worktreeTerminalId } = await createPtySession(config, '/bin/bash', [scriptPath], worktreePath);
820
+ logger.info(`Worktree terminal session created: ${worktreeTerminalId}`);
601
821
  const builder = {
602
822
  id: builderId,
603
823
  name: 'Worktree session',
604
- port,
605
- pid: ttydProcess.pid,
824
+ port: 0,
825
+ pid: 0,
606
826
  status: 'spawning',
607
827
  phase: 'interactive',
608
828
  worktree: worktreePath,
609
829
  branch: branchName,
610
830
  tmuxSession: sessionName,
611
831
  type: 'worktree',
832
+ terminalId: worktreeTerminalId,
612
833
  };
613
834
  upsertBuilder(builder);
614
835
  logger.blank();
615
836
  logger.success(`Worktree ${builderId} spawned!`);
616
- logger.kv('Terminal', `http://localhost:${port}`);
837
+ logger.kv('Terminal', `node-pty:${worktreeTerminalId}`);
617
838
  }
618
839
  /**
619
840
  * Generate a slug from an issue title (max 30 chars, lowercase, alphanumeric + hyphens)
@@ -696,53 +917,63 @@ async function spawnBugfix(options, config) {
696
917
  const builderId = `bugfix-${issueNumber}`;
697
918
  const branchName = `builder/bugfix-${issueNumber}-${slug}`;
698
919
  const worktreePath = resolve(config.buildersDir, builderId);
920
+ // Resolve protocol (allows --use-protocol override)
921
+ const protocol = await resolveProtocol(options, config);
922
+ const protocolDef = loadProtocol(config, protocol);
923
+ // Resolve mode: --soft flag > protocol defaults > input type defaults (bugfix defaults to soft)
924
+ const mode = resolveMode(options, protocolDef);
699
925
  logger.kv('Title', issue.title);
700
926
  logger.kv('Branch', branchName);
701
927
  logger.kv('Worktree', worktreePath);
702
- // Check for collisions
703
- await checkBugfixCollisions(issueNumber, worktreePath, issue, !!options.force);
928
+ logger.kv('Protocol', protocol.toUpperCase());
929
+ logger.kv('Mode', mode.toUpperCase());
930
+ // Execute pre-spawn hooks from protocol.json (collision check, issue comment)
931
+ // If protocol has hooks defined, use them; otherwise fall back to hardcoded behavior
932
+ if (protocolDef?.hooks?.['pre-spawn']) {
933
+ await executePreSpawnHooks(protocolDef, {
934
+ issueNumber,
935
+ issue,
936
+ worktreePath,
937
+ force: options.force,
938
+ noComment: options.noComment,
939
+ });
940
+ }
941
+ else {
942
+ // Fallback: hardcoded behavior for backwards compatibility
943
+ await checkBugfixCollisions(issueNumber, worktreePath, issue, !!options.force);
944
+ if (!options.noComment) {
945
+ logger.info('Commenting on issue...');
946
+ try {
947
+ await run(`gh issue comment ${issueNumber} --body "On it! Working on a fix now."`);
948
+ }
949
+ catch {
950
+ logger.warn('Warning: Failed to comment on issue (continuing anyway)');
951
+ }
952
+ }
953
+ }
704
954
  await ensureDirectories(config);
705
955
  await checkDependencies();
706
956
  await createWorktree(config, branchName, worktreePath);
707
- // Comment on the issue (unless --no-comment)
708
- if (!options.noComment) {
709
- logger.info('Commenting on issue...');
710
- try {
711
- await run(`gh issue comment ${issueNumber} --body "On it! Working on a fix now."`);
712
- }
713
- catch {
714
- logger.warn('Warning: Failed to comment on issue (continuing anyway)');
715
- }
716
- }
717
- // Build the prompt with issue context
718
- const prompt = `You are a Builder working on a BUGFIX task.
719
-
720
- ## Protocol
721
- Follow the BUGFIX protocol: codev/protocols/bugfix/protocol.md
722
-
723
- ## Issue #${issueNumber}
724
- **Title**: ${issue.title}
725
-
726
- **Description**:
727
- ${issue.body || '(No description provided)'}
728
-
729
- ## Your Mission
730
- 1. Reproduce the bug
731
- 2. Identify root cause
732
- 3. Implement fix (< 300 LOC)
733
- 4. Add regression test
734
- 5. Run CMAP review (3-way parallel: Gemini, Codex, Claude)
735
- 6. Create PR with "Fixes #${issueNumber}" in body
736
-
737
- If the fix is too complex (> 300 LOC or architectural changes), notify the Architect via:
738
- af send architect "Issue #${issueNumber} is more complex than expected. [Reason]. Recommend escalating to SPIDER/TICK."
739
-
740
- Start by reading the issue and reproducing the bug.`;
957
+ // Build the prompt using template
958
+ const templateContext = {
959
+ protocol_name: protocol.toUpperCase(),
960
+ mode,
961
+ mode_soft: mode === 'soft',
962
+ mode_strict: mode === 'strict',
963
+ project_id: builderId,
964
+ input_description: `a fix for GitHub Issue #${issueNumber}`,
965
+ issue: {
966
+ number: issueNumber,
967
+ title: issue.title,
968
+ body: issue.body || '(No description provided)',
969
+ },
970
+ };
971
+ const prompt = buildPromptFromTemplate(config, protocol, templateContext);
741
972
  const builderPrompt = `You are a Builder. Read codev/roles/builder.md for your full role definition.\n\n${prompt}`;
742
973
  // Load role
743
974
  const role = options.noRole ? null : loadRolePrompt(config, 'builder');
744
975
  const commands = getResolvedCommands();
745
- const { port, pid, sessionName } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
976
+ const { port, pid, sessionName, terminalId } = await startBuilderSession(config, builderId, worktreePath, commands.builder, builderPrompt, role?.content ?? null, role?.source ?? null);
746
977
  const builder = {
747
978
  id: builderId,
748
979
  name: `Bugfix #${issueNumber}: ${issue.title.substring(0, 40)}${issue.title.length > 40 ? '...' : ''}`,
@@ -755,11 +986,18 @@ Start by reading the issue and reproducing the bug.`;
755
986
  tmuxSession: sessionName,
756
987
  type: 'bugfix',
757
988
  issueNumber,
989
+ terminalId,
758
990
  };
759
991
  upsertBuilder(builder);
760
992
  logger.blank();
761
993
  logger.success(`Bugfix builder for issue #${issueNumber} spawned!`);
762
- logger.kv('Terminal', `http://localhost:${port}`);
994
+ logger.kv('Mode', mode === 'strict' ? 'Strict (porch-driven)' : 'Soft (protocol-guided)');
995
+ if (terminalId) {
996
+ logger.kv('Terminal', `ws://localhost:${config.dashboardPort}/ws/terminal/${terminalId}`);
997
+ }
998
+ else {
999
+ logger.kv('Terminal', `http://localhost:${port}`);
1000
+ }
763
1001
  }
764
1002
  // =============================================================================
765
1003
  // Main entry point