@worca/app 1.0.0 → 1.1.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 (138) hide show
  1. package/README.md +22 -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 +319 -45
  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 +189 -21
  32. package/src/core/ask/catalog.mjs +111 -0
  33. package/src/core/ask/comment-deps.mjs +55 -0
  34. package/src/core/ask/events.mjs +506 -0
  35. package/src/core/ask/follow.mjs +107 -0
  36. package/src/core/ask/git-allowlist.mjs +226 -0
  37. package/src/core/ask/limits.mjs +54 -0
  38. package/src/core/ask/mcp-stdio.mjs +135 -0
  39. package/src/core/ask/models.mjs +125 -0
  40. package/src/core/ask/prompt.mjs +261 -0
  41. package/src/core/ask/proposal.mjs +170 -0
  42. package/src/core/ask/redact.mjs +30 -0
  43. package/src/core/ask/spawn.mjs +153 -0
  44. package/src/core/ask/store.mjs +360 -0
  45. package/src/core/ask/tool-deps.mjs +63 -0
  46. package/src/core/ask/tools.mjs +848 -0
  47. package/src/core/ask/turn.mjs +416 -0
  48. package/src/core/ask/worktree-deps.mjs +27 -0
  49. package/src/core/ask/worktrees.mjs +285 -0
  50. package/src/core/chat/command-router.mjs +20 -3
  51. package/src/core/claude-runner.mjs +434 -57
  52. package/src/core/config.mjs +264 -41
  53. package/src/core/cost-budget.mjs +29 -2
  54. package/src/core/db.mjs +684 -47
  55. package/src/core/diff-anchor.mjs +213 -0
  56. package/src/core/diff-comments.mjs +273 -0
  57. package/src/core/engine-select.mjs +32 -0
  58. package/src/core/git-info.mjs +49 -10
  59. package/src/core/graph/builtin-workflows.mjs +51 -0
  60. package/src/core/graph/executor.mjs +894 -0
  61. package/src/core/graph/registry-ports.mjs +12 -0
  62. package/src/core/graph/scheduler.mjs +1065 -0
  63. package/src/core/graph/seed-templates.mjs +318 -0
  64. package/src/core/model-env.mjs +112 -8
  65. package/src/core/model-test.mjs +79 -0
  66. package/src/core/orchestrator.mjs +902 -4098
  67. package/src/core/overview-agent.mjs +15 -3
  68. package/src/core/phases.mjs +208 -537
  69. package/src/core/pipeline-delete.mjs +13 -2
  70. package/src/core/plugin-api.mjs +8 -3
  71. package/src/core/plugin-config.mjs +178 -28
  72. package/src/core/plugin-inventory.mjs +6 -2
  73. package/src/core/plugin-manifest.mjs +199 -11
  74. package/src/core/plugin-models.mjs +1 -0
  75. package/src/core/plugin-repo.mjs +16 -4
  76. package/src/core/plugin-shim-child.mjs +9 -3
  77. package/src/core/plugin-shim.mjs +77 -14
  78. package/src/core/plugin-store.mjs +236 -29
  79. package/src/core/plugin-workflows.mjs +90 -41
  80. package/src/core/preflight.mjs +135 -3
  81. package/src/core/projects.mjs +7 -5
  82. package/src/core/protocol.mjs +8 -35
  83. package/src/core/recoverable-error.mjs +1 -1
  84. package/src/core/run-harness.mjs +3585 -0
  85. package/src/core/run-manifest.mjs +5 -1
  86. package/src/core/settings.mjs +109 -13
  87. package/src/core/skills.mjs +10 -3
  88. package/src/core/source-bindings.mjs +175 -0
  89. package/src/core/sources.mjs +87 -25
  90. package/src/core/stats.mjs +25 -6
  91. package/src/core/title.mjs +51 -4
  92. package/src/core/workflows.mjs +358 -259
  93. package/src/core/workspace-scan.mjs +4 -0
  94. package/src/core/worktree.mjs +98 -7
  95. package/src/shared/graph/agent-meta.mjs +278 -0
  96. package/src/shared/graph/constants.mjs +105 -0
  97. package/src/shared/graph/geometry.mjs +157 -0
  98. package/src/shared/graph/layout.mjs +134 -0
  99. package/src/shared/graph/loops.mjs +130 -0
  100. package/src/shared/graph/manifest.mjs +257 -0
  101. package/src/shared/graph/ports.mjs +153 -0
  102. package/src/shared/graph/route.mjs +397 -0
  103. package/src/shared/graph/template.mjs +165 -0
  104. package/src/shared/graph/thumbnail.mjs +67 -0
  105. package/src/shared/graph/validate.mjs +491 -0
  106. package/src/shared/graph/verdict.mjs +41 -0
  107. package/ui/public/app.js +4008 -1670
  108. package/ui/public/ask-markdown.mjs +145 -0
  109. package/ui/public/ask-model.mjs +264 -0
  110. package/ui/public/ask-panel.mjs +1880 -0
  111. package/ui/public/chat-settings-view.mjs +6 -2
  112. package/ui/public/diff-view.mjs +66 -11
  113. package/ui/public/file-tree.mjs +305 -0
  114. package/ui/public/graph/composer.mjs +889 -0
  115. package/ui/public/graph/inspector.mjs +183 -0
  116. package/ui/public/graph/model.mjs +37 -0
  117. package/ui/public/graph/palette.mjs +144 -0
  118. package/ui/public/graph/run-decor.mjs +410 -0
  119. package/ui/public/graph/run-hosts.mjs +201 -0
  120. package/ui/public/graph/save-dialog.mjs +56 -0
  121. package/ui/public/graph/view.mjs +858 -0
  122. package/ui/public/guardrails-view.mjs +4 -2
  123. package/ui/public/hljs-loader.mjs +180 -0
  124. package/ui/public/index.html +269 -265
  125. package/ui/public/log-filter.mjs +22 -4
  126. package/ui/public/log-line.mjs +45 -19
  127. package/ui/public/models-view.mjs +171 -9
  128. package/ui/public/plugins-view.mjs +106 -4
  129. package/ui/public/source-pane.mjs +190 -8
  130. package/ui/public/stats-view.mjs +81 -1
  131. package/ui/public/style.css +1459 -229
  132. package/ui/public/syntax-highlight.mjs +270 -0
  133. package/ui/public/thinking-orb.mjs +110 -0
  134. package/ui/server.mjs +1667 -98
  135. package/src/core/channels.mjs +0 -302
  136. package/src/core/runners.mjs +0 -167
  137. package/src/core/workflow-validator.mjs +0 -185
  138. 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
