@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,416 @@
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 } 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
+ deps = {},
44
+ } = {}) {
45
+ super();
46
+ this.threadId = threadId;
47
+ this.assistantMessageId = assistantMessageId;
48
+ this.userMessageId = userMessageId;
49
+ this.prompt = prompt;
50
+ this.systemPrompt = systemPrompt;
51
+ this.restoredPrompt = restoredPrompt;
52
+ this.model = model;
53
+ this.effort = effort;
54
+ this.resumeSessionId = resumeSessionId || null;
55
+ this.firstTurn = !!firstTurn;
56
+ this.firstText = firstText;
57
+ this.deterministicTitle = deterministicTitle ?? null;
58
+ this.mock = mock || null;
59
+ this.attachmentNames = attachmentNames || {};
60
+ this.deps = {
61
+ runClaudeImpl: deps.runClaudeImpl ?? runClaude,
62
+ store: {
63
+ finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle,
64
+ ...(deps.store || {}),
65
+ },
66
+ validateProposal: deps.validateProposal ?? validateProposal,
67
+ generateTitle: deps.generateTitle ?? generateTitle,
68
+ askLimits: deps.askLimits ?? askLimits,
69
+ limits: deps.limits ?? ASK_LIMITS,
70
+ resolveModelEnv: deps.resolveModelEnv ?? resolveModelEnv,
71
+ resolveModelCost: deps.resolveModelCost ?? resolveModelCost,
72
+ worcaHome: deps.worcaHome ?? worcaHome,
73
+ buildMcpConfig: deps.buildMcpConfig ?? buildMcpConfig,
74
+ serverPath: deps.serverPath ?? ASK_MCP_SERVER_PATH,
75
+ newAskId: deps.newAskId ?? newAskId,
76
+ setPendingCardComments: deps.setPendingCardComments ?? setPendingCardComments,
77
+ recordAskCost: deps.recordAskCost ?? recordAskCostDelta,
78
+ now: deps.now ?? Date.now,
79
+ // Default timers unref so a 30-minute clock never holds the process open
80
+ // (orchestrator.mjs:2627 _backoff precedent). Tests inject both.
81
+ setTimeout: deps.setTimeout ?? ((fn, ms) => { const t = setTimeout(fn, ms); t.unref?.(); return t; }),
82
+ clearTimeout: deps.clearTimeout ?? ((t) => clearTimeout(t)),
83
+ fs: deps.fs ?? { mkdir, writeFile, unlink },
84
+ onFrame: deps.onFrame ?? (() => {}),
85
+ onOutOfTurn: deps.onOutOfTurn ?? (() => {}),
86
+ onCommentMutation: deps.onCommentMutation ?? (() => {}),
87
+ };
88
+ this.abort = new AbortController();
89
+ this.status = 'created';
90
+ this.timedOut = false;
91
+ this.stopping = false;
92
+ this.reducer = null;
93
+ this.sessionId = this.resumeSessionId;
94
+ this.scratchDir = null;
95
+ this.titlePromise = Promise.resolve();
96
+ this._completed = false;
97
+ }
98
+
99
+ stop() {
100
+ if (TERMINAL.has(this.status)) return;
101
+ this.stopping = true;
102
+ try { this.abort.abort(); } catch { /* ignore */ }
103
+ }
104
+
105
+ _frame(frame) {
106
+ try { this.deps.onFrame(frame); } catch { /* a broken sink must not break the turn */ }
107
+ }
108
+
109
+ _emit(event, payload) {
110
+ // EventEmitter special-cases 'error': emitting it with ZERO listeners throws
111
+ // ERR_UNHANDLED_ERROR, and a listener that throws escapes too — either would
112
+ // break the "run() NEVER throws" contract P3 builds on. Both terminal emits
113
+ // go through here; same swallow posture as _frame.
114
+ if (event === 'error' && this.listenerCount('error') === 0) return;
115
+ try { this.emit(event, payload); } catch { /* a broken listener must not break the turn */ }
116
+ }
117
+
118
+ _persistBlocks() {
119
+ // R-A: the card (and every mid-turn notice) must survive a server restart
120
+ // and be visible to findCard/updateCardBlock while the turn streams.
121
+ try { this.deps.store.setMessageBlocks(this.assistantMessageId, this.reducer.snapshot().blocks); }
122
+ catch { /* thread may be gone — the terminal write is equally guarded */ }
123
+ }
124
+
125
+ async _onProposal(input) {
126
+ const d = this.deps;
127
+ const cardId = d.newAskId('card');
128
+ try {
129
+ const r = await d.validateProposal(input && typeof input === 'object' ? input : {}, { cardId });
130
+ if (r && r.ok) {
131
+ this.reducer.addBlock({ kind: 'card', id: cardId, state: 'proposed', card: r.card });
132
+ // commentIds are propose_run INPUT only: they never enter the card block (its
133
+ // key set is pinned in test/ask-proposal.test.mjs) nor CARD_PATCH_KEYS. Parked
134
+ // against the card id until the user starts the run; unknown ids are dropped,
135
+ // because the model may cite a comment the user has since deleted and that
136
+ // must not sink an otherwise valid proposal.
137
+ try { d.setPendingCardComments(cardId, input?.commentIds); }
138
+ catch { /* comment metadata is never worth failing a proposal for */ }
139
+ } else {
140
+ const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
141
+ this.reducer.addBlock({ kind: 'notice', text: `Proposal rejected: ${errors.join('; ')}` });
142
+ }
143
+ } catch (err) {
144
+ this.reducer.addBlock({ kind: 'notice', text: `Proposal rejected: ${err?.message || err}` });
145
+ }
146
+ this._persistBlocks();
147
+ }
148
+
149
+ _makeReducer() {
150
+ const d = this.deps;
151
+ this.reducer = createTurnReducer({
152
+ onFrame: (f) => this._frame(f),
153
+ now: d.now,
154
+ setTimeout: d.setTimeout,
155
+ clearTimeout: d.clearTimeout,
156
+ attachmentNames: this.attachmentNames,
157
+ // Ask spend feeds the SAME windowed budget as pipeline spend
158
+ // (cost-budget.mjs combinedWindowedSpendUsd), so an on-prem model the CLI
159
+ // prices by name inflates it from here too — re-price the turn exactly as
160
+ // the orchestrator's result intake does. Trusts the CLI when the model
161
+ // carries no override, which is the default.
162
+ resolveCost: (cliCostUsd, usage) => d.resolveModelCost(this.model, cliCostUsd, usage),
163
+ limits: d.limits,
164
+ onProposal: ({ input }) => this._onProposal(input),
165
+ // The MCP child cannot broadcast; the parent turns its comment writes into
166
+ // the same poke the REST routes emit.
167
+ onCommentMutation: (e) => { try { this.deps.onCommentMutation(e); } catch { /* a broken sink never breaks the turn */ } },
168
+ });
169
+ return this.reducer;
170
+ }
171
+
172
+ async _settle() {
173
+ // R-A verbatim: settle() has no timeout of its own — race it against the
174
+ // turn's abort so a hung proposal hook cannot wedge the terminal write.
175
+ const aborted = new Promise((res) => {
176
+ if (this.abort.signal.aborted) return res();
177
+ this.abort.signal.addEventListener('abort', () => res(), { once: true });
178
+ });
179
+ await Promise.race([this.reducer.settle(), aborted]);
180
+ }
181
+
182
+ /**
183
+ * The single terminal writer — called exactly once per run().
184
+ * kind 'done' → ask-done{status:'done'|'stopped', reason?}
185
+ * kind 'error' → ask-error{message, errorClass?} with message status 'error'.
186
+ * The §6.2.8 costUsd:null rule needs no plumbing here: the P1 reducer sets
187
+ * lastResult and sawResult together (events.mjs currentCost() reads lastResult,
188
+ * set only by a `result` frame), so summary.costUsd is ALREADY null whenever no
189
+ * `result` arrived — source-verified. P1's ask-events tests pin only the
190
+ * per-frame ask-usage costUsd:null (:51); the R-C stop test in THIS file is
191
+ * the end-to-end pin of the summary rule.
192
+ */
193
+ async _complete({ kind, status, reason = null, message = null, errorClass = undefined }) {
194
+ if (this._completed) return { status: this.status };
195
+ this._completed = true;
196
+ const d = this.deps;
197
+ await this._settle();
198
+ const summary = this.reducer.finish();
199
+ const finalStatus = kind === 'error' ? 'error' : status;
200
+ // Already AUTHORITATIVE: the reducer applied this turn's per-model cost
201
+ // override (the `resolveCost` hook in _makeReducer), so this one value is
202
+ // correct for all four sinks below — the message row, the thread totals, the
203
+ // budget ledger, and the ask-done frame.
204
+ const costUsd = summary.costUsd;
205
+ // Persist BEFORE broadcasting: a client re-fetch on the terminal frame must
206
+ // never see a still-streaming row. finishMessage gets the FULL patch (B-5).
207
+ try {
208
+ d.store.finishMessage(this.assistantMessageId, {
209
+ text: summary.text, blocks: summary.blocks, status: finalStatus, reason,
210
+ usage: summary.usage, costUsd, durationMs: summary.durationMs,
211
+ });
212
+ } catch { /* deleted thread — the frames still settle the UI */ }
213
+ let threadTotals = null;
214
+ try {
215
+ threadTotals = d.store.addThreadTotals(this.threadId, {
216
+ costUsd, usage: summary.usage, agents: summary.agents,
217
+ });
218
+ } catch { /* deleted thread */ }
219
+ // D10: the spend is a financial fact even when the thread was deleted
220
+ // mid-turn — sits OUTSIDE the store try/catches above so it is never
221
+ // skipped; best-effort so a DB hiccup still settles the frames. Written
222
+ // after finishMessage: a process death between the two loses only this
223
+ // row (accepted — the v20 backfill never re-runs). Runs on done, stopped
224
+ // AND error turns alike: a result frame means money was spent.
225
+ try {
226
+ d.recordAskCost({
227
+ threadId: this.threadId, messageId: this.assistantMessageId,
228
+ amountUsd: costUsd, // null → the writer no-ops (D2)
229
+ tokens: ['input', 'output', 'cacheRead', 'cacheCreation']
230
+ .reduce((a, k) => a + (Number(summary.usage?.[k]) || 0), 0),
231
+ model: this.model, tsMs: d.now(),
232
+ });
233
+ } catch { /* ledger append is best-effort */ }
234
+ this.status = finalStatus;
235
+ if (summary.reducerErrors) {
236
+ console.warn(`[worca-ask] turn ${this.assistantMessageId}: ${summary.reducerErrors} reducer error(s) absorbed`);
237
+ }
238
+ if (kind === 'error') {
239
+ this._frame({ type: 'ask-error', message: message || 'unknown error', ...(errorClass !== undefined ? { errorClass } : {}) });
240
+ this._emit('error', { message: message || 'unknown error' });
241
+ } else {
242
+ this._frame({
243
+ type: 'ask-done', text: summary.text, blocks: summary.blocks, usage: summary.usage,
244
+ costUsd, durationMs: summary.durationMs, model: this.model, status: finalStatus,
245
+ ...(reason ? { reason } : {}), threadTotals,
246
+ });
247
+ this._emit('done', { status: finalStatus, reason });
248
+ }
249
+ return { status: finalStatus };
250
+ }
251
+
252
+ _limitNotice(reason, limitsNow) {
253
+ const text = reason === 'max_budget'
254
+ ? `Stopped: reached the $${limitsNow.maxBudgetUsd} per-turn cap (Settings → Ask Worca)`
255
+ : `Stopped: reached the ${limitsNow.maxTurns}-turn limit (Settings → Ask Worca)`;
256
+ this.reducer.addBlock({ kind: 'notice', text });
257
+ this._persistBlocks();
258
+ }
259
+
260
+ async run() {
261
+ if (this.status !== 'created') return { status: this.status };
262
+ this.status = 'running';
263
+ const d = this.deps;
264
+ this._makeReducer();
265
+ this._frame({
266
+ type: 'ask-start', userMessageId: this.userMessageId,
267
+ model: this.model, effort: this.effort, startedAt: new Date(d.now()).toISOString(),
268
+ });
269
+ let timer = null;
270
+ let mcpConfigPath = null;
271
+ let out;
272
+ try {
273
+ // R-G: ONE scratch dir for all threads, RAW home base (never worcaHome()
274
+ // itself — it already ends in /.worca-cc), per-message config json.
275
+ const scratchDir = join(d.worcaHome(), 'tmp', 'ask');
276
+ this.scratchDir = scratchDir;
277
+ await d.fs.mkdir(scratchDir, { recursive: true });
278
+ const homeBase = process.env.WORCA_HOME?.trim()
279
+ ? pathResolve(process.env.WORCA_HOME)
280
+ : dirname(d.worcaHome());
281
+ mcpConfigPath = join(scratchDir, `mcp-${this.assistantMessageId}.json`);
282
+ await d.fs.writeFile(
283
+ mcpConfigPath,
284
+ JSON.stringify(d.buildMcpConfig({ homeBase, threadId: this.threadId, serverPath: d.serverPath }), null, 2),
285
+ 'utf8',
286
+ );
287
+ // One 30-minute budget for the whole turn, retry included. The timedOut
288
+ // flag and abort() run in ONE synchronous callback, so R-C always reads
289
+ // the flag set (the awaiting continuation resumes a microtask later);
290
+ // flag-first is kept as defensive style (plugin-shim.mjs:164 precedent).
291
+ timer = d.setTimeout(() => { this.timedOut = true; try { this.abort.abort(); } catch { /* ignore */ } }, d.limits.turnTimeoutMs);
292
+ const limitsNow = d.askLimits(); // D12: read fresh every turn
293
+ out = await this._attempts(limitsNow, mcpConfigPath, scratchDir);
294
+ } catch (err) {
295
+ // Backstop for a deps failure (mkdir/write) — _attempts itself never throws.
296
+ out = await this._complete({ kind: 'error', message: err?.message || String(err) });
297
+ } finally {
298
+ if (timer != null) d.clearTimeout(timer);
299
+ if (mcpConfigPath) await d.fs.unlink(mcpConfigPath).catch(() => {});
300
+ }
301
+ this._kickoffTitle();
302
+ return out;
303
+ }
304
+
305
+ async _attempts(limitsNow, mcpConfigPath, scratchDir) {
306
+ const d = this.deps;
307
+ for (let attempt = 1; attempt <= 2; attempt += 1) {
308
+ const isRetry = attempt === 2;
309
+ if (isRetry) {
310
+ this._makeReducer(); // fresh reducer; the dead attempt's reducer is discarded unfinished
311
+ // Deliberate deviation from §6.2.7's ordering (which posts the notice
312
+ // after a successful restore): the notice is added EAGERLY so it is
313
+ // visible while the retry streams (R-A persistence below). If the retry
314
+ // then fails, the notice stays above the ask-error — acceptable, and
315
+ // recorded in the Clarifications Q&A.
316
+ this.reducer.addBlock({ kind: 'notice', text: 'Context restored from history' });
317
+ this._persistBlocks();
318
+ }
319
+ const options = buildAskSpawnOptions({
320
+ thread: { id: this.threadId, sessionId: isRetry ? null : this.resumeSessionId }, // B-7: the only no-resume lever
321
+ turn: {
322
+ prompt: isRetry ? this.restoredPrompt : this.prompt,
323
+ systemPrompt: this.systemPrompt,
324
+ model: this.model,
325
+ effort: this.effort,
326
+ modelEnv: d.resolveModelEnv(this.model),
327
+ mock: this.mock, // R-F: markers on EVERY attempt
328
+ signal: this.abort.signal,
329
+ onEvent: (e) => {
330
+ if (e && e.type === 'session' && typeof e.sessionId === 'string' && e.sessionId) {
331
+ // §6.2.4: stored on the thread immediately, not at turn end.
332
+ this.sessionId = e.sessionId;
333
+ try { d.store.updateThread(this.threadId, { sessionId: e.sessionId }); } catch { /* deleted thread */ }
334
+ }
335
+ this.reducer.push(e);
336
+ },
337
+ },
338
+ limits: limitsNow,
339
+ mcpConfigPath,
340
+ scratchDir,
341
+ });
342
+ try {
343
+ await d.runClaudeImpl(options);
344
+ // Resolve path. Future-proofing: if a later CLI exits 0 on a limit,
345
+ // the reducer still computed status/reason from the result subtype.
346
+ await this._settle();
347
+ const s = this.reducer.snapshot();
348
+ if (/max_turns|max_budget/.test(s.resultSubtype ?? '')) this._limitNotice(s.reason, limitsNow);
349
+ return await this._complete({ kind: 'done', status: s.reason ? 'stopped' : 'done', reason: s.reason ?? null });
350
+ } catch (err) {
351
+ const s = this.reducer.snapshot();
352
+ // R-C, literal order. (1) The abort branch FIRST — B-4: a pre-aborted
353
+ // runClaude throws before any init, so this must precede the resume test.
354
+ if (err?.name === 'AbortError') {
355
+ // costUsd falls out of the reducer: no `result` seen ⇒ summary.costUsd
356
+ // is null (spec §6.2.8); a result that DID land before the abort keeps
357
+ // its real cost.
358
+ if (this.timedOut) {
359
+ return await this._complete({ kind: 'error', message: 'timed out after 30 min' });
360
+ }
361
+ return await this._complete({ kind: 'done', status: 'stopped', reason: 'user' });
362
+ }
363
+ // (2) The per-turn limits — F5: exit 1, classify from the reducer.
364
+ if (/max_turns|max_budget/.test(s.resultSubtype ?? '')) {
365
+ this._limitNotice(s.reason, limitsNow);
366
+ return await this._complete({ kind: 'done', status: 'stopped', reason: s.reason });
367
+ }
368
+ // (3) The narrow resume-fallback predicate (F9): only a session that
369
+ // never produced an init or said "No conversation found".
370
+ if (!isRetry && this.resumeSessionId
371
+ && (!s.sawInit || s.errors.some((m) => /No conversation found/.test(m)))) {
372
+ continue;
373
+ }
374
+ // (4) Everything else is a turn failure.
375
+ if (isRetry) {
376
+ try { d.store.updateThread(this.threadId, { sessionId: null }); } catch { /* deleted */ }
377
+ }
378
+ return await this._complete({
379
+ kind: 'error',
380
+ message: err?.message || String(err),
381
+ errorClass: err?.errorClass ?? undefined,
382
+ });
383
+ }
384
+ }
385
+ /* c8 ignore next */
386
+ return { status: this.status };
387
+ }
388
+
389
+ _kickoffTitle() {
390
+ if (!this.firstTurn) return;
391
+ const d = this.deps;
392
+ // Fire-and-forget after ANY terminal status of the first turn (§7.4).
393
+ // Stored for test determinism, never awaited by run() (orchestrator.mjs:3821).
394
+ // NO signal: after a user stop this.abort is already aborted and would kill
395
+ // the call before it spawns. permissionMode 'dontAsk' is the B-1 fix.
396
+ this.titlePromise = Promise.resolve()
397
+ .then(() => d.generateTitle(this.firstText, {
398
+ cwd: this.scratchDir || join(d.worcaHome(), 'tmp', 'ask'),
399
+ tools: [], strictMcpConfig: true, settingSources: ['project'],
400
+ disableSlashCommands: true, envScrub: true, envAllowlist: [],
401
+ permissionMode: 'dontAsk',
402
+ }))
403
+ .then((title) => {
404
+ if (!title || title === this.deterministicTitle) return;
405
+ // setThreadTitle's onlyIf is the rename guard: a PATCHed or deleted
406
+ // thread makes the UPDATE match 0 rows and the frame is suppressed.
407
+ let applied = false;
408
+ try { applied = d.store.setThreadTitle(this.threadId, title, { onlyIf: this.deterministicTitle }); }
409
+ catch { /* deleted thread */ }
410
+ if (applied) {
411
+ try { d.onOutOfTurn({ type: 'ask-title', title }); } catch { /* sink */ }
412
+ }
413
+ })
414
+ .catch(() => { /* generateTitle already swallows; final backstop */ });
415
+ }
416
+ }
@@ -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
+ }