@worca/app 1.0.0 → 1.2.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/README.md +30 -9
  2. package/agents/clarify.meta.json +4 -4
  3. package/agents/decomposer.meta.json +5 -5
  4. package/agents/implementer.meta.json +15 -5
  5. package/agents/manualTestsChecklist.meta.json +5 -4
  6. package/agents/manualWebUiTesting.meta.json +9 -4
  7. package/agents/planReviewer.meta.json +12 -4
  8. package/agents/planner.meta.json +12 -5
  9. package/agents/refiner.meta.json +15 -4
  10. package/agents/reviewer.meta.json +14 -4
  11. package/agents/worca-cc-clarify.md +7 -0
  12. package/agents/worca-cc-code-reviewer.md +11 -6
  13. package/agents/worca-cc-decomposer.md +7 -0
  14. package/agents/worca-cc-implementer.md +9 -0
  15. package/agents/worca-cc-manual-tests-checklist.md +8 -5
  16. package/agents/worca-cc-manual-web-ui-testing.md +10 -6
  17. package/agents/worca-cc-plan-refiner.md +11 -6
  18. package/agents/worca-cc-plan-reviewer.md +10 -7
  19. package/agents/worca-cc-planner.md +9 -0
  20. package/agents/worca-cc-workspace-reviewer.md +11 -4
  21. package/agents/worca-cc-workspace-scanner.md +8 -4
  22. package/agents/workspaceReviewer.meta.json +15 -4
  23. package/agents/workspaceScanner.meta.json +5 -4
  24. package/package.json +8 -2
  25. package/skills/worca/SKILL.md +5 -5
  26. package/src/cli/render.mjs +148 -0
  27. package/src/cli/worca-cc.mjs +386 -56
  28. package/src/core/agent-gen.mjs +69 -31
  29. package/src/core/agent-registry.mjs +124 -144
  30. package/src/core/agent-store.mjs +164 -4
  31. package/src/core/artifacts.mjs +199 -23
  32. package/src/core/ask/attachment-kind.mjs +95 -0
  33. package/src/core/ask/catalog.mjs +111 -0
  34. package/src/core/ask/comment-deps.mjs +55 -0
  35. package/src/core/ask/events.mjs +545 -0
  36. package/src/core/ask/follow.mjs +113 -0
  37. package/src/core/ask/git-allowlist.mjs +226 -0
  38. package/src/core/ask/limits.mjs +57 -0
  39. package/src/core/ask/mcp-stdio.mjs +135 -0
  40. package/src/core/ask/models.mjs +125 -0
  41. package/src/core/ask/prompt.mjs +286 -0
  42. package/src/core/ask/proposal.mjs +170 -0
  43. package/src/core/ask/redact.mjs +30 -0
  44. package/src/core/ask/spawn.mjs +156 -0
  45. package/src/core/ask/store.mjs +438 -0
  46. package/src/core/ask/tool-deps.mjs +87 -0
  47. package/src/core/ask/tools.mjs +879 -0
  48. package/src/core/ask/turn.mjs +462 -0
  49. package/src/core/ask/worktree-deps.mjs +27 -0
  50. package/src/core/ask/worktrees.mjs +285 -0
  51. package/src/core/chat/command-router.mjs +28 -7
  52. package/src/core/chat/notifier.mjs +6 -1
  53. package/src/core/chat/renderers.mjs +15 -8
  54. package/src/core/claude-runner.mjs +541 -62
  55. package/src/core/config.mjs +310 -44
  56. package/src/core/cost-budget.mjs +29 -2
  57. package/src/core/db.mjs +773 -53
  58. package/src/core/diff-anchor.mjs +213 -0
  59. package/src/core/diff-comments.mjs +273 -0
  60. package/src/core/engine-select.mjs +32 -0
  61. package/src/core/failure-policy.mjs +201 -0
  62. package/src/core/git-info.mjs +49 -10
  63. package/src/core/graph/builtin-workflows.mjs +51 -0
  64. package/src/core/graph/executor.mjs +894 -0
  65. package/src/core/graph/registry-ports.mjs +12 -0
  66. package/src/core/graph/scheduler.mjs +1072 -0
  67. package/src/core/graph/seed-templates.mjs +318 -0
  68. package/src/core/host-guard.mjs +271 -0
  69. package/src/core/model-env.mjs +180 -8
  70. package/src/core/model-test.mjs +79 -0
  71. package/src/core/orchestrator.mjs +994 -4097
  72. package/src/core/overview-agent.mjs +15 -3
  73. package/src/core/phases.mjs +208 -537
  74. package/src/core/pipeline-delete.mjs +13 -2
  75. package/src/core/plugin-api.mjs +8 -3
  76. package/src/core/plugin-config.mjs +178 -28
  77. package/src/core/plugin-inventory.mjs +6 -2
  78. package/src/core/plugin-manifest.mjs +199 -11
  79. package/src/core/plugin-models.mjs +1 -0
  80. package/src/core/plugin-repo.mjs +16 -4
  81. package/src/core/plugin-shim-child.mjs +9 -3
  82. package/src/core/plugin-shim.mjs +80 -17
  83. package/src/core/plugin-store.mjs +236 -29
  84. package/src/core/plugin-workflows.mjs +90 -41
  85. package/src/core/preflight.mjs +135 -3
  86. package/src/core/projects.mjs +7 -5
  87. package/src/core/protocol.mjs +8 -35
  88. package/src/core/recoverable-error.mjs +1 -1
  89. package/src/core/run-harness.mjs +3934 -0
  90. package/src/core/run-manifest.mjs +5 -1
  91. package/src/core/settings.mjs +184 -13
  92. package/src/core/skills.mjs +10 -3
  93. package/src/core/source-bindings.mjs +175 -0
  94. package/src/core/sources.mjs +87 -25
  95. package/src/core/stats.mjs +25 -6
  96. package/src/core/title.mjs +51 -4
  97. package/src/core/workflows.mjs +358 -259
  98. package/src/core/workspace-scan.mjs +4 -0
  99. package/src/core/worktree.mjs +98 -7
  100. package/src/shared/graph/agent-meta.mjs +278 -0
  101. package/src/shared/graph/constants.mjs +105 -0
  102. package/src/shared/graph/geometry.mjs +157 -0
  103. package/src/shared/graph/layout.mjs +134 -0
  104. package/src/shared/graph/loops.mjs +130 -0
  105. package/src/shared/graph/manifest.mjs +257 -0
  106. package/src/shared/graph/ports.mjs +153 -0
  107. package/src/shared/graph/route.mjs +397 -0
  108. package/src/shared/graph/template.mjs +165 -0
  109. package/src/shared/graph/thumbnail.mjs +67 -0
  110. package/src/shared/graph/validate.mjs +491 -0
  111. package/src/shared/graph/verdict.mjs +41 -0
  112. package/ui/public/app.js +4240 -1682
  113. package/ui/public/ask-markdown.mjs +145 -0
  114. package/ui/public/ask-model.mjs +317 -0
  115. package/ui/public/ask-panel.mjs +2129 -0
  116. package/ui/public/chat-settings-view.mjs +6 -2
  117. package/ui/public/diff-view.mjs +66 -11
  118. package/ui/public/file-tree.mjs +305 -0
  119. package/ui/public/graph/composer.mjs +889 -0
  120. package/ui/public/graph/inspector.mjs +183 -0
  121. package/ui/public/graph/model.mjs +37 -0
  122. package/ui/public/graph/palette.mjs +144 -0
  123. package/ui/public/graph/run-decor.mjs +410 -0
  124. package/ui/public/graph/run-hosts.mjs +201 -0
  125. package/ui/public/graph/save-dialog.mjs +56 -0
  126. package/ui/public/graph/view.mjs +858 -0
  127. package/ui/public/guardrails-view.mjs +4 -2
  128. package/ui/public/hljs-loader.mjs +180 -0
  129. package/ui/public/index.html +311 -265
  130. package/ui/public/log-filter.mjs +22 -4
  131. package/ui/public/log-line.mjs +45 -19
  132. package/ui/public/models-view.mjs +171 -9
  133. package/ui/public/plugins-view.mjs +106 -4
  134. package/ui/public/source-pane.mjs +190 -8
  135. package/ui/public/stats-view.mjs +81 -1
  136. package/ui/public/style.css +1487 -229
  137. package/ui/public/syntax-highlight.mjs +270 -0
  138. package/ui/public/thinking-orb.mjs +110 -0
  139. package/ui/server.mjs +1894 -104
  140. package/src/core/channels.mjs +0 -302
  141. package/src/core/runners.mjs +0 -167
  142. package/src/core/workflow-validator.mjs +0 -185
  143. package/ui/public/composer-core.mjs +0 -211