+ });
@@ -20,13 +20,17 @@ export const EFFORTS = ['medium', 'high', 'xhigh', 'max'];
20
20
  // Env keys a model entry may NOT set (§4.4): process fundamentals and worca's
21
21
  // own runtime knobs, any of which injection could otherwise subvert (mock
22
22
  // mode, the claude binary path, the effort flag name). Everything else —
23
- // including all ANTHROPIC_* / CLAUDE_* — is allowed: routing them is the
23
+ // including all other ANTHROPIC_* / CLAUDE_* — is allowed: routing them is the
24
24
  // point. CLAUDE_CODE_SUBPROCESS_ENV_SCRUB is the CLI-2.1.220 permission-mode
25
25
  // landmine documented at claude-runner.mjs#buildSpawnEnv.
26
+ // CLAUDE_CODE_SUBAGENT_MODEL is reserved too: the per-node `subagentModel`
27
+ // prompt policy is the only sanctioned wire for a child's model, and a catalog
28
+ // entry silently flooring every fan-out child would contradict the per-node
29
+ // control the UI shows.
26
30
  export const RESERVED_MODEL_ENV_KEYS = [
27
31
  'PATH', 'HOME', 'TMPDIR', 'SHELL', 'USER', 'LOGNAME', 'TERM',
28
32
  'NODE_OPTIONS', 'NODE_EXTRA_CA_CERTS',
29
- 'CLAUDECODE', 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB',
33
+ 'CLAUDECODE', 'CLAUDE_CODE_SUBPROCESS_ENV_SCRUB', 'CLAUDE_CODE_SUBAGENT_MODEL',
30
34
  ];
