zelari-code 1.28.0 → 1.30.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.
Files changed (60) hide show
  1. package/dist/cli/headless.js +14 -0
  2. package/dist/cli/headless.js.map +1 -1
  3. package/dist/cli/hooks/useSlashDispatch.js +11 -0
  4. package/dist/cli/hooks/useSlashDispatch.js.map +1 -1
  5. package/dist/cli/kraken/executor.js +725 -42
  6. package/dist/cli/kraken/executor.js.map +1 -1
  7. package/dist/cli/kraken/graphMemory.js +27 -5
  8. package/dist/cli/kraken/graphMemory.js.map +1 -1
  9. package/dist/cli/kraken/graphStatus.js +59 -0
  10. package/dist/cli/kraken/graphStatus.js.map +1 -1
  11. package/dist/cli/kraken/planner.js +189 -12
  12. package/dist/cli/kraken/planner.js.map +1 -1
  13. package/dist/cli/kraken/planner.test.js +43 -0
  14. package/dist/cli/kraken/planner.test.js.map +1 -0
  15. package/dist/cli/kraken/runtime/compile.js +73 -0
  16. package/dist/cli/kraken/runtime/compile.js.map +1 -0
  17. package/dist/cli/kraken/runtime/runScriptPlan.js +195 -0
  18. package/dist/cli/kraken/runtime/runScriptPlan.js.map +1 -0
  19. package/dist/cli/kraken/scriptPlanner.js +286 -0
  20. package/dist/cli/kraken/scriptPlanner.js.map +1 -0
  21. package/dist/cli/kraken/scriptPlanner.test.js +152 -0
  22. package/dist/cli/kraken/scriptPlanner.test.js.map +1 -0
  23. package/dist/cli/kraken/skillSuggest.js +97 -0
  24. package/dist/cli/kraken/skillSuggest.js.map +1 -0
  25. package/dist/cli/kraken/skillSuggest.test.js +157 -0
  26. package/dist/cli/kraken/skillSuggest.test.js.map +1 -0
  27. package/dist/cli/kraken/weaknessMeter.js +183 -0
  28. package/dist/cli/kraken/weaknessMeter.js.map +1 -0
  29. package/dist/cli/kraken/weaknessMeter.test.js +212 -0
  30. package/dist/cli/kraken/weaknessMeter.test.js.map +1 -0
  31. package/dist/cli/kraken/workbench.js +296 -0
  32. package/dist/cli/kraken/workbench.js.map +1 -0
  33. package/dist/cli/kraken/workbench.test.js +253 -0
  34. package/dist/cli/kraken/workbench.test.js.map +1 -0
  35. package/dist/cli/kraken/workbenchView.js +155 -0
  36. package/dist/cli/kraken/workbenchView.js.map +1 -0
  37. package/dist/cli/kraken/workbenchView.test.js +130 -0
  38. package/dist/cli/kraken/workbenchView.test.js.map +1 -0
  39. package/dist/cli/main.bundled.js +2930 -353
  40. package/dist/cli/main.bundled.js.map +4 -4
  41. package/dist/cli/runHeadless.js +83 -8
  42. package/dist/cli/runHeadless.js.map +1 -1
  43. package/dist/cli/slashCommands.js +16 -0
  44. package/dist/cli/slashCommands.js.map +1 -1
  45. package/dist/cli/slashHandlers/krakenFanout.js +200 -0
  46. package/dist/cli/slashHandlers/krakenFanout.js.map +1 -0
  47. package/dist/cli/slashHandlers/krakenGraph.js +10 -3
  48. package/dist/cli/slashHandlers/krakenGraph.js.map +1 -1
  49. package/dist/cli/slashHandlers/krakenWorkbench.js +49 -0
  50. package/dist/cli/slashHandlers/krakenWorkbench.js.map +1 -0
  51. package/dist/cli/tools/krakenCsvFanout.js +260 -0
  52. package/dist/cli/tools/krakenCsvFanout.js.map +1 -0
  53. package/dist/cli/tools/krakenCsvFanout.test.js +200 -0
  54. package/dist/cli/tools/krakenCsvFanout.test.js.map +1 -0
  55. package/dist/cli/tools/krakenModel.js +32 -0
  56. package/dist/cli/tools/krakenModel.js.map +1 -1
  57. package/dist/cli/tools/krakenRadio.js.map +1 -1
  58. package/dist/cli/tools/taskTool.js +2 -2
  59. package/dist/cli/tools/taskTool.js.map +1 -1
  60. package/package.json +2 -2
@@ -6,6 +6,9 @@
6
6
  * (`runTentacle`) to execute a validated `TaskGraph`:
7
7
  * - repeatedly forms a parallel-safe "wave" of ready nodes (bounded by
8
8
  * ZELARI_KRAKEN_MAX_PARALLEL) and runs it concurrently;
9
+ * - hands each node the conclusions of its completed dependencies
10
+ * (`buildUpstreamContext`), so a dep edge carries information and not just
11
+ * ordering;
9
12
  * - retries a failed node up to `node.maxRetries`, then spawns a `fix`
10
13
  * node (bounded by a global fix budget) that inherits the failed
11
14
  * node's deps/scope/acceptance; a node that still fails is left
@@ -31,7 +34,7 @@
31
34
  *
32
35
  * @since v0.10.x — Kraken graph engine (F3)
33
36
  */
34
- import { getReadyNodes, isSettled, isConverged, failedNodeIds, countByStatus, selectParallelWave, } from '@zelari/core';
37
+ import { getReadyNodes, isSettled, isConverged, failedNodeIds, countByStatus, canRunParallel, parseVerifyVerdict, } from '@zelari/core';
35
38
  import { runTentacle, } from './tentacle.js';
36
39
  import { mergeKrakenWorktree, } from '../tools/krakenWorktree.js';
37
40
  import { appendKrakenRadio } from '../tools/krakenRadio.js';
@@ -51,12 +54,58 @@ export function resolveMaxParallel(env = process.env) {
51
54
  const n = Number.parseInt(raw, 10);
52
55
  return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_PARALLEL;
53
56
  }