@@ -0,0 +1,318 @@
1
+ // src/core/graph/seed-templates.mjs
2
+ // The 7 hand-written v2 seed templates the V17 migration re-seeds the user's
3
+ // saved pipelines as, plus the static overlay-migration maps.
4
+ //
5
+ // Each entry is a FLAT Template object — `{ id, name, version, domain, createdAt,
6
+ // nodes, wires }` at the TOP level, never nested under `graph`. Flat is
7
+ // load-bearing: validateGraph runs on the whole template and V1 reads `version`
8
+ // off the object it is handed.
9
+ //
10
+ // Shape rules the graphs follow (a copy-check, not an authoring recipe — these
11
+ // were hand-authored and verified against the v1 rows):
12
+ // - Every template has a Task source node and exactly ONE End node, wired from
13
+ // `webui.pass` where a webui node exists, else `reviewer.pass`.
14
+ // - `implementer.done -> reviewer.done`, `reviewer.pass -> checklist.await` and
15
+ // `checklist.checklist -> webui.checklist` reproduce v1's linear order; refine
16
+ // self-loops and review loops carry `config.maxCycles: 3`.
17
+ // - The double-loop templates (wf_full, wf_provided-plan, wf_full-no-decompose)
18
+ // fan `reviewer.review -> n_or.in1` and `webui.review -> n_or.in2` — each
19
+ // in-wire keeps its OWN maxCycles, because those are the loop wires and the
20
+ // gate sites — while `n_or.out -> implementer.fix` carries NO config
21
+ // (maxCycles on an always-sourced wire fails V13). wf_no-clarify has no or
22
+ // card: only w10 targets `fix`, and `webui.review` is UNWIRED, matching its
23
+ // two-feedback v1 row.
24
+ //
25
+ // Parity note: the OR in-wires keep their v1-era wire ids and budgets, so source
26
+ // firings, per-wire delivery counts and gate sites are identical to the direct
27
+ // wire shape; the OR adds one $0 instant exec row per loop emission and forwards
28
+ // the identical review path to `fix`.
29
+
30
+ import { deepFreeze } from './builtin-workflows.mjs';
31
+
32
+ /** Full: clarify -> plan -> refine -> decompose -> implement -> review ->
33
+ * checklist -> web UI, with both review loops fanned through the or card.
34
+ * 11 nodes / 17 wires. */
35
+ const WF_FULL = {
36
+ id: 'wf_full',
37
+ name: 'Full',
38
+ version: 2,
39
+ domain: 'coding',
40
+ createdAt: '2026-07-29T19:39:27.650Z',
41
+ nodes: [
42
+ { id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
43
+ { id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
44
+ { id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
45
+ { id: 'n_refine', kind: 'agent', key: 'refiner', x: 960, y: 198, config: {} },
46
+ { id: 'n_decompose', kind: 'agent', key: 'decomposer', x: 1260, y: 198, config: {} },
47
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 1560, y: 198, config: {} },
48
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 1860, y: 198, config: {} },
49
+ { id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 2160, y: 198, config: {} },
50
+ { id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 2460, y: 198, config: {} },
51
+ { id: 'n_end', kind: 'end', x: 2760, y: 198, config: {} },
52
+ { id: 'n_or', kind: 'or', x: 2010, y: 430, config: { arity: 2 } },
53
+ ],
54
+ wires: [
55
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
56
+ { id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
57
+ { id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
58
+ { id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
59
+ { id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
60
+ { id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_decompose', port: 'plan' } },
61
+ { id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
62
+ { id: 'w8', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
63
+ { id: 'w9', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
64
+ { id: 'w10', from: { node: 'n_decompose', port: 'tasks' }, to: { node: 'n_impl', port: 'task' } },
65
+ { id: 'w11', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
66
+ { id: 'w12', from: { node: 'n_review', port: 'review' }, to: { node: 'n_or', port: 'in1' }, config: { maxCycles: 3 } },
67
+ { id: 'w13', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
68
+ { id: 'w14', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
69
+ { id: 'w15', from: { node: 'n_webui', port: 'review' }, to: { node: 'n_or', port: 'in2' }, config: { maxCycles: 3 } },
70
+ { id: 'w16', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
71
+ { id: 'w17', from: { node: 'n_or', port: 'out' }, to: { node: 'n_impl', port: 'fix' } },
72
+ ],
73
+ };
74
+
75
+ /** No Clarify: Full minus the clarify node, and with only ONE feedback into
76
+ * `fix` — w10 reviewer.review. `webui.review` is deliberately unwired, matching
77
+ * the v1 row's two feedbacks. 9 nodes / 13 wires. */
78
+ const WF_NO_CLARIFY = {
79
+ id: 'wf_no-clarify',
80
+ name: 'No Clarify',
81
+ version: 2,
82
+ domain: 'coding',
83
+ createdAt: '2026-07-29T19:40:22.212Z',
84
+ nodes: [
85
+ { id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
86
+ { id: 'n_plan', kind: 'agent', key: 'planner', x: 360, y: 198, config: {} },
87
+ { id: 'n_refine', kind: 'agent', key: 'refiner', x: 660, y: 198, config: {} },
88
+ { id: 'n_decompose', kind: 'agent', key: 'decomposer', x: 960, y: 198, config: {} },
89
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 1260, y: 198, config: {} },
90
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 1560, y: 198, config: {} },
91
+ { id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 1860, y: 198, config: {} },
92
+ { id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 2160, y: 198, config: {} },
93
+ { id: 'n_end', kind: 'end', x: 2460, y: 198, config: {} },
94
+ ],
95
+ wires: [
96
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
97
+ { id: 'w2', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
98
+ { id: 'w3', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
99
+ { id: 'w4', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_decompose', port: 'plan' } },
100
+ { id: 'w5', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
101
+ { id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
102
+ { id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
103
+ { id: 'w8', from: { node: 'n_decompose', port: 'tasks' }, to: { node: 'n_impl', port: 'task' } },
104
+ { id: 'w9', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
105
+ { id: 'w10', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
106
+ { id: 'w11', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
107
+ { id: 'w12', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
108
+ { id: 'w13', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
109
+ ],
110
+ };
111
+
112
+ /** Provided Plan: no planner — the task node carries `planStoreSeed` (A2) and
113
+ * wires straight into `refiner.plan`. 9 nodes / 14 wires. */
114
+ const WF_PROVIDED_PLAN = {
115
+ id: 'wf_provided-plan',
116
+ name: 'Provided Plan',
117
+ version: 2,
118
+ domain: 'coding',
119
+ createdAt: '2026-08-07T11:29:56.074Z',
120
+ nodes: [
121
+ { id: 'n_task', kind: 'task', x: 60, y: 198, config: { planStoreSeed: true } },
122
+ { id: 'n_refine', kind: 'agent', key: 'refiner', x: 360, y: 198, config: {} },
123
+ { id: 'n_decompose', kind: 'agent', key: 'decomposer', x: 660, y: 198, config: {} },
124
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 960, y: 198, config: {} },
125
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 1260, y: 198, config: {} },
126
+ { id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 1560, y: 198, config: {} },
127
+ { id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 1860, y: 198, config: {} },
128
+ { id: 'n_end', kind: 'end', x: 2160, y: 198, config: {} },
129
+ { id: 'n_or', kind: 'or', x: 1410, y: 430, config: { arity: 2 } },
130
+ ],
131
+ wires: [
132
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_refine', port: 'plan' } },
133
+ { id: 'w2', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
134
+ { id: 'w3', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_decompose', port: 'plan' } },
135
+ { id: 'w4', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
136
+ { id: 'w5', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
137
+ { id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
138
+ { id: 'w7', from: { node: 'n_decompose', port: 'tasks' }, to: { node: 'n_impl', port: 'task' } },
139
+ { id: 'w8', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
140
+ { id: 'w9', from: { node: 'n_review', port: 'review' }, to: { node: 'n_or', port: 'in1' }, config: { maxCycles: 3 } },
141
+ { id: 'w10', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
142
+ { id: 'w11', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
143
+ { id: 'w12', from: { node: 'n_webui', port: 'review' }, to: { node: 'n_or', port: 'in2' }, config: { maxCycles: 3 } },
144
+ { id: 'w13', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
145
+ { id: 'w14', from: { node: 'n_or', port: 'out' }, to: { node: 'n_impl', port: 'fix' } },
146
+ ],
147
+ };
148
+
149
+ /** FULL-NO-Decompose: Full minus the decomposer, so the implementer takes the
150
+ * whole plan. 10 nodes / 15 wires. */
151
+ const WF_FULL_NO_DECOMPOSE = {
152
+ id: 'wf_full-no-decompose',
153
+ name: 'FULL-NO-Decompose',
154
+ version: 2,
155
+ domain: 'coding',
156
+ createdAt: '2026-08-08T00:02:32.776Z',
157
+ nodes: [
158
+ { id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
159
+ { id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
160
+ { id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
161
+ { id: 'n_refine', kind: 'agent', key: 'refiner', x: 960, y: 198, config: {} },
162
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 1260, y: 198, config: {} },
163
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 1560, y: 198, config: {} },
164
+ { id: 'n_check', kind: 'agent', key: 'manualTestsChecklist', x: 1860, y: 198, config: {} },
165
+ { id: 'n_webui', kind: 'agent', key: 'manualWebUiTesting', x: 2160, y: 198, config: {} },
166
+ { id: 'n_end', kind: 'end', x: 2460, y: 198, config: {} },
167
+ { id: 'n_or', kind: 'or', x: 1710, y: 430, config: { arity: 2 } },
168
+ ],
169
+ wires: [
170
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
171
+ { id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
172
+ { id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
173
+ { id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
174
+ { id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
175
+ { id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
176
+ { id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
177
+ { id: 'w8', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_check', port: 'plan' } },
178
+ { id: 'w9', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
179
+ { id: 'w10', from: { node: 'n_review', port: 'review' }, to: { node: 'n_or', port: 'in1' }, config: { maxCycles: 3 } },
180
+ { id: 'w11', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_check', port: 'await' } },
181
+ { id: 'w12', from: { node: 'n_check', port: 'checklist' }, to: { node: 'n_webui', port: 'checklist' } },
182
+ { id: 'w13', from: { node: 'n_webui', port: 'review' }, to: { node: 'n_or', port: 'in2' }, config: { maxCycles: 3 } },
183
+ { id: 'w14', from: { node: 'n_webui', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
184
+ { id: 'w15', from: { node: 'n_or', port: 'out' }, to: { node: 'n_impl', port: 'fix' } },
185
+ ],
186
+ };
187
+
188
+ /** Quick Fix: plan -> implement -> review, no refiner, no checklist. The
189
+ * reviewer's only other input is the VOID `done` port, which is why it stays
190
+ * warning-free under V18 (exemption (b)). 5 nodes / 6 wires. */
191
+ const WF_QUICK_FIX = {
192
+ id: 'wf_quick-fix',
193
+ name: 'Quick Fix',
194
+ version: 2,
195
+ domain: 'coding',
196
+ createdAt: '2026-08-09T14:40:59.262Z',
197
+ nodes: [
198
+ { id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
199
+ { id: 'n_plan', kind: 'agent', key: 'planner', x: 360, y: 198, config: {} },
200
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 660, y: 198, config: {} },
201
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 960, y: 198, config: {} },
202
+ { id: 'n_end', kind: 'end', x: 1260, y: 198, config: {} },
203
+ ],
204
+ wires: [
205
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
206
+ { id: 'w2', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
207
+ { id: 'w3', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
208
+ { id: 'w4', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
209
+ { id: 'w5', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
210
+ { id: 'w6', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
211
+ ],
212
+ };
213
+
214
+ /** Clarify -> Implement: clarify -> plan -> refine -> implement -> review.
215
+ * 7 nodes / 10 wires. */
216
+ const WF_CLARIFY_IMPLEMENT = {
217
+ id: 'wf_clarify-implement',
218
+ name: 'Clarify -> Implement',
219
+ version: 2,
220
+ domain: 'coding',
221
+ createdAt: '2026-08-09T15:16:43.806Z',
222
+ nodes: [
223
+ { id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
224
+ { id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
225
+ { id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
226
+ { id: 'n_refine', kind: 'agent', key: 'refiner', x: 960, y: 198, config: {} },
227
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 1260, y: 198, config: {} },
228
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 1560, y: 198, config: {} },
229
+ { id: 'n_end', kind: 'end', x: 1860, y: 198, config: {} },
230
+ ],
231
+ wires: [
232
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
233
+ { id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
234
+ { id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
235
+ { id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_refine', port: 'plan' } },
236
+ { id: 'w5', from: { node: 'n_refine', port: 'revise' }, to: { node: 'n_refine', port: 'revise' }, config: { maxCycles: 3 } },
237
+ { id: 'w6', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
238
+ { id: 'w7', from: { node: 'n_refine', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
239
+ { id: 'w8', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
240
+ { id: 'w9', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
241
+ { id: 'w10', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
242
+ ],
243
+ };
244
+
245
+ /** Clarify -> Quick Fix: Quick Fix with a clarify step in front. 6 nodes /
246
+ * 8 wires. */
247
+ const WF_CLARIFY_QUICK_FIX = {
248
+ id: 'wf_clarify-quick-fix',
249
+ name: 'Clarify -> Quick Fix',
250
+ version: 2,
251
+ domain: 'coding',
252
+ createdAt: '2026-08-09T15:18:40.077Z',
253
+ nodes: [
254
+ { id: 'n_task', kind: 'task', x: 60, y: 198, config: {} },
255
+ { id: 'n_clarify', kind: 'agent', key: 'clarify', x: 360, y: 198, config: {} },
256
+ { id: 'n_plan', kind: 'agent', key: 'planner', x: 660, y: 198, config: {} },
257
+ { id: 'n_impl', kind: 'agent', key: 'implementer', x: 960, y: 198, config: {} },
258
+ { id: 'n_review', kind: 'agent', key: 'reviewer', x: 1260, y: 198, config: {} },
259
+ { id: 'n_end', kind: 'end', x: 1560, y: 198, config: {} },
260
+ ],
261
+ wires: [
262
+ { id: 'w1', from: { node: 'n_task', port: 'task' }, to: { node: 'n_clarify', port: 'task' } },
263
+ { id: 'w2', from: { node: 'n_task', port: 'task' }, to: { node: 'n_plan', port: 'task' } },
264
+ { id: 'w3', from: { node: 'n_clarify', port: 'answers' }, to: { node: 'n_plan', port: 'answers' } },
265
+ { id: 'w4', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_impl', port: 'plan' } },
266
+ { id: 'w5', from: { node: 'n_plan', port: 'plan' }, to: { node: 'n_review', port: 'plan' } },
267
+ { id: 'w6', from: { node: 'n_impl', port: 'done' }, to: { node: 'n_review', port: 'done' } },
268
+ { id: 'w7', from: { node: 'n_review', port: 'review' }, to: { node: 'n_impl', port: 'fix' }, config: { maxCycles: 3 } },
269
+ { id: 'w8', from: { node: 'n_review', port: 'pass' }, to: { node: 'n_end', port: 'result' } },
270
+ ],
271
+ };
272
+
273
+ /** The 7 seeds, in seeding order. */
274
+ export const SEED_TEMPLATES = deepFreeze([
275
+ WF_FULL,
276
+ WF_NO_CLARIFY,
277
+ WF_PROVIDED_PLAN,
278
+ WF_FULL_NO_DECOMPOSE,
279
+ WF_QUICK_FIX,
280
+ WF_CLARIFY_IMPLEMENT,
281
+ WF_CLARIFY_QUICK_FIX,
282
+ ]);
283
+
284
+ /** V17 overlay migration: `config_workflow_nodes.node_id` rewrites, old v1 step
285
+ * id -> v2 node id. wf_default is included for projects carrying overlays on
286
+ * the builtin default workflow. */
287
+ export const NODE_ID_MAP = deepFreeze({
288
+ wf_full: { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_refine', s3_0: 'n_decompose', s4_0: 'n_impl', s5_0: 'n_review', s6_0: 'n_check', s7_0: 'n_webui' },
289
+ 'wf_no-clarify': { s0_0: 'n_plan', s1_0: 'n_refine', s2_0: 'n_decompose', s3_0: 'n_impl', s4_0: 'n_review', s5_0: 'n_check', s6_0: 'n_webui' },
290
+ 'wf_provided-plan': { s0_0: 'n_refine', s1_0: 'n_decompose', s2_0: 'n_impl', s3_0: 'n_review', s4_0: 'n_check', s5_0: 'n_webui' },
291
+ 'wf_full-no-decompose': { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_refine', s3_0: 'n_impl', s4_0: 'n_review', s5_0: 'n_check', s6_0: 'n_webui' },
292
+ 'wf_quick-fix': { s0_0: 'n_plan', s1_0: 'n_impl', s2_0: 'n_review' },
293
+ 'wf_clarify-implement': { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_refine', s3_0: 'n_impl', s4_0: 'n_review' },
294
+ 'wf_clarify-quick-fix': { s0_0: 'n_clarify', s1_0: 'n_plan', s2_0: 'n_impl', s3_0: 'n_review' },
295
+ wf_default: { s_clarify: 'n_clarify', s0_0: 'n_plan', s1_0: 'n_refine', s2_0: 'n_impl', s3_0: 'n_review' },
296
+ });
297
+
298
+ /** V17 overlay migration: `config_workflow_feedbacks` -> `config_workflow_wires`
299
+ * rows, old feedback id -> v2 wire id. Wire ids reflect Amendment f as revised
300
+ * (single-wire inputs; the double-loop seeds' blocking wires keep their ids as
301
+ * the OR valve's in-wires — exactly where budgets and gates now count, so
302
+ * migrated overlays land correctly by construction).
303
+ *
304
+ * wf_no-clarify's fb_0 is the refine self-wire and fb_1 the review loop wire —
305
+ * the user's max_cycles=6 overlays ride those two. wf_clarify-implement's fb
306
+ * order is BELIEVED swapped in the DB and is UNVERIFIED (that template is absent
307
+ * from the reference DB); V17's dynamic resolver, not this row, is what makes
308
+ * the migration safe. */
309
+ export const FB_WIRE_MAP = deepFreeze({
310
+ wf_full: { fb_0: 'w5', fb_1: 'w12', fb_2: 'w15' },
311
+ 'wf_no-clarify': { fb_0: 'w3', fb_1: 'w10' },
312
+ 'wf_provided-plan': { fb_0: 'w2', fb_1: 'w9', fb_2: 'w12' },
313
+ 'wf_full-no-decompose': { fb_0: 'w5', fb_1: 'w10', fb_2: 'w13' },
314
+ 'wf_quick-fix': { fb_0: 'w5' },
315
+ 'wf_clarify-implement': { fb_0: 'w9', fb_1: 'w5' },
316
+ 'wf_clarify-quick-fix': { fb_0: 'w7' },
317
+ wf_default: { fb_refine: 'w5', fb_review: 'w9' },
318
+ });
@@ -0,0 +1,271 @@
1
+ // src/core/host-guard.mjs
2
+ // The host-process guard. Every agent worca spawns carries this file as a
3
+ // PreToolUse hook on Bash (see buildSettingsPayload in claude-runner.mjs), so
4
+ // no agent — predefined role, custom agent, plugin agent, or their in-process
5
+ // sub-agents — can kill the worca server that runs it.
6
+ //
7
+ // Born of the 2026-08-31 incident: an implementer cleaning up stray test
8
+ // servers ran `ps aux | grep '[n]ode --disable' | awk '{print $2}' | while
9
+ // read p; do kill $p; done` and took down its own host mid-run (the pattern
10
+ // matches the production server argv exactly).
11
+ //
12
+ // Policy — deny when the command:
13
+ // - invokes `pkill` or `killall` (pattern kills, unbounded blast radius);
14
+ // - invokes `kill` with anything but literal numeric PIDs or `%N` jobspecs
15
+ // (variables, substitutions, piped/xargs input, `-PGID` group targets —
16
+ // all of these are how a kill reaches processes nobody named);
17
+ // - names the host PID (WORCA_HOST_PID) as a literal kill target;
18
+ // - wraps a kill in a nested `sh -c` / `bash -c` payload.
19
+ // Everything else is allowed, so an agent can still stop processes it spawned:
20
+ // look PIDs up first (ps is read-only and always allowed), then kill the
21
+ // literal numbers. Forcing literal PIDs is the point — every literal target
22
+ // passes through the host-PID check.
23
+ //
24
+ // The hook CLI is deliberately fail-open on malformed input: a broken payload
25
+ // must not brick every Bash call of every agent. The DECISION is fail-closed.
26
+ import { fileURLToPath, pathToFileURL } from 'node:url';
27
+
28
+ /** ON unless WORCA_HOST_GUARD is "0"/"false" — the one kill-switch for the
29
+ * hook, the WORCA_HOST_PID env var, and the system-prompt preamble alike. */
30
+ export function hostGuardEnabled() {
31
+ const v = process.env.WORCA_HOST_GUARD;
32
+ return !(v === '0' || String(v ?? '').toLowerCase() === 'false');
33
+ }
34
+
35
+ /** The PreToolUse hook entry buildSettingsPayload merges into --settings.
36
+ * Runs THIS file with the server's own node; JSON.stringify double-quotes
37
+ * both paths for the shell. */
38
+ export function hostGuardHookEntry() {
39
+ // Forward slashes on purpose: JSON.stringify would double every Windows
40
+ // backslash and the hook shell's unescaping is unverified there, while
41
+ // CreateProcess and Git Bash both accept forward-slash paths. POSIX node
42
+ // paths never contain backslashes, so the replace is a no-op off Windows.
43
+ const q = (s) => JSON.stringify(String(s).replaceAll('\\', '/'));
44
+ return {
45
+ matcher: 'Bash',
46
+ hooks: [{ type: 'command', command: `${q(process.execPath)} ${q(fileURLToPath(import.meta.url))}` }],
47
+ };
48
+ }
49
+
50
+ /** The system-prompt preamble every real spawn carries (runReal prepends it). */
51
+ export function hostGuardSystemPrompt(pid) {
52
+ return [
53
+ '## Host process protection',
54
+ `You run under the worca app server (PID ${pid}, \`node ui/server.mjs\`). Never kill it, and never kill any process you did not spawn yourself.`,
55
+ 'Pattern kills are forbidden and a PreToolUse hook blocks them on every OS: `pkill`, `killall`, `xargs kill`, `taskkill /IM`, `Stop-Process -Name`, `wmic process … delete`, and any `kill` fed from a pipe, variable, or substitution (e.g. `ps aux | grep … | while read p; do kill $p; done`).',
56
+ 'To stop a process you started: record its PID when you spawn it (or list PIDs with `ps`, which is always allowed), then run `kill <literal pid>`.',
57
+ ].join('\n');
58
+ }
59
+
60
+ /** Command words that may prefix the one we care about. */
61
+ const SKIP_WORDS = new Set([
62
+ 'do', 'then', 'else', 'elif', 'if', 'while', 'until',
63
+ 'exec', 'command', 'builtin', 'nohup', 'time', 'sudo', 'env',
64
+ ]);
65
+
66
+ const NESTED_SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'powershell', 'pwsh', 'cmd']);
67
+
68
+ /** Any killer, POSIX or Windows, appearing anywhere in a nested-shell payload. */
69
+ const KILLER_WORD_RE = /\b(?:p?kill|killall|taskkill|stop-process|spps|wmic)\b/i;
70
+
71
+ /** Killers that xargs / find -exec can hand targets to. */
72
+ const KILLER_CMDS = new Set(['kill', 'pkill', 'killall', 'taskkill']);
73
+ const isKillerToken = (t) => KILLER_CMDS.has(basename(t).toLowerCase().replace(/\.exe$/, ''));
74
+
75
+ /** Replace quoted spans so quoted data ("fix; killall handling") never looks
76
+ * like a command, and substitutions so `kill $(…)` / `kill \`…\`` surface as a
77
+ * non-literal target instead of vanishing into the segment split; the RAW
78
+ * text is still consulted for nested-shell payloads. The `{}` placeholder
79
+ * (xargs -I{}, find -exec … {}) becomes a token BEFORE the split eats the
80
+ * braces — otherwise `xargs -I{} kill {}` splits into an xargs segment with
81
+ * no kill word and a kill segment with no targets, and both pass. */
82
+ function stripQuotes(s) {
83
+ return s
84
+ .replace(/'[^']*'/g, ' __q__ ')
85
+ .replace(/"(?:[^"\\]|\\.)*"/g, ' __q__ ')
86
+ .replace(/\$\([^()]*\)/g, ' __sub__ ')
87
+ .replace(/`[^`]*`/g, ' __sub__ ')
88
+ .replace(/\{\}/g, ' __ph__ ');
89
+ }
90
+
91
+ const basename = (w) => w.slice(w.lastIndexOf('/') + 1);
92
+
93
+ /** Leading env assignments and wrapper words stripped off a token list. */
94
+ function commandWord(tokens) {
95
+ let i = 0;
96
+ while (i < tokens.length && (SKIP_WORDS.has(tokens[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[i]))) i++;
97
+ return { cmd: basename(tokens[i] ?? ''), rest: tokens.slice(i + 1) };
98
+ }
99
+
100
+ /** kill's targets: signal options consumed, redirections ignored. */
101
+ function killTargets(rest) {
102
+ const targets = [];
103
+ let sawSignal = false;
104
+ let afterDashDash = false;
105
+ for (let i = 0; i < rest.length; i++) {
106
+ const t = rest[i];
107
+ if (t.startsWith('#')) break; // trailing comment
108
+ if (/^\d*(?:>>?|<<?|>&|&>>?)$/.test(t)) { i++; continue; } // spaced redirect: skip its operand too
109
+ if (/^\d*[<>]/.test(t) || t.includes('>') || t.includes('<')) continue; // attached redirection
110
+ if (!afterDashDash && t === '--') { afterDashDash = true; continue; }
111
+ if (!afterDashDash && t.startsWith('-')) {
112
+ if (t === '-s' || t === '-n') { i++; sawSignal = true; continue; } // -s TERM / -n 15
113
+ if (!sawSignal && /^-(?:\d+|[A-Za-z]+\d*)$/.test(t)) { sawSignal = true; continue; } // first -9/-TERM
114
+ targets.push(t); // second dash token = a target
115
+ continue;
116
+ }
117
+ targets.push(t);
118
+ }
119
+ return targets;
120
+ }
121
+
122
+ /**
123
+ * The pure decision. `null` = allow; a non-empty string = deny, with the reason
124
+ * the agent will read (hook exit 2 feeds stderr back to the model).
125
+ * @param {string} command the Bash tool's command text
126
+ * @param {number} [hostPid] the protected server PID (WORCA_HOST_PID); pattern
127
+ * bans apply even without it
128
+ * @returns {string|null}
129
+ */
130
+ export function evaluateKillCommand(command, hostPid) {
131
+ const raw = String(command ?? '');
132
+ if (!raw.trim()) return null;
133
+ const pidNote = Number.isFinite(hostPid)
134
+ ? ` The worca app server (PID ${hostPid}) runs this agent and must survive.`
135
+ : ' The worca app server runs this agent and must survive.';
136
+ const advice = ' To stop a process you spawned: list PIDs first (ps is always allowed), then `kill <literal pid>`.';
137
+
138
+ const segments = stripQuotes(raw).split(/(?:\|\||&&|;|\||&|\n|\$\(|`|[(){}])+/);
139
+ for (const seg of segments) {
140
+ const tokens = seg.trim().split(/\s+/).filter(Boolean);
141
+ if (!tokens.length) continue;
142
+ const { cmd: cmdRaw, rest } = commandWord(tokens);
143
+ // Windows commands are case-insensitive and may carry .exe (`TASKKILL`,
144
+ // `taskkill.exe`) — normalize once; POSIX names pass through unchanged.
145
+ const cmd = cmdRaw.toLowerCase().replace(/\.exe$/, '');
146
+
147
+ if (cmd === 'pkill' || cmd === 'killall') {
148
+ return `host guard: blocked \`${cmd}\` — pattern kills are forbidden.${pidNote}${advice}`;
149
+ }
150
+ if (cmd === 'xargs' && rest.some(isKillerToken)) {
151
+ return `host guard: blocked \`xargs kill\` — kill may only take literal numeric PIDs you name yourself.${pidNote}${advice}`;
152
+ }
153
+ // find spawns its -exec/-ok payload itself, so the kill never surfaces as
154
+ // a command word of its own segment; a killer anywhere in find's arguments
155
+ // alongside an -exec-family flag is the same unbounded fan-out as xargs.
156
+ if (cmd === 'find'
157
+ && rest.some((t) => /^-(?:exec|ok)(?:dir)?$/.test(t))
158
+ && rest.some(isKillerToken)) {
159
+ return `host guard: blocked \`find -exec kill\` — kill may only take literal numeric PIDs you name yourself.${pidNote}${advice}`;
160
+ }
161
+ // A nested shell is suspicious only when it carries an INLINE payload
162
+ // (-c / /c / -Command); `bash scripts/kill-dev-server.sh` is a script FILE
163
+ // and stays allowed (deliberate evasion via files is out of the threat
164
+ // model). PowerShell is the exception: its bare argument IS an inline
165
+ // command (`powershell "Stop-Process -Name node"`), so it is always scanned.
166
+ if (NESTED_SHELLS.has(cmd)
167
+ && (cmd === 'powershell' || cmd === 'pwsh' || rest.some((t) => /^(?:-c|\/c|-command|--command)$/i.test(t)))
168
+ && KILLER_WORD_RE.test(raw)) {
169
+ return `host guard: blocked a kill inside a nested \`${cmd}\` invocation — run the kill directly with literal PIDs so it can be checked.${pidNote}${advice}`;
170
+ }
171
+
172
+ // ── Windows-native killers (reachable from Git Bash on Windows) ──────────
173
+ if (cmd === 'taskkill') {
174
+ for (let i = 0; i < rest.length; i++) {
175
+ const flag = rest[i].toLowerCase().replace(/^[/-]+/, '');
176
+ if (flag === 'im') {
177
+ return `host guard: blocked \`taskkill /IM\` — killing by image name is a pattern kill.${pidNote}${advice}`;
178
+ }
179
+ if (flag === 'pid') {
180
+ const target = rest[++i] ?? '';
181
+ if (!/^\d+$/.test(target)) {
182
+ return `host guard: blocked \`taskkill /PID ${target}\` — only literal numeric PIDs are allowed.${pidNote}${advice}`;
183
+ }
184
+ if (Number.isFinite(hostPid) && Number(target) === hostPid) {
185
+ return `host guard: blocked taskkill of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
186
+ }
187
+ }
188
+ }
189
+ continue;
190
+ }
191
+ if (cmd === 'stop-process' || cmd === 'spps') {
192
+ let sawId = false;
193
+ for (let i = 0; i < rest.length; i++) {
194
+ const t = rest[i].toLowerCase();
195
+ if (t === '-name' || t.startsWith('-name:')) {
196
+ return `host guard: blocked \`Stop-Process -Name\` — killing by process name is a pattern kill.${pidNote}${advice}`;
197
+ }
198
+ if (t === '-id' || t.startsWith('-id:')) {
199
+ sawId = true;
200
+ const value = t.includes(':') ? t.split(':')[1] : (rest[++i] ?? '');
201
+ for (const part of value.split(',')) {
202
+ if (!/^\d+$/.test(part)) {
203
+ return `host guard: blocked \`Stop-Process -Id ${part}\` — only literal numeric PIDs are allowed.${pidNote}${advice}`;
204
+ }
205
+ if (Number.isFinite(hostPid) && Number(part) === hostPid) {
206
+ return `host guard: blocked Stop-Process of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
207
+ }
208
+ }
209
+ }
210
+ }
211
+ if (!sawId) {
212
+ return `host guard: blocked \`Stop-Process\` with no literal -Id — pipeline-fed or bare Stop-Process is a pattern kill.${pidNote}${advice}`;
213
+ }
214
+ continue;
215
+ }
216
+ if (cmd === 'wmic') {
217
+ // Checked against the RAW command: a parenthesized WHERE clause splits
218
+ // the segment (`where (name="node.exe") delete`), hiding the verb from
219
+ // this segment's tokens. A kill-verb elsewhere in a compound command can
220
+ // false-positive here — acceptable, the reason explains itself.
221
+ if (/\bprocess\b/i.test(raw) && /\b(?:delete|terminate)\b/i.test(raw)) {
222
+ return `host guard: blocked \`wmic process … delete\` — pattern kills are forbidden.${pidNote}${advice}`;
223
+ }
224
+ continue; // read-only wmic queries stay allowed
225
+ }
226
+
227
+ if (cmd !== 'kill') continue;
228
+
229
+ for (const t of killTargets(rest)) {
230
+ if (/^\d+$/.test(t)) {
231
+ if (Number.isFinite(hostPid) && Number(t) === hostPid) {
232
+ return `host guard: blocked kill of PID ${hostPid} — that is the worca app server this agent runs under. Kill only processes you spawned yourself.`;
233
+ }
234
+ continue;
235
+ }
236
+ if (/^%\d+$/.test(t)) continue; // this shell's own job
237
+ if (t.startsWith('-')) {
238
+ return `host guard: blocked \`kill ${t}\` — process-group/broadcast kills are forbidden.${pidNote}${advice}`;
239
+ }
240
+ return `host guard: blocked \`kill ${t}\` — kill accepts only literal numeric PIDs (no variables, substitutions, or piped input).${pidNote}${advice}`;
241
+ }
242
+ }
243
+ return null;
244
+ }
245
+
246
+ // ── hook CLI ─────────────────────────────────────────────────────────────────
247
+ // stdin: the PreToolUse payload ({ tool_name, tool_input: { command } }).
248
+ // exit 0 = allow; exit 2 = block, stderr is shown to the agent.
249
+ const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
250
+ if (isMain) {
251
+ let raw = '';
252
+ process.stdin.setEncoding('utf8');
253
+ process.stdin.on('data', (d) => { raw += d; });
254
+ process.stdin.on('end', () => {
255
+ let command = '';
256
+ try {
257
+ const payload = JSON.parse(raw);
258
+ if (payload?.tool_name !== 'Bash') process.exit(0);
259
+ command = String(payload?.tool_input?.command ?? '');
260
+ } catch {
261
+ process.exit(0); // fail-open: a malformed payload must not brick Bash
262
+ }
263
+ const pid = Number(process.env.WORCA_HOST_PID);
264
+ const reason = evaluateKillCommand(command, Number.isFinite(pid) && pid > 0 ? pid : undefined);
265
+ if (reason) {
266
+ process.stderr.write(`${reason}\n`);
267
+ process.exit(2);
268
+ }
269
+ process.exit(0);
270
+ });
271
+ }