31
35
  export const RESERVED_MODEL_ENV_PREFIXES = ['WORCA_'];
32
36
 
@@ -49,9 +53,12 @@ export function modelEnvRef(value) {
49
53
  /**
50
54
  * Defensive spawn-time pass over a model entry's env (§4.4): drops reserved
51
55
  * keys and non-string values, and expands whole-value ${VAR} refs from
52
- * `sourceEnv` (an unset or empty var drops the key). Write-time validation
53
- * already rejects reserved keys, so a drop here means a hand-edited settings
54
- * file the caller owns warning about `dropped`.
56
+ * `sourceEnv` (an unset or empty var drops the key). Values are trimmed and a
57
+ * value that is empty after trimming drops the key an ANTHROPIC_MODEL wire id
58
+ * (#374) reaches `--model` verbatim, so a stray-whitespace or empty value must
59
+ * not survive. The user catalog rejects reserved keys at write time and plugin
60
+ * manifests strip them at normalize time, so a drop here means a hand-edited
61
+ * settings file — the caller owns warning about `dropped`.
55
62
  * @param {Record<string,*>|undefined} modelEnv
56
63
  * @param {Record<string,string|undefined>} [sourceEnv]
57
64
  * @returns {{env: Record<string,string>, dropped: string[]}}
@@ -64,11 +71,108 @@ export function prepareModelEnv(modelEnv, sourceEnv = process.env) {
64
71
  const ref = modelEnvRef(v);
65
72
  if (ref !== null) {
66
73
  const resolved = sourceEnv ? sourceEnv[ref] : undefined;
67
- if (resolved === undefined || resolved === '') { dropped.push(k); continue; }
68
- env[k] = resolved;
74
+ const t = typeof resolved === 'string' ? resolved.trim() : '';
75
+ if (!t) { dropped.push(k); continue; }
76
+ env[k] = t;
69
77
  } else {
70
- env[k] = v;
78
+ const t = v.trim();
79
+ if (!t) { dropped.push(k); continue; }
80
+ env[k] = t;
71
81
  }
72
82
  }
73
83
  return { env, dropped };
74
84
  }
85
+
86
+ // ── per-model cost override (opt-in pricing, config.mjs resolveModelCost) ─────
87
+ // Lives HERE for the same reason the env policy does: BOTH catalog layers must
88
+ // validate it against one rule. settings.mjs owns the user's global catalog and
89
+ // plugin-manifest.mjs owns a plugin's models — neither may import the other, and
90
+ // this leaf imports nothing. A plugin that ships a model routed at its own
91
+ // endpoint is exactly the case that needs a price pinned, so its manifest
92
+ // carries `cost` with the same shape and the same validation as a global entry.
93
+
94
+ /** Allowed per-million-token rate keys for a model's `cost.perMtok` table. */
95
+ export const COST_RATE_KEYS = ['input', 'output', 'cacheRead', 'cacheWrite', 'cacheWrite1h'];
96
+
97
+ /**
98
+ * Validate a model `cost` override. Returns the normalized shape or undefined;
99
+ * THROWS on malformed input (callers that must not throw catch and drop).
100
+ * { free: true } → recorded spend is always $0
101
+ * { perMtok: { input, output, cacheRead, … } } → USD per million tokens, >= 0
102
+ * `{ free: false }` / `{}` mean "no override" → undefined.
103
+ * @param {*} cost
104
+ * @returns {{free:true}|{perMtok:Record<string,number>}|undefined}
105
+ * @throws {Error}
106
+ */
107
+ export function assertModelCost(cost) {
108
+ if (cost === undefined || cost === null) return undefined;
109
+ if (typeof cost !== 'object' || Array.isArray(cost)) throw new Error('cost must be an object');
110
+ if (cost.free !== undefined && typeof cost.free !== 'boolean') throw new Error('cost.free must be a boolean');
111
+ if (cost.free === true) return { free: true };
112
+ if (cost.perMtok !== undefined) {
113
+ const p = cost.perMtok;
114
+ if (!p || typeof p !== 'object' || Array.isArray(p)) {
115
+ throw new Error('cost.perMtok must be an object of USD-per-million-token rates');
116
+ }
117
+ const rates = {};
118
+ for (const [k, v] of Object.entries(p)) {
119
+ if (!COST_RATE_KEYS.includes(k)) {
120
+ throw new Error(`unknown cost.perMtok rate ${JSON.stringify(k)} — allowed: ${COST_RATE_KEYS.join(', ')}`);
121
+ }
122
+ const n = Number(v);
123
+ if (!Number.isFinite(n) || n < 0) throw new Error(`cost.perMtok.${k} must be a finite number >= 0`);
124
+ rates[k] = n;
125
+ }
126
+ if (!Object.keys(rates).length) throw new Error('cost.perMtok must define at least one rate');
127
+ return { perMtok: rates };
128
+ }
129
+ return undefined; // { free: false } or {} — no override
130
+ }
131
+
132
+ // ── sub-agent model policy (per-node `subagentModel`) ─────────────────────────
133
+ // What a fan-out node's Task/Agent children run on. ONE wire — a prompt block
134
+ // (phases.mjs#subagentModelDirective) that tells the agent to pass `model` on
135
+ // every Task call — because the CLI resolves a child's model as Task-call
136
+ // `model` > the agent definition's own `model:` frontmatter > env default >
137
+ // parent: only the explicit Task-level value reliably binds every child. (The
138
+ // earlier CLAUDE_CODE_SUBAGENT_MODEL env floor was removed for exactly that
139
+ // reason — it bound only agents with no model key — and the key is reserved
140
+ // above so a catalog entry cannot resurrect it.)
141
+ //
142
+ // The vocabulary is deliberately NOT the worca catalog: the CLI's Task tool
143
+ // accepts an ALIAS enum, so a catalog id (or an ANTHROPIC_MODEL wire id) would
144
+ // be rejected at spawn time. Haiku is excluded by product decision.
145
+ export const SUBAGENT_MODELS = ['sonnet', 'opus', 'fable'];
146
+
147
+ /** "the agent picks per Task call" — a choice rubric in the prompt. */
148
+ export const SUBAGENT_AUTO = 'auto';
149
+
150
+ /** "children ride the CLI's own resolution" (an agent definition's frontmatter,
151
+ * else the parent's model) — the pre-feature prompt. Stored explicitly,
152
+ * because the DEFAULT for an unset node is `auto`, not this. */
153
+ export const SUBAGENT_INHERIT = 'inherit';
154
+
155
+ /** Every storable `subagentModel`. '' / absent are NOT storable — they mean
156
+ * "unset", which the runtime resolves to SUBAGENT_DEFAULT. */
157
+ export const SUBAGENT_MODEL_VALUES = [...SUBAGENT_MODELS, SUBAGENT_AUTO, SUBAGENT_INHERIT];
158
+
159
+ /** What an unset node resolves to at run time: agents choose BY DEFAULT. */
160
+ export const SUBAGENT_DEFAULT = SUBAGENT_AUTO;
161
+
162
+ /** Whether `v` is a storable subagentModel. */
163
+ export function isSubagentModelValue(v) {
164
+ return typeof v === 'string' && SUBAGENT_MODEL_VALUES.includes(v);
165
+ }
166
+
167
+ /** The policy a raw stored value puts in force: a legal value is itself; '' /
168
+ * absent / anything that escaped validation is the auto default. */
169
+ export function effectiveSubagentModel(v) {
170
+ return isSubagentModelValue(v) ? v : SUBAGENT_DEFAULT;
171
+ }
172
+
173
+ /** The one validation message every writer shares, '' when `v` is acceptable
174
+ * ('' and null/undefined mean clear/absent and are always fine). */
175
+ export function subagentModelIssue(v) {
176
+ if (v == null || v === '' || isSubagentModelValue(v)) return '';
177
+ return `unknown sub-agent model ${JSON.stringify(String(v))}`;
178
+ }
@@ -0,0 +1,79 @@
1
+ // src/core/model-test.mjs
2
+ // One minimal live claude spawn to verify a catalog model actually works —
3
+ // the Models-view Test button (POST /api/models/:id/test). Mirrors the cheap
4
+ // aux-run pattern of title.mjs: the model's catalog routing env travels with
5
+ // the id via resolveModelEnv, no tools, low effort, hard timeout. Never
6
+ // throws — the outcome is a result object either way.
7
+
8
+ import { runClaude } from './claude-runner.mjs';
9
+ import { resolveModelEnv } from './config.mjs';
10
+ import { classifyError } from './recoverable-error.mjs';
11
+
12
+ const TEST_TIMEOUT_MS = 60_000;
13
+ const REPLY_CAP = 100;
14
+
15
+ const SYSTEM = 'You are a connectivity check. Reply with exactly OK.';
16
+
17
+ /** Actionable hint for a recovery class ('' when there is nothing to add —
18
+ * the raw runner message already carries the detail). Pure, for tests. */
19
+ export function hintFor(errorClass) {
20
+ switch (errorClass) {
21
+ case 'auth': return 'authentication failed — check the token/secret for this model';
22
+ case 'network': return 'endpoint unreachable — check ANTHROPIC_BASE_URL';
23
+ case 'rate_limit': return 'the endpoint is rate-limiting or overloaded — try again shortly';
24
+ case 'quota': return 'quota/billing problem — check the account behind this endpoint';
25
+ case 'usage_limit': return 'usage limit reached on the account behind this endpoint';
26
+ case 'timeout': return `no reply — the test timed out after ${TEST_TIMEOUT_MS / 1000}s`;
27
+ default: return '';
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Live connectivity check for a catalog model id (global or plugin — the
33
+ * resolution precedence is resolveModelEnv's). Explicit user action only.
34
+ * @param {string} id catalog model id
35
+ * @param {{signal?:AbortSignal, bin?:string, run?:typeof runClaude}} [opts]
36
+ * `run` is injectable for unit tests.
37
+ * @returns {Promise<{ok:true, text:string}|{ok:false, errorClass:(string|null), message:string, hint?:string}>}
38
+ */
39
+ export async function testModel(id, { signal, bin, run = runClaude } = {}) {
40
+ const ctrl = new AbortController();
41
+ const onOuterAbort = () => ctrl.abort();
42
+ if (signal) {
43
+ if (signal.aborted) ctrl.abort();
44
+ else signal.addEventListener('abort', onOuterAbort, { once: true });
45
+ }
46
+ const timer = setTimeout(() => ctrl.abort(), TEST_TIMEOUT_MS);
47
+ timer.unref?.();
48
+ try {
49
+ const { text } = await run({
50
+ cwd: process.cwd(),
51
+ systemPrompt: SYSTEM,
52
+ prompt: 'Reply with exactly OK.',
53
+ model: id,
54
+ modelEnv: resolveModelEnv(id),
55
+ effort: 'low',
56
+ permissionMode: 'acceptEdits',
57
+ allowedTools: [], // empty → no --allowedTools flag; pure text gen
58
+ signal: ctrl.signal,
59
+ bin,
60
+ onEvent: () => {},
61
+ });
62
+ const reply = String(text || '').split(/\r?\n/).map((l) => l.trim()).find((l) => l) || '';
63
+ if (!reply) {
64
+ return { ok: false, errorClass: null, message: 'the model returned an empty reply' };
65
+ }
66
+ return { ok: true, text: reply.slice(0, REPLY_CAP) };
67
+ } catch (err) {
68
+ if (err && err.name === 'AbortError') {
69
+ return { ok: false, errorClass: 'timeout', message: `Timed out after ${TEST_TIMEOUT_MS / 1000}s`, hint: hintFor('timeout') };
70
+ }
71
+ const message = err && err.message ? err.message : String(err);
72
+ const errorClass = (err && err.errorClass) || classifyError(message);
73
+ const hint = hintFor(errorClass);
74
+ return { ok: false, errorClass, message, ...(hint ? { hint } : {}) };
75
+ } finally {
76
+ clearTimeout(timer);
77
+ if (signal) signal.removeEventListener?.('abort', onOuterAbort);
78
+ }
79
+ }