54
- export function resolveFixBudget(env = process.env) {
57
+ /**
58
+ * How many `fix` nodes this run may spawn.
59
+ *
60
+ * A flat budget of 3 is a graph-size-blind number: on a 20-node graph it is
61
+ * spent on the first three failures and every later failure goes terminal,
62
+ * cascade-skipping its dependents — the larger the graph, the less repair it
63
+ * gets, which is backwards. Scale with the node count, keeping 3 as the floor
64
+ * for small graphs. An explicit env value still wins outright.
65
+ */
66
+ export function resolveFixBudget(env = process.env, nodeCount = 0) {
55
67
  const raw = env.ZELARI_KRAKEN_FIX_BUDGET;
68
+ if (raw === undefined || raw === '') {
69
+ return Math.max(DEFAULT_FIX_BUDGET, Math.ceil(nodeCount / 2));
70
+ }
71
+ const n = Number.parseInt(raw, 10);
72
+ return Number.isFinite(n) && n >= 0
73
+ ? n
74
+ : Math.max(DEFAULT_FIX_BUDGET, Math.ceil(nodeCount / 2));
75
+ }
76
+ /**
77
+ * Default rework rounds per writer when its `verify` returns FAIL.
78
+ *
79
+ * One. A rework round is a full re-run of a writer plus a fresh verification,
80
+ * so the cost is roughly a doubling of that branch; and the second opinion of
81
+ * a model that just judged its own sibling's work has sharply diminishing
82
+ * value. Raise it deliberately (and with a wall-clock budget set) rather than
83
+ * by default.
84
+ */
85
+ export const DEFAULT_MAX_REVIEW_ROUNDS = 1;
86
+ export function resolveMaxReviewRounds(env = process.env) {
87
+ const raw = env.ZELARI_KRAKEN_MAX_REVIEW_ROUNDS;
88
+ if (raw === undefined || raw === '')
89
+ return DEFAULT_MAX_REVIEW_ROUNDS;
90
+ const n = Number.parseInt(raw, 10);
91
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_MAX_REVIEW_ROUNDS;
92
+ }
93
+ /**
94
+ * Wall-clock bound on the WHOLE graph run (ms); 0 (the default) disables it.
95
+ *
96
+ * Per-node timeouts bound each tentacle but say nothing about the total: a
97
+ * wide graph, plus retries, plus fix nodes, plus rework rounds, can run far
98
+ * longer than any single node's budget without anything noticing. On expiry
99
+ * the run takes the ordinary cancellation path, so it still settles and still
100
+ * prints its digest.
101
+ */
102
+ export const DEFAULT_GRAPH_TIMEOUT_MS = 0;
103
+ export function resolveGraphTimeoutMs(env = process.env) {
104
+ const raw = env.ZELARI_KRAKEN_GRAPH_TIMEOUT_MS;
56
105
  if (raw === undefined || raw === '')
57
- return DEFAULT_FIX_BUDGET;
106
+ return DEFAULT_GRAPH_TIMEOUT_MS;
58
107
  const n = Number.parseInt(raw, 10);
59
- return Number.isFinite(n) && n >= 0 ? n : DEFAULT_FIX_BUDGET;
108
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_GRAPH_TIMEOUT_MS;
60
109
  }
61
110
  /**
62
111
  * Default wall-clock bound per tentacle run (ms). The graph executor calls
@@ -143,6 +192,85 @@ function defaultChecksExists(cwd) {
143
192
  return false;
144
193
  }
145
194
  }
195
+ /**
196
+ * Per-dependency cap on injected upstream text. Mirrors `MAX_PRIOR_CHARS` in
197
+ * the council path (`packages/core/src/agents/councilApi.ts`), for the same
198
+ * reason: one verbose tentacle must not be able to saturate every downstream
199
+ * one.
200
+ */
201
+ export const MAX_UPSTREAM_CHARS_PER_DEP = 2800;
202
+ /** Total cap across all deps, so a wide fan-in cannot saturate a sub-agent. */
203
+ export const MAX_UPSTREAM_CHARS_TOTAL = 8000;
204
+ /**
205
+ * Render the conclusions of a node's already-completed dependencies as a
206
+ * prompt section for the sub-agent about to run.
207
+ *
208
+ * Without this the dependency edges were pure ordering constraints: a tentacle
209
+ * received only the prompt the PLANNER wrote before anything had executed, so
210
+ * an `explore` node's findings were computed, stored on the node, and then
211
+ * thrown away — the `general` nodes it "fed" started from zero and re-derived
212
+ * the same context (or guessed). The auto-injected `verify` node was worse
213
+ * still: it knew only the label of the work it was supposed to check.
214
+ *
215
+ * Direct dependencies only, deliberately — the transitive closure of a wide
216
+ * DAG would blow the sub-agent's context, and each hop's own conclusion is
217
+ * expected to carry forward what mattered.
218
+ */
219
+ export function buildUpstreamContext(graph, node) {
220
+ const parts = [];
221
+ const omitted = [];
222
+ let budget = MAX_UPSTREAM_CHARS_TOTAL;
223
+ for (const depId of node.deps) {
224
+ const dep = graph.nodes.get(depId);
225
+ if (!dep || dep.status !== 'done')
226
+ continue;
227
+ const raw = (dep.result ?? '').trim();
228
+ if (!raw)
229
+ continue;
230
+ const cap = Math.min(MAX_UPSTREAM_CHARS_PER_DEP, budget);
231
+ if (cap <= 0) {
232
+ omitted.push(dep.label);
233
+ continue;
234
+ }
235
+ const body = raw.length > cap
236
+ ? `${raw.slice(0, cap)}\n… [truncated ${raw.length}→${cap} chars]`
237
+ : raw;
238
+ budget -= Math.min(raw.length, cap);
239
+ const scope = dep.scope && dep.scope.length > 0 ? `, scope: ${dep.scope.join(', ')}` : '';
240
+ parts.push(`### ${dep.label} (${dep.kind}${scope})\n${body}`);
241
+ }
242
+ if (parts.length === 0)
243
+ return '';
244
+ const lines = [
245
+ '',
246
+ '## Context from completed upstream tasks',
247
+ 'Results reported by the tasks this one depends on. Treat them as ' +
248
+ 'hypotheses — prefer the actual files on disk where they conflict.',
249
+ '',
250
+ ...parts,
251
+ ];
252
+ if (omitted.length > 0) {
253
+ lines.push('', `(omitted for context budget: ${omitted.join(', ')})`);
254
+ }
255
+ return lines.join('\n');
256
+ }
257
+ /**
258
+ * Tool budget per node kind. Writers do real, multi-file work and were being
259
+ * run at the same `medium` budget as a read-only lookup; readers stay tight
260
+ * because a wide budget just invites them to dump the repository.
261
+ */
262
+ export function thoroughnessForKind(kind) {
263
+ return kind === 'general' || kind === 'fix' ? 'deep' : 'medium';
264
+ }
265
+ /** First non-empty line of a block of text, capped — for one-line markers. */
266
+ function firstLine(text, maxChars = 160) {
267
+ const line = text.trim().split('\n').find((l) => l.trim() !== '')?.trim() ?? '';
268
+ return line.length > maxChars ? `${line.slice(0, maxChars)}…` : line;
269
+ }
270
+ /** The tentacle kind a node runs as (`fix`/`merge` are driven as `general`). */
271
+ function agentForNode(node) {
272
+ return node.kind === 'explore' || node.kind === 'verify' ? node.kind : 'general';
273
+ }
146
274
  export class KrakenGraphExecutor {
147
275
  deps;
148
276
  parentCwd;
@@ -152,12 +280,44 @@ export class KrakenGraphExecutor {
152
280
  /** Explicit all-kinds override; when undefined the budget is per-kind. */
153
281
  nodeTimeoutMs;
154
282
  cancelGraceMs;
283
+ /** Explicit override; when undefined the budget scales with the graph size. */
284
+ fixBudgetOption;
155
285
  fixBudgetRemaining;
286
+ maxReviewRounds;
287
+ graphTimeoutMs;
156
288
  worldModelGateOverride;
157
289
  runTentacleFn;
158
290
  mergeFn;
159
291
  backtestFn;
160
292
  nodeRunState = new Map();
293
+ /** fix node id → id of the failed node it was spawned to repair. */
294
+ repairs = new Map();
295
+ /**
296
+ * rework node id → id of the writer whose work it is redoing. A rework is a
297
+ * `fix` node, but unlike a repair it must NOT create a worktree of its own:
298
+ * it edits the writer's existing one (see {@link spawnReworkPair}).
299
+ */
300
+ reworks = new Map();
301
+ /** lineage root writer id → rework rounds already spent on that lineage. */
302
+ reviewRounds = new Map();
303
+ /**
304
+ * rework node id → the ORIGINAL writer its lineage started from.
305
+ *
306
+ * The budget has to be per lineage, not per node: a rework is itself a
307
+ * writer, so counting rounds against the node being reworked reset the
308
+ * counter every round and the graph chained rework → verify → rework
309
+ * forever, terminating only on the scheduler's iteration cap.
310
+ */
311
+ reviewLineage = new Map();
312
+ /** Verify verdicts left unresolved when the run ends. */
313
+ unresolved = [];
314
+ /** Live cancellation handles for the tentacles currently running. */
315
+ nodeControllers = new Map();
316
+ /** Wall-clock duration of each node's last run, by node id. */
317
+ durationsMs = new Map();
318
+ signal;
319
+ /** Set once the run has been cancelled: stops admission, retries and fixes. */
320
+ aborted = false;
161
321
  fixCounter = 0;
162
322
  constructor(opts) {
163
323
  this.deps = opts.taskToolDeps;
@@ -167,19 +327,69 @@ export class KrakenGraphExecutor {
167
327
  this.maxParallel = opts.maxParallel ?? resolveMaxParallel();
168
328
  this.nodeTimeoutMs = opts.nodeTimeoutMs;
169
329
  this.cancelGraceMs = opts.cancelGraceMs;
330
+ this.fixBudgetOption = opts.fixBudget;
331
+ // Provisional: `execute()` re-resolves it once the graph size is known.
170
332
  this.fixBudgetRemaining = opts.fixBudget ?? resolveFixBudget();
333
+ this.maxReviewRounds = opts.maxReviewRounds ?? resolveMaxReviewRounds();
334
+ this.graphTimeoutMs = opts.graphTimeoutMs ?? resolveGraphTimeoutMs();
171
335
  this.worldModelGateOverride = opts.worldModelGate;
336
+ this.signal = opts.signal;
172
337
  this.runTentacleFn = opts.runTentacleFn ?? runTentacle;
173
338
  this.mergeFn = opts.mergeFn ?? mergeKrakenWorktree;
174
339
  this.backtestFn = opts.backtestFn ?? runBacktest;
175
340
  }
176
341
  /** Execute the graph in place (mutates node statuses) until it settles. */
177
342
  async execute(graph) {
343
+ // Now that the graph is known, size the repair budget to it (unless the
344
+ // caller pinned one). Done here rather than in the constructor because the
345
+ // node count is the whole input to the decision.
346
+ if (this.fixBudgetOption === undefined) {
347
+ this.fixBudgetRemaining = resolveFixBudget(process.env, graph.nodes.size);
348
+ }
349
+ // Cancel eagerly rather than at the next loop turn: without a listener the
350
+ // scheduler would sit in `Promise.race` until some node finished on its
351
+ // own, which for a stuck writer is the whole point of cancelling.
352
+ const onAbort = () => this.cancelRun();
353
+ if (this.signal) {
354
+ if (this.signal.aborted)
355
+ this.aborted = true;
356
+ else
357
+ this.signal.addEventListener('abort', onAbort, { once: true });
358
+ }
359
+ // Whole-run wall-clock bound. Reuses the cancellation path rather than
360
+ // inventing a second way to stop, so the run still settles and still
361
+ // reports.
362
+ let graphTimer;
363
+ if (this.graphTimeoutMs > 0) {
364
+ graphTimer = setTimeout(() => {
365
+ this.radio('graph_failed', {
366
+ description: 'graph executor',
367
+ detail: `graph exceeded its ${this.graphTimeoutMs}ms wall-clock budget — cancelling`,
368
+ ok: false,
369
+ });
370
+ this.cancelRun();
371
+ }, this.graphTimeoutMs);
372
+ // Do not hold the process open just to fire a cancellation.
373
+ graphTimer.unref?.();
374
+ }
375
+ try {
376
+ return await this.schedule(graph);
377
+ }
378
+ finally {
379
+ if (graphTimer)
380
+ clearTimeout(graphTimer);
381
+ this.signal?.removeEventListener('abort', onAbort);
382
+ }
383
+ }
384
+ /** The scheduling loop proper. See {@link execute} for the cancellation wrapper. */
385
+ async schedule(graph) {
178
386
  // Anti-explosion / self-inflicted-hang guard: cap total loop iterations
179
387
  // at a generous multiple of the node count instead of trusting the
180
388
  // graph to always shrink monotonically (fix-node spawns grow it).
181
389
  const maxIterations = Math.max(64, graph.nodes.size * 8);
182
390
  let iterations = 0;
391
+ /** Node id → its running tentacle. One entry per in-flight node. */
392
+ const inFlight = new Map();
183
393
  startKrakenGraphLive(graph);
184
394
  while (!isSettled(graph)) {
185
395
  iterations += 1;
@@ -191,30 +401,66 @@ export class KrakenGraphExecutor {
191
401
  });
192
402
  break;
193
403
  }
194
- const ready = getReadyNodes(graph);
195
- if (ready.length === 0) {
196
- // Nothing is ready but the graph hasn't settled: some pending nodes
197
- // are permanently blocked by a failed/skipped dependency. Cascade
198
- // skip them so the loop can terminate.
199
- const skippedAny = this.skipBlockedNodes(graph);
200
- if (!skippedAny) {
201
- // Nothing ready and nothing to skip should not happen for a
202
- // validated DAG, but bail out rather than spin forever.
404
+ if (this.aborted && inFlight.size === 0)
405
+ break;
406
+ // Admit everything that can start alongside what is already running,
407
+ // up to the concurrency cap. A cancelled run admits nothing.
408
+ const admitted = this.aborted ? [] : this.admit(graph, inFlight);
409
+ if (admitted.length > 0) {
410
+ // Mark and publish the whole admission BEFORE starting any of it: an
411
+ // async function body runs synchronously up to its first await, so
412
+ // starting a tentacle first would let it observe stale counts. (The
413
+ // StatusBar's "n↑" never appeared at all while this was sampled only
414
+ // after the work had already settled.)
415
+ for (const node of admitted)
416
+ node.status = 'running';
417
+ updateKrakenGraphLive(graph);
418
+ for (const node of admitted)
419
+ inFlight.set(node.id, this.runNodeSafely(node, graph));
420
+ }
421
+ if (inFlight.size === 0) {
422
+ // Nothing running and nothing admissible: the remaining pending nodes
423
+ // are permanently blocked by a failed/skipped dependency (or by the
424
+ // run being cancelled). Cascade skip them so the loop can terminate.
425
+ if (!this.skipBlockedNodes(graph)) {
426
+ // Nothing ready, nothing running, nothing to skip — should not
427
+ // happen for a validated DAG, but bail out rather than spin forever.
203
428
  break;
204
429
  }
205
430
  continue;
206
431
  }
207
- const wave = selectParallelWave(ready).slice(0, this.maxParallel);
208
- for (const node of wave)
209
- node.status = 'running';
210
- const results = await Promise.all(wave.map((node) => this.runNode(node, graph)));
211
- for (let i = 0; i < wave.length; i++) {
212
- this.applyResult(graph, wave[i], results[i]);
213
- }
432
+ // Settle ONE node at a time. Waiting for a whole wave meant a 15-minute
433
+ // writer held back every explore that became ready a second later, and
434
+ // left the concurrency budget idle for the duration.
435
+ const { id, res } = await Promise.race(inFlight.values());
436
+ inFlight.delete(id);
437
+ const settledNode = graph.nodes.get(id);
438
+ if (settledNode)
439
+ this.applyResult(graph, settledNode, res);
214
440
  updateKrakenGraphLive(graph);
215
441
  }
442
+ // The loop can break with work still in flight (iteration cap, or a
443
+ // cancellation whose tentacles have not unwound yet). Let those settle
444
+ // rather than returning a summary while they are still writing, and
445
+ // record what they produced.
446
+ if (inFlight.size > 0) {
447
+ for (const { id, res } of await Promise.all(inFlight.values())) {
448
+ const node = graph.nodes.get(id);
449
+ if (node)
450
+ this.applyResult(graph, node, res);
451
+ }
452
+ inFlight.clear();
453
+ }
454
+ // A cancelled run leaves nodes that never started: `skipped` says exactly
455
+ // that, and lets the graph settle so a summary can be returned.
456
+ if (this.aborted) {
457
+ for (const n of graph.nodes.values()) {
458
+ if (n.status === 'pending')
459
+ n.status = 'skipped';
460
+ }
461
+ }
216
462
  let backtest;
217
- const converged = isConverged(graph);
463
+ const converged = !this.aborted && isConverged(graph);
218
464
  if (converged) {
219
465
  const gateOn = this.worldModelGateOverride ?? isWorldModelGateEnabled(this.parentCwd);
220
466
  if (gateOn) {
@@ -229,45 +475,157 @@ export class KrakenGraphExecutor {
229
475
  else {
230
476
  this.radio('graph_failed', {
231
477
  description: 'graph executor',
232
- detail: `failed nodes: ${failedNodeIds(graph).join(', ') || 'none'}`,
478
+ detail: this.aborted
479
+ ? 'cancelled by caller'
480
+ : `failed nodes: ${failedNodeIds(graph).join(', ') || 'none'}`,
233
481
  ok: false,
234
482
  });
235
483
  }
236
484
  endKrakenGraphLive(graph, converged);
237
485
  // Cross-run memory: let the next planning pass see where this one stopped
238
486
  // instead of replanning the whole goal blind.
239
- await saveGraphSnapshot(this.parentCwd, toGraphSnapshot(graph, { goal: this.goal ?? graph.id, converged }));
487
+ await saveGraphSnapshot(this.parentCwd, toGraphSnapshot(graph, {
488
+ goal: this.goal ?? graph.id,
489
+ converged,
490
+ unresolvedFindings: this.unresolved,
491
+ }));
240
492
  return {
241
493
  graph,
242
494
  converged,
243
495
  failedNodeIds: failedNodeIds(graph),
244
496
  counts: countByStatus(graph),
497
+ durationsMs: Object.fromEntries(this.durationsMs),
498
+ cancelled: this.aborted,
499
+ unresolvedFindings: [...this.unresolved],
245
500
  ...(backtest ? { backtest } : {}),
246
501
  };
247
502
  }
503
+ /**
504
+ * Stop the run: no further admissions, and every tentacle currently running
505
+ * is told to unwind. Each node's own timeout/grace machinery then resolves
506
+ * it, so `execute()` settles instead of leaving orphans behind.
507
+ */
508
+ cancelRun() {
509
+ if (this.aborted)
510
+ return;
511
+ this.aborted = true;
512
+ this.radio('graph_failed', {
513
+ description: 'graph executor',
514
+ detail: `cancelling ${this.nodeControllers.size} running tentacle(s)`,
515
+ ok: false,
516
+ });
517
+ for (const controller of this.nodeControllers.values())
518
+ controller.abort();
519
+ }
520
+ /**
521
+ * Pick the ready nodes that may start right now: parallel-safe against every
522
+ * node already running AND against each other, within the concurrency cap.
523
+ *
524
+ * Unlike a wave-at-a-time scheduler this is called on every completion, so a
525
+ * node becomes eligible the moment its blocker settles instead of waiting
526
+ * for the slowest member of some earlier batch.
527
+ */
528
+ admit(graph, inFlight) {
529
+ const capacity = this.maxParallel - inFlight.size;
530
+ if (capacity <= 0)
531
+ return [];
532
+ const running = [];
533
+ for (const id of inFlight.keys()) {
534
+ const n = graph.nodes.get(id);
535
+ if (n)
536
+ running.push(n);
537
+ }
538
+ const admitted = [];
539
+ for (const node of getReadyNodes(graph)) {
540
+ if (admitted.length >= capacity)
541
+ break;
542
+ const safe = running.every((r) => canRunParallel(r, node)) &&
543
+ admitted.every((a) => canRunParallel(a, node));
544
+ if (safe)
545
+ admitted.push(node);
546
+ }
547
+ return admitted;
548
+ }
549
+ /**
550
+ * Run one node, tagging the result with its id and converting an unexpected
551
+ * throw into a node failure. The scheduler races these promises, so a
552
+ * rejection would abandon every other in-flight tentacle mid-write; one
553
+ * failed node that the retry/fix machinery can reason about is strictly
554
+ * better than an aborted graph.
555
+ */
556
+ runNodeSafely(node, graph) {
557
+ return this.runNode(node, graph).then((res) => ({ id: node.id, res }), (err) => ({
558
+ id: node.id,
559
+ res: {
560
+ ok: false,
561
+ agent: agentForNode(node),
562
+ error: `tentacle threw: ${err instanceof Error ? err.message : String(err)}`,
563
+ cancelled: true,
564
+ },
565
+ }));
566
+ }
248
567
  /** Run one node: dispatch to the merge handler for `merge` nodes, else a tentacle. */
249
568
  async runNode(node, graph) {
569
+ const startedAt = Date.now();
570
+ try {
571
+ return await this.runNodeInner(node, graph);
572
+ }
573
+ finally {
574
+ this.durationsMs.set(node.id, Date.now() - startedAt);
575
+ this.nodeControllers.delete(node.id);
576
+ }
577
+ }
578
+ async runNodeInner(node, graph) {
250
579
  this.radio('node_start', { description: node.label, agent: node.kind });
251
580
  if (node.kind === 'merge') {
252
581
  return this.runMergeNode(node, graph);
253
582
  }
254
- const usesWorktree = node.kind === 'general' || node.kind === 'fix';
255
- const agent = node.kind === 'fix' ? 'general' : node.kind;
583
+ // A rework edits the worktree its writer already produced (see
584
+ // spawnReworkPair) it must not open a second one on the same scope.
585
+ const isRework = this.reworks.has(node.id);
586
+ const usesWorktree = (node.kind === 'general' || node.kind === 'fix') && !isRework;
587
+ // Map script-runtime kinds onto the host's `TaskAgentKind`. Reviewer
588
+ // kinds (verify, spec, conformance) all run as 'verify' agents under
589
+ // the hood — the persona is enforced at the prompt level, not the
590
+ // runtime level. Pillar 2 will lift this once we have per-persona
591
+ // system prompt injection in `runTentacle`.
592
+ const agent = node.kind === 'fix'
593
+ ? 'general'
594
+ : node.kind === 'spec' || node.kind === 'conformance'
595
+ ? 'verify'
596
+ : node.kind;
256
597
  const controller = new AbortController();
598
+ // Registered so a cancelled run can reach in and stop this tentacle
599
+ // instead of waiting for it to finish on its own.
600
+ this.nodeControllers.set(node.id, controller);
601
+ if (this.aborted)
602
+ controller.abort();
603
+ // Hand the sub-agent what its dependencies actually concluded — the whole
604
+ // point of the dep edge (see buildUpstreamContext).
605
+ const upstream = buildUpstreamContext(graph, node);
606
+ // A verify inspects the tree its writer wrote in; a rework edits that same
607
+ // tree. Both resolve to the worktree recorded behind their deps.
608
+ const inheritedCwd = node.kind === 'verify' || isRework
609
+ ? this.inheritedWorktreeCwdFor(node, graph)
610
+ : undefined;
257
611
  const res = await this.withNodeTimeout(this.runTentacleFn({
258
- deps: this.deps,
612
+ // `allowWorktree: false` is what actually stops a rework from opening
613
+ // its own worktree: creation is driven by the agent kind ('general')
614
+ // inside runTentacle, not by anything the executor passes per-call.
615
+ deps: isRework ? { ...this.deps, allowWorktree: false } : this.deps,
259
616
  args: {
260
617
  description: node.label,
261
- prompt: node.prompt,
618
+ prompt: upstream ? `${node.prompt}\n${upstream}` : node.prompt,
262
619
  scope: node.scope,
263
620
  acceptance: node.acceptance,
264
621
  },
265
622
  agent,
266
- thoroughness: 'medium',
623
+ thoroughness: thoroughnessForKind(node.kind),
267
624
  parentCwd: this.parentCwd,
625
+ ...(inheritedCwd ? { cwdOverride: inheritedCwd } : {}),
268
626
  sessionId: this.sessionId,
269
627
  // Defer merge for writers so the executor controls merge ordering
270
- // (Correction 4); explore/verify never create a worktree.
628
+ // (Correction 4); explore/verify/rework never create a worktree.
271
629
  deferMerge: usesWorktree,
272
630
  graphId: graph.id,
273
631
  nodeId: node.id,
@@ -342,21 +700,74 @@ export class KrakenGraphExecutor {
342
700
  };
343
701
  }
344
702
  /**
345
- * Sequentially merge every dep node's deferred worktree (in dep order) into
346
- * parent HEAD. Deps without a recorded worktree handle (worktree isolation
347
- * disabled, or a read-only node) are a no-op. On conflict the branch is
348
- * kept and the conflict is surfaced in the merge node's error remaining
349
- * deps still attempt to merge (independent branches shouldn't be blocked
350
- * by one conflict).
703
+ * The worktree a node should run in, inherited from the writer behind it.
704
+ *
705
+ * For a `verify`: verification happens BEFORE the merge node, so when its
706
+ * writer worked in an isolated worktree the changes are not in the parent
707
+ * tree yet a verify tentacle pointed at `parentCwd` was inspecting a tree
708
+ * that provably did not contain the work it was asked to check, and reported
709
+ * it missing.
710
+ *
711
+ * For a rework: the same tree, for the stronger reason that writing anywhere
712
+ * else would strand the round on a second branch.
713
+ *
714
+ * Returns undefined when there is no single tree: no worktrees (isolation
715
+ * disabled — the writers edited the parent tree directly), or several
716
+ * distinct ones, in which case no single cwd is correct and the parent tree
717
+ * is the honest default.
718
+ */
719
+ inheritedWorktreeCwdFor(node, graph) {
720
+ const paths = new Set(this.collectWorktreeSources(node, graph).map((s) => s.handle.path));
721
+ return paths.size === 1 ? [...paths][0] : undefined;
722
+ }
723
+ /**
724
+ * Resolve the deferred worktrees produced behind a node's dependencies, in
725
+ * ancestors-first order. Used to decide what a `merge` node must merge, and
726
+ * which tree a `verify` node should actually inspect.
727
+ *
728
+ * A merge node's direct deps are NOT the writers: `buildGraphFromPlan`
729
+ * injects a `verify` node after every `general` node and points the merge at
730
+ * those verifies, while worktree handles are recorded against the writer
731
+ * node ids. Looking only at direct deps therefore found nothing to merge and
732
+ * silently reported success while every tentacle's work stayed stranded on
733
+ * its branch. Walk up through non-writer deps until the writers are found.
734
+ *
735
+ * Post-order so a writer that depends on another writer merges after it (the
736
+ * later branch was cut from a HEAD that already contained the earlier work).
737
+ * `merge` nodes terminate the walk: another merge already owns its subtree.
738
+ */
739
+ collectWorktreeSources(node, graph) {
740
+ const out = [];
741
+ const seen = new Set();
742
+ const visit = (id) => {
743
+ if (seen.has(id))
744
+ return;
745
+ seen.add(id);
746
+ const n = graph.nodes.get(id);
747
+ if (!n || n.kind === 'merge')
748
+ return;
749
+ for (const dep of n.deps)
750
+ visit(dep);
751
+ const handle = this.nodeRunState.get(id)?.worktreeHandle;
752
+ if (handle)
753
+ out.push({ id, handle });
754
+ };
755
+ for (const depId of node.deps)
756
+ visit(depId);
757
+ return out;
758
+ }
759
+ /**
760
+ * Sequentially merge every deferred worktree this node covers (in
761
+ * ancestors-first order) into parent HEAD. Nodes without a recorded worktree
762
+ * handle (worktree isolation disabled, or a read-only node) are a no-op. On
763
+ * conflict the branch is kept and the conflict is surfaced in the merge
764
+ * node's error — remaining sources still attempt to merge (independent
765
+ * branches shouldn't be blocked by one conflict).
351
766
  */
352
767
  async runMergeNode(node, graph) {
353
768
  const conflicts = [];
354
769
  const merged = [];
355
- for (const depId of node.deps) {
356
- const state = this.nodeRunState.get(depId);
357
- const handle = state?.worktreeHandle;
358
- if (!handle)
359
- continue; // nothing to merge for this dep
770
+ for (const { id: depId, handle } of this.collectWorktreeSources(node, graph)) {
360
771
  let result;
361
772
  try {
362
773
  result = await this.mergeFn(handle, {
@@ -376,6 +787,10 @@ export class KrakenGraphExecutor {
376
787
  conflicts.push(`${depId}: ${result.message}`);
377
788
  }
378
789
  else {
790
+ // The branch is merged (and, unless KEEP is set, the worktree is gone).
791
+ // Drop the handle so a second merge node covering the same ancestor
792
+ // cannot try to merge a branch that no longer exists.
793
+ this.nodeRunState.set(depId, { worktreeHandle: null });
379
794
  merged.push(depId);
380
795
  }
381
796
  }
@@ -403,9 +818,26 @@ export class KrakenGraphExecutor {
403
818
  node.status = 'done';
404
819
  node.result = res.result;
405
820
  this.radio('node_end', { description: node.label, agent: node.kind, ok: true });
821
+ this.reconcileRepairedNode(graph, node);
822
+ // A verify that RAN successfully is not the same thing as work that
823
+ // PASSED. Read what it actually concluded.
824
+ if (node.kind === 'verify')
825
+ this.applyVerifyVerdict(graph, node);
406
826
  return;
407
827
  }
408
828
  node.error = res.error;
829
+ // A cancelled run must not retry or spawn repairs: the caller asked for
830
+ // the graph to stop, and re-spawning work is the opposite of that.
831
+ if (this.aborted) {
832
+ node.status = 'error';
833
+ this.radio('node_end', {
834
+ description: node.label,
835
+ agent: node.kind,
836
+ detail: res.error,
837
+ ok: false,
838
+ });
839
+ return;
840
+ }
409
841
  // A run we could not confirm has stopped may still be writing this node's
410
842
  // scope. Re-spawning would put two tentacles in the same directory — the
411
843
  // failure mode that produced duplicate parallel implementations of the
@@ -455,6 +887,208 @@ export class KrakenGraphExecutor {
455
887
  ok: false,
456
888
  });
457
889
  }
890
+ /**
891
+ * A `fix` node just completed the work its failed predecessor could not.
892
+ * That unit of work IS done — but the predecessor was left terminally
893
+ * `error`, and since `isConverged` requires every node to be `done`/
894
+ * `skipped`, a fully repaired graph reported "did not converge" and listed
895
+ * the repaired node under `failedNodeIds`. The cross-run snapshot then told
896
+ * the next planner to redo work the fix had already completed.
897
+ *
898
+ * Marking it `done` has no scheduling effect (dependents were re-pointed at
899
+ * the fix when it was spawned) — it is purely how the run is reported. The
900
+ * original failure stays visible as the separate `fix: …` node and in the
901
+ * repaired node's result line.
902
+ */
903
+ reconcileRepairedNode(graph, fixNode) {
904
+ const failedId = this.repairs.get(fixNode.id);
905
+ if (!failedId)
906
+ return;
907
+ const failed = graph.nodes.get(failedId);
908
+ if (!failed || failed.status !== 'error')
909
+ return;
910
+ const original = failed.error ? ` (original failure: ${failed.error})` : '';
911
+ failed.status = 'done';
912
+ failed.result = `repaired by "${fixNode.label}"${original}${fixNode.result ? `: ${fixNode.result}` : ''}`;
913
+ // Clear the error: the snapshot lists a `done` node under "Already
914
+ // completed — do NOT redo this work", and a trailing error message there
915
+ // reads as if it still needs repair.
916
+ failed.error = undefined;
917
+ this.radio('node_end', {
918
+ description: failed.label,
919
+ agent: failed.kind,
920
+ detail: `repaired by ${fixNode.id}`,
921
+ ok: true,
922
+ });
923
+ }
924
+ /**
925
+ * Act on what a completed `verify` node concluded.
926
+ *
927
+ * The verify itself stays `done` either way — it did its job, and doing it
928
+ * well means being free to say "no". A FAIL instead sends the WRITER back
929
+ * through a bounded rework round.
930
+ *
931
+ * Without this the verdict text was never read: a verify that reported the
932
+ * work as wrong was recorded exactly like one that reported it correct, the
933
+ * graph converged over the defect, and the only iteration the engine could
934
+ * do was on execution failure. An `unknown` verdict (no parseable trailer)
935
+ * is deliberately non-blocking — a prompt drift must not be able to wedge
936
+ * every graph — but it is recorded, because a gate that has silently stopped
937
+ * working is worse than no gate.
938
+ */
939
+ applyVerifyVerdict(graph, verify) {
940
+ const { verdict, findings } = parseVerifyVerdict(verify.result);
941
+ if (verdict === 'pass')
942
+ return;
943
+ const writer = this.writerBehind(verify, graph);
944
+ if (!writer)
945
+ return; // nothing to send back to
946
+ // Bennett's Razor meter (opt-in): when ZELARI_KRAKEN_WEAKNESS_METER=1,
947
+ // fire a non-blocking LLM call to refine the persona verdict with
948
+ // a principled weakness score. The result lands in the radio
949
+ // stream as `node_meter` so the desktop can surface a "tightly
950
+ // asserted PASS" vs "loosely claimed PASS" distinction. The local
951
+ // heuristic already produced a score; the meter just refines it.
952
+ void this.maybeRunWeaknessMeter(verify, verdict);
953
+ if (verdict === 'unknown') {
954
+ this.unresolved.push({
955
+ nodeId: writer.id,
956
+ label: writer.label,
957
+ reason: 'unknown',
958
+ findings: findings || '(verify produced no parseable VERDICT line)',
959
+ });
960
+ return;
961
+ }
962
+ // A cancelled run must not spawn new work, and a lineage that has spent
963
+ // its rounds is done being reworked.
964
+ const root = this.reviewLineage.get(writer.id) ?? writer.id;
965
+ const spent = this.reviewRounds.get(root) ?? 0;
966
+ if (this.aborted || spent >= this.maxReviewRounds) {
967
+ this.unresolved.push({
968
+ nodeId: writer.id,
969
+ label: writer.label,
970
+ reason: 'fail',
971
+ findings,
972
+ });
973
+ // Keep the unresolved verdict attached to the node too, so the digest,
974
+ // the snapshot and any reader of the graph see it in the same place the
975
+ // repaired-node marker lives.
976
+ writer.result =
977
+ `${writer.result ?? ''}\n\n[accepted with unresolved verify findings from ` +
978
+ `"${verify.label}"]${findings ? `: ${firstLine(findings)}` : ''}`.trim();
979
+ this.radio('node_end', {
980
+ description: writer.label,
981
+ agent: writer.kind,
982
+ detail: this.aborted
983
+ ? 'verify FAIL left unresolved (run cancelled)'
984
+ : `verify FAIL left unresolved (rework budget ${this.maxReviewRounds} spent)`,
985
+ ok: false,
986
+ });
987
+ return;
988
+ }
989
+ this.reviewRounds.set(root, spent + 1);
990
+ this.spawnReworkPair(graph, writer, verify, findings, root, spent + 1);
991
+ }
992
+ /**
993
+ * The writer whose work a `verify` node judged.
994
+ *
995
+ * Walks up through non-writer deps, the same shape `collectWorktreeSources`
996
+ * relies on: a verify's dep is normally its writer directly, but after a
997
+ * rework round the chain is writer → verify → rework → verify, and the
998
+ * rework (a `fix` node) is itself the writer to send back.
999
+ */
1000
+ writerBehind(verify, graph) {
1001
+ const seen = new Set();
1002
+ const visit = (id) => {
1003
+ if (seen.has(id))
1004
+ return undefined;
1005
+ seen.add(id);
1006
+ const n = graph.nodes.get(id);
1007
+ if (!n || n.kind === 'merge')
1008
+ return undefined;
1009
+ if (n.kind === 'general' || n.kind === 'fix')
1010
+ return n;
1011
+ for (const dep of n.deps) {
1012
+ const found = visit(dep);
1013
+ if (found)
1014
+ return found;
1015
+ }
1016
+ return undefined;
1017
+ };
1018
+ for (const depId of verify.deps) {
1019
+ const found = visit(depId);
1020
+ if (found)
1021
+ return found;
1022
+ }
1023
+ return undefined;
1024
+ }
1025
+ /**
1026
+ * Send a writer's work back for one more round: a rework node carrying the
1027
+ * verify's findings, plus a fresh verify to judge the result.
1028
+ *
1029
+ * The rework runs INSIDE the writer's worktree instead of creating one of
1030
+ * its own. Two worktrees for one scope means two branches, and the merge
1031
+ * node walks up to the writer — so a rework on its own branch would be
1032
+ * merged never or twice, exactly the stranded-work failure the merge fix
1033
+ * addressed. `allowWorktree: false` on this node's deps suppresses creation,
1034
+ * and `cwdOverride` (resolved via {@link inheritedWorktreeCwdFor}) points it
1035
+ * at the existing tree; the handle stays registered against the writer.
1036
+ *
1037
+ * Acyclicity is preserved by construction: both new nodes point only at
1038
+ * nodes that already exist, and the rewiring moves an existing edge forward
1039
+ * along the chain rather than back into it.
1040
+ */
1041
+ spawnReworkPair(graph, writer, verify, findings, root, round) {
1042
+ // Named after the lineage root, so round 2 of g1 is `rework-g1-2` rather
1043
+ // than a nested `rework-rework-g1-1-1`.
1044
+ const reworkId = `rework-${root}-${round}`;
1045
+ const reworkNode = {
1046
+ id: reworkId,
1047
+ kind: 'fix',
1048
+ label: `rework: ${writer.label}`,
1049
+ prompt: `A reviewer inspected this work on disk and REJECTED it. Address every finding below, ` +
1050
+ `then leave the work in a state that satisfies the original task.\n\n` +
1051
+ `## Original task\n${writer.prompt}\n\n` +
1052
+ `## Reviewer findings (these are what must change)\n${findings || '(the reviewer reported FAIL without detail)'}`,
1053
+ ...(writer.scope ? { scope: writer.scope } : {}),
1054
+ ...(writer.acceptance ? { acceptance: writer.acceptance } : {}),
1055
+ // The verify is already `done`, so the rework is immediately ready.
1056
+ deps: [verify.id],
1057
+ status: 'pending',
1058
+ retryCount: 0,
1059
+ maxRetries: 0,
1060
+ };
1061
+ graph.nodes.set(reworkId, reworkNode);
1062
+ this.reworks.set(reworkId, writer.id);
1063
+ this.reviewLineage.set(reworkId, root);
1064
+ const reVerifyId = `verify-${reworkId}`;
1065
+ const reVerifyNode = {
1066
+ id: reVerifyId,
1067
+ kind: 'verify',
1068
+ label: `verify: ${writer.label} (rework ${round})`,
1069
+ prompt: verify.prompt,
1070
+ deps: [reworkId],
1071
+ status: 'pending',
1072
+ retryCount: 0,
1073
+ maxRetries: verify.maxRetries,
1074
+ };
1075
+ graph.nodes.set(reVerifyId, reVerifyNode);
1076
+ // Whatever waited on the old verify (typically the merge node) must now
1077
+ // wait on the new one, or it would merge the branch mid-rework.
1078
+ for (const other of graph.nodes.values()) {
1079
+ if (other.id === reworkId || other.id === reVerifyId)
1080
+ continue;
1081
+ if (other.deps.includes(verify.id)) {
1082
+ other.deps = other.deps.map((d) => (d === verify.id ? reVerifyId : d));
1083
+ }
1084
+ }
1085
+ this.radio('node_fix', {
1086
+ description: reworkNode.label,
1087
+ agent: 'fix',
1088
+ detail: `verify FAIL on "${writer.label}" — rework round ${round}/${this.maxReviewRounds}`,
1089
+ ok: false,
1090
+ });
1091
+ }
458
1092
  /**
459
1093
  * Create a `fix` node that attempts to redo the failed node's work, wired
460
1094
  * so downstream dependents of the failed node also wait on the fix.
@@ -477,9 +1111,12 @@ export class KrakenGraphExecutor {
477
1111
  // no further retries — one fix attempt per failed node in v1
478
1112
  };
479
1113
  graph.nodes.set(fixId, fixNode);
1114
+ this.repairs.set(fixId, failed.id);
480
1115
  // Downstream dependents wait on the fix attempt INSTEAD of the failed
481
- // node (not in addition to it) the failed node never becomes `done`,
482
- // so leaving it in `deps` would strand dependents forever.
1116
+ // node (not in addition to it): the failed node is terminally `error` and
1117
+ // will never run again, so leaving it in `deps` would strand them forever.
1118
+ // (If the fix succeeds, `reconcileRepairedNode` marks the failed node
1119
+ // `done` for reporting — after this rewiring, and with no effect on it.)
483
1120
  for (const other of graph.nodes.values()) {
484
1121
  if (other.id === fixId)
485
1122
  continue;
@@ -524,5 +1161,51 @@ export class KrakenGraphExecutor {
524
1161
  ...(fields.ok !== undefined ? { ok: fields.ok } : {}),
525
1162
  });
526
1163
  }
1164
+ /**
1165
+ * Bennett's Razor meter (Slice L/N+3 wiring): when the env flag is
1166
+ * set, fire a non-blocking LLM call to refine the persona verdict's
1167
+ * weakness score. The local heuristic already produced a score in
1168
+ * `parsePersonaVerdict`; the meter just refines it. Results land in
1169
+ * the radio stream as a `node_meter` event so the desktop / tail
1170
+ * can surface the distinction between a "tightly asserted" PASS
1171
+ * (specificity > 0.6) and a "loosely claimed" one (specificity < 0.3).
1172
+ *
1173
+ * No-op when:
1174
+ * - the meter is disabled (default)
1175
+ * - the result text is empty
1176
+ * - the meter call fails (silent: the local score is good enough)
1177
+ *
1178
+ * @since v1.31.x
1179
+ */
1180
+ async maybeRunWeaknessMeter(verify, verdict) {
1181
+ // Lazy import: keep the executor's cold-start path fast and avoid
1182
+ // a hard dep on the meter module's provider/key stack when the
1183
+ // meter is disabled (which is the default).
1184
+ const text = typeof verify.result === 'string' ? verify.result : '';
1185
+ if (text.length === 0)
1186
+ return;
1187
+ let meter;
1188
+ try {
1189
+ ({ measureWeaknessViaLLM: meter } = await import('./weaknessMeter.js'));
1190
+ }
1191
+ catch {
1192
+ return;
1193
+ }
1194
+ if (!meter)
1195
+ return;
1196
+ const outcome = await meter(text);
1197
+ if (!outcome)
1198
+ return; // disabled or failed — silent
1199
+ this.radio('node_meter', {
1200
+ description: `meter: ${verify.id}`,
1201
+ agent: 'weakness-meter',
1202
+ detail: `v=${verdict} specificity=${outcome.meter.specificity.toFixed(2)} ` +
1203
+ `weakness=${outcome.weakness.toFixed(2)} ` +
1204
+ `model=${outcome.model} ` +
1205
+ `dur=${outcome.durationMs}ms ` +
1206
+ `assumptions=${outcome.meter.assumptions.length}`,
1207
+ ok: true,
1208
+ });
1209
+ }
527
1210
  }
528
1211
  //# sourceMappingURL=executor.js.map