@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,462 @@
1
+ // One Ask Worca turn: spawn `claude -p` through the P1 sandbox recipe, feed
2
+ // every event to the P1 reducer, persist the assistant message, and emit bare
3
+ // ask-* frames through deps.onFrame (the SERVER stamps {threadId, messageId,
4
+ // seq} — spec §17 contract). run() NEVER throws; stop() aborts. One instance
5
+ // owns one turn INCLUDING the §6.2.7 resume retry (fresh reducer per attempt,
6
+ // one AbortController + one 30-minute wall clock spanning both attempts).
7
+ // Shape: agent-gen.mjs (EventEmitter, terminal latch, finally cleanup).
8
+ // Binding rules enforced here: R-A (settle-before-finish, persist card/notice
9
+ // mid-turn), R-C (rejection classification, abort branch FIRST — the runner
10
+ // throws a synchronous AbortError before any init when pre-aborted), R-F
11
+ // (turn.mock rides EVERY attempt), R-G (spawn wiring: scratch dir, RAW home
12
+ // base, per-message mcp json deleted in finally), R-D + B-1 (title call:
13
+ // hardened options + permissionMode 'dontAsk', no signal).
14
+ import { EventEmitter } from 'node:events';
15
+ import { join, dirname, resolve as pathResolve } from 'node:path';
16
+ import { mkdir, writeFile, unlink } from 'node:fs/promises';
17
+
18
+ import { runClaude } from '../claude-runner.mjs';
19
+ import { resolveModelEnv, resolveModelCost, estimateCost, liveCostRates as defaultLiveCostRates } from '../config.mjs';
20
+ import { worcaHome } from '../projects.mjs';
21
+ import { generateTitle } from '../title.mjs';
22
+ import { createTurnReducer } from './events.mjs';
23
+ import { buildAskSpawnOptions, buildMcpConfig, ASK_MCP_SERVER_PATH } from './spawn.mjs';
24
+ import { validateProposal } from './proposal.mjs';
25
+ import { askLimits, ASK_LIMITS } from './limits.mjs';
26
+ import {
27
+ newAskId, finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle,
28
+ } from './store.mjs';
29
+ import { recordAskCostDelta } from '../cost-budget.mjs';
30
+ import { setPendingCardComments } from '../diff-comments.mjs';
31
+
32
+ export function createAskTurn(opts) { return new AskTurn(opts); }
33
+
34
+ const TERMINAL = new Set(['done', 'stopped', 'error']);
35
+
36
+ class AskTurn extends EventEmitter {
37
+ constructor({
38
+ threadId, assistantMessageId, userMessageId,
39
+ prompt, systemPrompt, restoredPrompt = '',
40
+ model, effort, resumeSessionId = null,
41
+ firstTurn = false, firstText = '', deterministicTitle = null,
42
+ mock = null, attachmentNames = {},
43
+ pinnedScope = null,
44
+ deps = {},
45
+ } = {}) {
46
+ super();
47
+ this.threadId = threadId;
48
+ this.assistantMessageId = assistantMessageId;
49
+ this.userMessageId = userMessageId;
50
+ this.prompt = prompt;
51
+ this.systemPrompt = systemPrompt;
52
+ this.restoredPrompt = restoredPrompt;
53
+ this.model = model;
54
+ this.effort = effort;
55
+ this.resumeSessionId = resumeSessionId || null;
56
+ this.firstTurn = !!firstTurn;
57
+ this.firstText = firstText;
58
+ this.deterministicTitle = deterministicTitle ?? null;
59
+ this.mock = mock || null;
60
+ this.attachmentNames = attachmentNames || {};
61
+ // #397: {projectKey}|{workspaceId}|null — the user-pinned scope at POST time.
62
+ this.pinnedScope = pinnedScope && typeof pinnedScope === 'object' ? pinnedScope : null;
63
+ this.deps = {
64
+ runClaudeImpl: deps.runClaudeImpl ?? runClaude,
65
+ store: {
66
+ finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle,
67
+ ...(deps.store || {}),
68
+ },
69
+ validateProposal: deps.validateProposal ?? validateProposal,
70
+ generateTitle: deps.generateTitle ?? generateTitle,
71
+ askLimits: deps.askLimits ?? askLimits,
72
+ limits: deps.limits ?? ASK_LIMITS,
73
+ resolveModelEnv: deps.resolveModelEnv ?? resolveModelEnv,
74
+ resolveModelCost: deps.resolveModelCost ?? resolveModelCost,
75
+ worcaHome: deps.worcaHome ?? worcaHome,
76
+ buildMcpConfig: deps.buildMcpConfig ?? buildMcpConfig,
77
+ serverPath: deps.serverPath ?? ASK_MCP_SERVER_PATH,
78
+ newAskId: deps.newAskId ?? newAskId,
79
+ setPendingCardComments: deps.setPendingCardComments ?? setPendingCardComments,
80
+ recordAskCost: deps.recordAskCost ?? recordAskCostDelta,
81
+ now: deps.now ?? Date.now,
82
+ // Default timers unref so a 30-minute clock never holds the process open
83
+ // (orchestrator.mjs:2627 _backoff precedent). Tests inject both.
84
+ setTimeout: deps.setTimeout ?? ((fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; }),
85
+ clearTimeout: deps.clearTimeout ?? ((t) => clearTimeout(t)),
86
+ fs: deps.fs ?? { mkdir, writeFile, unlink },
87
+ onFrame: deps.onFrame ?? (() => {}),
88
+ onOutOfTurn: deps.onOutOfTurn ?? (() => {}),
89
+ onCommentMutation: deps.onCommentMutation ?? (() => {}),
90
+ onWorktreeMutation: deps.onWorktreeMutation ?? (() => {}),
91
+ // DISPLAY-ONLY rates for the footer's live "≈" estimate (config.mjs
92
+ // liveCostRates: override → list price → null). Injectable so tests pin
93
+ // the frame arithmetic without the catalog.
94
+ liveCostRates: deps.liveCostRates ?? defaultLiveCostRates,
95
+ };
96
+ this.abort = new AbortController();
97
+ this.status = 'created';
98
+ this.timedOut = false;
99
+ this.stopping = false;
100
+ this.reducer = null;
101
+ this.sessionId = this.resumeSessionId;
102
+ this.scratchDir = null;
103
+ this.titlePromise = Promise.resolve();
104
+ this._titleKicked = false;
105
+ this._completed = false;
106
+ }
107
+
108
+ stop() {
109
+ if (TERMINAL.has(this.status)) return;
110
+ this.stopping = true;
111
+ try { this.abort.abort(); } catch { /* ignore */ }
112
+ }
113
+
114
+ _frame(frame) {
115
+ try { this.deps.onFrame(frame); } catch { /* a broken sink must not break the turn */ }
116
+ }
117
+
118
+ _emit(event, payload) {
119
+ // EventEmitter special-cases 'error': emitting it with ZERO listeners throws
120
+ // ERR_UNHANDLED_ERROR, and a listener that throws escapes too — either would
121
+ // break the "run() NEVER throws" contract P3 builds on. Both terminal emits
122
+ // go through here; same swallow posture as _frame.
123
+ if (event === 'error' && this.listenerCount('error') === 0) return;
124
+ try { this.emit(event, payload); } catch { /* a broken listener must not break the turn */ }
125
+ }
126
+
127
+ _persistBlocks() {
128
+ // R-A: the card (and every mid-turn notice) must survive a server restart
129
+ // and be visible to findCard/updateCardBlock while the turn streams.
130
+ try { this.deps.store.setMessageBlocks(this.assistantMessageId, this.reducer.snapshot().blocks); }
131
+ catch { /* thread may be gone — the terminal write is equally guarded */ }
132
+ }
133
+
134
+ async _onProposal(input) {
135
+ const d = this.deps;
136
+ const cardId = d.newAskId('card');
137
+ const raw = input && typeof input === 'object' ? input : {};
138
+ // #397: a proposal that names NO target falls back to the user-pinned scope.
139
+ // Mirrors the MCP child's own defaulting, so this authoritative re-validation
140
+ // builds the same card the model was shown.
141
+ const pin = this.pinnedScope;
142
+ const hasTarget = (typeof raw.projectKey === 'string' && raw.projectKey.trim())
143
+ || (typeof raw.workspaceId === 'string' && raw.workspaceId.trim());
144
+ const inp = pin && !hasTarget ? { ...raw, ...pin } : raw;
145
+ try {
146
+ const r = await d.validateProposal(inp, { cardId });
147
+ if (r && r.ok) {
148
+ // #397 guardrail: a proposal targeting a DIFFERENT project/workspace than
149
+ // the pinned one is accepted but flagged — the card renders the mismatch
150
+ // instead of silently absorbing it.
151
+ const scopeMismatch = !!pin && ((pin.projectKey && r.card.projectKey !== pin.projectKey)
152
+ || (pin.workspaceId && r.card.workspaceId !== pin.workspaceId));
153
+ this.reducer.addBlock({ kind: 'card', id: cardId, state: 'proposed', card: r.card, ...(scopeMismatch ? { scopeMismatch: true } : {}) });
154
+ // commentIds are propose_run INPUT only: they never enter the card block (its
155
+ // key set is pinned in test/ask-proposal.test.mjs) nor CARD_PATCH_KEYS. Parked
156
+ // against the card id until the user starts the run; unknown ids are dropped,
157
+ // because the model may cite a comment the user has since deleted and that
158
+ // must not sink an otherwise valid proposal.
159
+ try { d.setPendingCardComments(cardId, input?.commentIds); }
160
+ catch { /* comment metadata is never worth failing a proposal for */ }
161
+ } else {
162
+ const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
163
+ this.reducer.addBlock({ kind: 'notice', text: `Proposal rejected: ${errors.join('; ')}` });
164
+ }
165
+ } catch (err) {
166
+ this.reducer.addBlock({ kind: 'notice', text: `Proposal rejected: ${err?.message || err}` });
167
+ }
168
+ this._persistBlocks();
169
+ }
170
+
171
+ _makeReducer() {
172
+ const d = this.deps;
173
+ // One settings read per attempt, never per frame. null → the frames carry
174
+ // estimatedCostUsd:null and the footer keeps today's behaviour.
175
+ let liveRates = null;
176
+ try { liveRates = d.liveCostRates(this.model) ?? null; } catch { liveRates = null; }
177
+ this.reducer = createTurnReducer({
178
+ onFrame: (f) => this._frame(f),
179
+ now: d.now,
180
+ setTimeout: d.setTimeout,
181
+ clearTimeout: d.clearTimeout,
182
+ attachmentNames: this.attachmentNames,
183
+ // Ask spend feeds the SAME windowed budget as pipeline spend
184
+ // (cost-budget.mjs combinedWindowedSpendUsd), so an on-prem model the CLI
185
+ // prices by name inflates it from here too — re-price the turn exactly as
186
+ // the orchestrator's result intake does. Trusts the CLI when the model
187
+ // carries no override, which is the default.
188
+ resolveCost: (cliCostUsd, usage) => d.resolveModelCost(this.model, cliCostUsd, usage),
189
+ limits: d.limits,
190
+ onProposal: ({ input }) => this._onProposal(input),
191
+ // The MCP child cannot broadcast; the parent turns its comment writes into
192
+ // the same poke the REST routes emit.
193
+ onCommentMutation: (e) => { try { this.deps.onCommentMutation(e); } catch { /* a broken sink never breaks the turn */ } },
194
+ // Same shape for worktrees: open/remove/navigate in the child → the server
195
+ // broadcasts the thread's worktree envelope (ui/server.mjs emitAskWorktrees).
196
+ onWorktreeMutation: (e) => { try { this.deps.onWorktreeMutation(e); } catch { /* a broken sink never breaks the turn */ } },
197
+ // DISPLAY ONLY — never a sink input: prices the running usage sum (main +
198
+ // sub-agent tokens) at the TURN model's rates; the "≈" in the footer owns
199
+ // that approximation. _complete() reads summary.costUsd, not this.
200
+ estimateLiveCost: liveRates ? (usage) => estimateCost(usage, liveRates) : null,
201
+ });
202
+ return this.reducer;
203
+ }
204
+
205
+ async _settle() {
206
+ // R-A verbatim: settle() has no timeout of its own — race it against the
207
+ // turn's abort so a hung proposal hook cannot wedge the terminal write.
208
+ const aborted = new Promise((res) => {
209
+ if (this.abort.signal.aborted) return res();
210
+ this.abort.signal.addEventListener('abort', () => res(), { once: true });
211
+ });
212
+ await Promise.race([this.reducer.settle(), aborted]);
213
+ }
214
+
215
+ /**
216
+ * The single terminal writer — called exactly once per run().
217
+ * kind 'done' → ask-done{status:'done'|'stopped', reason?}
218
+ * kind 'error' → ask-error{message, errorClass?} with message status 'error'.
219
+ * The §6.2.8 costUsd:null rule needs no plumbing here: the P1 reducer sets
220
+ * lastResult and sawResult together (events.mjs currentCost() reads lastResult,
221
+ * set only by a `result` frame), so summary.costUsd is ALREADY null whenever no
222
+ * `result` arrived — source-verified. P1's ask-events tests pin only the
223
+ * per-frame ask-usage costUsd:null (:51); the R-C stop test in THIS file is
224
+ * the end-to-end pin of the summary rule.
225
+ */
226
+ async _complete({ kind, status, reason = null, message = null, errorClass = undefined }) {
227
+ if (this._completed) return { status: this.status };
228
+ this._completed = true;
229
+ const d = this.deps;
230
+ await this._settle();
231
+ const summary = this.reducer.finish();
232
+ const finalStatus = kind === 'error' ? 'error' : status;
233
+ // Already AUTHORITATIVE: the reducer applied this turn's per-model cost
234
+ // override (the `resolveCost` hook in _makeReducer), so this one value is
235
+ // correct for all four sinks below — the message row, the thread totals, the
236
+ // budget ledger, and the ask-done frame.
237
+ const costUsd = summary.costUsd;
238
+ // Persist BEFORE broadcasting: a client re-fetch on the terminal frame must
239
+ // never see a still-streaming row. finishMessage gets the FULL patch (B-5).
240
+ try {
241
+ d.store.finishMessage(this.assistantMessageId, {
242
+ text: summary.text, blocks: summary.blocks, status: finalStatus, reason,
243
+ usage: summary.usage, costUsd, durationMs: summary.durationMs,
244
+ });
245
+ } catch { /* deleted thread — the frames still settle the UI */ }
246
+ let threadTotals = null;
247
+ try {
248
+ threadTotals = d.store.addThreadTotals(this.threadId, {
249
+ costUsd, usage: summary.usage, agents: summary.agents,
250
+ });
251
+ } catch { /* deleted thread */ }
252
+ // D10: the spend is a financial fact even when the thread was deleted
253
+ // mid-turn — sits OUTSIDE the store try/catches above so it is never
254
+ // skipped; best-effort so a DB hiccup still settles the frames. Written
255
+ // after finishMessage: a process death between the two loses only this
256
+ // row (accepted — the v20 backfill never re-runs). Runs on done, stopped
257
+ // AND error turns alike: a result frame means money was spent.
258
+ try {
259
+ d.recordAskCost({
260
+ threadId: this.threadId, messageId: this.assistantMessageId,
261
+ amountUsd: costUsd, // null → the writer no-ops (D2)
262
+ tokens: ['input', 'output', 'cacheRead', 'cacheCreation']
263
+ .reduce((a, k) => a + (Number(summary.usage?.[k]) || 0), 0),
264
+ model: this.model, tsMs: d.now(),
265
+ });
266
+ } catch { /* ledger append is best-effort */ }
267
+ this.status = finalStatus;
268
+ if (summary.reducerErrors) {
269
+ console.warn(`[worca-ask] turn ${this.assistantMessageId}: ${summary.reducerErrors} reducer error(s) absorbed`);
270
+ }
271
+ if (kind === 'error') {
272
+ this._frame({ type: 'ask-error', message: message || 'unknown error', ...(errorClass !== undefined ? { errorClass } : {}) });
273
+ this._emit('error', { message: message || 'unknown error' });
274
+ } else {
275
+ this._frame({
276
+ type: 'ask-done', text: summary.text, blocks: summary.blocks, usage: summary.usage,
277
+ costUsd, durationMs: summary.durationMs, model: this.model, status: finalStatus,
278
+ ...(reason ? { reason } : {}), threadTotals,
279
+ });
280
+ this._emit('done', { status: finalStatus, reason });
281
+ }
282
+ return { status: finalStatus };
283
+ }
284
+
285
+ _limitNotice(reason, limitsNow) {
286
+ const text = reason === 'max_budget'
287
+ ? `Stopped: reached the $${limitsNow.maxBudgetUsd} per-turn cap (Settings → Ask Worca)`
288
+ : `Stopped: reached the ${limitsNow.maxTurns}-turn limit (Settings → Ask Worca)`;
289
+ this.reducer.addBlock({ kind: 'notice', text });
290
+ this._persistBlocks();
291
+ }
292
+
293
+ async run() {
294
+ if (this.status !== 'created') return { status: this.status };
295
+ this.status = 'running';
296
+ const d = this.deps;
297
+ this._makeReducer();
298
+ this._frame({
299
+ type: 'ask-start', userMessageId: this.userMessageId,
300
+ model: this.model, effort: this.effort, startedAt: new Date(d.now()).toISOString(),
301
+ });
302
+ let timer = null;
303
+ let mcpConfigPath = null;
304
+ let out;
305
+ try {
306
+ // R-G: ONE scratch dir for all threads, RAW home base (never worcaHome()
307
+ // itself — it already ends in /.worca-cc), per-message config json.
308
+ const scratchDir = join(d.worcaHome(), 'tmp', 'ask');
309
+ this.scratchDir = scratchDir;
310
+ await d.fs.mkdir(scratchDir, { recursive: true });
311
+ // D13 title runs CONCURRENTLY with the turn from here — the haiku call
312
+ // cwd's into scratchDir, so not a line earlier. Idempotent: the call after
313
+ // _attempts below is the backstop for a mkdir/write failure, so "fires
314
+ // after ANY terminal status of the first turn" stays true.
315
+ this._kickoffTitle();
316
+ const homeBase = process.env.WORCA_HOME?.trim()
317
+ ? pathResolve(process.env.WORCA_HOME)
318
+ : dirname(d.worcaHome());
319
+ mcpConfigPath = join(scratchDir, `mcp-${this.assistantMessageId}.json`);
320
+ await d.fs.writeFile(
321
+ mcpConfigPath,
322
+ JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath }), null, 2),
323
+ 'utf8',
324
+ );
325
+ // One 30-minute budget for the whole turn, retry included. The timedOut
326
+ // flag and abort() run in ONE synchronous callback, so R-C always reads
327
+ // the flag set (the awaiting continuation resumes a microtask later);
328
+ // flag-first is kept as defensive style (plugin-shim.mjs:164 precedent).
329
+ timer = d.setTimeout(() => { this.timedOut = true; try { this.abort.abort(); } catch { /* ignore */ } }, d.limits.turnTimeoutMs);
330
+ const limitsNow = d.askLimits(); // D12: read fresh every turn
331
+ out = await this._attempts(limitsNow, mcpConfigPath, scratchDir);
332
+ } catch (err) {
333
+ // Backstop for a deps failure (mkdir/write) — _attempts itself never throws.
334
+ out = await this._complete({ kind: 'error', message: err?.message || String(err) });
335
+ } finally {
336
+ if (timer != null) d.clearTimeout(timer);
337
+ if (mcpConfigPath) await d.fs.unlink(mcpConfigPath).catch(() => {});
338
+ }
339
+ this._kickoffTitle();
340
+ return out;
341
+ }
342
+
343
+ async _attempts(limitsNow, mcpConfigPath, scratchDir) {
344
+ const d = this.deps;
345
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
346
+ const isRetry = attempt === 2;
347
+ if (isRetry) {
348
+ this._makeReducer(); // fresh reducer; the dead attempt's reducer is discarded unfinished
349
+ // Deliberate deviation from §6.2.7's ordering (which posts the notice
350
+ // after a successful restore): the notice is added EAGERLY so it is
351
+ // visible while the retry streams (R-A persistence below). If the retry
352
+ // then fails, the notice stays above the ask-error — acceptable, and
353
+ // recorded in the Clarifications Q&A.
354
+ this.reducer.addBlock({ kind: 'notice', text: 'Context restored from history' });
355
+ this._persistBlocks();
356
+ }
357
+ const options = buildAskSpawnOptions({
358
+ thread: { id: this.threadId, sessionId: isRetry ? null : this.resumeSessionId }, // B-7: the only no-resume lever
359
+ turn: {
360
+ prompt: isRetry ? this.restoredPrompt : this.prompt,
361
+ systemPrompt: this.systemPrompt,
362
+ model: this.model,
363
+ effort: this.effort,
364
+ modelEnv: d.resolveModelEnv(this.model),
365
+ mock: this.mock, // R-F: markers on EVERY attempt
366
+ signal: this.abort.signal,
367
+ onEvent: (e) => {
368
+ if (e && e.type === 'session' && typeof e.sessionId === 'string' && e.sessionId) {
369
+ // §6.2.4: stored on the thread immediately, not at turn end.
370
+ this.sessionId = e.sessionId;
371
+ try { d.store.updateThread(this.threadId, { sessionId: e.sessionId }); } catch { /* deleted thread */ }
372
+ }
373
+ this.reducer.push(e);
374
+ },
375
+ },
376
+ limits: limitsNow,
377
+ mcpConfigPath,
378
+ scratchDir,
379
+ });
380
+ try {
381
+ await d.runClaudeImpl(options);
382
+ // Resolve path. Future-proofing: if a later CLI exits 0 on a limit,
383
+ // the reducer still computed status/reason from the result subtype.
384
+ await this._settle();
385
+ const s = this.reducer.snapshot();
386
+ if (/max_turns|max_budget/.test(s.resultSubtype ?? '')) this._limitNotice(s.reason, limitsNow);
387
+ return await this._complete({ kind: 'done', status: s.reason ? 'stopped' : 'done', reason: s.reason ?? null });
388
+ } catch (err) {
389
+ const s = this.reducer.snapshot();
390
+ // R-C, literal order. (1) The abort branch FIRST — B-4: a pre-aborted
391
+ // runClaude throws before any init, so this must precede the resume test.
392
+ if (err?.name === 'AbortError') {
393
+ // costUsd falls out of the reducer: no `result` seen ⇒ summary.costUsd
394
+ // is null (spec §6.2.8); a result that DID land before the abort keeps
395
+ // its real cost.
396
+ if (this.timedOut) {
397
+ return await this._complete({ kind: 'error', message: 'timed out after 30 min' });
398
+ }
399
+ return await this._complete({ kind: 'done', status: 'stopped', reason: 'user' });
400
+ }
401
+ // (2) The per-turn limits — F5: exit 1, classify from the reducer.
402
+ if (/max_turns|max_budget/.test(s.resultSubtype ?? '')) {
403
+ this._limitNotice(s.reason, limitsNow);
404
+ return await this._complete({ kind: 'done', status: 'stopped', reason: s.reason });
405
+ }
406
+ // (3) The narrow resume-fallback predicate (F9): only a session that
407
+ // never produced an init or said "No conversation found".
408
+ if (!isRetry && this.resumeSessionId
409
+ && (!s.sawInit || s.errors.some((m) => /No conversation found/.test(m)))) {
410
+ continue;
411
+ }
412
+ // (4) Everything else is a turn failure.
413
+ if (isRetry) {
414
+ try { d.store.updateThread(this.threadId, { sessionId: null }); } catch { /* deleted */ }
415
+ }
416
+ return await this._complete({
417
+ kind: 'error',
418
+ message: err?.message || String(err),
419
+ errorClass: err?.errorClass ?? undefined,
420
+ });
421
+ }
422
+ }
423
+ /* c8 ignore next */
424
+ return { status: this.status };
425
+ }
426
+
427
+ _kickoffTitle() {
428
+ if (!this.firstTurn || this._titleKicked) return;
429
+ this._titleKicked = true;
430
+ const d = this.deps;
431
+ // Fire-and-forget: kicked off at the START of the first turn (right after
432
+ // the scratch dir exists) and backstopped after its terminal status (§7.4).
433
+ // Stored for test determinism, never awaited by run() (orchestrator.mjs:3821).
434
+ // NO signal: a user stop aborts this.abort mid-turn and would kill the call
435
+ // before it spawns. permissionMode 'dontAsk' is the B-1 fix.
436
+ this.titlePromise = Promise.resolve()
437
+ .then(() => d.generateTitle(this.firstText, {
438
+ cwd: this.scratchDir || join(d.worcaHome(), 'tmp', 'ask'),
439
+ tools: [], strictMcpConfig: true, settingSources: ['project'],
440
+ disableSlashCommands: true, envScrub: true, envAllowlist: [],
441
+ permissionMode: 'dontAsk',
442
+ }))
443
+ .then((generated) => {
444
+ // The route stamps NOTHING before the 202 (the header reads "Ask Worca"
445
+ // until this frame lands), so an empty result — generateTitle swallows
446
+ // every failure/abort/refusal into '' — falls back to the route's
447
+ // deterministicTitle (sanitized first 80 chars, or "New chat"). That is
448
+ // the ONLY moment the prompt text may become the title.
449
+ const title = generated || this.deterministicTitle;
450
+ if (!title) return;
451
+ // `onlyIf: null` (title IS NULL) is the rename guard: a PATCHed or
452
+ // deleted thread makes the UPDATE match 0 rows and the frame is suppressed.
453
+ let applied = false;
454
+ try { applied = d.store.setThreadTitle(this.threadId, title, { onlyIf: null }); }
455
+ catch { /* deleted thread */ }
456
+ if (applied) {
457
+ try { d.onOutOfTurn({ type: 'ask-title', title }); } catch { /* sink */ }
458
+ }
459
+ })
460
+ .catch(() => { /* generateTitle already swallows; final backstop */ });
461
+ }
462
+ }
@@ -0,0 +1,27 @@
1
+ // src/core/ask/worktree-deps.mjs
2
+ // The WRITE-CAPABLE dep bundle of the worktree tools (ask-worca-worktrees-
3
+ // design.md §8). Deliberately separate from tool-deps.mjs, whose source is
4
+ // scanned as read-only: everything that creates/removes checkouts or updates
5
+ // ask_worktrees rows is reachable ONLY through here, and
6
+ // test/ask-worktree-tools.test.mjs pins this module's import surface.
7
+ import {
8
+ openAskWorktree, listAskWorktrees, getAskWorktree, removeAskWorktree,
9
+ noteWorktreeNavigation,
10
+ } from './worktrees.mjs';
11
+ import { runGitCapture } from '../worktree.mjs';
12
+ import { validateGitArgs } from './git-allowlist.mjs';
13
+
14
+ /** @param {{threadId:string}} opts every operation is scoped to this thread */
15
+ export function defaultWorktreeDeps({ threadId }) {
16
+ return {
17
+ worktrees: {
18
+ open: (input) => openAskWorktree({ ...input, threadId }),
19
+ list: () => listAskWorktrees(threadId),
20
+ get: (wtId) => getAskWorktree(threadId, wtId),
21
+ remove: (wtId) => removeAskWorktree({ threadId, wtId }),
22
+ noteNav: (wtId, patch) => noteWorktreeNavigation(threadId, wtId, patch),
23
+ runGit: (cwd, args, opts) => runGitCapture(cwd, args, opts), // opts passthrough (timeout) for callers; the tool dispatcher passes none today
24
+ validateGitArgs,
25
+ },
26
+ };
27
+ }