@worca/app 1.2.0 → 1.3.0
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.
- package/README.md +42 -0
- package/agents/memoryDefragmenter.meta.json +24 -0
- package/agents/worca-cc-code-reviewer.md +6 -1
- package/agents/worca-cc-implementer.md +6 -1
- package/agents/worca-cc-memory-defragmenter.md +32 -0
- package/agents/worca-cc-planner.md +5 -1
- package/package.json +5 -2
- package/src/cli/render.mjs +36 -0
- package/src/cli/worca-cc.mjs +137 -8
- package/src/core/agent-registry.mjs +12 -34
- package/src/core/artifacts.mjs +132 -8
- package/src/core/ask/catalog.mjs +32 -7
- package/src/core/ask/comment-deps.mjs +5 -2
- package/src/core/ask/events.mjs +65 -2
- package/src/core/ask/limits.mjs +9 -0
- package/src/core/ask/mcp-stdio.mjs +10 -0
- package/src/core/ask/memory-deps.mjs +107 -0
- package/src/core/ask/metrics-deps.mjs +124 -0
- package/src/core/ask/metrics-proposal.mjs +175 -0
- package/src/core/ask/prompt.mjs +53 -10
- package/src/core/ask/proposal.mjs +49 -2
- package/src/core/ask/spawn.mjs +21 -4
- package/src/core/ask/store.mjs +14 -5
- package/src/core/ask/tool-deps.mjs +26 -2
- package/src/core/ask/tools.mjs +439 -6
- package/src/core/ask/turn.mjs +163 -4
- package/src/core/ask/workflow-deps.mjs +226 -0
- package/src/core/auto/classify.mjs +352 -0
- package/src/core/auto/fingerprint.mjs +141 -0
- package/src/core/auto/match.mjs +30 -0
- package/src/core/auto/model.mjs +23 -0
- package/src/core/auto/proposal.mjs +132 -0
- package/src/core/auto/recipes.mjs +75 -0
- package/src/core/auto/repo-look.mjs +46 -0
- package/src/core/claude-runner.mjs +132 -11
- package/src/core/config.mjs +120 -3
- package/src/core/db.mjs +44 -1
- package/src/core/diff-comments.mjs +55 -9
- package/src/core/frontmatter.mjs +75 -0
- package/src/core/git-info.mjs +233 -26
- package/src/core/graph/builtin-workflows.mjs +50 -0
- package/src/core/graph/executor.mjs +11 -3
- package/src/core/index-html.mjs +17 -0
- package/src/core/memory-store.mjs +441 -0
- package/src/core/memory-sync.mjs +300 -0
- package/src/core/metrics/ledger.mjs +47 -0
- package/src/core/metrics/lock.mjs +117 -0
- package/src/core/metrics/read.mjs +303 -0
- package/src/core/metrics/record.mjs +389 -0
- package/src/core/metrics/sync.mjs +1100 -0
- package/src/core/onboarding.mjs +99 -0
- package/src/core/orchestrator.mjs +394 -7
- package/src/core/phases.mjs +16 -3
- package/src/core/pipeline-delete.mjs +1 -1
- package/src/core/plugin-store.mjs +2 -10
- package/src/core/preflight.mjs +2 -3
- package/src/core/projects.mjs +16 -1
- package/src/core/run-harness.mjs +458 -32
- package/src/core/run-report.mjs +896 -0
- package/src/core/settings.mjs +162 -0
- package/src/core/sources.mjs +4 -1
- package/src/core/store.mjs +5 -0
- package/src/core/workflow-export.mjs +2 -0
- package/src/core/workflow-share.mjs +1 -0
- package/src/core/workflows.mjs +43 -23
- package/src/core/workspaces.mjs +37 -8
- package/src/shared/graph/agent-meta.mjs +5 -2
- package/src/shared/graph/assemble.mjs +455 -0
- package/src/shared/graph/flow-layout.mjs +249 -0
- package/src/shared/graph/geometry.mjs +48 -28
- package/src/shared/graph/isomorphic.mjs +101 -0
- package/src/shared/report-reasons.mjs +58 -0
- package/src/shared/team-metrics/aggregate.mjs +341 -0
- package/src/shared/team-metrics/workspace-match.mjs +13 -0
- package/ui/public/about-links.mjs +21 -0
- package/ui/public/app.js +3715 -479
- package/ui/public/artifact-view.mjs +135 -0
- package/ui/public/ask-model.mjs +18 -1
- package/ui/public/ask-panel.mjs +1359 -214
- package/ui/public/ask-run-card.mjs +209 -0
- package/ui/public/assets/worca-logo-mask.png +0 -0
- package/ui/public/assets/worca-mark-mask.png +0 -0
- package/ui/public/auto-build.mjs +95 -0
- package/ui/public/auto-proposal.mjs +174 -0
- package/ui/public/comment-thread.mjs +55 -0
- package/ui/public/getting-started.mjs +261 -0
- package/ui/public/graph/composer.mjs +41 -5
- package/ui/public/graph/inspector.mjs +3 -1
- package/ui/public/graph/model.mjs +1 -0
- package/ui/public/graph/run-hosts.mjs +73 -12
- package/ui/public/graph/view.mjs +218 -50
- package/ui/public/guide-spot.mjs +215 -0
- package/ui/public/index.html +423 -25
- package/ui/public/memory-view.mjs +192 -0
- package/ui/public/node-tunables.mjs +201 -0
- package/ui/public/report-run.mjs +75 -0
- package/ui/public/results-view.mjs +25 -0
- package/ui/public/source-pane.mjs +16 -2
- package/ui/public/stats-view.mjs +2 -2
- package/ui/public/style.css +1450 -303
- package/ui/public/team-metrics-surfaces.mjs +452 -0
- package/ui/public/team-metrics-view.mjs +533 -0
- package/ui/public/thinking-orb.mjs +46 -8
- package/ui/server.mjs +1282 -193
package/src/core/ask/turn.mjs
CHANGED
|
@@ -19,12 +19,16 @@ import { runClaude } from '../claude-runner.mjs';
|
|
|
19
19
|
import { resolveModelEnv, resolveModelCost, estimateCost, liveCostRates as defaultLiveCostRates } from '../config.mjs';
|
|
20
20
|
import { worcaHome } from '../projects.mjs';
|
|
21
21
|
import { generateTitle } from '../title.mjs';
|
|
22
|
+
import { cleanText } from '../../shared/graph/assemble.mjs';
|
|
22
23
|
import { createTurnReducer } from './events.mjs';
|
|
23
24
|
import { buildAskSpawnOptions, buildMcpConfig, ASK_MCP_SERVER_PATH } from './spawn.mjs';
|
|
25
|
+
import { refreshAskMemoryMount } from './memory-deps.mjs';
|
|
24
26
|
import { validateProposal } from './proposal.mjs';
|
|
27
|
+
import { validateMetricsChange } from './metrics-deps.mjs';
|
|
28
|
+
import { revalidateWorkflowProposal } from './workflow-deps.mjs';
|
|
25
29
|
import { askLimits, ASK_LIMITS } from './limits.mjs';
|
|
26
30
|
import {
|
|
27
|
-
newAskId, finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle,
|
|
31
|
+
newAskId, finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle, listAttachments,
|
|
28
32
|
} from './store.mjs';
|
|
29
33
|
import { recordAskCostDelta } from '../cost-budget.mjs';
|
|
30
34
|
import { setPendingCardComments } from '../diff-comments.mjs';
|
|
@@ -41,6 +45,7 @@ class AskTurn extends EventEmitter {
|
|
|
41
45
|
firstTurn = false, firstText = '', deterministicTitle = null,
|
|
42
46
|
mock = null, attachmentNames = {},
|
|
43
47
|
pinnedScope = null,
|
|
48
|
+
memoryProject = null,
|
|
44
49
|
deps = {},
|
|
45
50
|
} = {}) {
|
|
46
51
|
super();
|
|
@@ -60,13 +65,23 @@ class AskTurn extends EventEmitter {
|
|
|
60
65
|
this.attachmentNames = attachmentNames || {};
|
|
61
66
|
// #397: {projectKey}|{workspaceId}|null — the user-pinned scope at POST time.
|
|
62
67
|
this.pinnedScope = pinnedScope && typeof pinnedScope === 'object' ? pinnedScope : null;
|
|
68
|
+
// Native-rules revision (D16): {key, name}|null — the scope set this turn mounts through --add-dir.
|
|
69
|
+
this.memoryProject = memoryProject && typeof memoryProject === 'object' ? memoryProject : null;
|
|
70
|
+
this.memoryDir = null;
|
|
71
|
+
this._wfCards = new Map(); // tool_use id → card id (START → RESULT of one propose_workflow call)
|
|
72
|
+
this._tracked = new Set(); // pipeline ids minted as progress cards in THIS reply (one card per pipeline)
|
|
73
|
+
this.extraCostUsd = 0; // PD2: money the MCP child spent on the workflow classifier, booked by this turn
|
|
63
74
|
this.deps = {
|
|
64
75
|
runClaudeImpl: deps.runClaudeImpl ?? runClaude,
|
|
76
|
+
memoryMount: deps.memoryMount ?? refreshAskMemoryMount,
|
|
65
77
|
store: {
|
|
66
|
-
finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle,
|
|
78
|
+
finishMessage, setMessageBlocks, addThreadTotals, updateThread, setThreadTitle, listAttachments,
|
|
67
79
|
...(deps.store || {}),
|
|
68
80
|
},
|
|
69
81
|
validateProposal: deps.validateProposal ?? validateProposal,
|
|
82
|
+
revalidateWorkflow: deps.revalidateWorkflow ?? revalidateWorkflowProposal,
|
|
83
|
+
validateMetricsChange: deps.validateMetricsChange ?? validateMetricsChange,
|
|
84
|
+
trackRun: deps.trackRun ?? null,
|
|
70
85
|
generateTitle: deps.generateTitle ?? generateTitle,
|
|
71
86
|
askLimits: deps.askLimits ?? askLimits,
|
|
72
87
|
limits: deps.limits ?? ASK_LIMITS,
|
|
@@ -88,6 +103,7 @@ class AskTurn extends EventEmitter {
|
|
|
88
103
|
onOutOfTurn: deps.onOutOfTurn ?? (() => {}),
|
|
89
104
|
onCommentMutation: deps.onCommentMutation ?? (() => {}),
|
|
90
105
|
onWorktreeMutation: deps.onWorktreeMutation ?? (() => {}),
|
|
106
|
+
onMemoryMutation: deps.onMemoryMutation ?? (() => {}),
|
|
91
107
|
// DISPLAY-ONLY rates for the footer's live "≈" estimate (config.mjs
|
|
92
108
|
// liveCostRates: override → list price → null). Injectable so tests pin
|
|
93
109
|
// the frame arithmetic without the catalog.
|
|
@@ -125,6 +141,7 @@ class AskTurn extends EventEmitter {
|
|
|
125
141
|
}
|
|
126
142
|
|
|
127
143
|
_persistBlocks() {
|
|
144
|
+
if (this._completed) return; // v7: after _complete() the terminal write owns the row
|
|
128
145
|
// R-A: the card (and every mid-turn notice) must survive a server restart
|
|
129
146
|
// and be visible to findCard/updateCardBlock while the turn streams.
|
|
130
147
|
try { this.deps.store.setMessageBlocks(this.assistantMessageId, this.reducer.snapshot().blocks); }
|
|
@@ -143,7 +160,11 @@ class AskTurn extends EventEmitter {
|
|
|
143
160
|
|| (typeof raw.workspaceId === 'string' && raw.workspaceId.trim());
|
|
144
161
|
const inp = pin && !hasTarget ? { ...raw, ...pin } : raw;
|
|
145
162
|
try {
|
|
146
|
-
|
|
163
|
+
// The thread's attachment ledger, so attachmentIds the model cites resolve
|
|
164
|
+
// to real rows (spec §6.4). A ledger failure never blocks the card.
|
|
165
|
+
let attachments = [];
|
|
166
|
+
try { attachments = (typeof d.store.listAttachments === 'function' && d.store.listAttachments(this.threadId)) || []; } catch { attachments = []; }
|
|
167
|
+
const r = await d.validateProposal(inp, { cardId, attachments });
|
|
147
168
|
if (r && r.ok) {
|
|
148
169
|
// #397 guardrail: a proposal targeting a DIFFERENT project/workspace than
|
|
149
170
|
// the pinned one is accepted but flagged — the card renders the mismatch
|
|
@@ -168,7 +189,123 @@ class AskTurn extends EventEmitter {
|
|
|
168
189
|
this._persistBlocks();
|
|
169
190
|
}
|
|
170
191
|
|
|
192
|
+
/** track_run's card (D3/D4): the MCP child only resolved the id — deps.trackRun (ui/server.mjs askTrackRun)
|
|
193
|
+
* links the run to this thread and follows a live one; this mints ONE stateless progress card per pipeline
|
|
194
|
+
* per reply (older replies keep theirs — every card derives what it shows). */
|
|
195
|
+
async _onTrackRun(input, isError) {
|
|
196
|
+
if (isError) return;
|
|
197
|
+
const d = this.deps;
|
|
198
|
+
if (typeof d.trackRun !== 'function') return;
|
|
199
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
200
|
+
let r = null;
|
|
201
|
+
try { r = await d.trackRun(raw, { threadId: this.threadId, pin: this.pinnedScope }); }
|
|
202
|
+
catch (err) { r = { ok: false, error: err?.message || String(err) }; }
|
|
203
|
+
if (!r || !r.ok || !r.card || !r.card.pipelineId) {
|
|
204
|
+
this.reducer.addBlock({ kind: 'notice', text: `Could not track the run: ${(r && r.error) || 'unknown error'}` });
|
|
205
|
+
this._persistBlocks();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (this._tracked.has(r.card.pipelineId)) return; // the hook continuations run one at a time after their await
|
|
209
|
+
this._tracked.add(r.card.pipelineId);
|
|
210
|
+
const block = this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'tracked', card: r.card });
|
|
211
|
+
if (!block) return; // null after finish() (events.mjs:511) — _onWorkflowResult's own guard
|
|
212
|
+
this._persistBlocks(); // a store write; the browser gets the reducer's ask-card frame
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* propose_metrics_change RESULT: the child validated for the model's self-correction; the parent re-validates the
|
|
217
|
+
* same INPUT authoritatively (metrics-proposal.mjs is pure over the real readers) and mints the card. An isError
|
|
218
|
+
* result or a child {ok:false} already reached the model as text — no card, no notice.
|
|
219
|
+
*/
|
|
220
|
+
async _onMetricsProposal(input, text, isError) {
|
|
221
|
+
if (isError) return;
|
|
222
|
+
let out = null;
|
|
223
|
+
try { out = JSON.parse(text); } catch { out = null; }
|
|
224
|
+
if (!out || out.ok !== true) return;
|
|
225
|
+
const d = this.deps;
|
|
226
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
227
|
+
// The child's pinned-scope default, replayed (tools.mjs propose_metrics_change): the card matches what the model saw.
|
|
228
|
+
const pin = this.pinnedScope;
|
|
229
|
+
const kind = typeof raw.kind === 'string' ? raw.kind.trim() : '';
|
|
230
|
+
let inp = raw;
|
|
231
|
+
if (pin && !(typeof raw.projectKey === 'string' && raw.projectKey.trim()) && !(typeof raw.workspaceId === 'string' && raw.workspaceId.trim())) {
|
|
232
|
+
if (pin.projectKey && (kind === 'enable' || kind === 'record')) inp = { ...raw, projectKey: pin.projectKey };
|
|
233
|
+
if (pin.workspaceId && (kind === 'workspace_home' || kind === 'route_members')) inp = { ...raw, workspaceId: pin.workspaceId };
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const r = await d.validateMetricsChange(inp);
|
|
237
|
+
if (r && r.ok) this.reducer.addBlock({ kind: 'card', id: d.newAskId('card'), state: 'proposed', card: r.card });
|
|
238
|
+
else {
|
|
239
|
+
const errors = (r && Array.isArray(r.errors) && r.errors.length) ? r.errors : ['invalid proposal'];
|
|
240
|
+
this.reducer.addBlock({ kind: 'notice', text: `Metrics change rejected: ${errors.join('; ')}` });
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
this.reducer.addBlock({ kind: 'notice', text: `Metrics change rejected: ${err?.message || err}` });
|
|
244
|
+
}
|
|
245
|
+
this._persistBlocks();
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The card exists from the tool_use on (spec §8.2, PD7): a building block with the four-step trace, persisted. */
|
|
249
|
+
_onWorkflowStart(toolUseId, input) {
|
|
250
|
+
const d = this.deps;
|
|
251
|
+
const cardId = d.newAskId('card');
|
|
252
|
+
this._wfCards.set(toolUseId, cardId);
|
|
253
|
+
const raw = input && typeof input === 'object' ? input : {};
|
|
254
|
+
const pin = this.pinnedScope;
|
|
255
|
+
// A pinned WORKSPACE is not a default target (D19/PD17): the building card then carries projectKey null and the child's
|
|
256
|
+
// own "projectKey is required" error flips it to failed at RESULT.
|
|
257
|
+
const projectKey = (typeof raw.projectKey === 'string' && raw.projectKey.trim()) || (pin && pin.projectKey) || null;
|
|
258
|
+
const mode = typeof raw.task === 'string' && raw.task.trim() ? 'task' : 'shape';
|
|
259
|
+
this.reducer.addBlock({ kind: 'card', id: cardId, state: 'building', card: {
|
|
260
|
+
type: 'workflow', mode, projectKey, projectName: null,
|
|
261
|
+
// v7: the building payload is transient (the proposed flip replaces `card` wholesale) — cap a hand-authored shape like the task text.
|
|
262
|
+
...(mode === 'task' ? { task: String(raw.task).slice(0, 2000) } : { shape: raw.shape && typeof raw.shape === 'object' && JSON.stringify(raw.shape).length <= 8000 ? raw.shape : null }),
|
|
263
|
+
name: cleanText(raw.name, 60), note: cleanText(raw.note, d.limits.workflowNoteMaxChars ?? 200), thenRun: raw.thenRun === true,
|
|
264
|
+
trace: { step: 1, startedAt: new Date(d.now()).toISOString() },
|
|
265
|
+
} });
|
|
266
|
+
this._persistBlocks();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** RESULT: re-validate the returned shape in the parent (assemble, validateGraph, match, buildProposal) and flip the block. */
|
|
270
|
+
async _onWorkflowResult(toolUseId, text, isError) {
|
|
271
|
+
const d = this.deps;
|
|
272
|
+
const cardId = this._wfCards.get(toolUseId);
|
|
273
|
+
if (!cardId) return;
|
|
274
|
+
this._wfCards.delete(toolUseId);
|
|
275
|
+
const fail = (reason) => this.reducer.updateBlock(cardId, { state: 'failed', error: cleanText(reason, 300) || 'proposal failed' });
|
|
276
|
+
let out = null;
|
|
277
|
+
if (!isError) { try { out = JSON.parse(text); } catch { out = null; } }
|
|
278
|
+
// PD2: the classifier ran in the child whatever happens next — book its spend BEFORE judging the result. v7: a
|
|
279
|
+
// {ok:false, error, costUsd} result (classifier timeout / two unusable replies / a shape still rejected after the retry)
|
|
280
|
+
// carries what the failed attempts cost (ClassifierError.costUsd); v6 booked only on ok:true and lost it.
|
|
281
|
+
if (out && Number(out.costUsd) > 0) this.extraCostUsd += Number(out.costUsd);
|
|
282
|
+
if (isError || !out || out.ok !== true || !out.shape || typeof out.shape !== 'object') {
|
|
283
|
+
const reason = isError ? String(text ?? '').replace(/^error:\s*/, '') : (out && typeof out.error === 'string' && out.error) || 'the tool returned no shape';
|
|
284
|
+
if (!fail(reason)) return; // null: the reducer is finished (stop/timeout) — the terminal write already happened
|
|
285
|
+
this._persistBlocks();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const r = await d.revalidateWorkflow({ shape: out.shape, projectKey: out.projectKey, warnings: Array.isArray(out.warnings) ? out.warnings : [], costUsd: Number(out.costUsd) || 0, fingerprint: typeof out.fingerprint === 'string' ? out.fingerprint : '' });
|
|
290
|
+
// v4: the child's projectName first (the real child resolves it), else the parent's own lookup (the MOCK child
|
|
291
|
+
// returns null — without this every mock card, and its context-header line, would have no project name).
|
|
292
|
+
const projectName = cleanText(out.projectName, 120) || cleanText(r.project && r.project.name, 120) || null;
|
|
293
|
+
const flipped = this.reducer.updateBlock(cardId, { state: 'proposed', card: {
|
|
294
|
+
type: 'workflow', mode: out.mode === 'shape' ? 'shape' : 'task', projectKey: typeof out.projectKey === 'string' ? out.projectKey : null,
|
|
295
|
+
projectName, note: cleanText(out.note, 200), thenRun: out.thenRun === true,
|
|
296
|
+
shape: r.shape, summary: cleanText(r.summary, 2000),
|
|
297
|
+
...r.proposal,
|
|
298
|
+
} });
|
|
299
|
+
if (!flipped) return; // v7: finished reducer (the turn was stopped mid-revalidate) — never persist a stale snapshot
|
|
300
|
+
} catch (err) {
|
|
301
|
+
if (!fail(err && err.message ? err.message : String(err))) return;
|
|
302
|
+
}
|
|
303
|
+
this._persistBlocks();
|
|
304
|
+
}
|
|
305
|
+
|
|
171
306
|
_makeReducer() {
|
|
307
|
+
this._wfCards.clear();
|
|
308
|
+
this._tracked.clear();
|
|
172
309
|
const d = this.deps;
|
|
173
310
|
// One settings read per attempt, never per frame. null → the frames carry
|
|
174
311
|
// estimatedCostUsd:null and the footer keeps today's behaviour.
|
|
@@ -188,12 +325,18 @@ class AskTurn extends EventEmitter {
|
|
|
188
325
|
resolveCost: (cliCostUsd, usage) => d.resolveModelCost(this.model, cliCostUsd, usage),
|
|
189
326
|
limits: d.limits,
|
|
190
327
|
onProposal: ({ input }) => this._onProposal(input),
|
|
328
|
+
onWorkflowStart: ({ toolUseId, input }) => this._onWorkflowStart(toolUseId, input),
|
|
329
|
+
onWorkflowResult: ({ toolUseId, text, isError }) => this._onWorkflowResult(toolUseId, text, isError), // the hook's `input` is not needed here: the card is rebuilt from `out`
|
|
330
|
+
onTrackRun: ({ input, isError }) => this._onTrackRun(input, isError),
|
|
331
|
+
onMetricsProposal: ({ input, text, isError }) => this._onMetricsProposal(input, text, isError),
|
|
191
332
|
// The MCP child cannot broadcast; the parent turns its comment writes into
|
|
192
333
|
// the same poke the REST routes emit.
|
|
193
334
|
onCommentMutation: (e) => { try { this.deps.onCommentMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
194
335
|
// Same shape for worktrees: open/remove/navigate in the child → the server
|
|
195
336
|
// broadcasts the thread's worktree envelope (ui/server.mjs emitAskWorktrees).
|
|
196
337
|
onWorktreeMutation: (e) => { try { this.deps.onWorktreeMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
338
|
+
// ...and for memory: a remember/forget in the child becomes the server's memory-changed frame.
|
|
339
|
+
onMemoryMutation: (e) => { try { this.deps.onMemoryMutation(e); } catch { /* a broken sink never breaks the turn */ } },
|
|
197
340
|
// DISPLAY ONLY — never a sink input: prices the running usage sum (main +
|
|
198
341
|
// sub-agent tokens) at the TURN model's rates; the "≈" in the footer owns
|
|
199
342
|
// that approximation. _complete() reads summary.costUsd, not this.
|
|
@@ -228,13 +371,20 @@ class AskTurn extends EventEmitter {
|
|
|
228
371
|
this._completed = true;
|
|
229
372
|
const d = this.deps;
|
|
230
373
|
await this._settle();
|
|
374
|
+
// PD7: a card still building when the reply ends can never flip — fail it while the reducer is still open.
|
|
375
|
+
for (const b of this.reducer.snapshot().blocks) {
|
|
376
|
+
if (b && b.kind === 'card' && b.state === 'building') this.reducer.updateBlock(b.id, { state: 'failed', error: 'the reply ended before the proposal was ready' });
|
|
377
|
+
}
|
|
231
378
|
const summary = this.reducer.finish();
|
|
232
379
|
const finalStatus = kind === 'error' ? 'error' : status;
|
|
233
380
|
// Already AUTHORITATIVE: the reducer applied this turn's per-model cost
|
|
234
381
|
// override (the `resolveCost` hook in _makeReducer), so this one value is
|
|
235
382
|
// correct for all four sinks below — the message row, the thread totals, the
|
|
236
383
|
// budget ledger, and the ask-done frame.
|
|
237
|
-
|
|
384
|
+
// PD2: the MCP child's classifier spend (propose_workflow task mode) is real money this turn caused — it rides
|
|
385
|
+
// the SAME figure the four sinks read. No `result` frame (stopped early) still books it: the classifier ran.
|
|
386
|
+
const extra = Math.round((Number(this.extraCostUsd) || 0) * 1e6) / 1e6;
|
|
387
|
+
const costUsd = summary.costUsd == null ? (extra > 0 ? extra : null) : Math.round((summary.costUsd + extra) * 1e6) / 1e6;
|
|
238
388
|
// Persist BEFORE broadcasting: a client re-fetch on the terminal frame must
|
|
239
389
|
// never see a still-streaming row. finishMessage gets the FULL patch (B-5).
|
|
240
390
|
try {
|
|
@@ -313,6 +463,14 @@ class AskTurn extends EventEmitter {
|
|
|
313
463
|
// _attempts below is the backstop for a mkdir/write failure, so "fires
|
|
314
464
|
// after ANY terminal status of the first turn" stays true.
|
|
315
465
|
this._kickoffTitle();
|
|
466
|
+
// Native-rules revision: refresh the chat's memory mount for this turn's scope set and hand
|
|
467
|
+
// it to the spawn as --add-dir. A store failure never breaks a turn — the turn carries no
|
|
468
|
+
// memory and says so on the server log (the rules are additive; the chat is unaffected).
|
|
469
|
+
try { this.memoryDir = await d.memoryMount({ projectKey: this.memoryProject?.key ?? null, projectName: this.memoryProject?.name ?? null }); }
|
|
470
|
+
catch (err) {
|
|
471
|
+
this.memoryDir = null;
|
|
472
|
+
console.warn(`[worca-ask] thread ${this.threadId}: memory mount failed (${err?.message || err}) — this turn carries no memory`);
|
|
473
|
+
}
|
|
316
474
|
const homeBase = process.env.WORCA_HOME?.trim()
|
|
317
475
|
? pathResolve(process.env.WORCA_HOME)
|
|
318
476
|
: dirname(d.worcaHome());
|
|
@@ -376,6 +534,7 @@ class AskTurn extends EventEmitter {
|
|
|
376
534
|
limits: limitsNow,
|
|
377
535
|
mcpConfigPath,
|
|
378
536
|
scratchDir,
|
|
537
|
+
memoryDir: this.memoryDir,
|
|
379
538
|
});
|
|
380
539
|
try {
|
|
381
540
|
await d.runClaudeImpl(options);
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// src/core/ask/workflow-deps.mjs
|
|
2
|
+
// The propose_workflow dependency bundle (auto-workflow-design.md §8.1-8.2, plan P3 PD1/PD8): the ONE module that
|
|
3
|
+
// runs the Auto pipeline outside a run — fingerprint → classifier → assembler → matcher → buildProposal. Two callers:
|
|
4
|
+
// • the MCP child: tools.mjs propose_workflow → deps.workflow.propose(): classifies / normalizes, assembles, matches,
|
|
5
|
+
// and RETURNS the normalized shape (the parent cannot see the child's memory — the shape travels in the tool result);
|
|
6
|
+
// • the parent: turn.mjs _onWorkflowResult and the cards route → revalidateWorkflowProposal(): re-assembles that shape,
|
|
7
|
+
// re-matches and builds the card payload (buildProposal) — deterministic, so Save re-derives the same template.
|
|
8
|
+
// Reads only (the workflows row is written by the cards route; test/ask-workflow-deps.test.mjs scans this file).
|
|
9
|
+
// Names no agent key (D23). tools.mjs may not import anything, hence every reader/spawner lives here.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { mkdir } from 'node:fs/promises';
|
|
12
|
+
import { loadAgentRegistry } from '../agent-registry.mjs';
|
|
13
|
+
import { listProjects, worcaHome } from '../projects.mjs';
|
|
14
|
+
import { listModels, resolveRunConfig } from '../config.mjs';
|
|
15
|
+
import { mockEnabled } from '../claude-runner.mjs';
|
|
16
|
+
import { assembleShape, ShapeError, cleanText, normalizeShape } from '../../shared/graph/assemble.mjs';
|
|
17
|
+
import { fingerprintProject } from '../auto/fingerprint.mjs';
|
|
18
|
+
import { classifyTask, checkShapeModels, ClassifierError } from '../auto/classify.mjs';
|
|
19
|
+
import { autoCandidates, findEquivalentWorkflow } from '../auto/match.mjs';
|
|
20
|
+
import { openRepoLook } from '../auto/repo-look.mjs';
|
|
21
|
+
import { buildProposal, remapTunables } from '../auto/proposal.mjs';
|
|
22
|
+
import { resolveAutoModel } from '../auto/model.mjs';
|
|
23
|
+
import { ASK_LIMITS } from './limits.mjs';
|
|
24
|
+
|
|
25
|
+
const TUNABLE_KEYS = ['model', 'effort', 'fanOut', 'askQuestions'];
|
|
26
|
+
const flat = (v, max) => cleanText(v, max);
|
|
27
|
+
|
|
28
|
+
/** {key, name, path} for a project key, or null. */
|
|
29
|
+
export async function projectByKey(key) {
|
|
30
|
+
if (typeof key !== 'string' || !key) return null;
|
|
31
|
+
const rows = await listProjects();
|
|
32
|
+
const p = rows.find((x) => x && x.key === key);
|
|
33
|
+
return p ? { key: p.key, name: p.name || '', path: p.path } : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The CLI's proposal vocabulary (src/cli/render.mjs formatWorkflowProposal) as ONE line for the model. */
|
|
37
|
+
export function proposalSummary(proposal) {
|
|
38
|
+
const nodes = proposal?.manifest?.graph?.nodes || [];
|
|
39
|
+
const wires = proposal?.manifest?.graph?.wires || [];
|
|
40
|
+
const labelOf = (id) => nodes.find((n) => n.id === id)?.label || id;
|
|
41
|
+
const stages = (proposal?.order || []).map((id) => {
|
|
42
|
+
const n = proposal.nodes?.[id] || {};
|
|
43
|
+
const tune = [n.model, n.effort].filter(Boolean).join(' · ');
|
|
44
|
+
return `${n.label || id}${tune ? ` (${tune})` : ''}${n.fanOut ? ' ⤴' : ''}`;
|
|
45
|
+
});
|
|
46
|
+
const loops = wires.filter((w) => w.loop && Number.isInteger(w.maxCycles)).map((w) => `${labelOf(w.from.node)} → ${labelOf(w.to.node)} (max ${w.maxCycles} cycles)`);
|
|
47
|
+
return `stages: ${stages.join(' → ')}${loops.length ? `; loops: ${loops.join(', ')}` : ''}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Bake accepted tunables into a template's agent node config — a NEW row only (a matched row is never touched).
|
|
51
|
+
* '' clears a key (sanitizeProposalAnswer's "model '' clears model AND effort"). */
|
|
52
|
+
export function applyTunables(template, tunables = {}) {
|
|
53
|
+
return {
|
|
54
|
+
...template,
|
|
55
|
+
nodes: (template.nodes || []).map((n) => {
|
|
56
|
+
const t = tunables && tunables[n.id];
|
|
57
|
+
if (!t || n.kind !== 'agent') return n;
|
|
58
|
+
const config = { ...(n.config || {}) };
|
|
59
|
+
for (const k of TUNABLE_KEYS) {
|
|
60
|
+
if (t[k] === undefined) continue;
|
|
61
|
+
if (t[k] === '') delete config[k]; else config[k] = t[k];
|
|
62
|
+
}
|
|
63
|
+
return { ...n, config };
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Drop model/effort a hand-authored shape names that the catalog does not carry (PD19). Reads a NORMALIZED shape. Returns warnings. */
|
|
69
|
+
function dropUnknownModels(shape, models) {
|
|
70
|
+
const issues = checkShapeModels(shape, models);
|
|
71
|
+
if (!issues.length) return [];
|
|
72
|
+
const warnings = [];
|
|
73
|
+
const units = (s) => (Array.isArray(s.parallel) ? s.parallel : [s]);
|
|
74
|
+
for (const st of shape.stages.flatMap(units)) {
|
|
75
|
+
const hit = issues.filter((i) => i.stageId === st.id);
|
|
76
|
+
if (!hit.length) continue;
|
|
77
|
+
const t = st.tunables || st;
|
|
78
|
+
delete t.model; delete t.effort;
|
|
79
|
+
warnings.push(`stage ${st.id}: ${hit.map((i) => i.message.replace(/^stage "[^"]*": /, '')).join('; ')} — dropped`);
|
|
80
|
+
}
|
|
81
|
+
return warnings;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The deterministic half (spec §8.2): assemble (validateGraph inside), match, buildProposal. Pure w.r.t. the workflows table.
|
|
86
|
+
* Also resolves the target project (v4): the parent needs its NAME for the card and the context header, and the MCP
|
|
87
|
+
* child's result may not carry it (the mock never does).
|
|
88
|
+
* @param {{shape:object, projectKey?:string|null, warnings?:Array, costUsd?:number, fingerprint?:string, models?:Array|null, registry?:object|null}} o
|
|
89
|
+
* @returns {Promise<{proposal:object, template:object, match:{id,name}|null, tunables:object, shape:object, summary:string, project:{key,name,path}|null}>}
|
|
90
|
+
* @throws {ShapeError} on an unassemblable shape
|
|
91
|
+
*/
|
|
92
|
+
export async function revalidateWorkflowProposal({ shape, projectKey = null, warnings = [], costUsd = 0, fingerprint = '', models = null, registry = null }) {
|
|
93
|
+
const reg = registry || loadAgentRegistry();
|
|
94
|
+
const catalog = models || await listModels('');
|
|
95
|
+
const built = assembleShape(shape, { registry: reg, humanInLoop: true });
|
|
96
|
+
const match = findEquivalentWorkflow(built.template, await autoCandidates());
|
|
97
|
+
const project = await projectByKey(projectKey); // null for no key / an unknown key — never throws
|
|
98
|
+
let template = built.template;
|
|
99
|
+
let tunables = built.tunables;
|
|
100
|
+
let ignoredProjectOverrides = false;
|
|
101
|
+
if (match) {
|
|
102
|
+
tunables = remapTunables(tunables, match.nodeMap);
|
|
103
|
+
template = match.candidate;
|
|
104
|
+
if (project) {
|
|
105
|
+
try {
|
|
106
|
+
const rc = await resolveRunConfig(project.path, match.candidate.id);
|
|
107
|
+
ignoredProjectOverrides = Object.keys(rc?.nodes || {}).length > 0 || Object.keys(rc?.wires || {}).length > 0;
|
|
108
|
+
} catch { ignoredProjectOverrides = false; }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const proposal = buildProposal({
|
|
112
|
+
round: 1, shape: built.shape, template,
|
|
113
|
+
match: match ? { id: match.candidate.id, name: match.candidate.name } : null,
|
|
114
|
+
tunables, registry: reg, models: catalog,
|
|
115
|
+
warnings: [...(warnings || []), ...built.warnings], costUsd, fingerprint, ignoredProjectOverrides,
|
|
116
|
+
});
|
|
117
|
+
return { proposal, template, match: proposal.match, tunables, shape: built.shape, summary: proposalSummary(proposal), project };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `[worca event] …` — the synthetic turn's prompt (spec §8.4). Names are cleaned: they land in a prompt line. v7: the name sits
|
|
121
|
+
* inside double quotes that the mock's event regex (and the model) key on, so `"` becomes `'`, and a `[worca context]` /
|
|
122
|
+
* `[/worca context]` tag typed into a name is neutralised (prompt.mjs:47-49 does that only for header lines). */
|
|
123
|
+
const eventName = (name) => flat(name, 60).replace(/"/g, "'").replace(/\[(\/?)worca context\]/gi, '($1worca context)');
|
|
124
|
+
export function workflowEventPrompt({ cardId, state, workflowId = null, name = '', thenRun = false, projectKey = '' }) {
|
|
125
|
+
const p = flat(projectKey, 120);
|
|
126
|
+
if (state === 'declined') return `[worca event] workflow card ${cardId} declined; project=${p}`;
|
|
127
|
+
return `[worca event] workflow card ${cardId} saved as ${flat(workflowId, 120)} "${eventName(name)}"; thenRun=${thenRun ? 'true' : 'false'}; project=${p}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The user-row notice (mockup §C copy). */
|
|
131
|
+
export function workflowNoticeText({ state, name = '', matched = false, thenRun = false }) {
|
|
132
|
+
const n = flat(name, 60);
|
|
133
|
+
if (state === 'declined') return `Workflow "${n}" declined`;
|
|
134
|
+
const head = matched ? `Using your saved workflow "${n}"` : `Workflow "${n}" saved`;
|
|
135
|
+
return thenRun ? `${head} · Auto will propose a run next` : head;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* @param {{threadId?:string|null, signal?:AbortSignal|null, classify?:Function}} [o]
|
|
140
|
+
* threadId unused today (the bundle is thread-agnostic); keeps the *-deps.mjs signature uniform
|
|
141
|
+
* signal the MCP child's lifetime signal (mcp-stdio main() aborts it when stdin closes = the chat turn ended/was stopped) —
|
|
142
|
+
* the classifier honours it, so a Stop in the chat no longer leaves a nested claude running for up to 4 × 90 s (v7)
|
|
143
|
+
* classify test seam for the classifier (defaults to classifyTask); tests inject a thrower / a recorder, never a spawn
|
|
144
|
+
*/
|
|
145
|
+
export function defaultWorkflowDeps({ threadId = null, signal = null, classify = classifyTask } = {}) { // eslint-disable-line no-unused-vars
|
|
146
|
+
const bundleSignal = signal;
|
|
147
|
+
return {
|
|
148
|
+
workflow: {
|
|
149
|
+
/**
|
|
150
|
+
* The child-side half. Task mode = the orchestrator's _autoRound (classify, ONE retry on a ShapeError with the issues as
|
|
151
|
+
* feedback); shape mode = normalize + drop unknown models. Both then run revalidateWorkflowProposal.
|
|
152
|
+
* Resolves {ok:true,…} — or, in task mode, {ok:false, error, costUsd, mode, projectKey, projectName} when the classifier
|
|
153
|
+
* failed (timeout / two unusable replies / a shape the assembler still rejects after the retry): the money it spent is
|
|
154
|
+
* REAL and rides `costUsd` so the parent books it (PD2, v7 — v6 rethrew and lost the spend). Input errors (unknown
|
|
155
|
+
* project, an unassemblable hand-authored shape: nothing was spent) still THROW and reach the model as tool-error text.
|
|
156
|
+
* @param {{mode:'task'|'shape', task?:string, shape?:object, name?:string, projectKey:string, note?:string, thenRun?:boolean, signal?:AbortSignal}} o
|
|
157
|
+
*/
|
|
158
|
+
async propose({ mode, task = '', shape = null, name = '', projectKey, note = '', thenRun = false, signal = null }) {
|
|
159
|
+
const project = await projectByKey(projectKey);
|
|
160
|
+
if (!project) throw new Error(`unknown projectKey "${flat(projectKey, 120)}" — call list_projects`);
|
|
161
|
+
const registry = loadAgentRegistry();
|
|
162
|
+
const models = await listModels('');
|
|
163
|
+
const fingerprint = await fingerprintProject(project.path);
|
|
164
|
+
const warnings = [];
|
|
165
|
+
let picked;
|
|
166
|
+
let costUsd = 0;
|
|
167
|
+
let r;
|
|
168
|
+
if (mode === 'task') {
|
|
169
|
+
const cwd = join(worcaHome(), 'tmp', 'ask'); // the ask scratch dir — never the user's checkout (spec §4.5)
|
|
170
|
+
await mkdir(cwd, { recursive: true });
|
|
171
|
+
// D6 amendment: the classifier may Read/Grep/Glob a throwaway detached checkout of the
|
|
172
|
+
// project's HEAD to SIZE the change. Mock never spawns, so it never borrows one; a project
|
|
173
|
+
// without git (or a failed `git worktree add`) classifies text-only, exactly as before.
|
|
174
|
+
const look = mockEnabled({}) ? null : await openRepoLook(project.path, cwd, { signal: signal || bundleSignal });
|
|
175
|
+
const input = {
|
|
176
|
+
taskText: String(task).slice(0, ASK_LIMITS.workflowTaskMaxChars), extras: [], fingerprint, models, registry, domain: 'coding',
|
|
177
|
+
humanInLoop: true, feedback: [], priorShape: null, model: resolveAutoModel(models), cwd: look ? look.cwd : cwd, repoLook: !!look,
|
|
178
|
+
mock: mockEnabled({}), signal: signal || bundleSignal,
|
|
179
|
+
};
|
|
180
|
+
// Every failure AFTER money may have been spent resolves {ok:false, costUsd} (v7): ClassifierError carries what its
|
|
181
|
+
// failed attempts cost (classify.mjs); a ShapeError after the retry means two billed classifier calls.
|
|
182
|
+
const spent = (err) => ({
|
|
183
|
+
ok: false, mode, projectKey: project.key, projectName: flat(project.name, 120),
|
|
184
|
+
error: flat(err && err.message ? err.message : String(err), 300), costUsd: Math.round((costUsd + (Number(err && err.costUsd) || 0)) * 1e6) / 1e6,
|
|
185
|
+
});
|
|
186
|
+
try {
|
|
187
|
+
let classified;
|
|
188
|
+
try { classified = await classify(input); }
|
|
189
|
+
catch (err) { if (err instanceof ClassifierError) return spent(err); throw err; }
|
|
190
|
+
costUsd += Number(classified.costUsd) || 0;
|
|
191
|
+
warnings.push(...(classified.warnings || []));
|
|
192
|
+
picked = name ? { ...classified.shape, name } : classified.shape;
|
|
193
|
+
try {
|
|
194
|
+
r = await revalidateWorkflowProposal({ shape: picked, projectKey: project.key, warnings, costUsd, fingerprint, models, registry });
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (!(err instanceof ShapeError)) throw err;
|
|
197
|
+
let again;
|
|
198
|
+
try {
|
|
199
|
+
again = await classify({ ...input, priorShape: classified.shape,
|
|
200
|
+
feedback: [`The previous shape could not be assembled: ${err.issues.map((i) => i.message).join('; ')}. Fix it and reply with the full shape.`] });
|
|
201
|
+
} catch (e2) { if (e2 instanceof ClassifierError) return spent(e2); throw e2; }
|
|
202
|
+
costUsd += Number(again.costUsd) || 0;
|
|
203
|
+
warnings.push(...(again.warnings || []));
|
|
204
|
+
picked = name ? { ...again.shape, name } : again.shape;
|
|
205
|
+
try {
|
|
206
|
+
r = await revalidateWorkflowProposal({ shape: picked, projectKey: project.key, warnings, costUsd, fingerprint, models, registry });
|
|
207
|
+
} catch (e3) { if (e3 instanceof ShapeError) return spent(e3); throw e3; }
|
|
208
|
+
}
|
|
209
|
+
} finally {
|
|
210
|
+
if (look) await look.close(); // the checkout never outlives the call (every `return spent(...)` above passes through here)
|
|
211
|
+
}
|
|
212
|
+
} else {
|
|
213
|
+
picked = normalizeShape(name ? { ...shape, name } : shape); // throws ShapeError with EVERY issue — the model fixes them in one go
|
|
214
|
+
warnings.push(...dropUnknownModels(picked, models));
|
|
215
|
+
r = await revalidateWorkflowProposal({ shape: picked, projectKey: project.key, warnings, costUsd, fingerprint, models, registry });
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
ok: true, mode, projectKey: project.key, projectName: flat(project.name, 120),
|
|
219
|
+
name: r.proposal.name, match: r.match, warnings: r.proposal.warnings, summary: r.summary,
|
|
220
|
+
shape: r.shape, costUsd: r.proposal.costUsd, fingerprint, note: flat(note, ASK_LIMITS.workflowNoteMaxChars), thenRun: thenRun === true,
|
|
221
|
+
};
|
|
222
|
+
},
|
|
223
|
+
revalidate: revalidateWorkflowProposal,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|